Wallet Attached Storage v0.3

Unofficial Draft

More details about this document
Latest published version:
https://w3c-ccg.github.io/wallet-attached-storage-spec/
Latest editor's draft:
https://w3c-ccg.github.io/wallet-attached-storage-spec/
History:
Commit history
Editor:
Dmitri Zagidulin
Feedback:
GitHub w3c-ccg/wallet-attached-storage-spec (pull requests, new issue, open issues)

Abstract

Wallet Attached Storage is a general purpose permissioned storage API.

Status of This Document

This document is a draft of a potential specification. It has no official standing of any kind and does not represent the support or consensus of any standards organization.

This is an experimental specification and is undergoing regular revisions.

1. Conformance

As well as sections marked as non-normative, all authoring guidelines, diagrams, examples, and notes in this specification are non-normative. Everything else in this specification is normative.

The key words MAY, MUST, MUST NOT, OPTIONAL, RECOMMENDED, REQUIRED, SHOULD, and SHOULD NOT in this document are to be interpreted as described in BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all capitals, as shown here.

2. Introduction

The Wallet Attached Storage (WAS) specification brings together the lessons learned from many attempts to standardize permissioned cloud storage over the years.

This specification aims to provide:

Note
This document is deliberately comprehensive: it specifies both a small required core and a set of optional extensions. A conformant minimal server implements only Resource CRUD (11. Resources and Blobs) and the authorization profile (5.1 WAS Authorization Profile v0.1); every other endpoint group is OPTIONAL. See 2.4 Scope and Conformance Profiles for the full conformance-tier map, and 2.5 Quickstart: Your First Request to watch a first request succeed.

2.1 Version History and Roadmap

Note
This subsection is non-normative.
  • v0.1 (January 2025) -- Initial version, created for the MIT Digital Credentials Consortium in collaboration with Benjamin Goering, as the "Wallet Attached Storage" specification.
  • v0.2 (April 2026) -- Added examples and editorial fixes. Snapshot at https://wallet.storage/spec.
  • v0.3 (through June 2026) -- Initial incubation at MIT DCC, based on implementation experience.
  • v0.4 (mid-July 2026 - now) -- Migrated to the W3C Credentials Community Group (CCG)'s Capability Based Storage Task Force for further incubation. Goal: updating spec to match deployments and learnings.
  • v0.5 (tbd) - Goal: Breaking changes, refactoring, community input. Likely pending a rename.

2.2 Reading This Document

Note
This subsection is non-normative. It collects conventions that the rest of the document relies on, so that a section read in isolation is still intelligible.

Authorization: ... is a placeholder. Every request example that carries an Authorization header abbreviates a signed zCap (capability) invocation (as opposed to a bearer token). Reads are authorized in WAS just as writes are. The expanded form -- with the Digest, Capability-Invocation, and Signature headers -- appears once, in 5.1.3 Performing Authorized API Calls.

A trailing slash means the container. A path ending in / addresses a container: GET lists its members and POST adds a member to it. The same path without the trailing slash addresses the item itself: GET returns that item's description, PUT creates or replaces it, DELETE removes it. So /space/{space_id}/{collection_id}/ is the Collection's member list, while /space/{space_id}/{collection_id} is the Collection's description.

All examples share one Space. Every example in this document uses the Space id 81246131-69a4-45ab-9bff-9c946b59cf2e on the host example.com. Path segments in braces -- {space_id}, {collection_id}, {resource_id} -- are placeholders for those identifiers.

Normative lists live in the appendices. Error type URIs are catalogued in F. Error Type Registry, path segments this specification reserves in B. Reserved Path Segment Registry, and client-side encryption schemes in C. Encryption Scheme Registry. Those registries are normative: they, not the surrounding prose, are where an implementation looks such values up.

2.3 Use Cases

Initial use cases that are motivating this work:

2.4 Scope and Conformance Profiles

This specification represents a layered and modular approach to storage, combining core features and optional extension points.

The layers below describe conformance tiers. Each tier adds optional capability on top of the one before it, so an implementer can stop at any tier and still be conformant. The normative body, by contrast, is organized container-first (outermost to innermost: Spaces Repositories, Spaces, Collections, then Resources), which is convenient as a reference but is the reverse of the tiers. If you're new to the spec, scan the profile table below for the conformance tiers, then walk through 2.5 Quickstart: Your First Request to watch a request succeed. The core tier is just 11. Resources and Blobs plus 5.1 WAS Authorization Profile v0.1.

Profile Adds Defining sections
Minimal Resource CRUD (KV + blob read/write) + authorization 11. Resources and Blobs, 5.1 WAS Authorization Profile v0.1
+ Listing list resources / collections / spaces 10.5 List Collection operation, 9.5 List All Collections operation, 8.2 List Spaces Operation
+ Collection mgmt create / manage collections in a Space 10. Collections
+ Space mgmt manage an individual Space 9.2 Read Space operation (Space endpoints)
+ Multi-tenant create / manage many Spaces on a server 8. Spaces Repositories
+ Extensions linksets, policy, metadata, export, backends, query, quotas, encryption, replication, versioning 13. Linksets

Each tier stacks on the one above it. The Minimal profile is a permissioned key/value CRUD API built from simple HTTP verbs and delegatable capability-based authorization -- enough, on its own, to read and write any resource (text, structured document, or binary blob) without collection or space management.Listing, Collection and Space management will feel familiar to anyone who has used a GUI front end for a database or file system. Multi-tenant support lets a provider host many Spaces on one server. The Extensions tier layers on optional features -- an access-policy resource, user-writable metadata (for example, "tags" on binary files), a Space export endpoint, pluggable 14. Backends, query, quotas, client-side encryption (via Encrypted Data Vaults), replication, and versioning -- discovered through the linkset feature-detection mechanism (from [RFC9264]; see 13. Linksets).

Normative status. Section back-placement does not imply informative status. Everything from 2. Introduction through 15. Quotas is normative, as are the appendices A. Pagination, B. Reserved Path Segment Registry, C. Encryption Scheme Registry, and F. Error Type Registry (each of which also carries an inline "This appendix is normative." banner). The remaining appendices -- G. Goals and Requirements and H. IANA Considerations -- are informative. Optionality is orthogonal to normativity: many normative sections describe OPTIONAL endpoint groups, but a server that implements them MUST follow the stated requirements.

2.5 Quickstart: Your First Request

Note
This walkthrough is a non-normative tutorial, not a conformance requirement. It assumes a server that already hosts a single Space as well as a messages collection, and that you are that Space's controller. The goal is to store one JSON Resource and read it back.

Step 1: Write. PUT a JSON document to a resource path under a collection. A 204 No Content confirms the write:

PUT /space/81246131-69a4-45ab-9bff-9c946b59cf2e/messages/hello-world HTTP/1.1
Host: example.com
Content-Type: application/json
Authorization: ...

{"message":"hi"}
HTTP/1.1 204 No Content

Step 2: Read. GET the same path to retrieve what you just stored:

GET /space/81246131-69a4-45ab-9bff-9c946b59cf2e/messages/hello-world HTTP/1.1
Host: example.com
Accept: application/json
Authorization: ...
HTTP/1.1 200 OK
Content-type: application/json

{"message":"hi"}

(The Authorization: ... placeholder stands for a signed zCap invocation). WAS reads are authorized too, so the header is required on the GET just as on the PUT. How that invocation is constructed is defined in 5.1.3 Performing Authorized API Calls, and its fully expanded form -- with the Digest, Capability-Invocation, and Signature headers spelled out -- is shown once in the worked example within that section. Producing that signature requires a conformant WAS client: it is an Ed25519 did:key capability invocation, not something you can hand-write with curl.

To go beyond a single pre-existing Space -- to create your own Space, or delegate access to others -- see the conformance-profile table in 2.4 Scope and Conformance Profiles and the full endpoint map in 2.6 API Summary.

2.6 API Summary

API summary at a glance.

Unless otherwise specified by the controller, all operations require authorization. This can be overridden by the controller via the (optional) /policy endpoints. Hosting "public-read" resources, such as HTML files for websites, or media files you can link to via <img src="">, is a common use case.

The endpoints below divide into a Core profile that every conformant server implements, and Optional extensions grouped by feature. Each row links to the section that defines its operation. Rows marked Reserved name a path this specification anchors (see B. Reserved Path Segment Registry) but does not yet define an operation for; a server MUST NOT repurpose a reserved path.

2.6.1 Core

Resource CRUD (Create, Read, Update, Delete):

2.6.2 Optional extensions

List resources in a Collection:

If not implemented on a server, implies that only individual Key/Value operations are supported.

Manage Collections in a Space:

If not implemented on a server, implies that collections are pre-configured or implicit, controlled by the server.

Collection Metadata Endpoints:

Spaces Repository Endpoints -- Manage Spaces on a Server:

If not implemented on a server, implies that any existing Spaces are pre-configured and controlled by the server.

Space Endpoints -- Manage an individual Space:

Advanced Resource Endpoints:

Chunked Resource Endpoints (see 12. Chunked Resources; available only on a backend advertising chunked-streams):

  • PUT|GET|HEAD|DELETE /space/{space_id}/{collection_id}/{resource_id}/chunks/{index} -- store, read, head, delete a single chunk.
  • GET /space/{space_id}/{collection_id}/{resource_id}/chunks/ -- list a Resource's chunks.

Policy Related Endpoints:

Policy overrides are hierarchical and inherited. A policy set for the entire Space applies to all its Collections and Resources (unless overridden by a more specific policy, either at the Collection or Resource level).

  • GET|PUT|DELETE /space/{space_id}/policy -- Reserved / not yet specified. CRUD on the policy object for the Space.
  • GET|PUT|DELETE /space/{space_id}/{collection_id}/policy -- Reserved / not yet specified. CRUD on the policy object for the Collection.
  • GET|PUT|DELETE /space/{space_id}/{collection_id}/{resource_id}/policy -- Reserved / not yet specified. CRUD on the policy object for the Resource.

Linkset / Discovery Endpoints:

Required if Space endpoints or Collection endpoints are supported.

Query Endpoints:

  • POST /space/{space_id}/query -- Cross-collection queries (backend-specific).
  • POST /space/{space_id}/{collection_id}/query -- Queries within a Collection, discriminated by a request-body profile; catalogued in D. Query Profile Registry.

Backend Management Endpoints (see 14. Backends):

Quota Endpoints (see 15. Quotas):

  • GET /space/{space_id}/quotas -- 15. Quotas: the Quota report object, grouped by available Backend (add ?include=collections for a per-collection usage breakdown)
  • GET /space/{space_id}/{collection_id}/quota -- 15. Quotas: the Quota report object for the specific Collection (not all Backends will support per-collection quotas however)

3. Terminology

action (allowedAction)
The kind of operation a request performs on a target, named by a capability so it can be authorized. WAS uses the uppercase HTTP method names (GET, POST, PUT, DELETE) as its action vocabulary. See section 5.1.5 Authorization Actions and the Root Capability.
backend
A storage engine that a collection's resources are physically stored on, registered at the Space level either by server configuration or by client-side "Bring Your Own Storage" registration. Backends are an OPTIONAL concern, orthogonal to the Space > Collection > Resource hierarchy: a Collection names one via its backend property, and is assigned the default backend when it does not. See section 14. Backends.
chunk
One opaque byte sequence of a chunked Resource, addressed by a non-negative integer index under the Resource's reserved chunks sub-path. The server stores a chunk exactly like a binary Resource representation and never parses it; framing and reassembly are the client's concern. Available only on a backend advertising the chunked-streams feature. See section 12. Chunked Resources.
collection
A namespace and configuration container for resources. Conceptually maps to folders (for file system like storage), buckets (for object storage), or database tables (for RDBMSs). See section 10. Collections.
controller
An entity that can make changes to a given object.
decentralized identifier (DID)
See [DID-CORE].
instance, server
A deployed instance of an application or service that implements this specification's API.
policy
A JSON document with a required type property that declares what access a target grants to callers in general, independent of any zCap a caller might present. A policy is stored at the /policy auxiliary resource of a Space, Collection, or Resource. Policies are inherited most-specific-wins (Resource over Collection over Space), can only broaden access (never deny a caller holding a valid capability), and grant nothing when absent or of an unrecognized type. See section 5.1.7 Access Control Policies.
quota
A storage limit enforced per backend, together with the usage measurement of the storage consumed against it. Quota reporting and enforcement are OPTIONAL and backend-dependent: a Space's per-backend report is read from its /quotas auxiliary resource, and a per-Collection breakdown from /quota. A write that would exceed a quota is rejected with quota-exceeded (507); a single upload larger than the backend's maxUploadBytes constraint is rejected with payload-too-large (413). See section 15. Quotas.
root capability
The implied capability for a target whose controller is the Space's controller; it is the root of trust from which all other capabilities for that target are delegated. See section 5.1.5.1 Root Capability.
target (invocationTarget)
The resource a request acts on, including the full request URL (scheme, host, port, and path) -- and the scope a capability authorizes. A capability's invocationTarget MUST match the request target for the invocation to be valid. See section 5.1.5 Authorization Actions and the Root Capability.
zCap (Authorization Capability)
See [zCap Developer Guide](https://interop-alliance.github.io/zcap-developer-guide/) for more details.

4. Identifiers

4.1 Identifier Required Properties

Space, Collection, and Resource identifiers used in this specification are required to have the following properties.

  1. URL-safety - All characters in a given identifier MUST be URL-safe.
  2. Uniqueness - All identifiers MUST be unique within a given container. That is: Space ids (denoted by {space_id} in URL templates) MUST be unique within a given server, Collection ids (denoted by {collection_id} in URL templates) MUST be unique within a given Space, and Resource ids (denoted by {resource_id} in URL templates) MUST be unique within a given Collection.

4.2 Identifier Length and Format

Identifier length limits are currently left to the implementer. However, implementations SHOULD limit identifier length to their appropriate use case.

Identifier format constraints are currently left to the implementer. Common identifier formats include:

5. Authorization

The ability to do cross-domain, operator-independent, standardized cloud storage operations requires an authorization system that is:

As the state of the art in cross-domain authorization advances, we expect there to be multiple profiles and specs that could be used to perform WAS API calls. However, to start with, this specification will focus on a single minimal authorization profile.

5.1 WAS Authorization Profile v0.1

Like many authorization specifications, the WAS Authorization Profile tries to address opposing tensions. On the one hand, to cover the full range of use cases, it needs to be delegatable, revocable, secure, flexible, and thus capability-based. On the other hand, for ease of implementation and adoption, and for maximum developer usability, the profile must make the most common operations as simple and friction-free as possible.

To that end, the profile offers the following layered mechanisms.

  1. Root Access: For basic admin CRUD operations, use the space's controller DID directly to sign API calls with HTTP Signatures.
  2. Public Read: For the common "public read" use case (the typical web publishing workflow, where a site or a file is shared for anyone to access via an HTTP GET), use the simple { "type": "PublicCanRead" } WAS Authorization syntax, see below.
  3. Advanced Delegatable Capabilities ("anyone with the link..." style): Use zCaps Authorization Capabilities v0.3
  4. Policy Based Access Control (including the familiar "share with this list of people or groups" style): Use the space's linkset property to point to a linkset that includes a URL to an access control policy document.

5.1.1 Authorization Specification Dependencies at a Glance

The initial WAS Authorization Profile uses the following specifications.

  1. Identity (for controllers or clients/agents): DID 1.0
  2. Capability data model: Authorization Capabilities for Linked Data v0.3
  3. Protocol for getting authorization: Out of scope (implementers are encouraged to use VC-API, OpenId4VP, OAuth2, or GNAP, as appropriate)
  4. Proof of Possession / authorization invocation: HTTP Signatures. MUST - RFC 9421 HTTP Message Signatures, MAY - HTTP Signatures (Cavage draft 12)
  5. Request body integrity: the Digest header, bound to the request signature -- see 5.1.4 Request Body Integrity (Digest Header)
  6. Access Control / Policy language data model: see 5.1.7 Access Control Policies (PublicCanRead is the only normative type for v0.1)

5.1.2 Space controller and the Root of Trust

Conceptually, the space's controller serves as the root of trust and authorization for any operations on the space or its collections or resources. That is, any operation requiring an authorization MUST provide a chain of proof all the way to the space controller, by one of the following:

  1. Direct: Provide a root capability invoked directly by the controller, or
  2. Delegated: Invoke a capability delegated to some other agent by the controller, or
  3. Matching Policy: (if using any kind of access control policy mechanism) Match an authorization policy specified in the linkset property of the space. This resource is related to the space controller because initially, it can only be modified either by the controller or an authorized party delegated to by the controller.

Space controllers MUST be in the form of a DID.

For minimal compatibility, all WAS implementations MUST support the did:key DID Method, using the Multikey encoding of Ed25519 elliptic curve keys, as specified in the Multikey section of the CID spec as the space controller.

When a space is created via an HTTP POST or PUT operation (see 8.1.1 (HTTP API) POST /spaces/ and 9.3.1 (HTTP API) PUT /space/{space_id}), the controller for that space is set explicitly. That is, a client specifies the controller as part of the payload of the PUT or POST create space request, and the server MUST verify that the invocation is authorized by that controller, by one of the first two mechanisms above: either directly -- the signing key (key ID) used in the headers is authorized in the capabilityInvocation section of the controller's DID document -- or via a capability delegated by the controller to the signing DID. (The third mechanism, matching policy, does not apply: no Space, and therefore no policy, exists yet.)

See 8.1.1 (HTTP API) POST /spaces/ below for examples of controller determination and verification.

5.1.3 Performing Authorized API Calls

Unless otherwise explicitly allowed via access control policy (see below), all WAS API calls require authorization.

This can be done in one of two ways:

  1. (for admin-like root access) Use the controller DID directly to sign HTTP API requests using the HTTP Signatures specification, invoking the target's root capability.
  2. (for advanced delegatable use cases) Use HTTP Signatures in combination with Authorization Capabilities v0.3, and include a capability invocation header in the API request.

Throughout this specification, the request examples abbreviate this header as a placeholder, Authorization: ..., rather than reproducing a full signature or capability invocation. In each case it stands for a credential constructed as described in 5.1.3 Performing Authorized API Calls.

5.1.4 Request Body Integrity (Digest Header)

When an authorized request carries a body (a Resource write, a Space create, and so on), this profile binds the body to the request's HTTP Signature so that the payload cannot be substituted without invalidating the signature:

  1. The client MUST include a Digest header whose value is the hash of the request body, carried as a mh (multihash) parameter: a multibase base64url-encoded (u prefix) multihash of the body's SHA-256 digest (Multihash and Multibase as defined in the CID 1.0 specification). For example:

    Digest: mh=uEiCPO-qYr-z0GYV5F75-N1l8Rhjv4xIkKZsnbTZeZ7emSA
    
  2. The content-type and digest headers MUST be included in the signature's covered (signed) headers list, alongside (key-id), (created), (expires), (request-target), host, and capability-invocation.

  3. For any request that carries a Content-Type header, the server MUST require digest among the covered headers, and SHOULD independently recompute the digest of the received body and compare it to the Digest header value. A missing, malformed, or non-matching Digest on a request with a body is rejected with an invalid-authorization-header (400) error.

Bodyless requests (GET, HEAD, DELETE) carry no Digest header.

Example authorized write request, showing the Digest header and the covered headers list (the space's controller invoking the root capability for the target; line breaks within the Authorization header are for display only):

PUT /space/81246131-69a4-45ab-9bff-9c946b59cf2e/photos/sunset.png HTTP/1.1
Host: example.com
Content-Type: image/png
Digest: mh=uEiCPO-qYr-z0GYV5F75-N1l8Rhjv4xIkKZsnbTZeZ7emSA
Capability-Invocation: zcap id="urn:zcap:root:https%3A%2F%2Fexample.com%2Fspace%2F81246131-69a4-45ab-9bff-9c946b59cf2e%2Fphotos%2Fsunset.png",action="PUT"
Authorization: Signature keyId="did:key:z6MkpBMbMaRSv5nsgifRAwEKvHHoiKDMhiAHShTFNmkJNdVW#z6MkpBMbMaRSv5nsgifRAwEKvHHoiKDMhiAHShTFNmkJNdVW",
  headers="(key-id) (created) (expires) (request-target) host capability-invocation content-type digest",
  signature="6GoRQ+rW69wBhNyERkafAXEZXZArezHvGRNUWC0HNI4Ss1xAiiMHdayS5aA2R6hLuYRNw6h9J9eCmQVMuHE1Bw==",
  created="1758150502",expires="1758151102"

...binary PNG bytes...
Editor's note
**Digest vs Content-Digest.** The Digest header used by this profile descends from [RFC3230] (Instance Digests in HTTP). [RFC9530] (Digest Fields) obsoletes RFC 3230 and replaces Digest with Content-Digest / Repr-Digest. The current WAS implementation stack uses the legacy header with a multihash value; migration to Content-Digest (alongside the move to [RFC9421] HTTP Message Signatures) is a future direction for this profile.

5.1.5 Authorization Actions and the Root Capability

A capability invocation names an action that the invoked capability must permit. WAS uses the uppercase HTTP method names as its action vocabulary:

  • GET -- read a Space, Collection, or Resource. A HEAD request is authorized as a GET.
  • POST -- create a child item in a container (add a Resource to a Collection, a Collection to a Space, or a Space to the Spaces Repository).
  • PUT -- create-by-id or replace a Space, Collection, or Resource.
  • DELETE -- delete a Space, Collection, or Resource.

A request is authorized by a capability when all the following hold:

  1. the capability's invocationTarget matches the request's target -- the full request URL (scheme, host, port, and path);
  2. the capability's allowedAction includes the request's action (the HTTP method); and
  3. the invocation is signed by a key the capability authorizes, carried as a valid HTTP Signature over the request (see 5.1.3 Performing Authorized API Calls).
5.1.5.1 Root Capability

Every target has an implied root capability whose controller is the Space's controller. It is identified by the URI urn:zcap:root: followed by the percent-encoded target URL:

{
  "@context": "https://w3id.org/zcap/v1",
  "id": "urn:zcap:root:https%3A%2F%2Fexample.com%2Fspace%2F81246131-69a4-45ab-9bff-9c946b59cf2e%2Fmessages%2Fhello-world",
  "invocationTarget": "https://example.com/space/81246131-69a4-45ab-9bff-9c946b59cf2e/messages/hello-world",
  "controller": "did:key:z6MkpBMbMaRSv5nsgifRAwEKvHHoiKDMhiAHShTFNmkJNdVW"
}

The Space controller MAY invoke the root capability directly -- signing the request with a key listed in the capabilityInvocation section of the controller's DID document -- to perform any operation. This is the "root access" path. All other authorized access derives from a capability delegated, directly or transitively, from this root.

5.1.5.2 Delegation

To grant another agent access, the controller (or any agent holding a sufficiently broad capability) delegates a capability that names the grantee as its new controller, the invocationTarget to scope it to, and the allowedActions to permit. A delegation MAY set an expires time. For example, granting another DID read-only access to a single Collection:

{
  "@context": "https://w3id.org/zcap/v1",
  "id": "urn:uuid:6c9f3a1e-2b4d-4f8a-9c1e-7d2b3a4c5e6f",
  "parentCapability": "urn:zcap:root:https%3A%2F%2Fexample.com%2Fspace%2F81246131-69a4-45ab-9bff-9c946b59cf2e%2Fmessages%2F",
  "invocationTarget": "https://example.com/space/81246131-69a4-45ab-9bff-9c946b59cf2e/messages/",
  "controller": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
  "allowedAction": ["GET"],
  "expires": "2026-12-31T23:59:59Z",
  "proof": { "...": "delegation proof signed by the parent capability's controller" }
}

The delegated capability is handed to the recipient out of band. The recipient invokes it by signing a request with their own key and including the capability in the Capability-Invocation header.

Editor's note
**Revocation.** This specification does not yet define a revocation operation, although its goals require that a grant can be withdrawn before it expires. The current WAS implementation stack ships a Space-scoped revocation endpoint (POST /space/{space_id}/zcaps/revocations/{revocation_id}; every Space-rooted capability verification checks the presented delegation chain against the recorded revocations). A future revision will specify the operation and reserve its path segments.

5.1.7 Access Control Policies

Capabilities answer the question "does the caller hold a credential that grants this action?" Access control policies answer the complementary question "does this target grant this action to callers in general (or to a named set of principals)?" Policies are how a controller makes a target public-readable (or, in future profiles, shares it with a list of people or groups) without having to issue a capability to each caller.

A policy is a JSON document with a required type property, stored at the /policy auxiliary resource of a Space, Collection, or Resource and discoverable via the policy relation in the linkset (see 13.1 Space Linkset and 13.2 Collection Linkset).

Evaluation contract:

  • Capability first, policy second. A request is first checked against any capability invocation it carries. The effective policy is consulted only as a fallback, and it can only broaden access -- a policy never denies a caller who presents a valid capability.
  • Fail-closed. An absent policy, or a policy whose type an implementation does not recognize, grants nothing.
  • Most-specific-wins inheritance. The effective policy for a target is the one set at the most specific level that has a policy document: a Resource policy overrides a Collection policy, which overrides a Space policy.
  • Access kind. For policy evaluation, the request action is reduced to a coarse access kind: GET (and HEAD) is a read; POST, PUT, and DELETE are a write.
5.1.7.1 PublicCanRead

For v0.1, the only normative policy type is PublicCanRead:

{ "type": "PublicCanRead" }

It grants the read access kind to any caller (including unauthenticated ones) and grants no write access. This is the canonical "public read" pattern -- for example, hosting an HTML file or an image that anyone may GET, while writes still require a capability. Setting it on a Space makes the whole Space public-readable (subject to any more specific Collection or Resource policy); setting it on a single Resource exposes only that Resource.

6. Error Handling

This specification uses [RFC9457] Problem Details for HTTP APIs for error responses.

The type property is a URI identifying the kind of problem (not the operation), and the same type is reused across operations. See Appendix F. Error Type Registry for the catalog of type URIs this specification defines, along with their typical status codes and a canonical example response for each kind.

When returning errors, keep in mind the principle of maximum privacy: always "not found" instead of "not authorized". The existence of a resource, collection, or space, is by itself an item of sensitive information. If a client makes an API call, and they have insufficient authorization to perform that action, a "not found" error (such as HTTP 404) MUST be returned, just as if that resource (or space or collection) did not exist. To put it another way, an unauthorized client (meaning, either not carrying any authorization in the request itself, or possessing insufficient permissions) MUST NOT be able to discover the existence of a resource based on the error response.

This privacy rule governs failures that would otherwise reveal whether a target exists. It does not require masking failures that describe the request itself:

"List Spaces" operations are an exception to 404 masking: rather than returning an error, they return 200 OK with only the subset of items the caller is authorized to see (an empty items array if none) -- see 8.2 List Spaces Operation.

7. Common Behaviors

This section defines HTTP mechanics that are shared across the operations in this specification, rather than belonging to any single endpoint: caching, conditional requests (optimistic concurrency control), and pagination of list responses.

7.1 Caching

WAS relies on ordinary [RFC9111] HTTP caching and defines no caching layer of its own. On the read side, a Resource's strong ETag validator (see 7.2 Conditional Requests) drives standard validation: a GET carrying If-None-Match: "<etag>" yields 304 Not Modified when the Resource is unchanged. Servers SHOULD emit ETag (and MAY emit Last-Modified) on GET/HEAD responses, and SHOULD mark responses to non-idempotent operations as non-cacheable (for example, Cache-Control: no-store).

Editor's note
Freshness lifetime and Cache-Control directive semantics beyond validation are not yet specified.

7.2 Conditional Requests

Servers and backends MAY support [RFC9110] conditional requests to provide optimistic concurrency control on writes. This mechanism helps prevent the "lost update" problem, where two clients that both read version N of a Resource each write version N+1 and the second silently clobbers the first. A backend that supports this advertises the conditional-writes feature in its Backend description (see 14.1 Backend Data Model); a client SHOULD use these preconditions only against a backend that advertises support.

When supported, a Resource carries a strong ETag validator that changes whenever its stored content changes. Servers SHOULD return the ETag on GET/HEAD responses. The validator is opaque to clients: how a backend derives it is a server-side concern (see 14.1 Backend Data Model).

A state-changing request (PUT or DELETE) MAY carry a precondition:

A server that supports conditional writes MUST evaluate the precondition atomically with the write, so that two concurrent writers cannot both observe the same prior version and both succeed; how a backend achieves this atomicity is a server-side concern (see 14.1 Backend Data Model). A client recovers from a 412 by re-reading the current Resource, re-applying its change on top of the new version, and retrying.

As with id-conflict, a server MUST verify the caller's authorization before evaluating a precondition, so a 412 is only ever observed by a caller already authorized to write the target; an under-authorized caller receives the merged not-found (404) instead, per the maximum-privacy rule in 6. Error Handling.

The mechanism extends to the Collection Description itself, whose update operation replaces the whole description -- so two concurrent recipient changes to a key-epoch roster (see C.4 Key Epochs) would otherwise silently clobber one another:

Editor's note
Authorization for recipient changes is the plain Collection-update capability in this version. A dedicated action for recipient management (a policy operation, distinct from content writes) remains an open question.

The Collection Metadata object carries a validator of its own on the same pattern, mutually independent of both the Collection Description's and every Resource's: its metaVersion changes only on Collection metadata writes, never on a Collection Description update or a Resource write (and vice versa). Like the Collection Description validator, it is not gated on the conditional-writes backend feature: a server implementing the Collection metadata endpoints MUST maintain it and MUST support If-Match and If-None-Match: * on the 10.9 Update Collection Metadata Operation, evaluated atomically with the write. A Metadata object that has never been written has no validator, and If-None-Match: * succeeds exactly then.

A 412 arises only from an explicit If-Match / If-None-Match precondition header. It is deliberately distinct from the header-less 409 conflict kinds -- id-conflict (a POST create whose chosen id is already taken) and reserved-id -- which describe a conflict with current state where the client stated no precondition. Conditional requests are the versioned, header-driven concurrency mechanism; the 409 kinds are not.

7.3 Paginated List Responses

The list operations -- 8.2 List Spaces Operation, 9.5 List All Collections operation, and 10.5 List Collection operation -- MAY paginate their responses, returning one page of items at a time using the cursor-based profile defined in Appendix A. Pagination. Pagination is OPTIONAL: a server that returns every item in a single response is conformant, and a client MUST be prepared for either behavior.

8. Spaces Repositories

A Spaces Repository is a set of API endpoints that supports the creation and management of multiple spaces on a given server. This /spaces/ set of API endpoints is optional. If a server does not support this feature (for example, if it is a single-tenant server with an existing hardcoded Space), then it can implement only the /space/{space_id}/ endpoints and get most of the functionality of this specification.

Editor's note
The Spaces Repository endpoints are a candidate for extraction into a standalone companion specification (multi-tenant space provisioning) in a future version of this document.

8.1 Create Space operation

To create a Space:

8.1.1 (HTTP API) POST /spaces/

To create a space via HTTP API using the Spaces Repository POST API:

POST /spaces/ HTTP/1.1
Host: example.com
Accept: application/json
Content-type: application/json
Authorization: ...

{
  "type": ["Space"],
  "name": "Example space #1",
  "controller": "did:key:z6MkpBMbMaRSv5nsgifRAwEKvHHoiKDMhiAHShTFNmkJNdVW"
}

Example success response:

HTTP/1.1 201 Created
Content-type: application/json
Location: https://example.com/space/81246131-69a4-45ab-9bff-9c946b59cf2e

{
  "id": "81246131-69a4-45ab-9bff-9c946b59cf2e",
  "type": ["Space"],
  "name": "Example space #1",
  "controller": "did:key:z6MkpBMbMaRSv5nsgifRAwEKvHHoiKDMhiAHShTFNmkJNdVW"
}

Note that in the example above:

  • the id was not specified in the body of the request, and so was generated by the server and returned in the response

8.1.2 Create Space Errors

Errors (see F. Error Type Registry for canonical examples):

  • invalid-request-body (400) -- the required controller property is missing from the request body.
  • missing-authorization (401) -- the request lacks a valid proof of possession of the controller DID.
  • controller-mismatch (400) -- the invocation is not currently authorized by the controller DID in the request body: it is neither signed by that DID nor accompanied by a valid, unexpired delegation chain rooted in it. A chain rooted in a different DID, an expired delegation, and a chain whose proof fails verification all fall under this one type. Servers are not required to distinguish these (delegation-chain verifiers often report failure opaquely), but SHOULD differentiate the cause in the non-normative detail string where they can. In a delegated provisioning flow, the cause determines who must act (the user re-delegates an expired capability; the service corrects a request whose chain is rooted in the wrong DID).
  • invalid-id (400) -- the supplied Space id is not URL-safe (see 4. Identifiers).
  • id-conflict (409) -- a Space with the supplied id already exists.

Onboarding requirements, if any, are provider-specific and out of scope for this specification (see the optional onboarding material above). Their error type, title, and status code are likewise provider-defined.

A POST /spaces/ that supplies an id already in use returns the id-conflict error. To create or replace a Space at a client-chosen id without conflict, use the idempotent 9.3 Update (or Create by Id) Space operation instead.

The existence check behind id-conflict is security-critical here, not merely a conformance detail. Create Space is the one operation whose capability invocation is verified against the controller supplied in the request body -- the Space does not exist yet, so there is no stored controller to verify against. A server that instead treats POST /spaces/ as create-or-replace turns this operation into a takeover: any caller able to construct a valid invocation for their own controller could overwrite an existing Space's description (controller included) simply by POSTing its id. Servers MUST check for an existing Space with the supplied id, and reject with id-conflict, before writing anything.

Note
The id-conflict response on this operation necessarily reveals whether a Space id is taken: any caller permitted to attempt creation can distinguish 409 from 201 -- just as they could by observing whether creation succeeds. This disclosure is inherent to client-chosen ids on a create endpoint, and is not a violation of the maximum-privacy principle of 6. Error Handling, which governs errors about *existing* targets the caller is not authorized for. Unguessable (for example, UUID) Space ids keep the signal worthless to an attacker, and providers that gate Space creation behind onboarding requirements also bound who can observe it.
Note
Differentiated detail strings on controller-mismatch are likewise privacy-safe: Create Space has no existing target to protect, and everything its verification examines (the body's controller, the capability chain, its signatures) is supplied by the caller, so failure granularity reveals nothing the caller does not already hold. This reasoning does *not* extend to failure causes that depend on server-side state (for example, capability revocation status or per-controller onboarding allowances); whether and how to disclose those remains provider-defined.

8.2 List Spaces Operation

8.2.1 (HTTP API) GET /spaces/

Example request:

GET /spaces/ HTTP/1.1
Host: example.com
Accept: application/json
Authorization: ...

Example success response (requester has read access to at least one space):

HTTP/1.1 200 OK
Content-type: application/json

{
  "url": "/spaces/",
  "totalItems": 1,
  "items": [
    {
      "id": "81246131-69a4-45ab-9bff-9c946b59cf2e",
      "url": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e"
    }
  ]
}

Example success response (requester does NOT have access to any spaces):

HTTP/1.1 200 OK
Content-type: application/json

{
  "url": "/spaces/",
  "totalItems": 0,
  "items": []
}

A request that carries no authorization (or authorization for no spaces) is not an error: like any list operation it returns the 200 OK empty-list response shown above, revealing nothing about which spaces exist (see 6. Error Handling).

9. Spaces

A space is a namespace for collections and a unit of general configuration, a volume of storage that contains one or more collections. Conceptually, is maps to a disk partition (for file systems), or a database (for relational databases).

9.1 Space Data Model

Space properties:

Space properties automatically added by the server:

9.2 Read Space operation

The format of the response is determined based on content negotiation; application/json is the REQUIRED baseline (see 11.3 Content Types and Representations).

9.2.1 (HTTP API) GET /space/{space_id}

Example request:

GET /space/81246131-69a4-45ab-9bff-9c946b59cf2e HTTP/1.1
Host: example.com
Accept: application/json
Authorization: ...

Example success response:

HTTP/1.1 200 OK
Content-type: application/json

{
  "id": "81246131-69a4-45ab-9bff-9c946b59cf2e",
  "url": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e",
  "type": ["Space"],
  "name": "Example space #1",
  "controller": "did:key:z6MkpBMbMaRSv5nsgifRAwEKvHHoiKDMhiAHShTFNmkJNdVW",
  "createdBy": "did:key:z6MkpBMbMaRSv5nsgifRAwEKvHHoiKDMhiAHShTFNmkJNdVW",
  "linkset": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e/linkset"
}

9.2.2 Read Space Errors

Errors (see F. Error Type Registry for canonical examples):

  • not-found (404) -- the Space does not exist, or the caller has missing or insufficient authorization. A server MUST return the same error response in both cases, per 6. Error Handling.

9.3 Update (or Create by Id) Space operation

When creating or modifying a Space via PUT, the client specifies the id of the Space.

The controller property in the request body is the proposed value, not the root of trust for the request. When the Space already exists, the capability invocation MUST be verified against the Space's currently stored controller: only the current controller (or its delegate) may update the Space, including transferring it by writing a new controller. The body's controller participates in verification only when the PUT creates the Space -- there is no stored controller yet, so, as with 8.1 Create Space operation, the invocation MUST be authorized by the body's controller: signed by it directly, or presented with a valid, unexpired delegation chain rooted in it (violations are controller-mismatch, as for POST). A server that verifies an update against the body's controller reopens the takeover described under 8.1.2 Create Space Errors: any caller could seize an existing Space by PUTting its id with themselves as the controller.

A server that records createdBy (see 9.1 Space Data Model) sets it only on the PUT that creates the Space, to the did of the party that invoked the capability -- which is not necessarily the body's controller. On a PUT that updates an existing Space, the stored createdBy is preserved, so transferring the Space by writing a new controller does not rewrite its creator.

9.3.1 (HTTP API) PUT /space/{space_id}

Note that this is a full update (partial updates via http PATCH verb might be supported later). However, some fields cannot be updated (like id) and so may be omitted from the request payload.

Note that this operation is idempotent.

  • When creating a space via PUT, a controller property is required in the PUT request body.

Example request (creating a new space via PUT), note the lack of trailing slash:

PUT /space/81246131-69a4-45ab-9bff-9c946b59cf2e HTTP/1.1
Host: example.com
Accept: application/json
Content-type: application/json
Authorization: ...

{
  "id": "81246131-69a4-45ab-9bff-9c946b59cf2e",
  "type": ["Space"],
  "name": "Example space #1",
  "controller": "did:key:z6MkpBMbMaRSv5nsgifRAwEKvHHoiKDMhiAHShTFNmkJNdVW"
}

Example success response:

HTTP/1.1 201 Created
Content-type: application/json
Location: https://example.com/space/81246131-69a4-45ab-9bff-9c946b59cf2e

Example request (updating the name property of a space). Note that server-managed properties such as url and linkset are not client-writable and are omitted from the request body:

PUT /space/81246131-69a4-45ab-9bff-9c946b59cf2e HTTP/1.1
Host: example.com
Content-type: application/json
Accept: application/json
Authorization: ...

{
  "id": "81246131-69a4-45ab-9bff-9c946b59cf2e",
  "type": ["Space"],
  "name": "Newly renamed space #1",
  "controller": "did:key:z6MkpBMbMaRSv5nsgifRAwEKvHHoiKDMhiAHShTFNmkJNdVW"
}

Example success response:

HTTP/1.1 204 No Content

Errors (see F. Error Type Registry for canonical examples):

  • not-found (404) -- the Space does not exist, or the caller has missing or insufficient authorization; per 6. Error Handling the two are indistinguishable.
  • invalid-request-body (400) -- the client is attempting to change an immutable field, such as setting a body id that does not match the {space_id} in the request URL.

9.4 Delete Space operation

9.4.1 (HTTP API) DELETE /space/{space_id}

Example request (no request body):

DELETE /space/81246131-69a4-45ab-9bff-9c946b59cf2e HTTP/1.1
Host: example.com
Accept: application/json
Authorization: ...

Example success response:

HTTP/1.1 204 No Content

Errors (see F. Error Type Registry for canonical examples):

  • not-found (404) -- the caller has missing or insufficient authorization. Because DELETE is idempotent, an authorized request for an already-absent Space returns 204; an under-authorized request returns 404 instead, per 6. Error Handling, so that an unauthorized caller cannot probe for existence.
  • invalid-id (400) -- the supplied Space id is not URL-safe.

9.5 List All Collections operation

9.5.1 (HTTP API) GET /space/{space_id}/collections/

Example request (note the trailing slash):

GET /space/81246131-69a4-45ab-9bff-9c946b59cf2e/collections/ HTTP/1.1
Host: example.com
Accept: application/json
Authorization: ...

Example response:

HTTP/1.1 200 OK
Content-type: application/json

{
  "url": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e/collections/",
  "totalItems": 2,
  "items": [
    {
      "id": "example",
      "url": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e/example",
      "name": "Example Collection",
      "public": false
    },
    {
      "id": "73WakrfVbNJBaAmhQtEeDv",
      "url": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e/73WakrfVbNJBaAmhQtEeDv",
      "name": "73WakrfVbNJBaAmhQtEeDv",
      "public": true
    }
  ]
}

Errors (see F. Error Type Registry for canonical examples):

  • not-found (404) -- the Space does not exist, or the caller has missing or insufficient authorization; per 6. Error Handling a Space the caller is not authorized to read is indistinguishable from one that does not exist.

10. Collections

A collection is a namespace for Resources, and a unit of configuration, within a space.

In other storage systems, the concept of collections has many different names. For example, Directory, Folder, RDBMS Table, Document Collection, Graph, WebAPI FileList, Bucket, LDP Basic Container, EDV Vault, and so on.

Collections do not contain other collections (this specification adopts a flat collection structure).

Note
Rationale: Nested collections (such as those used by Solid/LDP/LWS) encode query relationships into URL hierarchy, which creates significant complexity (recursive permissions, recursive deletes, path traversal, depth-limited listing) and maps poorly to flat backends like RDBMS tables or S3 buckets. The use cases that motivate nesting (e.g., "all comments on a post") are better served by the query endpoint with field-based filtering.

10.1 Collection Data Model

Collection properties (user-writable):

Collection properties automatically added by the server:

Each Collection also has an associated Metadata object, distinct from this Collection Description -- server-managed properties (optional timestamps and createdBy) plus user-writable ones (name and tags, nested under custom) -- addressable at the reserved meta path segment under the Collection URL. See 10.7 Collection Metadata Data Model.

Example collection (JSON representation):

{
  "id": "73WakrfVbNJBaAmhQtEeDv",
  "url": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e/73WakrfVbNJBaAmhQtEeDv",
  "type": ["Collection"],
  "name": "Verifiable Credentials Collection",
  "createdBy": "did:key:z6MkpBMbMaRSv5nsgifRAwEKvHHoiKDMhiAHShTFNmkJNdVW",
  "generator": "did:key:z6MkfriqYRX3JBqzsbVbKuBUxDR2nsjLTu6AbrxJZAmFmXWb",
  "generatorOrigin": "https://app.example.com",
  "linkset": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e/73WakrfVbNJBaAmhQtEeDv/linkset"
}

10.2 Create Collection (Add Collection to a Space) operation

When a Collection is created via a POST, the client can specify the id of the Collection. If the id is not specified, one is auto-generated by the server and returned as part of the Location response header.

10.2.1 (HTTP API) POST /space/{space_id}/collections/

Example request (id not specified, auto-generated by the server and returned in the response Location header):

POST /space/81246131-69a4-45ab-9bff-9c946b59cf2e/collections/ HTTP/1.1
Host: example.com
Content-Type: application/json
Authorization: ...

{
  "name": "Verifiable Credentials Collection",
  "type": ["Collection"]
}

In this example, the id in the body is not specified, and will be auto-generated by the server. The backend is not specified, and will be assigned the default value.

Example response:

HTTP/1.1 201 Created
Content-type: application/json
Location: https://example.com/space/81246131-69a4-45ab-9bff-9c946b59cf2e/21f81693-f4a3-4caa-b81c-b663d6e1e3ae

Example request, a valid id specified in the body:

POST /space/81246131-69a4-45ab-9bff-9c946b59cf2e/collections/ HTTP/1.1
Host: example.com
Content-Type: application/json
Authorization: ...

{
  "id": "credentials",
  "name": "Verifiable Credentials Collection",
  "type": ["Collection"],
  "backend": { "id": "default" }
}

Example response:

HTTP/1.1 201 Created
Content-type: application/json
Location: https://example.com/space/81246131-69a4-45ab-9bff-9c946b59cf2e/credentials

Errors (see F. Error Type Registry for canonical examples):

10.3 Update (or Create By Id) Collection operation

When creating or modifying a Collection via PUT, the client specifies the id of the Collection. This Collection id MUST NOT collide with the list of B.1 Space-level reserved endpoints.

10.3.1 (HTTP API) PUT /space/{space_id}/{collection_id}

Example successful "create" request (note the lack of trailing slash):

PUT /space/81246131-69a4-45ab-9bff-9c946b59cf2e/73WakrfVbNJBaAmhQtEeDv HTTP/1.1
Host: example.com
Content-Type: application/json
Authorization: ...

{
  "id": "73WakrfVbNJBaAmhQtEeDv",
  "name": "Verifiable Credentials Collection",
  "type": ["Collection"],
  "backend": { "id": "default" }
}
HTTP/1.1 201 Created

Errors (see F. Error Type Registry for canonical examples):

10.4 Get Collection Description operation

10.4.1 (HTTP API) GET /space/{space_id}/{collection_id}

Example request (note: no trailing slash):

GET /space/81246131-69a4-45ab-9bff-9c946b59cf2e/73WakrfVbNJBaAmhQtEeDv HTTP/1.1
Host: example.com
Accept: application/json
Authorization: ...

Example response:

HTTP/1.1 200 OK
Content-type: application/json

{
  "id": "73WakrfVbNJBaAmhQtEeDv",
  "url": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e/73WakrfVbNJBaAmhQtEeDv",
  "name": "Verifiable Credentials Collection",
  "type": ["Collection"],
  "linkset": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e/73WakrfVbNJBaAmhQtEeDv/linkset",
  "backend": { "id": "default" }
}

Errors (see F. Error Type Registry for canonical examples):

  • not-found (404) -- the Collection does not exist, or the caller has missing or insufficient authorization; per 6. Error Handling a Collection the caller is not authorized to read is indistinguishable from one that does not exist.

10.5 List Collection operation

10.5.1 (HTTP API) GET /space/{space_id}/{collection_id}/

Example request (note the trailing slash):

GET /space/81246131-69a4-45ab-9bff-9c946b59cf2e/73WakrfVbNJBaAmhQtEeDv/ HTTP/1.1
Host: example.com
Accept: application/json
Authorization: ...

Example response:

HTTP/1.1 200 OK
Content-type: application/json

{
  "id": "73WakrfVbNJBaAmhQtEeDv",
  "url": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e/73WakrfVbNJBaAmhQtEeDv",
  "name": "Example JSON Documents Collection",
  "type": ["Collection"],
  "totalItems": 2,
  "items": [
    {
      "id": "321efd4e-23cb-497c-aaee-7bd26e66d39e",
      "url": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e/73WakrfVbNJBaAmhQtEeDv/321efd4e-23cb-497c-aaee-7bd26e66d39e",
      "contentType": "application/json"
    },
    {
      "id": "3943c87f-b617-44bc-ba75-8de2b16c3640",
      "url": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e/73WakrfVbNJBaAmhQtEeDv/3943c87f-b617-44bc-ba75-8de2b16c3640",
      "contentType": "application/json"
    }
  ]
}
10.5.1.1 Paginated example

A client requests a bounded page with limit (here, two items from a Collection that holds more):

GET /space/81246131-69a4-45ab-9bff-9c946b59cf2e/73WakrfVbNJBaAmhQtEeDv/?limit=2 HTTP/1.1
Host: example.com
Accept: application/json
Authorization: ...

The response carries the page of items plus a next link, signalling that more items follow. totalItems is omitted here -- the server does not return a total for this (potentially large) Collection:

HTTP/1.1 200 OK
Content-type: application/json

{
  "id": "73WakrfVbNJBaAmhQtEeDv",
  "url": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e/73WakrfVbNJBaAmhQtEeDv",
  "name": "Example JSON Documents Collection",
  "type": ["Collection"],
  "items": [
    {
      "id": "321efd4e-23cb-497c-aaee-7bd26e66d39e",
      "url": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e/73WakrfVbNJBaAmhQtEeDv/321efd4e-23cb-497c-aaee-7bd26e66d39e",
      "contentType": "application/json"
    },
    {
      "id": "3943c87f-b617-44bc-ba75-8de2b16c3640",
      "url": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e/73WakrfVbNJBaAmhQtEeDv/3943c87f-b617-44bc-ba75-8de2b16c3640",
      "contentType": "application/json"
    }
  ],
  "next": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e/73WakrfVbNJBaAmhQtEeDv/?limit=2&cursor=eyJhZnRlciI6IjM5NDNjODdmLWI2MTctNDRiYy1iYTc1LThkZTJiMTZjMzY0MCJ9"
}

The client follows next verbatim to fetch the subsequent page (the cursor is opaque and supplied by the server -- the client does not build it):

GET /space/81246131-69a4-45ab-9bff-9c946b59cf2e/73WakrfVbNJBaAmhQtEeDv/?limit=2&cursor=eyJhZnRlciI6IjM5NDNjODdmLWI2MTctNDRiYy1iYTc1LThkZTJiMTZjMzY0MCJ9 HTTP/1.1
Host: example.com
Accept: application/json
Authorization: ...

The final page omits next, marking the end of the list:

HTTP/1.1 200 OK
Content-type: application/json

{
  "id": "73WakrfVbNJBaAmhQtEeDv",
  "url": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e/73WakrfVbNJBaAmhQtEeDv",
  "name": "Example JSON Documents Collection",
  "type": ["Collection"],
  "items": [
    {
      "id": "9c7b2e51-0d4a-4c2f-8b3e-1f6a5d8e7c90",
      "url": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e/73WakrfVbNJBaAmhQtEeDv/9c7b2e51-0d4a-4c2f-8b3e-1f6a5d8e7c90",
      "contentType": "application/json"
    }
  ]
}

Where a Resource carries a key-epoch stamp (see C.4 Key Epochs), its item summary additionally carries it as epoch (optional), mirroring the Resource Metadata property -- so a reader walking a listing can select its epoch key without a per-Resource metadata fetch. A writer-attribution label (see 11.8.1 Writer attribution: writerId and createdBy) is likewise carried as writerId (optional), mirroring the Resource Metadata property.

On a Collection that declares an encryption descriptor (see C. Encryption Scheme Registry), each item's user-writable metadata is stored encrypted (see 11.8 Resource Metadata Data Model). Item summaries for an encrypted Collection therefore carry only server-visible fields (id, url, contentType, and, where declared, epoch and writerId) and omit name; a client that needs names decrypts each Resource's Metadata itself.

Errors (see F. Error Type Registry for canonical examples):

  • not-found (404) -- the Collection does not exist, or the caller has missing or insufficient authorization; per 6. Error Handling a Collection the caller is not authorized to read is indistinguishable from one that does not exist.

10.6 Delete Collection operation

10.6.1 (HTTP API) DELETE /space/{space_id}/{collection_id}

  • Requires appropriate authorization

    • For example, when using zCaps for authorization, the request must either: be signed by the resource's or the space's controller, or invoke a delegated capability that allows the DELETE action.
  • This operation is idempotent

  • (Assuming the request carries appropriate authorization) Sending a DELETE request to a collection that does not exist (or has already been deleted) results in a 204 success response

Example request (note the lack of trailing slash):

DELETE /space/81246131-69a4-45ab-9bff-9c946b59cf2e/73WakrfVbNJBaAmhQtEeDv HTTP/1.1
Host: example.com
Authorization: ...

Example success response:

HTTP/1.1 204 No Content

10.7 Collection Metadata Data Model

Each Collection has an associated Metadata object of its own, addressable at the reserved meta path segment under the Collection URL (one of the B.2 Collection-level reserved endpoints). Because that segment occupies the {resource_id} position, meta is a reserved Resource id: creating a Resource with the id meta is a reserved-id conflict (see B. Reserved Path Segment Registry). A GET or PUT at this path is therefore always the Collection metadata operation, never a Resource operation; the reserved-id rejection arises where a Resource id is supplied explicitly, as in a POST body's id. Methods this specification does not define at this path (DELETE in particular) are not Resource operations either: a server MAY answer them with 405 Method Not Allowed, or -- treating the request as a Resource operation on a reserved id -- with a reserved-id (409) error, but MUST NOT let them act on a stored Resource. The Collection metadata endpoints are OPTIONAL on the same terms as the Resource-level ones; a server that does not implement them SHOULD return an unsupported-operation (501) error for requests to these paths.

The object mirrors the 11.8 Resource Metadata Data Model: server-managed properties at the top level, user-writable properties nested under custom, and epoch as a writable sibling of custom. The differences follow from what a Collection is -- a container, not a document. There are no contentType and size properties, because those describe a stored representation and a Collection has none.

Server-managed properties (all OPTIONAL):

User-writable properties:

There is no writerId member: Collection metadata is configuration maintained by the Space controller, not replicated per-revision content, so the attribution machinery of 11.8.1 Writer attribution: writerId and createdBy does not apply at this level.

On a Collection that declares an encryption descriptor (see C. Encryption Scheme Registry), custom is stored encrypted, exactly as at Resource level: its value is an envelope of the Collection's declared scheme, validated structurally on write (rejecting a plaintext custom with an encryption-scheme-mismatch) and never decrypted by the server, while the server-managed properties and epoch remain plaintext. This is what gives an encrypted Collection a client-encrypted display name and tags, and a discoverable, conditionally-writable home for profile-level configuration such as the [WAS-EC] blinded-index schema. One consequence: on an encrypted Collection every metadata write MUST carry a conforming envelope as its custom, so the plaintext clearing convention (a PUT with no custom; see 10.7.2 Lifecycle) is unavailable -- the cleared state is instead an envelope encrypting an empty object.

10.7.1 Versioning

The Metadata object carries its own monotonic version, metaVersion, independent of both the Collection Description's validator and every Resource's: writing one never bumps another. (The name is the same one the changes query profile reports per Resource; this is its Collection-scoped counterpart.) A server implementing these endpoints MUST maintain it and expose it as a strong ETag validator on read, and MUST honor If-Match / If-None-Match: * preconditions on update, evaluated atomically with the write; see 7.2 Conditional Requests. The version is surfaced only as the ETag: it is not a member of the Metadata object's JSON representation. A Metadata object that has never been written carries no validator (a read returns no ETag), and If-None-Match: * succeeds exactly then -- it is the "write only if never written" precondition. This gives a read-reconcile-write client (for example, one maintaining the [WAS-EC] blinded-index schema in this envelope) lost-update protection without involving the Collection Description or any Resource. Collection metadata writes are, however, invisible to replication: the changes query profile is per-Resource and emits no entry for them, so a client mirroring this object re-reads it and compares the returned ETag.

10.7.2 Lifecycle

The Metadata object is created and deleted together with the Collection: a GET of /meta on an existing Collection always succeeds, even before the first metadata write (reporting only createdBy, when recorded -- no timestamps, no custom, no ETag). There is no DELETE /meta operation; to clear the user-writable properties, send a PUT with an empty custom object or, on a plaintext collection, an empty body object (on an encrypted Collection the cleared state is an envelope encrypting an empty object, as above). Clearing is itself a write and advances metaVersion, so a cleared Metadata object remains distinguishable from a never-written one. Deleting the Collection removes its Metadata object with it, and a Collection later re-created under the same id starts over with never-written metadata -- its metaVersion restarts, so a client MUST NOT compare validators across a delete and re-create.

10.8 Read Collection Metadata Operation

10.8.1 (HTTP API) GET /space/{space_id}/{collection_id}/meta

  • Requires appropriate authorization
    • For example, when using zCaps for authorization, the request must either: be signed by the space's controller, or invoke a delegated capability that allows the GET action
  • When the Metadata object has been written at least once, the response includes an ETag header over its metaVersion, for use with If-Match on a subsequent update (see 7.2 Conditional Requests)

Example request:

GET /space/81246131-69a4-45ab-9bff-9c946b59cf2e/73WakrfVbNJBaAmhQtEeDv/meta HTTP/1.1
Host: example.com
Accept: application/json
Authorization: ...

Example success response:

HTTP/1.1 200 OK
Content-type: application/json
ETag: "2"

{
  "createdAt": "2026-06-10T09:12:00Z",
  "updatedAt": "2026-06-12T13:25:00Z",
  "createdBy": "did:key:z6MkpBMbMaRSv5nsgifRAwEKvHHoiKDMhiAHShTFNmkJNdVW",
  "custom": {
    "name": "Verifiable Credentials Collection",
    "tags": { "project": "demo" }
  }
}

Errors (see F. Error Type Registry for canonical examples):

  • not-found (404) -- the Collection does not exist, or the caller has missing or insufficient authorization; per 6. Error Handling a Metadata object the caller is not authorized to read is indistinguishable from one that does not exist.
  • unsupported-operation (501) -- the server does not implement the optional Collection metadata endpoints.

10.9 Update Collection Metadata Operation

As at Resource level, a PUT to the Collection's /meta endpoint is a full replacement of the Metadata object's custom object: the stored custom object is replaced by the one in the request body, so any user-writable property omitted from it is cleared (and, on a plaintext collection, a request body with no custom property clears them all).

The only other writable top-level member is epoch, whose omission clears the stored stamp (see the 10.7 Collection Metadata Data Model for the rule and its rationale -- this is the inverse of the Resource-level behavior). There is no writerId member at this level.

Server-managed properties are not affected by this operation; a server MUST ignore any top-level properties other than custom and epoch present in the request body (so that a client may read the Metadata object, modify it, and PUT it back without first stripping the server-managed properties).

A PUT to a Collection's /meta does not create anything: a Metadata object cannot exist apart from its Collection, so a PUT to the /meta path of a nonexistent Collection returns a not-found (404) error.

10.9.1 (HTTP API) PUT /space/{space_id}/{collection_id}/meta

  • Requires appropriate authorization
    • For example, when using zCaps for authorization, the request must either: be signed by the space's controller, or invoke a delegated capability that allows the PUT action
  • MAY carry an If-Match or If-None-Match: * precondition against the Metadata object's own validator (see 7.2 Conditional Requests)
  • This operation is idempotent with respect to the stored state (though every accepted write advances the metaVersion validator)
  • Returns a 204 success response, with an ETag header carrying the new validator

Example request:

PUT /space/81246131-69a4-45ab-9bff-9c946b59cf2e/73WakrfVbNJBaAmhQtEeDv/meta HTTP/1.1
Host: example.com
Content-Type: application/json
Authorization: ...

{
  "custom": {
    "name": "Verifiable Credentials Collection",
    "tags": { "project": "demo" }
  }
}

Example success response:

HTTP/1.1 204 No Content
ETag: "3"

Errors (see F. Error Type Registry for canonical examples):

11. Resources and Blobs

11.1 Blob Data Model

A unit of data, in transit or at rest. (As described in the W3C FileAPI: Blob Interface).

Blob properties:

When a Blob is stored as a Resource, its type and size are reported as the contentType and size properties of the Resource's Metadata object (see 11.8 Resource Metadata Data Model), and the stored bytes are returned verbatim on reads (see 11.3 Content Types and Representations).

11.2 Resource Data Model

A resource is a named (addressable) Blob stored in a given collection, with metadata. The data model is derived from W3C FileAPI: File Interface, but with the addition of a few crucial properties.

In similar storage systems, a resource is called "File", "Object", "Document", "Row", "Graph", and so on.

Resource properties:

11.3 Content Types and Representations

A Resource has exactly one current representation: the stored bytes plus the content type they are stored under. Every successful write (POST or PUT) replaces that representation entirely -- including replacing a representation previously stored under a different content type. Servers are not required to store, convert between, or negotiate multiple representations of a Resource.

Writing. A Resource write request MUST carry a Content-Type header; a server MUST reject a write without one with a missing-content-type (400) error. The request body is interpreted according to its content type:

Reading. A GET returns the current representation, verbatim, with the stored content type as the response Content-Type. Because Resources are single-representation, the request's Accept header is advisory: a server MAY ignore it, and MUST NOT reject a Resource read for lack of an acceptable representation (no 406 Not Acceptable) -- the stored representation is always the answer. A HEAD request returns the same headers without the body; the response Content-Type and Content-Length correspond to the contentType and size properties of the Resource's Metadata object (see 11.8 Resource Metadata Data Model).

Description objects. API documents generated by the server -- Space and Collection descriptions, listings, quota reports, Metadata objects -- MUST be available as application/json. Linkset documents use application/linkset+json [RFC9264], and error responses use application/problem+json [RFC9457] (see 6. Error Handling). Other representations of these documents (negotiated via Accept) MAY be offered in addition to, never instead of, the JSON baseline.

11.4 Create Resource (Add Resource to Collection) Operation

11.4.1 (HTTP API) POST /space/{space_id}/{collection_id}/

The request MAY include a Key-Epoch header declaring the key epoch the body was encrypted under (see C.4 Key Epochs); the value is stored as the Resource Metadata epoch property. When absent, any stored epoch stamp is cleared. The request MAY likewise include a Writer-Id header declaring the writing agent's attribution label (see 11.8.1 Writer attribution: writerId and createdBy); the value is stored as the Resource Metadata writerId property, and when absent any stored writerId is cleared.

Example request (adds a JSON object to the messages collection). Note that since no Resource id was specified, the server auto-generated an id and returned it as part of the Location response header.

POST /space/81246131-69a4-45ab-9bff-9c946b59cf2e/messages/ HTTP/1.1
Host: example.com
Content-Type: application/json
Authorization: ...

{"message":"hi"}

Example success response:

HTTP/1.1 201 Created
Content-type: application/json
Location: https://example.com/space/81246131-69a4-45ab-9bff-9c946b59cf2e/messages/6b5be748-5f39-4936-a895-409e393c399c

Example request (multipart upload of a photo to the photos collection, on a server that supports the OPTIONAL multipart form; see 11.3 Content Types and Representations). The stored content type is the file part's own type, image/png, not multipart/form-data:

POST /space/81246131-69a4-45ab-9bff-9c946b59cf2e/photos/ HTTP/1.1
Host: example.com
Content-Type: multipart/form-data; boundary=----boundary314
Authorization: ...

------boundary314
Content-Disposition: form-data; name="file"; filename="sunset.png"
Content-Type: image/png

...binary PNG bytes...
------boundary314--

Example success response:

HTTP/1.1 201 Created
Location: https://example.com/space/81246131-69a4-45ab-9bff-9c946b59cf2e/photos/d2887cf0-186f-4e2e-a575-c9ce1b9d8a45

Errors (see F. Error Type Registry for canonical examples):

11.5 Read Resource Operation

A read returns the Resource's single stored representation, with the stored content type; see 11.3 Content Types and Representations for content type and Accept header handling (including the HEAD variant).

11.5.1 (HTTP API) GET /space/{space_id}/{collection_id}/{resource_id}

  • Requires appropriate authorization
    • For example, when using zCaps for authorization, the request must either: be signed by the resource's or the space's controller, or invoke a delegated capability that allows the GET action

Example request to retrieve a resource:

GET /space/81246131-69a4-45ab-9bff-9c946b59cf2e/messages/hello-world HTTP/1.1
Host: example.com
Accept: application/json

Example success response:

HTTP/1.1 200 OK
Content-type: application/json

{"message":"hi"}

Errors (see F. Error Type Registry for canonical examples):

  • not-found (404) -- the Resource does not exist, or the caller has missing or insufficient authorization; per 6. Error Handling a resource the caller is not authorized to read is indistinguishable from one that does not exist.

11.6 Update (or Create By Id) Resource Operation

When creating or modifying a Resource via PUT, the client specifies the id of the Resource. This Resource id MUST NOT collide with the list of B.2 Collection-level reserved endpoints.

11.6.1 (HTTP API) PUT /space/{space_id}/{collection_id}/{resource_id}

  • Requires appropriate authorization
    • For example, when using zCaps for authorization, the request must either: be signed by the resource's or the space's controller, or invoke a delegated capability that allows the PUT action
  • This operation is idempotent
  • Returns a 204 success response

The request MAY include a Key-Epoch header declaring the key epoch the body was encrypted under (see C.4 Key Epochs); the value is stored as the Resource Metadata epoch property. When absent, any stored epoch stamp is cleared. The request MAY likewise include a Writer-Id header declaring the writing agent's attribution label (see 11.8.1 Writer attribution: writerId and createdBy); the value is stored as the Resource Metadata writerId property, and when absent any stored writerId is cleared.

Example request to create a resource via PUT:

PUT /space/81246131-69a4-45ab-9bff-9c946b59cf2e/messages/hello-world HTTP/1.1
Host: example.com
Content-Type: application/json
Authorization: ...

{"message":"hi"}

Example success response:

HTTP/1.1 204 No Content

Example request to update the created resource via PUT:

PUT /space/81246131-69a4-45ab-9bff-9c946b59cf2e/messages/hello-world HTTP/1.1
Host: example.com
Content-Type: application/json
Authorization: ...

{"message":"no I've changed my mind"}

Example success response:

HTTP/1.1 204 No Content

Example request (uploading a binary Blob via PUT; the bytes are stored verbatim under the supplied content type, see 11.3 Content Types and Representations). The Digest header binds the body to the request signature when using the WAS Authorization Profile (see 5.1.4 Request Body Integrity (Digest Header)):

PUT /space/81246131-69a4-45ab-9bff-9c946b59cf2e/photos/sunset.png HTTP/1.1
Host: example.com
Content-Type: image/png
Digest: mh=uEiCPO-qYr-z0GYV5F75-N1l8Rhjv4xIkKZsnbTZeZ7emSA
Authorization: ...

...binary PNG bytes...

Example success response:

HTTP/1.1 204 No Content

Errors (see F. Error Type Registry for canonical examples):

This operation accepts the If-Match / If-None-Match write preconditions described in 7.2 Conditional Requests when the target backend advertises the conditional-writes feature.

11.7 Delete Resource Operation

11.7.1 (HTTP API) DELETE /space/{space_id}/{collection_id}/{resource_id}

  • Requires appropriate authorization

    • For example, when using zCaps for authorization, the request must either: be signed by the resource's or the space's controller, or invoke a delegated capability that allows the DELETE action
  • This operation is idempotent

  • (Assuming the request carries appropriate authorization) Sending a DELETE request to a resource that does not exist (or has already been deleted) results in a 204 success response

The request MAY include a Writer-Id header declaring the deleting agent's attribution label (see 11.8.1 Writer attribution: writerId and createdBy): a deletion is a revision like any other, and where the server keeps a tombstone the label it carries attributes the deletion (see the tombstone notes of the changes profile), on the same declare-or-clear terms as a content write.

Example request to delete a resource via DELETE:

DELETE /space/81246131-69a4-45ab-9bff-9c946b59cf2e/messages/hello-world HTTP/1.1
Host: example.com
Authorization: ...

Example success response:

HTTP/1.1 204 No Content

Errors (see F. Error Type Registry for canonical examples):

  • not-found (404) -- the caller has missing or insufficient authorization. Because DELETE is idempotent, an authorized request for an already-absent resource returns 204; a request that lacks sufficient authorization instead returns 404, per 6. Error Handling, so that an unauthorized caller cannot probe for existence.

11.8 Resource Metadata Data Model

Each Resource has an associated Metadata object, addressable at the reserved /meta path segment under the Resource URL (one of the B.3 Resource-level reserved endpoints). The metadata endpoints are OPTIONAL; a server that does not implement them SHOULD return an unsupported-operation (501) error for requests to /meta paths. (A Collection has a Metadata object of its own, at the reserved meta segment under the Collection URL; see 10.7 Collection Metadata Data Model.)

The Metadata object is a JSON document containing two groups of properties:

Server-managed properties (contentType and size are REQUIRED in every Metadata object; the timestamps and createdBy are OPTIONAL):

Editor's note
**Versioning is optional and backend-advertised.** This metadata model does not mandate a stored etag / version property. When a backend advertises the conditional-writes feature it exposes a Resource version as an HTTP ETag validator and honors If-Match / If-None-Match preconditions; see 7.2 Conditional Requests. A backend without that feature performs unconditional last-writer-wins upserts. The broader Transaction (multi-write atomic) mechanism remains deferred.

User-writable properties:

On a Collection that declares an encryption descriptor (see C. Encryption Scheme Registry), the user-writable custom object is stored encrypted: its value is an envelope of the Collection's declared scheme (the same envelope profile used for a Resource's content). The server-managed top-level properties (contentType, size, the timestamps, and createdBy) remain plaintext -- the server needs them for listings, GET/HEAD headers, quotas, and, in the case of createdBy, because it is the very identity the server authenticated on the creating write. The server validates the custom envelope structurally on write (rejecting a plaintext custom with an encryption-scheme-mismatch) and never decrypts it; a client holding the keys decrypts custom back to { name, tags, ... } after reading. Because the server cannot read an encrypted name, 10.5 List Collection operation summaries for an encrypted Collection carry no name.

A Resource's Metadata object is created and deleted together with the Resource itself: it comes into existence (with only server-managed properties) when the Resource is created and is removed when the Resource is deleted. There is no DELETE /meta operation; to clear the user-writable properties, send a PUT with an empty custom object ({ "custom": {} }) or an empty body object ({}).

11.8.1 Writer attribution: writerId and createdBy

createdBy and writerId (both optional) answer different questions, and neither can substitute for the other:

  • createdBy is a keyed, server-verified identity: the did the server itself authenticated on the creating capability invocation. It is server-managed, read-only, and names the creator only; later writes never change it.
  • writerId is an unkeyed, client-declared attribution label: an opaque string the writing agent volunteers about itself, naming which writing agent produced the current revision. The server stores and serves it verbatim and MUST NOT verify it, MUST NOT compute or default it, and MUST NOT use it as an input to authorization or any other server decision. It is advisory replication metadata, nothing more.

These two leave room for a third, distinct axis: a client-asserted identity -- a property whose value is a did the controller writes and maintains about another party. The Collection-level generator property (see 10.1 Collection Data Model), naming the application a Collection was provisioned for, is this axis. Unlike writerId it is an identity claim and a stable join key; unlike createdBy it is a controller assertion rather than a server-verified fact, and a reader treats it as exactly that.

In every usage, including the Writer-Id request header and the top-level writerId member of an Update Resource Metadata request, the label is an opaque non-empty string. A present but empty or malformed value is an invalid-request-body error.

The reason writerId is necessary: several distinct writing agents may share one invoking did (for example, the same delegated capability held by a user's several installs of one app), so createdBy (or any other server-verified identity) structurally cannot distinguish exactly the writers that most often race each other. A per-revision label lets replication clients break same-timestamp last-writer-wins ties with a shared, deterministic (updatedAt, writerId) sort key. It also helps them recognize their own writes echoed back on the changesfeed without a fetch (on an encrypted Collection, without a decrypt), and attribute revision history for display.

Requirements on the writing client:

  • A writerId MUST NOT be derived from any secret, key material, or other identity: it is an attribution label, never an identity, and it MUST NOT be treated as one (that is the createdBy axis). A random value minted per writing agent is RECOMMENDED.
  • A client performing a read-modify-write of a Metadata object SHOULD replace the writerId it read with its own label (or omit the member) rather than echoing the previous writer's back.

Privacy considerations: unlike the encrypted custom object, writerId is plaintext to the server, and a stable per-agent label reveals to the server (and to any reader of the Collection) how many writing agents stand behind one invoking did, durably per revision -- information the server could otherwise only approximate from traffic analysis. This is accepted as the explicit price of pre-fetch echo suppression, which only a server-visible field can provide, and it is bounded: the label reveals the multiplicity of one principal's own writing agents, not any cross-party relationship (the axis the C.4 Key Epochs recipients/grantees separation protects). And because it is random, unkeyed, and clearable, it cannot be linked across users or upgraded into a tracking identity. The field is optional end-to-end: a client that does not want the server to hold even this simply never declares one.

11.9 Read Resource Metadata Operation

11.9.1 (HTTP API) GET /space/{space_id}/{collection_id}/{resource_id}/meta

  • Requires appropriate authorization
    • For example, when using zCaps for authorization, the request must either: be signed by the resource's or the space's controller, or invoke a delegated capability that allows the GET action

Example request:

GET /space/81246131-69a4-45ab-9bff-9c946b59cf2e/messages/hello-world/meta HTTP/1.1
Host: example.com
Accept: application/json
Authorization: ...

Example success response:

HTTP/1.1 200 OK
Content-type: application/json

{
  "contentType": "application/json",
  "size": 16,
  "createdAt": "2026-06-10T09:12:00Z",
  "updatedAt": "2026-06-12T13:25:00Z",
  "createdBy": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
  "writerId": "z6fVXHKn8PdQm2Rt",
  "custom": {
    "name": "Hello World greeting",
    "tags": { "project": "demo", "status": "draft" }
  }
}

Errors (see F. Error Type Registry for canonical examples):

  • not-found (404) -- the Resource does not exist, or the caller has missing or insufficient authorization; per 6. Error Handling a Metadata object the caller is not authorized to read is indistinguishable from one that does not exist.
  • unsupported-operation (501) -- the server does not implement the optional metadata endpoints.

11.10 Update Resource Metadata Operation

A PUT to the /meta endpoint is a full replacement of the Metadata object's custom object: the stored custom object is replaced by the one in the request body, so any user-writable property omitted from it is cleared (and a request body with no custom property clears them all).

Two other top-level members are also writable, each with its own omission behavior. A request MAY carry epoch to set the Resource's key-epoch stamp; a request that omits it preserves the stored stamp, because the stamp describes the content write, not the metadata write (see C.4 Key Epochs). A request MAY carry writerId to declare the writing agent's attribution label; a request that omits it clears the stored label (see 11.8.1 Writer attribution: writerId and createdBy). The label is cleared rather than preserved because a metadata write is itself a revision, and on an encrypted Collection it replaces the custom envelope wholesale, so keeping a previous writer's label would misattribute it.

Server-managed properties are not affected by this operation; a server MUST ignore any top-level properties other than custom, epoch, and writerId present in the request body (so that a client may read the Metadata object, modify it, and PUT it back without first stripping the server-managed properties).

Unlike the 11.6 Update (or Create By Id) Resource Operation, a PUT to /meta does not create anything: a Metadata object cannot exist apart from its Resource, so a PUT to the /meta path of a nonexistent Resource returns a not-found (404) error.

11.10.1 (HTTP API) PUT /space/{space_id}/{collection_id}/{resource_id}/meta

  • Requires appropriate authorization
    • For example, when using zCaps for authorization, the request must either: be signed by the resource's or the space's controller, or invoke a delegated capability that allows the PUT action
  • This operation is idempotent
  • Returns a 204 success response

Example request:

PUT /space/81246131-69a4-45ab-9bff-9c946b59cf2e/messages/hello-world/meta HTTP/1.1
Host: example.com
Content-Type: application/json
Authorization: ...

{
  "custom": {
    "name": "Hello World greeting",
    "tags": { "project": "demo", "status": "draft" }
  }
}

Example success response:

HTTP/1.1 204 No Content

Errors (see F. Error Type Registry for canonical examples):

12. Chunked Resources

Note
Chunked Resources are an OPTIONAL feature, available only against a backend that advertises the chunked-streams token in its Backend description (see 14.1 Backend Data Model). A server whose backends do not advertise chunked-streams MAY omit these endpoints entirely and remain conformant; see 2.4 Scope and Conformance Profiles.

A Resource MAY carry an ordered set of chunks: opaque byte sequences, each addressed by a non-negative integer index under the Resource's reserved chunks sub-path. Chunks let a client store a representation larger than a single request (or a single encryption envelope) can carry, without the server ever parsing or reassembling anything. The server treats each chunk exactly like a binary Resource representation -- stored bytes plus a content type (see 11.3 Content Types and Representations) -- and framing and reassembly (including any client-side encryption) are entirely the client's concern. The server never concatenates a Resource's chunks, and reading the parent Resource's own content (see 11.5 Read Resource Operation) returns only that content, not its chunks; the chunk set is discovered and read through the endpoints below.

Chunks are the substrate the E. EDV-over-WAS Profile v0.1 uses to store a large or streamed encrypted document, but the mechanism itself is scheme-agnostic: the bytes of a chunk are opaque to the server whether they are plaintext, ciphertext, or anything else.

12.1 The chunk address

A single chunk is addressed in member form (no trailing slash), and a Resource's chunk set is listed in container form (trailing slash), following the trailing-slash convention in 2.2 Reading This Document:

As elsewhere, a request to the non-canonical variant of either form is redirected to the canonical one with 308 Permanent Redirect (which, unlike a 302, requires the client to replay the same method and body): a GET of the member-form chunks container without its trailing slash redirects to the trailing-slash form, and a member PUT (or other member method) carrying a trailing slash redirects to the no-slash form.

The {index} path segment MUST be a canonical non-negative decimal integer: a single 0, or a digit run with no leading zero, no sign, and no non-digit characters (so 0, 1, 42 are valid; 01, +1, -1, 1e3, 1.0 are not). A server MUST reject a non-canonical index with an invalid-id (400) error. Requiring a canonical spelling keeps each chunk addressable at exactly one URL.

12.2 Store Chunk Operation

12.2.1 (HTTP API) PUT /space/{space_id}/{collection_id}/{resource_id}/chunks/{index}

  • Requires appropriate authorization
    • For example, when using zCaps for authorization, the request must either: be signed by the space's controller, or invoke a delegated capability that allows the PUT action, whose invocationTarget is the chunk's own full URL (see 12.7 Chunk authorization).
  • Upserts the chunk at {index}: a write replaces any chunk already stored there. Indexes need not be written contiguously or in order.
  • Returns a 204 success response carrying the chunk's ETag.

The request body is raw bytes under any Content-Type. The server MUST NOT parse or validate a chunk body. This holds even for a Collection that declares an encryption descriptor (see C. Encryption Scheme Registry): the scheme's envelope validation applies to a Resource's own content, not to its chunks, because the chunks of an encrypted stream are ciphertext fragments, not envelope documents. The parent Resource MUST already exist; a PUT to a chunk of a Resource that does not exist is rejected with not-found (404), so a chunk can never be orphaned. The Digest request-body-integrity requirement (see 5.1.4 Request Body Integrity (Digest Header)) applies per request -- that is, per chunk. The backend's maxUploadBytes cap and quota accounting apply to a chunk write exactly as they do to a Resource write (see 15. Quotas).

Each chunk carries its own strong ETag validator, independent of the parent Resource's and of the other chunks'. The If-Match / If-None-Match write preconditions of 7.2 Conditional Requests apply per chunk, against that validator, on a backend that advertises conditional-writes.

Example request (storing chunk 0 as raw bytes):

PUT /space/81246131-69a4-45ab-9bff-9c946b59cf2e/backups/bigfile/chunks/0 HTTP/1.1
Host: example.com
Content-Type: application/octet-stream
Digest: mh=uEiCPO-qYr-z0GYV5F75-N1l8Rhjv4xIkKZsnbTZeZ7emSA
Authorization: ...

...raw chunk bytes...

Example success response:

HTTP/1.1 204 No Content
ETag: "1"

Errors (see F. Error Type Registry for canonical examples):

  • invalid-id (400) -- the {index} segment is not a canonical non-negative decimal integer (see 12.1 The chunk address).
  • not-found (404) -- the parent Resource (or its enclosing Space or Collection) does not exist, or the caller has missing or insufficient authorization; per 6. Error Handling an under-authorized request is indistinguishable from a missing target.
  • payload-too-large (413) -- the chunk exceeds the backend's maxUploadBytes constraint (see 15. Quotas).
  • quota-exceeded (507) -- the Collection's backend has no storage quota remaining (see 15. Quotas).
  • precondition-failed (412) -- a conditional write's If-Match / If-None-Match precondition evaluated false against the chunk's own ETag, on a backend that advertises conditional-writes (see 7.2 Conditional Requests).

12.3 Read Chunk Operation

12.3.1 (HTTP API) GET /space/{space_id}/{collection_id}/{resource_id}/chunks/{index}

A read returns the chunk's stored bytes, verbatim, with the content type they were stored under, and the chunk's ETag. A HEAD on the same address returns those same headers -- the response Content-Type and Content-Length are read from the chunk's stored metadata, so the byte stream is never opened -- with no body, mirroring the Resource HEAD variant in 11.3 Content Types and Representations. Authorization for a chunk read (and HEAD) is capability-or-policy and resolves at the parent Resource's access level (see 12.7 Chunk authorization).

Example request:

GET /space/81246131-69a4-45ab-9bff-9c946b59cf2e/backups/bigfile/chunks/0 HTTP/1.1
Host: example.com
Authorization: ...

Example success response:

HTTP/1.1 200 OK
Content-Type: application/octet-stream
ETag: "1"

...raw chunk bytes...

Errors (see F. Error Type Registry for canonical examples):

  • invalid-id (400) -- the {index} segment is not canonical (see 12.1 The chunk address).
  • not-found (404) -- no chunk is stored at {index} (the parent Resource may exist but have no chunk there), or the caller has missing or insufficient authorization; per 6. Error Handling the two are indistinguishable.

12.4 Delete Chunk Operation

12.4.1 (HTTP API) DELETE /space/{space_id}/{collection_id}/{resource_id}/chunks/{index}

  • Requires appropriate authorization on the same terms as the Store Chunk operation (capability-only against the chunk's own URL; see 12.7 Chunk authorization).
  • Removes the chunk at {index} and returns a 204 success response.
  • Accepts the If-Match precondition of 7.2 Conditional Requests against the chunk's own ETag on a conditional-writes backend.

Unlike the 11.7 Delete Resource Operation, deleting a chunk is not idempotent: a DELETE of an absent chunk is rejected with not-found (404), not a 204. This is deliberate -- a client reassembling a chunked representation must be able to distinguish a chunk that is gone from one that was never written, which an idempotent delete would erase.

Example request:

DELETE /space/81246131-69a4-45ab-9bff-9c946b59cf2e/backups/bigfile/chunks/0 HTTP/1.1
Host: example.com
Authorization: ...

Example success response:

HTTP/1.1 204 No Content

Errors (see F. Error Type Registry for canonical examples):

  • invalid-id (400) -- the {index} segment is not canonical.
  • not-found (404) -- no chunk is stored at {index} (see above), or the caller has missing or insufficient authorization; per 6. Error Handling the two are indistinguishable.
  • precondition-failed (412) -- an If-Match precondition evaluated false against the chunk's ETag, on a conditional-writes backend.

12.5 List Chunks Operation

12.5.1 (HTTP API) GET /space/{space_id}/{collection_id}/{resource_id}/chunks/

The container form lists a Resource's stored chunks. Because the server never reassembles a chunked Resource, this listing is the discovery mechanism: a reader learns the chunk set here -- how many chunks exist and each one's index, size, and content type -- and then reads indexes 0 through count - 1 itself. Authorization is capability-or-policy against the chunks/ container URL, resolving at the parent Resource's access level (see 12.7 Chunk authorization).

The response is an application/json object:

  • resourceId -- the parent Resource's id.
  • count -- the number of stored chunks.
  • chunks -- an array, in ascending index order, of one entry per stored chunk:
    • index -- the chunk's non-negative integer index.
    • size -- the length in bytes of the stored chunk.
    • contentType -- the content type the chunk was stored under.
    • version (optional) -- the chunk's monotonic version, the integer from which its strong ETag is derived (the ETag is this integer, quoted). Present only on a backend that advertises conditional-writes and therefore tracks a per-chunk version; absent otherwise.

The parent Resource MUST exist for its chunk container to: a listing under an absent Resource is a not-found (404). A Resource that exists but has no chunks lists as count 0 with an empty chunks array.

Example request:

GET /space/81246131-69a4-45ab-9bff-9c946b59cf2e/backups/bigfile/chunks/ HTTP/1.1
Host: example.com
Accept: application/json
Authorization: ...

Example success response:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "resourceId": "bigfile",
  "count": 3,
  "chunks": [
    { "index": 0, "size": 1048576, "contentType": "application/octet-stream", "version": 1 },
    { "index": 1, "size": 1048576, "contentType": "application/octet-stream", "version": 1 },
    { "index": 2, "size": 524288, "contentType": "application/octet-stream", "version": 1 }
  ]
}

Errors (see F. Error Type Registry for canonical examples):

  • not-found (404) -- the parent Resource does not exist, or the caller has missing or insufficient authorization; per 6. Error Handling the two are indistinguishable.

12.6 Chunk lifecycle

A Resource's chunks are bound to the Resource. Deleting the parent Resource (see 11.7 Delete Resource Operation) MUST cascade-delete all of its chunks; there is no way to leave chunks behind a deleted Resource. A Resource's chunks are carried alongside its content by a Space export and restored by the matching import (the export reserved segment; see B. Reserved Path Segment Registry).

Chunk writes and deletes are invisible to the changes query profile (see D.1 changes profile): storing or deleting a chunk affects only the chunk's own ETag validator and MUST NOT advance the parent Resource's position in the feed, and the feed enumerates Resources only, never chunks. A client replicating a chunked Resource MUST therefore finish the write by updating the parent Resource's own content (its manifest; see 11.6 Update (or Create By Id) Resource Operation) after its chunks are stored: that final Resource write is what surfaces the change to replicating readers. A client that mutates a Resource purely through its chunks never appears on the feed.

12.7 Chunk authorization

Chunk operations use the same authorization model as every other operation in this specification (see 5. Authorization): writes (PUT, DELETE) are capability-only, while reads (GET, HEAD, and the container listing) are capability-or-policy. A chunk write's capability invocationTarget MUST be the chunk's own full URL (member form), and the listing's the chunks/ container URL -- the same exact-match target rule that governs every WAS URL (see target). For a read, the governing access-control policy is the parent Resource's: a chunk exposes a fragment of the same content the Resource holds, so whoever may read the Resource may read its chunks, and the maximum-privacy not-found rule (see 6. Error Handling) applies to a chunk exactly as to the Resource.

13. Linksets

Note
Linksets are an OPTIONAL extension. A server MAY omit this feature-detection mechanism entirely and remain conformant; see 2.4 Scope and Conformance Profiles.

Linksets (from [RFC9264]) serve as the main feature detection and extension mechanism. They can be discovered, via the linkset property, from the following:

13.1 Space Linkset

The space linkset resource (one of the B.1 Space-level reserved endpoints), located at /space/{space_id}/linkset contains a set of links to auxiliary resources and extension points:

Example space linkset resource request and response:

GET /space/81246131-69a4-45ab-9bff-9c946b59cf2e/linkset HTTP/1.1
Accept: application/linkset+json
Authorization: ...

Response:

HTTP/1.1 200 OK
Content-type: application/linkset+json

{
  "linkset": [
    {
      "anchor": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e",
      "https://wallet.storage/spec#policy": [
        {
          "href": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e/policy",
          "type": "application/json"
        }
      ],
      "https://wallet.storage/spec#backends-available": [
        {
          "href": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e/backends",
          "type": "application/json"
        }
      ],
      "https://wallet.storage/spec#quotas": [
        {
          "href": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e/quotas",
          "type": "application/json"
        }
      ]
    }
  ]
}

13.2 Collection Linkset

The collection linkset resource (one of the B.2 Collection-level reserved endpoints), located at /space/{space_id}/{collection_id}/linkset contains a set of links to auxiliary resources and extension points:

Example collection linkset resource request and response:

GET /space/81246131-69a4-45ab-9bff-9c946b59cf2e/messages/linkset HTTP/1.1
Accept: application/linkset+json
Authorization: ...

Response:

HTTP/1.1 200 OK
Content-type: application/linkset+json

{
  "linkset": [
    {
      "anchor": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e/messages",
      "https://wallet.storage/spec#policy": [
        {
          "href": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e/messages/policy",
          "type": "application/json"
        }
      ],
      "https://wallet.storage/spec#backend": [
        {
          "href": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e/messages/backend",
          "type": "application/json"
        }
      ],
      "https://wallet.storage/spec#quota": [
        {
          "href": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e/messages/quota",
          "type": "application/json"
        }
      ]
    }
  ]
}

14. Backends

Note
This section defines an OPTIONAL extension. A server MAY skip pluggable backends entirely and remain conformant; see 2.4 Scope and Conformance Profiles.

Backends are an optional infrastructure concern that is orthogonal to the hierarchical Spaces Repository > Space > Collection > Resource storage model. They exist to serve advanced use cases that need fine-grained control over storage configurations.

Available backends are registered on the Space level, as a combination of server-side configuration and client-side "Bring Your Own Storage" registration.

For example, on the server configuration side, a given server might support several backends -- a file system default backend, an EDV encrypted backend, and a PostgreSQL database backend. And on the client side, a user might register an external storage provider by connecting to their Dropbox account.

When a Collection is created, the client can optionally specify the preferred backend for that Collection. If no preferred backend is specified, one is assigned by the server (usually the default backend).

An implementer or client of a given server can omit the backend property when creating a Collection. By default, if not specified, all Collections are assigned the default backend.

14.1 Backend Data Model

A Backend description object advertises a backend's identity and features so that clients can select a suitable backend for each Collection (and so that storage management UIs can present meaningful choices to users).

Backend description properties:

Each token names something the server must actively do. Note that client-side encryption is deliberately not a backend feature: an encrypted document is opaque client-encrypted JSON that any document-capable backend stores faithfully with no server cooperation, and whether a given Collection is encrypted varies per Collection on the very same backend. Encryption is therefore a property of a Collection's data (signaled at the Collection level and held in the client's keys), not a capability of the backend. Concretely, this signal is the Collection's optional encryption descriptor (see 10.1 Collection Data Model): a non-secret declaration any authorized reader discovers from the Collection Description, while the keys stay in the client.

A backend that advertises conditional-writes takes on the server-side half of the client contract in 7.2 Conditional Requests. It MAY derive the ETag from an internal monotonic version counter (such as an Encrypted Data Vault document sequence); the validator is opaque to clients. It MUST evaluate a write's If-Match / If-None-Match precondition atomically with the write (for example, under a per-Resource lock), so that two concurrent writers cannot both observe the same prior version and both succeed. An in-process per-Resource lock satisfies this for a single-instance server only; a horizontally-scaled deployment needs to coordinate the check-and-write across instances (e.g. an atomic compare-and-swap on the stored version or a shared lock).

Editor's note
The schema of a backend's connection configuration (server-internal connections vs OAuth-style setup flows for external providers) is not yet specified.

14.2 Space Backends Available

The list of backends registered on a Space is discoverable via the backends-available relation in the linkset (see 13.1 Space Linkset).

Example request:

GET /space/81246131-69a4-45ab-9bff-9c946b59cf2e/backends HTTP/1.1
Accept: application/json
Authorization: ...

Example success response:

HTTP/1.1 200 OK
Content-type: application/json

[
  {
    "id": "default",
    "name": "Server Filesystem",
    "managedBy": "server",
    "storageMode": ["document", "blob"],
    "persistence": "durable"
  },
  {
    "id": "dropbox",
    "name": "Dropbox",
    "managedBy": "external",
    "storageMode": ["blob"],
    "persistence": "durable"
  },
  {
    "id": "edv",
    "name": "Encrypted Data Vault",
    "managedBy": "server",
    "storageMode": ["document", "blob"],
    "persistence": "durable",
    "features": ["conditional-writes", "blinded-index-query", "chunked-streams"]
  }
]

14.3 Collection Backend Selected

Each collection has an optional backend property that is set during its creation (see 10.1 Collection Data Model). If not specified, it is assumed to have the id of default. The selected backend is discoverable via the backend relation in the collection's linkset (see 13.2 Collection Linkset).

Editor's note
**Replicas (planned generalization).** The single backend property is expected to generalize to one or more _replicas_ per Collection, each replica hosted on a backend, with the current single-backend configuration as the degenerate one-replica case. (Collections do not "live" on a single backend; backends are infrastructure endpoints, not a fourth tier of the storage hierarchy.) That generalization will bring with it:
  • Client-evaluated replica roles (active vs available) -- this specification will define the vocabulary for declaring replica topology; the logic for selecting the active replica (based on latency, battery, network status) belongs to the client SDK.
  • A sync-status vocabulary per replica (e.g. synced, syncing, stale); the exact state machine, including how "cold boots" of volatile backends are surfaced, is to be determined.
  • A Collection-level storageMode declaration, validated against each replica's backend at creation time.
  • Logical vs physical byte accounting in quota reports: the deduplicated size of the user's data vs the total bytes consumed across all replicas. These only diverge once replication exists, so the 15. Quotas report currently carries plain per-backend usage.
  • Concurrency control (If-Match ETags) for updates to replica topology, building on the per-Resource conditional-writes mechanism (see 7.2 Conditional Requests) generalized to multi-replica writes.
Editor's note
**Lifecycle configuration (administrative retention).** Retention rules such as "delete guest data after one hour" (a time-to-live, a grace period, and an expiry action) are expected to be configurable at the Space or Collection level. Lifecycle configuration is deliberately distinct from both the backend's persistence property (a technical attribute of the storage engine) and from access-control policy (who may act on the data). The property naming is to be determined, precisely to avoid overloading the term "policy".

15. Quotas

Note
This section defines an OPTIONAL extension. A server MAY omit quota reporting and enforcement entirely and remain conformant; see 2.4 Scope and Conformance Profiles.

Quota reporting and enforcement are optional, and support is backend-dependent. The quota API serves two distinct consumers:

Quota endpoints follow the same maximum-privacy invariant as the rest of this specification (see 6. Error Handling): a caller not authorized to read a Space's quota report MUST receive a not-found (404) error, never a 403.

15.1 (HTTP API) GET /space/{space_id}/quotas

Returns the storage report for a Space, grouped by backend, and is discoverable via the quotas relation in the Space's linkset (see 13.1 Space Linkset). Each entry in the backends array carries:

Example request:

GET /space/81246131-69a4-45ab-9bff-9c946b59cf2e/quotas HTTP/1.1
Accept: application/json
Authorization: ...

Example success response:

HTTP/1.1 200 OK
Content-type: application/json

{
  "respondedAt": "2026-06-12T13:25:00Z",
  "backends": [
    {
      "id": "default",
      "name": "Server Filesystem",
      "managedBy": "server",
      "state": "ok",
      "usageBytes": 524288000,
      "limit": { "capacityBytes": 10737418240, "isUnlimited": false },
      "restrictedActions": [],
      "measuredAt": "2026-06-12T13:25:00Z"
    },
    {
      "id": "dropbox",
      "name": "Dropbox",
      "managedBy": "external",
      "state": "near-limit",
      "usageBytes": 314572800,
      "limit": { "capacityBytes": 367001600, "isUnlimited": false },
      "constraints": { "maxUploadBytes": 157286400 },
      "restrictedActions": [],
      "measuredAt": "2026-06-12T13:24:45Z"
    }
  ]
}

15.2 (HTTP API) GET /space/{space_id}/quotas?include=collections

With the include=collections query parameter, each backend entry additionally carries a usageByCollection array, giving storage-manager applications a full breakdown in a single request while keeping the hot-path payload lean:

HTTP/1.1 200 OK
Content-type: application/json

{
  "respondedAt": "2026-06-12T13:25:00Z",
  "backends": [
    {
      "id": "default",
      "name": "Server Filesystem",
      "managedBy": "server",
      "state": "ok",
      "usageBytes": 524288000,
      "limit": { "capacityBytes": 10737418240, "isUnlimited": false },
      "restrictedActions": [],
      "measuredAt": "2026-06-12T13:25:00Z",
      "usageByCollection": [
        { "id": "credentials", "usageBytes": 419430400 },
        { "id": "inbox", "usageBytes": 104857600 }
      ]
    }
  ]
}

15.3 (HTTP API) GET /space/{space_id}/{collection_id}/quota

Returns the storage report for a single Collection, scoped to that Collection's backend (the entry has the same shape as a backends array entry, with usageBytes reflecting only this Collection's consumption). It is discoverable via the quota relation in the Collection's linkset (see 13.2 Collection Linkset).

Not all backends support per-collection accounting. Where unsupported, the server returns an unsupported-operation (501) error.

Errors (see F. Error Type Registry for canonical examples):

A. Pagination

This appendix is normative. It defines the full pagination profile summarized in 7.3 Paginated List Responses.

The list operations -- 8.2 List Spaces Operation, 9.5 List All Collections operation, and 10.5 List Collection operation -- return a collection of items in the common envelope (url, totalItems, items). A Space or Collection may hold more items than is practical to return in a single response, so servers MAY paginate these responses, returning one page of items at a time. Pagination is OPTIONAL: a server that returns every item in a single response (as the examples in those sections show) is conformant, and a client MUST be prepared for either behavior.

This profile uses cursor-based (also called "keyset") pagination rather than numeric offsets. A cursor identifies a position in a stably ordered result set, so paging stays correct and cheap even as items are concurrently added or removed, and at any depth into a large collection.

A.1 Ordering

A paginated list operation MUST return items in a stable total order: an ordering that is deterministic and in which no two items compare equal. The default order is ascending by item id (which is unique within its parent, see 4. Identifiers). A server MAY offer additional orderings, but every order it supports for pagination MUST be stable and total -- if the primary sort key is not unique (for example, a timestamp), the server MUST break ties on a unique key (such as id) so that the combined order is total. This stable order is what a cursor seeks within; an unstable order cannot be paginated correctly.

A.2 Requesting a page

A client requests pagination with two OPTIONAL query parameters:

The first page is requested with no cursor (a bare limit, or neither parameter).

A.3 The paginated response

A paginated response carries the usual envelope, plus a next member when more items may follow:

A client consumes a multi-page list by following next from each response until a response omits it. A server SHOULD ensure that an item present throughout the traversal appears exactly once across the pages; an item added or removed concurrently with the traversal MAY or MAY NOT appear. Snapshot consistency (a paginated traversal observing a single point in time) is permitted but not required. A server MAY encode a snapshot identifier into the cursor to provide such consistency.

A cursor that is malformed, or that a server can no longer honor (for example, an expired snapshot), is rejected with an invalid-cursor (400) error. As with other request-validation failures, a server MUST verify the caller's authorization for the target before validating the cursor, so the error is only ever observed by a caller already authorized to list that target; an under-authorized caller receives the merged not-found (404) instead, per 6. Error Handling.

A.4 Pagination parameters and authorization

A capability authorizes a list target independent of which page is being read: a capability that authorizes listing a Space, Collection, or Spaces Repository authorizes retrieval of every page of that list. The limit and cursor parameters select a page within an already-authorized target; they do not narrow, widen, or otherwise change the target a capability must match (see 5.1.5.1 Root Capability). A server MUST NOT require a distinct capability per page.

B. Reserved Path Segment Registry

This appendix is normative.

B.1 Space-level reserved endpoints

The following path segments represent reserved API endpoints for 9. Spaces level operations. Usually, the path segment following the /space/{space_id}/ prefix is the id of a Collection. The list of reserved endpoints below means that collections ids MUST NOT collide with the corresponding reserved segments.

Reserved API Endpoint Reserved segment Purpose
/space/{space_id}/policy policy Access control policy
/space/{space_id}/backends backends Storage backends available
/space/{space_id}/collections collections List and create collections
/space/{space_id}/export export Export (download) space contents
/space/{space_id}/import import Import (upload) a space export archive
/space/{space_id}/linkset linkset Links to auxiliary resources
/space/{space_id}/query query Reserved for cross-collection queries
/space/{space_id}/quotas quotas Reserved for per-backend quota report

If a client attempts to create a collection with an id that collides with a reserved segment list above, the server MUST return a 409 Conflict error.

B.2 Collection-level reserved endpoints

The following path segments represent reserved API endpoints for 10. Collections level operations. Usually, the path segment following the /space/{space_id}/{collection_id}/ prefix is the id of a Resource. The list of reserved endpoints below means that resource ids MUST NOT collide with the corresponding reserved segments.

Reserved API Endpoint Reserved segment Purpose
/space/{space_id}/{collection_id}/policy policy Access control policy
/space/{space_id}/{collection_id}/backend backend Storage backend selected
/space/{space_id}/{collection_id}/linkset linkset Links to auxiliary resources
/space/{space_id}/{collection_id}/meta meta Collection metadata (server-managed and user-writable); see 10.7 Collection Metadata Data Model
/space/{space_id}/{collection_id}/query query Query resources within a collection (see D. Query Profile Registry)
/space/{space_id}/{collection_id}/quota quota Storage quota report for collection

If a client attempts to create a resource with an id that collides with a reserved segment list above, the server MUST return a 409 Conflict error.

B.3 Resource-level reserved endpoints

The following path segments represent reserved API endpoints for Resource level operations.

Reserved API Endpoint Reserved segment Purpose
/space/{space_id}/{collection_id}/{resource_id}/meta meta Resource metadata (server-managed and user-writable)
/space/{space_id}/{collection_id}/{resource_id}/chunks chunks Chunk addressing for a chunked Resource (see 12. Chunked Resources)

Unlike the Space- and Collection-level reserved segments, which occupy the id position one level down and so constrain Collection and Resource id choice, these sit below the Resource level -- they qualify a {resource_id} -- and impose no constraint of their own on id choice at any level. Note meta's distinct role at each level: the Collection-level row occupies the {resource_id} position, so meta is a reserved Resource id (by that row, not this table), while the Resource-level /meta and /chunks segments sit one level lower and shadow nothing -- a Resource whose own id is chunks is unaffected.

C. Encryption Scheme Registry

This appendix is normative.

Note
This registry defines values used by an OPTIONAL feature (client-side encryption): a server MAY support no encryption schemes and remain conformant; see 2.4 Scope and Conformance Profiles. Optionality is orthogonal to normativity -- a server that does implement encrypted Collections MUST follow the wire formats catalogued here.

A Collection's optional encryption descriptor (see 10.1 Collection Data Model) names a client-side encryption scheme and a version of that scheme's wire format -- a positive integer, starting at 1 for each scheme and incremented per registered revision (an absent version means 1). This registry maps each scheme/version pair to the wire format the server can expect for Resources in such a Collection, so that a server can hold the collection's fail-closed guarantee structurally, by validating the shape of what is written, rather than relying on every client to encrypt correctly. The server never holds key material and never decrypts; it validates only the non-secret envelope structure, so this enforcement neither requires nor weakens confidentiality.

scheme version Media type Envelope profile Reference
edv 1 application/json An Encrypted Data Vault Encrypted Document: a JSON object whose jwe member is a JWE in JSON Serialization ([RFC7516] §7.2) -- a JSON object carrying at least a ciphertext member and either a recipients array (general serialization) or a top-level encrypted_key/protected (flattened serialization). The document MAY also carry EDV bookkeeping members (id, sequence, indexed); these are opaque to the server. Encrypted Data Vaults

C.1 Server-side write validation

A server that recognizes the scheme/version pair declared by a Collection's encryption descriptor MUST validate the body of every Resource content write (POST or PUT) into that Collection against that pair's envelope profile, and MUST reject a non-conforming body -- or a body sent under a Content-Type other than the scheme's registered media type -- with an encryption-scheme-mismatch error.

This is the structural fail-closed guarantee: a client that has not encrypted the body, whether through a bug or an omission, cannot store server-visible plaintext in an encrypted Collection. The check is purely structural; a server MUST NOT attempt decryption and MUST NOT inspect the envelope's contents.

The rule applies to a Resource's stored representation and, on an encrypted Collection, to the user-writable custom object of its 11.8 Resource Metadata Data Model (see the encrypted-Collection note there). It does not apply to the rest of the server-managed API documents -- Collection Descriptions, the server-managed top-level Metadata properties, policy documents, linksets -- which remain application/json regardless of the Collection's encryption status. For the Metadata object specifically, the document itself stays a plaintext application/json object (so the server keeps its top-level contentType, size, and timestamps); only the custom sub-value MUST be a conforming envelope on an encrypted Collection, validated the same fail-closed way (an encryption-scheme-mismatch on non-conformance).

As with id-conflict, a server MUST verify the caller's authorization before validating the envelope, so that encryption-scheme-mismatch is observable only to callers already authorized to write at that target; an under-authorized caller receives the merged not-found instead, and learns nothing about the Collection.

C.2 Accepting a descriptor only when it can be enforced

When a Collection create or update operation declares an encryption descriptor (see 10.3 Update (or Create By Id) Collection operation), a server SHOULD reject a scheme -- or a version of a recognized scheme -- that it does not recognize with an unsupported-encryption-scheme error, rather than storing a descriptor it cannot enforce. This ensures that every descriptor a server accepts is one it validates on write: "this Collection is declared encrypted" then structurally implies "plaintext writes to it are rejected here," closing the gap that a silently unenforced descriptor would reopen.

A server MAY instead choose to store descriptors for schemes it does not enforce (treating the descriptor as fully opaque, per 10.1 Collection Data Model), but such a server MUST document that it provides no server-side fail-closed guarantee for those Collections, leaving the guarantee entirely to clients.

C.3 Validation profile

A server MAY implement the edv 1 envelope profile with a JSON Schema equivalent to the following. The outer object MUST carry a jwe member; only the jwe's structural members are checked; their values are opaque ciphertext and are not interpreted. A plaintext object under application/json (one with no valid jwe) fails this profile and is rejected with an encryption-scheme-mismatch, preserving the fail-closed guarantee.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["jwe"],
  "properties": {
    "jwe": {
      "type": "object",
      "required": ["ciphertext"],
      "properties": {
        "protected": { "type": "string" },
        "iv": { "type": "string" },
        "ciphertext": { "type": "string" },
        "tag": { "type": "string" },
        "encrypted_key": { "type": "string" },
        "recipients": {
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "header": { "type": "object" },
              "encrypted_key": { "type": "string" }
            }
          }
        }
      },
      "anyOf": [
        { "required": ["recipients"] },
        { "required": ["encrypted_key"] },
        { "required": ["protected"] }
      ]
    }
  }
}

C.4 Key Epochs

A key epoch is one generation of a Collection's encryption key. Rather than encrypting every Resource under a single long-lived key, writers encrypt each Resource under whichever epoch was current when it was written; the Collection's encryption descriptor carries the full, append-only roster of epochs, so the roster as a whole reads as the Collection's access history. The full client-side construction is specified in [WAS-EC]; this section defines only what the server stores and validates.

An encrypted Collection with a single shared key set has no cryptographic notion of removing a reader: revoking the reader's capability stops the server serving it ciphertext, but the reader still holds the key. Key epochs make removal meaningful by separating two axes:

Removing a reader means doing both: revoke its capability AND rotate the epoch. Neither alone suffices.

C.4.1 Epoch data model

A Collection's encryption descriptor MAY carry two additional members:

  • epochs - An array of epoch objects, each with:
    • id - A unique, opaque epoch identifier (a non-empty string). The reference client uses the did:key identifier of the epoch's public key-agreement key, so that a stored envelope's JWE recipient kid names its epoch directly, but servers MUST treat the value as opaque.
    • recipients - A non-empty array of wrapped-key entries, one per reader. Each entry reuses the JWE General Serialization recipients entry shape verbatim ([RFC7516] section 7.2): a header object with at least a non-empty string kid (the reader's key-agreement key identifier) and alg (e.g. ECDH-ES+A256KW), plus the wrapped epoch key as a string encrypted_key. This is the same shape the edv envelope profile already validates -- one wire vocabulary for "a key wrapped to a recipient".
  • currentEpoch - The id of the epoch new writes encrypt under. MUST name an entry in epochs.

Nothing secret appears in these fields: recipient entries hold public key identifiers and wrapped-key ciphertext only. The server never holds key material, never unwraps, and never verifies that a wrapped key is correct -- that is unverifiable without the keys, by construction.

Example descriptor:

{
  "scheme": "edv",
  "currentEpoch": "did:key:z6LSepoch2...#z6LSepoch2...",
  "epochs": [
    {
      "id": "did:key:z6LSepoch1...#z6LSepoch1...",
      "recipients": [
        {
          "header": {
            "kid": "did:key:z6MkappA...#z6LSkakA...",
            "alg": "ECDH-ES+A256KW",
            "epk": { "kty": "OKP", "crv": "X25519", "x": "..." },
            "apu": "...",
            "apv": "..."
          },
          "encrypted_key": "base64url..."
        }
      ]
    },
    {
      "id": "did:key:z6LSepoch2...#z6LSepoch2...",
      "recipients": [ "..." ]
    }
  ]
}

C.4.2 Server validation

A server that recognizes the edv scheme MUST validate the key-epoch members when a Collection create or update supplies them, rejecting a violation with an invalid-request-body error (and a JSON pointer to the offending member):

  • epochs and currentEpoch MUST appear together or not at all.
  • epochs MUST be a non-empty array; each entry MUST have a non-empty string id, unique across the array, and a non-empty recipients array.
  • Each recipients entry MUST have a header object with non-empty string kid and alg members, plus a string encrypted_key.
  • currentEpoch MUST name an id present in epochs.

On an update of a descriptor that already carries epochs, two further rails apply. These protect the Collection's own readers from client bugs -- a dropped epoch strands every Resource stamped with it -- and are enforceable without the server reading a single byte of what the epochs protect:

  • epochs is append-only. Every previously stored epoch id MUST still be present. (Entries within an existing epoch's recipients MAY change: adding a reader wraps existing epoch keys to it.)
  • currentEpoch never moves backwards. The updated currentEpoch MUST either equal the stored one or name an epoch id that was not previously present (a newly appended epoch). This formulation is independent of array order.

Beyond these rails the server MUST NOT interpret the fields. In particular it MUST NOT attempt to check that recipients matches any set of capability grantees -- the two axes are deliberately independent, and keeping them in sync is the client's job.

C.4.3 Epoch stamping on Resources

A reader must know which epoch key to unwrap before attempting decryption. The writer therefore declares the epoch a Resource was encrypted under, and the server stores and serves that declaration:

  • A Resource content write (POST or PUT) MAY carry a Key-Epoch request header whose value is the epoch id (a non-empty string; a present but empty or malformed value is an invalid-request-body error). The server MUST store the value verbatim as the Resource Metadata epoch property. A content write without the header MUST clear any stored stamp: the new representation's epoch is unknown, and a stale stamp is worse than none. The value is client-declared, advisory metadata: the server MUST NOT validate it against the descriptor's epochs (a write may race a rotation) and MUST NOT compute it.
  • An Update Resource Metadata request MAY carry a top-level epoch member (a sibling of custom, and like the header a non-empty string). Unlike custom, which is a full replacement, an omitted epoch PRESERVES the stored value -- the stamp describes the content write, not the metadata write. It MUST NOT live inside custom: on an encrypted Collection custom is the opaque envelope and is replaced wholesale by every metadata write.
  • An Update Collection Metadata request MAY likewise carry a top-level epoch member, stamping the epoch the Collection Metadata's own custom envelope was encrypted under. Its omission rule is inverted: an omitted epoch CLEARS the stored value, because at that level the stamp describes the custom envelope itself, which every metadata write replaces wholesale. See 10.7 Collection Metadata Data Model.
  • The server MUST return the stored stamp as the epoch property of the Resource Metadata object, on each item of a List Collection response, and on each document of the changes query profile -- so a reader walking a listing or replicating from the feed can select its epoch key without a per-Resource metadata fetch.

C.4.4 Client procedures

The reference construction (implemented by @interop/was-client and normatively specified in [WAS-EC]); other constructions are conformant at this specification's level so long as stored envelopes keep satisfying the edv envelope profile:

  • A collection's descriptor carries its epoch roster from creation. The first epoch is installed at provision time by a create-if-absent write (an existing roster, including a concurrent provisioner's is adopted, not overwritten), before the collection's first content write. There is no epoch-less era: a client of this construction refuses fail-closed to build a cipher from an edv descriptor without epochs, and never seals an envelope directly to a reader's own key-agreement key.
  • An epoch key is a fresh 32-byte secret used as the seed of an X25519 key-agreement key pair, freshly generated per epoch. The epoch id is the did:key identifier of the epoch public key, so any standard did:key resolver that supports X25519 type did:key DIDs can resolve the JWE recipient kid of a stored envelope, and the kid itself names the epoch.
  • Resources are encrypted with a fresh per-resource content encryption key wrapped to the epoch key (ECDH-ES+A256KW, the epoch key pair as the JWE's sole recipient). The stored envelope is the ordinary EDV Encrypted Document shape; only the key resolution process differs from a native EDV. Each envelope additionally binds its epoch into the JWE protected header ([WAS-EC]'s was binding), checked unconditionally on read.
  • The descriptor's recipients entries wrap the 32-byte epoch secret to each reader's own key-agreement key with ECDH-ES+A256KW (ephemeral-static ECDH, the RFC 7518 Concat KDF, AES key wrap). A reader finds its kid in an epoch's recipients, unwraps the epoch secret with its own key, and reconstructs the epoch key pair. A failed unwrap is a failure. In particular, key servers whose unwrap operation resolves a null key on mismatch must be handled by trying the next candidate or failing with a typed error.
  • Reads use the Resource's epoch stamp (from metadata, the listing item, or the feed document) as advisory pre-fetch routing: it lets a replica select and unwrap the epoch key before fetching the envelope. The authoritative epoch is the envelope's own -- the JWE recipient kid names it, and the AEAD-bound epoch binding is verified against the decrypting key ([WAS-EC]). An absent stamp therefore just means route-after-fetch; it is never treated as "assume currentEpoch", and there are no unstamped pre-epoch resources to tolerate.
  • Writes always encrypt under currentEpoch and stamp it via Key-Epoch.
  • Adding a reader wraps EVERY epoch's key to it (adding a reader means it can read the Collection, history included) and writes the updated description with If-Match. No rotation: adds are inexpensive, removals rotate.
  • Removing a reader is one indivisible procedure: (1) revoke the reader's capabilities; (2) mint a fresh epoch key, wrap it to each REMAINING recipient, append the epoch, repoint currentEpoch, write with If-Match; (3) subsequent writes use the new epoch. Client libraries should not expose a rotate-without-revoke or revoke-without-rotate implementation of "remove".
  • On a precondition-failed response, re-read the description, re-apply the recipient change to the fresh descriptor, and retry (bounded).

C.4.5 Security considerations

  • Limitations. Rotation protects Resources written after the rotation, and nothing else. It cannot somehow delete the data a removed reader already downloaded; Resources still stored under an earlier epoch remain readable to a removed reader that obtains their ciphertext (a backup, a colluding reader, a feed pull made before revocation); and it provides no post-compromise security for the removed reader's past traffic. Closing those gaps requires re-encrypting the Collection under the new epoch, which is a client-side bulk rewrite, out of scope here. Specifications and libraries documenting removal MUST state this limitation rather than implying stronger guarantees.
  • Pull and read stay separate. The capability governs pull (server-enforced, immediate); the epoch key governs read (mathematics, prospective). Documentation and error messages should never conflate them.
  • The blinded-index key does not rotate with the epoch. Rotating the hmac reference on removal would invalidate every blinded index in the Collection. A removed reader retaining the ability to compute blinded index terms is harmless: the server gates the blinded-index query profile behind a capability the reader no longer holds.
  • A rotation emits no changes feed entry. A rekey is a Collection Description change, not a Resource change. A replicating reader that encounters an epoch it does not know MUST re-read the Collection Description.

D. Query Profile Registry

This appendix is normative.

Note
This registry defines values used by an OPTIONAL feature (the query endpoint): a server MAY expose no query endpoint and remain conformant; see 2.4 Scope and Conformance Profiles. Optionality is orthogonal to normativity: a server that does serve queries MUST follow the request and response shapes catalogued here.

A collection MAY expose an OPTIONAL query endpoint at POST /space/{space_id}/{collection_id}/query. The endpoint is a single URL that serves several distinct query dialects; a request selects one by carrying a profile discriminator in its body. This registry maps each profile token to the request and response wire shape of that dialect.

The request body is an application/json object with a REQUIRED profile string member. All other members are profile-specific. A server that recognizes the profile answers with 200 OK on success and a profile-specific response body. A server that does not serve a given profile, whether because the server does not implement it at all, or because the Collection's backend lacks the capability (see 14.1 Backend Data Model), MUST respond with an unsupported-operation (501) error.

The profile vocabulary is open and additive, mirroring the backend features vocabulary (see 14.1 Backend Data Model): this specification defines the tokens below, and future profiles register additional tokens. A server MUST treat a profile it does not recognize the same as one it does not serve, with unsupported-operation.

Authorization. The query endpoint requires read authorization (a capability or a policy grant, as for any read); the invoked action is POST. The capability's invocationTarget is the bare /query URL. Every query parameter travels in the request body, which is signed and covered by the request Digest (see 5.1.4 Request Body Integrity (Digest Header)), so no query-string attenuation is involved, and a capability that authorizes the /query target authorizes any query body it signs. As with every other operation, a server MUST verify authorization before validating the request body. An under-authorized caller therefore receives the privacy-preserving not-found (404, see 6. Error Handling) and never learns whether its query body was well-formed, nor whether the target Collection exists.

profile Summary Defining subsection
changes Ordered replication change feed (a resumable pull loop) D.1 changes profile
blinded-index Matching over client-computed blinded (HMAC'd) attributes D.2 blinded-index profile

D.1 changes profile

The changes profile serves an ordered, resumable feed of the content and metadata changes in a collection, so that an offline-first replication client can pull a Collection's state incrementally and keep a local replica in sync. A server that serves this profile SHOULD advertise the changes-query backend feature (see 14.1 Backend Data Model).

Request body:

{
  "profile": "changes",
  "checkpoint": { "id": "<resourceId>", "updatedAt": "<ISO-8601 timestamp>" },
  "limit": 100
}

Response body:

{
  "documents": [
    {
      "id": "hello-world",
      "_deleted": false,
      "updatedAt": "2026-01-15T12:00:00.000Z",
      "version": 1,
      "metaVersion": 3,
      "createdBy": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
      "writerId": "z6fVXHKn8PdQm2Rt",
      "data": { "message": "hi" },
      "custom": {
        "name": "Hello World greeting",
        "tags": { "project": "demo", "status": "draft" }
      }
    }
  ],
  "checkpoint": { "id": "hello-world", "updatedAt": "2026-01-15T12:00:00.000Z" }
}

Each entry in documents describes one changed Resource:

Tombstones. A soft-deleted Resource surfaces as { "id", "_deleted": true, "updatedAt", "version" } with no data member; the deletion bumps version. A tombstone retains its createdBy, where one was recorded, so that a deletion replicates together with the attribution of the Resource it removes. It also carries as writerId the label the deleting request declared (see 11.8.1 Writer attribution: writerId and createdBy), if any. Since a deletion is a revision, its attribution and tie-breaking work like any other write's.

Ordering and resumption. Entries are ordered by an ascending (updatedAt, id) keyset. The top-level checkpoint echoes the position of the last entry in documents, or is null when nothing changed past the supplied checkpoint (the end of the feed). A client resumes by sending the returned checkpoint as the checkpoint of its next request; the resulting sequence of calls is the pull loop of a replication protocol. A metadata-only edit re-surfaces the Resource with a bumped updatedAt and metaVersion but an unchanged version and data.

Scope. Binary (non-JSON) Resources are excluded from the changes feed; blob replication is out of scope for this profile.

Example -- request the first batch and receive one changed document plus the checkpoint to resume from:

POST /space/81246131-69a4-45ab-9bff-9c946b59cf2e/messages/query HTTP/1.1
Host: example.com
Content-Type: application/json
Authorization: ...

{ "profile": "changes", "limit": 100 }
HTTP/1.1 200 OK
Content-type: application/json

{
  "documents": [
    {
      "id": "hello-world",
      "_deleted": false,
      "updatedAt": "2026-01-15T12:00:00.000Z",
      "version": 1,
      "createdBy": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
      "data": { "message": "hi" }
    }
  ],
  "checkpoint": { "id": "hello-world", "updatedAt": "2026-01-15T12:00:00.000Z" }
}

D.2 blinded-index profile

The blinded-index profile serves queries over client-computed blinded (HMAC'd) attributes, so that a server can match encrypted documents without seeing plaintext attribute names or values. Its semantics follow the query operation of the Encrypted Data Vaults specification (the same specification the C. Encryption Scheme Registry references for the edv envelope). A server that serves this profile SHOULD advertise the blinded-index-query backend feature (see 14.1 Backend Data Model).

Documents opt in by carrying the EDV indexed member: an array of entries of the shape { "hmac": { "id", "type" }, "sequence", "attributes": [ { "name", "value", "unique"? } ] }, in which each name and value is a blinded (HMAC'd) token computed by the client. A server stores the indexed member and matches against it, but cannot invert it back to the plaintext attribute.

Request body:

{
  "profile": "blinded-index",
  "index": "<HMAC key id>",
  "equals": [ { "<blindedName>": "<blindedValue>" } ],
  "has": [ "<blindedName>" ],
  "count": false,
  "limit": 10,
  "cursor": "<opaque token>"
}

Response body (a document page):

{ "documents": [ { } ], "hasMore": false, "cursor": "<opaque token>" }
Note

Cursor pagination is a deliberate WAS extension over the Encrypted Data Vaults query operation, which offers only limit together with a hasMore flag and no means to fetch the next page. A server that also implements the EDV query operation directly may expose limit/hasMore there while offering the cursor continuation only through this profile.

Example -- match documents whose blinded index-2 attribute equals a blinded value:

POST /space/81246131-69a4-45ab-9bff-9c946b59cf2e/messages/query HTTP/1.1
Host: example.com
Content-Type: application/json
Authorization: ...

{
  "profile": "blinded-index",
  "index": "z1A...hmacKeyId",
  "equals": [ { "aBlindedName": "aBlindedValue" } ],
  "limit": 10
}
HTTP/1.1 200 OK
Content-type: application/json

{
  "documents": [
    { "id": "urn:uuid:...", "sequence": 0, "jwe": { "ciphertext": "..." } }
  ],
  "hasMore": false
}

D.2.1 Unique blinded attributes

An indexed attribute entry MAY carry "unique": true, marking its (hmac id, name, value) triple as a uniqueness claim within the Collection. A server MUST reject a Resource content write that would claim a triple already claimed with unique: true by a different Resource in the same collection, with an id-conflict (409) error. A document re-asserting its own existing claim never self-conflicts, and the same triple carried without unique coexists freely.

This uniqueness is enforced at write time, not at query time. As with the id-conflict rule elsewhere in this specification (see the id-conflict privacy note in F. Error Type Registry), a server MUST verify the caller's authorization to write at the target before performing this check, so that the existence-revealing 409 is observable only to a caller already authorized to write; an under-authorized caller receives the merged not-found instead. The check MUST be atomic with the write, so that two concurrent writers cannot both claim the same triple.

E. EDV-over-WAS Profile v0.1

This appendix is normative for clients that claim conformance to it.

Note
This profile is a **client-side layout convention**: it constrains how a client maps [Encrypted Data Vault](https://identity.foundation/edv-spec/) (EDV) operations onto ordinary WAS operations. A conforming WAS server needs nothing beyond the features it already advertises, and never learns that it is hosting an EDV. Its normative requirements therefore bind the *client*; a server's obligations are only the ones it already has for the underlying WAS operations.

E.1 Purpose

An Encrypted Data Vault stores JWE-encrypted documents that its server can query by blinded index but never decrypt. A client can realize all of that behavior on a plain WAS server, because a WAS Resource is an opaque byte store and WAS already carries the pieces an EDV needs. This profile fixes the mapping so that independent clients interoperate over the same encrypted Collection:

EDV concept WAS realization
Vault collection
Document Resource (the EDV envelope stored as its content)
Document sequence conditional writes (If-Match / If-None-Match; see 7.2 Conditional Requests)
Blinded index + query the blinded-index query profile (see D.2 blinded-index profile)
Stream chunks chunk addressing (see 12. Chunked Resources)

Encryption itself is out of scope of the server entirely: the client holds all keys and performs all encryption, decryption, and index blinding, as required by G.3 Stored data is opaque to the storage provider. The server enforces WAS authorization, maximum-privacy 404s, and policies over the ciphertext unchanged; the encryption is defense in depth layered on top, not a replacement for the WAS authorization model.

E.2 The encryption descriptor

A Collection realizing this profile declares the encryption descriptor { "scheme": "edv", "version": 1 } in its Collection Description (see 10.1 Collection Data Model; a bare { "scheme": "edv" } is equivalent, since an absent version means 1). The wire version is the integer registry key of the envelope format, distinct from this appendix's own "v0.1" maturity label. The edv scheme's envelope wire format and the server's structural fail-closed validation of it are defined by the C. Encryption Scheme Registry and are not restated here. A server that recognizes the edv scheme thereby guarantees, without holding any key, that a plaintext write into the Collection is rejected -- so the profile inherits the registry's fail-closed property for free.

Key management -- how the JWE's recipient keys are chosen, distributed, rotated, or organized into epochs for multi-recipient sharing -- is deliberately outside the scope of both this profile and this specification, exactly as it is for a native EDV: those keys live in the client, and the server sees only opaque envelopes. Multi-recipient encryption is carried either directly, in the JWE recipients structure the C. Encryption Scheme Registry describes, or through the key-epoch indirection of C.4 Key Epochs, whose public bookkeeping (the descriptor's epochs roster and the Resource epoch stamp) the server stores and serves without interpreting; the client-side epoch construction is specified in [WAS-EC].

E.3 Document layout

A client stores each EDV document as the EDV Encrypted Document envelope -- the JSON object { id, sequence, indexed?, jwe } -- as the Resource's content, at the Resource id equal to the EDV document id. The envelope is the edv scheme's registered wire format (see C. Encryption Scheme Registry); a client SHOULD write it under the JWE JSON Serialization media type application/jose+json ([RFC7516]) where the server registers a parser for it, and MAY fall back to application/json, which an unmodified WAS server accepts. The plaintext type of the resource, and any user-visible metadata, ride inside the JWE and are never server-visible; the server stores one opaque envelope regardless of what the decrypted document is.

E.4 Sequence mapping

EDV gives every document a monotonic sequence and enforces previous + 1 atomically server-side. This profile maps that onto WAS conditional writes (see 7.2 Conditional Requests):

A client SHOULD use these preconditions only against a backend advertising conditional-writes (see 14.1 Backend Data Model). Against a backend without it, the mapping degrades to advisory: the sequence is still carried in the envelope but is not enforced, and writes are last-writer-wins -- the floor the profile reaches on any conformant WAS server.

E.5 Chunked streams

A large or streamed EDV document is stored as chunks, using WAS chunk addressing (see 12. Chunked Resources) against a backend advertising chunked-streams:

  1. The client writes the document envelope first, as an ordinary Resource (satisfying the 12.2 Store Chunk Operation rule that the parent Resource must exist before any of its chunks). The envelope carries stream metadata -- { sequence, chunks }, where chunks is the total chunk count -- so a reader can learn the extent of the stream from the document alone.
  2. For each chunk i in 0 .. chunks - 1, the client serializes the EDV chunk object { index, offset, sequence, jwe } to JSON and PUTs it to chunk index i under the application/octet-stream content type. The octet-stream type is deliberate: it routes the chunk through the server's raw-binary write path, which is bounded by the backend's maxUploadBytes (tens of MiB) rather than the much smaller body-size limit servers typically impose on JSON-parsed requests (the reference server's is 1 MiB), which a full encrypted chunk would exceed. The server stores the bytes verbatim and never parses them, so a reader decodes and parses the chunk object back client-side.
  3. A reader fetches the document envelope, reads stream.chunks, fetches chunk indexes 0 .. chunks - 1 (see 12.3 Read Chunk Operation), and decrypts each jwe client-side to reassemble the stream.
Note
**Security consideration (stream integrity).** The chunk framing above authenticates the *bytes* of each chunk (each jwe is an AEAD ciphertext) but does **not** authenticate a chunk's *position* in the stream or the stream's *total length*: the index, offset, and count are carried in plaintext framing and in the (individually authenticated but not cross-linked) chunk objects. A malicious server that reorders, drops, duplicates, or truncates chunks is therefore **not** reliably detectable by the client with this framing. A hardened framing -- a per-stream authenticated header, an index-bound AAD on each chunk, and an authenticated total length, in the manner of a Cryptomator-style file header -- closes this gap and is planned upstream in the EDV specification. When it lands, this profile will adopt it under a new scheme version; until then, a deployment that does not trust its storage provider for availability and ordering integrity SHOULD treat streamed documents accordingly.

Content search over encrypted documents uses the blinded-index query profile (see D.2 blinded-index profile): a client attaches blinded (HMAC'd) indexed attributes to the envelope and queries them via POST /space/{space_id}/{collection_id}/query. Blinded-attribute uniqueness (unique: true) is enforced server-side by that profile (see D.2 blinded-index profile, Unique blinded attributes). Both require a backend advertising the blinded-index-query feature; a client SHOULD gate their use on it.

E.7 What this profile does not provide

Relative to a dedicated EDV server, this profile reaches full parity only for the features whose backend affordances the target backend advertises, and degrades to a client-side floor otherwise:

For a small single-writer Collection (for example, a credential wallet) the floor alone is fully usable; a large or multi-writer Collection wants a backend that advertises the affordances above.

F. Error Type Registry

This appendix is normative.

This specification uses [RFC9457] Problem Details for HTTP APIs for error responses (see 6. Error Handling). The type property of a problem response is a URI identifying the kind of problem. Following [RFC9457], a type is reused across operations: a single kind such as invalid-id is emitted by Create, Update, and Read operations across Spaces, Collections, and Resources alike. The per-occurrence specifics belong in the errors array (detail and an optional pointer), and the short human-readable summary in title.

Each type URI is a fragment anchor into this registry. The status codes listed below are typical; a single kind MAY be returned with more than one status code depending on the operation.

type URI Anchor Typical status Description
https://wallet.storage/spec#not-found not-found 404 The resource (Space, Collection, or Resource) does not exist, or the caller is not authorized to access it. These two conditions are deliberately indistinguishable -- see the privacy note below.
https://wallet.storage/spec#invalid-id invalid-id 400 A Space, Collection, or Resource id is missing or not URL-safe.
https://wallet.storage/spec#reserved-id reserved-id 409 A client-supplied id collides with a B. Reserved Path Segment Registry segment.
https://wallet.storage/spec#id-conflict id-conflict 409 A client-supplied id in a POST create operation already exists. (Create-or-replace by id is done idempotently via PUT, which does not conflict.)
https://wallet.storage/spec#invalid-request-body invalid-request-body 400 The request body is missing or invalid (e.g. a required property is absent). Entries in errors SHOULD carry a pointer to the offending field.
https://wallet.storage/spec#invalid-cursor invalid-cursor 400 A pagination cursor query parameter is malformed or can no longer be honored (e.g. an expired snapshot). See A. Pagination.
https://wallet.storage/spec#missing-content-type missing-content-type 400 A required Content-Type header is missing.
https://wallet.storage/spec#missing-authorization missing-authorization 401 Required Authorization / Capability-Invocation headers (or proof of possession) are missing.
https://wallet.storage/spec#invalid-authorization-header invalid-authorization-header 400 An Authorization, Capability-Invocation, or Digest header is malformed, unparseable, or failed verification.
https://wallet.storage/spec#controller-mismatch controller-mismatch 400 The capability invocation in a Create Space request is not currently authorized by the controller supplied in the request body: it is neither signed by that DID nor accompanied by a valid, unexpired delegation chain rooted in it. Servers SHOULD differentiate the cause (chain rooted elsewhere, expired delegation, failed proof) in the detail string where they can; see 8.1.2 Create Space Errors.
https://wallet.storage/spec#unsupported-backend unsupported-backend 409 A requested backend id is not in the space's 14.2 Space Backends Available list.
https://wallet.storage/spec#encryption-immutable encryption-immutable 409 A Collection update tried to change the scheme, decrease or remove the version, or clear an existing encryption descriptor. The descriptor is set-once, version-monotonic: declaring it on a Collection that lacks one is allowed (and re-declaring the standing values is a no-op), but changing its scheme, moving its version backward, or clearing it on a populated Collection would corrupt the stored, client-encrypted Resources. See 10.1 Collection Data Model.
https://wallet.storage/spec#encryption-scheme-mismatch encryption-scheme-mismatch 422 A write into an encrypted Collection -- a Resource's content, or the custom object of a Resource's or the Collection's own Metadata -- had a body (or Content-Type) that does not conform to the Collection's declared encryption scheme envelope profile. Reachable only by a caller already authorized to write -- see C. Encryption Scheme Registry.
https://wallet.storage/spec#unsupported-encryption-scheme unsupported-encryption-scheme 400 A Collection create/update declared an encryption scheme (or a version of one) the server does not recognize or support. See C. Encryption Scheme Registry.
https://wallet.storage/spec#precondition-failed precondition-failed 412 A conditional write's If-Match / If-None-Match precondition evaluated false: the Resource's current version did not match, or a create-if-absent target already exists. Header-driven and distinct from the 409 conflict kinds. See 7.2 Conditional Requests.
https://wallet.storage/spec#quota-exceeded quota-exceeded 507 A write was rejected because the target backend's storage quota is exhausted. See 15. Quotas.
https://wallet.storage/spec#payload-too-large payload-too-large 413 An upload exceeds the target backend's maxUploadBytes constraint (see 15. Quotas). Note that unlike quota-exceeded, this rejection is per-request: smaller uploads may still succeed.
https://wallet.storage/spec#unsupported-operation unsupported-operation 501 An optional operation that this server or the target backend does not support (for example, a per-collection quota report on a backend without per-collection accounting).
https://wallet.storage/spec#invalid-import invalid-import 400 An uploaded archive is not a valid WAS space export. The import operation itself is reserved and not yet specified (see B. Reserved Path Segment Registry); this kind is registered ahead of it so implementations converge on one error shape.
https://wallet.storage/spec#storage-error storage-error 500 An underlying storage operation failed.
https://wallet.storage/spec#internal-error internal-error 500 An unexpected server-side fault with no more specific kind.

Privacy: the not-found kind is intentionally merged. Under the principle of maximum privacy (see 6. Error Handling), an unauthorized client MUST NOT be able to discover the existence of a Space, Collection, or Resource from an error response. The not-found kind therefore covers both "resource absent" and "invalid authorization"; implementations MUST NOT split it into distinguishable type values, and MUST NOT otherwise let the response (status code, title, or detail) reveal whether the resource exists.

This merging applies to "insufficient-authorization" and "absent-authorization" against an existing target. It does not apply to request- or credential-validation failures, which describe the request rather than the target and so MAY use their own precise types -- see 6. Error Handling.

Privacy: id-conflict is existence-revealing by nature -- a 409 confirms that the supplied id is taken. For Create Collection and Create Resource, servers MUST therefore verify the caller's authorization before checking for a conflict, so that only callers already authorized to create at that level can observe the signal; an under-authorized caller receives the merged not-found instead. For Create Space, where any caller permitted to attempt creation learns the same fact from whether creation succeeds, the disclosure is inherent -- see the note under 8.1.2 Create Space Errors.

F.1 Error Response Examples

Canonical example responses for the error kinds defined above, referenced by the per-operation "Errors" lists throughout this specification. The title strings are illustrative, not normative: for example, the noun in a not-found title varies with the target (Space, Collection, or Resource), and a title may name the operation that failed. Kinds not shown here (missing-content-type, invalid-authorization-header, invalid-import, storage-error, internal-error) follow the same shape; they will gain examples as their corresponding sections are drafted.

not-found -- a missing target, or missing or insufficient authorization (deliberately indistinguishable, see the privacy note above):

HTTP/1.1 404 Not Found
Content-type: application/problem+json

{
  "type": "https://wallet.storage/spec#not-found",
  "title": "Resource not found or insufficient authorization."
}

invalid-id -- an id that is not URL-safe (see 4. Identifiers):

HTTP/1.1 400 Bad Request
Content-type: application/problem+json

{
  "type": "https://wallet.storage/spec#invalid-id",
  "title": "Invalid Space id.",
  "errors": [
    {
      "detail": "Space 'id' must be URL-safe.",
      "pointer": "#/id"
    }
  ]
}

reserved-id -- a client-supplied id that collides with a segment from the B. Reserved Path Segment Registry:

HTTP/1.1 409 Conflict
Content-type: application/problem+json

{
  "type": "https://wallet.storage/spec#reserved-id",
  "title": "Invalid collection id (from reserved list)."
}

id-conflict -- a POST create operation supplying an id that already exists:

HTTP/1.1 409 Conflict
Content-type: application/problem+json

{
  "type": "https://wallet.storage/spec#id-conflict",
  "title": "A Collection with this id already exists.",
  "errors": [
    {
      "detail": "Use PUT to create-or-replace a Collection at a chosen id.",
      "pointer": "#/id"
    }
  ]
}

precondition-failed -- a conditional PUT whose If-Match precondition did not match the Resource's current version (a concurrent write landed first):

HTTP/1.1 412 Precondition Failed
Content-type: application/problem+json

{
  "type": "https://wallet.storage/spec#precondition-failed",
  "title": "The resource was modified by another write.",
  "errors": [
    {
      "detail": "Re-read the current resource, re-apply your change, and retry."
    }
  ]
}

invalid-request-body -- a missing or invalid request body (here, a Create Space request without the required controller property):

HTTP/1.1 400 Bad Request
Content-type: application/problem+json

{
  "type": "https://wallet.storage/spec#invalid-request-body",
  "title": "Invalid Create Space body.",
  "errors": [
    {
      "detail": "'controller' property is required.",
      "pointer": "#/controller"
    }
  ]
}

missing-authorization -- required authorization (here, a proof of possession signature on a Create Space request) is missing:

HTTP/1.1 401 Unauthorized
Content-type: application/problem+json

{
  "type": "https://wallet.storage/spec#missing-authorization",
  "title": "Invalid Create Space request.",
  "errors": [
    {
      "detail": "Valid proof of possession of the 'controller' DID must be provided."
    }
  ]
}

controller-mismatch -- the invocation is not currently authorized by the DID specified in the controller property of a Create Space request body (the detail here is the generic catch-all; servers that can tell SHOULD name the specific cause instead, e.g. "The delegation chain is rooted in a DID other than the body's 'controller'." or "The delegated capability has expired."):

HTTP/1.1 400 Bad Request
Content-type: application/problem+json

{
  "type": "https://wallet.storage/spec#controller-mismatch",
  "title": "Invalid Create Space request.",
  "errors": [
    {
      "detail": "The invocation must be authorized by the 'controller' DID in the request body: signed by it, or via a delegation chain rooted in it.",
      "pointer": "#/controller"
    }
  ]
}

unsupported-backend -- a backend id that is not part of that space's 14.2 Space Backends Available list:

HTTP/1.1 409 Conflict
Content-type: application/problem+json

{
  "type": "https://wallet.storage/spec#unsupported-backend",
  "title": "Unsupported backend id, check the space's 'backends available' list."
}

encryption-immutable -- a Collection update tried to change the scheme, decrease or remove the version, or clear an existing encryption descriptor (it is set-once, version-monotonic; see 10.1 Collection Data Model):

HTTP/1.1 409 Conflict
Content-type: application/problem+json

{
  "type": "https://wallet.storage/spec#encryption-immutable",
  "title": "Collection encryption descriptor is immutable."
}

encryption-scheme-mismatch -- a write into an encrypted Collection whose body (a Resource's content, or the custom object of a metadata write) is not an envelope of the Collection's declared scheme (observable only by a caller already authorized to write; see C. Encryption Scheme Registry):

HTTP/1.1 422 Unprocessable Content
Content-type: application/problem+json

{
  "type": "https://wallet.storage/spec#encryption-scheme-mismatch",
  "title": "Body does not conform to the Collection's encryption scheme."
}

quota-exceeded -- a write rejected because the target backend's storage quota is exhausted (see 15. Quotas):

HTTP/1.1 507 Insufficient Storage
Content-type: application/problem+json

{
  "type": "https://wallet.storage/spec#quota-exceeded",
  "title": "Storage quota exceeded for backend 'default'."
}

payload-too-large -- an upload exceeding the target backend's maxUploadBytes constraint:

HTTP/1.1 413 Content Too Large
Content-type: application/problem+json

{
  "type": "https://wallet.storage/spec#payload-too-large",
  "title": "Upload exceeds the backend's maximum upload size.",
  "errors": [
    {
      "detail": "Upload size 209715200 exceeds 'maxUploadBytes' of 157286400 for backend 'dropbox'."
    }
  ]
}

unsupported-operation -- an optional operation this server or backend does not support (here, a per-collection quota report):

HTTP/1.1 501 Not Implemented
Content-type: application/problem+json

{
  "type": "https://wallet.storage/spec#unsupported-operation",
  "title": "Backend 'default' does not support per-collection quota reports."
}

G. Goals and Requirements

This section is non-normative.

This storage specification is intended to support the following goals and requirements.

G.1 Local-first and Offline capable storage

Users and apps need to be able to use (provision, set up, and start reading and writing to) storage spaces without being connected to the internet.

G.2 Storage and sharing of public, permissioned, and private encrypted data

Although the local-first offline functionality is necessary, writing data to stable internet-accessible URLs for the purposes of sharing them is one of the primary use cases of this specification.

G.3 Stored data is opaque to the storage provider

G.4 Replication to user-controlled local and cloud servers

G.5 Serve as a General Purpose application storage backend

Intended to serve as a storage backend for credential wallets, and any other client-side (Single Page Applications), server side, desktop, and mobile apps and services.

G.6 Data Portability

Data written to storage spaces using this specification needs to be portable:

G.7 A Plurality of Data Formats and Protocols

G.8 Permissioned Query and Search functionality

G.9 Upgradeable and legislation-compliant cryptography

All cryptography has a half-life.

G.10 Anti-Goals

G.10.1 Use cases do not include "zero trust" environments

In a "zero trust" storage environment, the sync and replication nodes are assumed to be untrusted: they hold only ciphertext, enforce no authorization of their own, and encryption alone serves as the access control mechanism.

This storage specification is intentionally positioned to not be used in such environments. All encryption has an unpredictable half-life, and some use cases do not permit relying on encryption only for access control.

What this specification pursues instead is a combination of the two: encryption protects data at rest and in transit, while minimally trusted servers independently enforce the applicable policy on every request. A broken cipher alone therefore does not grant an attacker access.

H. IANA Considerations

This section is non-normative.

This section will be submitted to the Internet Engineering Steering Group (IESG) for review, approval, and registration with IANA.

I. References

I.1 Normative references

[DID-CORE]
Decentralized Identifiers (DIDs) v1.0. Manu Sporny; Amy Guy; Markus Sabadello; Drummond Reed. W3C. 19 July 2022. W3C Recommendation. URL: https://www.w3.org/TR/did-core/
[RFC2119]
Key words for use in RFCs to Indicate Requirement Levels. S. Bradner. IETF. March 1997. Best Current Practice. URL: https://www.rfc-editor.org/info/rfc2119/
[RFC3339]
Date and Time on the Internet: Timestamps. G. Klyne; C. Newman. IETF. July 2002. Proposed Standard. URL: https://www.rfc-editor.org/info/rfc3339/
[RFC6839]
Additional Media Type Structured Syntax Suffixes. T. Hansen; A. Melnikov. IETF. January 2013. Informational. URL: https://www.rfc-editor.org/info/rfc6839/
[RFC7516]
JSON Web Encryption (JWE). M. Jones; J. Hildebrand. IETF. May 2015. Proposed Standard. URL: https://www.rfc-editor.org/info/rfc7516/
[RFC7578]
Returning Values from Forms: multipart/form-data. L. Masinter. IETF. July 2015. Proposed Standard. URL: https://www.rfc-editor.org/info/rfc7578/
[RFC8174]
Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words. B. Leiba. IETF. May 2017. Best Current Practice. URL: https://www.rfc-editor.org/info/rfc8174/
[RFC9110]
HTTP Semantics. R. Fielding, Ed.; M. Nottingham, Ed.; J. Reschke, Ed. IETF. June 2022. Internet Standard. URL: https://httpwg.org/specs/rfc9110.html
[RFC9111]
HTTP Caching. R. Fielding, Ed.; M. Nottingham, Ed.; J. Reschke, Ed. IETF. June 2022. Internet Standard. URL: https://httpwg.org/specs/rfc9111.html
[RFC9264]
Linkset: Media Types and a Link Relation Type for Link Sets. E. Wilde; H. Van de Sompel. IETF. July 2022. Proposed Standard. URL: https://www.rfc-editor.org/info/rfc9264/
[RFC9457]
Problem Details for HTTP APIs. M. Nottingham; E. Wilde; S. Dalal. IETF. July 2023. Proposed Standard. URL: https://www.rfc-editor.org/info/rfc9457/
[WAS-EC]
Encrypted Collections for Wallet Attached Storage. Dmitri Zagidulin. W3C Credentials Community Group (proposed work item). URL: https://interop-alliance.github.io/encrypted-collections-spec/

I.2 Informative references

[RFC3230]
Instance Digests in HTTP. J. Mogul; A. Van Hoff. IETF. January 2002. Proposed Standard. URL: https://www.rfc-editor.org/info/rfc3230/
[RFC9421]
HTTP Message Signatures. A. Backman, Ed.; J. Richer, Ed.; M. Sporny. IETF. February 2024. Proposed Standard. URL: https://www.rfc-editor.org/info/rfc9421/
[RFC9530]
Digest Fields. R. Polli; L. Pardue. IETF. February 2024. Proposed Standard. URL: https://www.rfc-editor.org/info/rfc9530/