Wallet Attached Storage v0.5

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 - September 2026) -- 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 (September 2026 - now) -- Breaking changes, refactoring, community input. Likely pending a rename. Breaking changes so far:
    • The description of a container moved to its meta sub-resource at every level, and a Collection's description merged with its Metadata object. GET/PUT at /space/{space_id}/meta read and write the 9.1 Space Metadata Data Model; GET/PUT at /space/{space_id}/{collection_id}/meta read and write the 10.1 Collection Metadata Data Model, which now carries the members the Collection Description used to hold. The term "Collection Description" is retired.
    • A container URL is canonically written with a trailing slash, and a trailing slash no longer distinguishes two operations (see 2.2 Reading This Document).
    • The Space is an ordinary container: GET /space/{space_id}/ lists its Collections and POST /space/{space_id}/ creates one. The /space/{space_id}/collections/ endpoint is retired; its segment stays reserved.
    • A method a server does not implement at a reserved endpoint answers 405 Method Not Allowed (see C.4 Methods at Reserved Endpoints). This replaces the earlier choice between 405 and a reserved-id (409) error at the Collection meta path. A PUT at a container URL is now a MUST 405 rather than a SHOULD.
    • An update of a Collection's Metadata object that omits backend keeps the stored backend selection, rather than resetting it to the default.
    • Create Resource no longer lists reserved-id, or id-conflict for an existing id. The server generates the Resource id there, and a client that chooses one uses Update (or Create by Id) Resource. That operation no longer lists reserved-id either, since a reserved segment in the Resource position is a reserved endpoint (see C.4 Methods at Reserved Endpoints).

No stored data moves across the v0.4-to-v0.5 path changes. The durable artifacts to audit are capabilities. A delegated capability whose invocationTarget was a v0.4 description URL with a PUT action has no meaning under the v0.5 layout and needs re-delegation against the meta URL, while a capability on a container URL keeps its prefix coverage unchanged. A server MAY serve the two route tables together during a transition.

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.

Every path has one canonical form, and a trailing slash marks a container. A container -- a Space or a Collection -- is canonically written with a trailing slash, and everything else without one. So /space/{space_id}/{collection_id}/ is the Collection, and /space/{space_id}/{collection_id}/{resource_id} is a Resource in it. The two forms of a path name the same thing. A request to the non-canonical form is not a different operation, and a server SHOULD answer it with a 308 Permanent Redirect to the canonical form. No two paths defined by this specification differ only by a trailing slash. Because a signed capability invocation covers the request target (see 5.1.3 Performing Authorized API Calls), a client that follows such a redirect MUST sign again for the new target rather than replay its Authorization header; sending the canonical form to begin with avoids the round trip.

A container's description lives at its meta sub-resource. GET on a container lists its members and POST adds one, at every level: a Space's members are its Collections, a Collection's are its Resources. What the container is -- its name, its configuration -- is a separate document one segment down, at meta: 9.1 Space Metadata Data Model at /space/{space_id}/meta and 10.1 Collection Metadata Data Model at /space/{space_id}/{collection_id}/meta. The same split holds for a Resource, whose content is at its own URL and whose 11.8 Resource Metadata Data Model is at meta beneath it. Reading a description is therefore always a GET of meta.

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 G. Error Type Registry, path segments this specification reserves in C. Reserved Path Segment Registry, and client-side encryption schemes in D. 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, C. Reserved Path Segment Registry, D. Encryption Scheme Registry, and G. Error Type Registry (each of which also carries an inline "This appendix is normative." banner). The remaining appendices -- H. Goals and Requirements and I. 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 C. Reserved Path Segment Registry) but does not yet define an operation for; a server MUST NOT repurpose a reserved path. A method the server does not implement at a reserved endpoint is answered with 405 Method Not Allowed; see C.4 Methods at Reserved Endpoints.

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.

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 E. 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 - HTTP Signatures (Cavage draft 12), MAY - [RFC9421] HTTP Message Signatures (a future direction for this profile, see the note below)
  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)
Editor's note
**Signature suite (transitional).** The signature suite of this profile is the Cavage HTTP Signatures draft (draft 12). The covered-headers list uses its pseudo-headers ((key-id), (created), (expires), (request-target)), and the worked examples carry an Authorization: Signature ... header in its syntax. This matches every current implementation. [RFC9421] HTTP Message Signatures (the Signature and Signature-Input headers) is a future direction for this profile. It will be adopted as a coordinated migration across the implementations, together with the Content-Digest migration described in 5.1.4 Request Body Integrity (Digest Header).

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.

A Space controller MAY use a DID method other than did:key, provided the method is listed in the B. Space Controller DID Method Registry. Support for every method other than did:key is OPTIONAL. A server refuses a controller whose DID method it does not support with invalid-request-body (400), whether at creation or on an update (see 8.1.2 Create Space Errors and 9.3 Update (or Create by Id) Space operation).

5.1.2.1 Current-key-set rule

Whatever the DID method, the key material that verifies an invocation or a delegation is the signer's DID document as resolved at the time of verification. A signature verifies if and only if its verification method is present in that document, under the verification relationship the operation requires: capabilityInvocation for an invocation, capabilityDelegation for a delegation. A did:key document never changes. A document of a method with a mutable, verifiable history (see B.1 Verified-log DID methods) changes as that history is extended, so the set of accepted keys is the current one. A delegation signed by a key that has since been removed from the delegator's document stops verifying the moment that verification method leaves the document, even though the capability itself was not revoked. Every capability delegated onward from it stops verifying with it. Removing a key from a controller's document is therefore an immediate, server-enforced withdrawal of everything that key delegated, independent of any capability revocation mechanism.

This rule applies to every DID that signs in a capability chain, not only to the Space's controller. A capability rooted in a did:key-controlled Space MAY be delegated to a DID of any method the server supports; the server resolves that delegate's document the same way when it verifies the delegate's invocation or onward delegation.

5.1.2.2 Setting a controller to an optional DID method

A server that supports an optional DID method decides at which point it admits a controller of that method. It MAY accept one at Space creation. It MAY instead require that every Space be created with a did:key controller and admit the optional method only through a later update of the existing Space (see 9.3 Update (or Create by Id) Space operation); such an update promotes the did:key-controlled Space to the new controller. In both cases the proposed controller MUST resolve and verify before it is stored; a proposed controller that does not is refused with invalid-request-body (400). After the controller is stored, later updates to the Space are authorized by the DID document being resolved, so storing an unresolvable DID (a typo, a history not yet published) would leave the Space with no party able to act on it.

When a space is created via an HTTP POST or PUT operation (see 8.1.1 (HTTP API) POST /spaces/ and #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 the Cavage draft-12 pseudo-headers (key-id), (created), (expires), and (request-target), and the host and capability-invocation headers.

  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, see 5.1.1 Authorization Specification Dependencies at a Glance) 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 Resource at its own URL, or a Space or a Collection through its meta sub-resource (see 2.2 Reading This Document).
  • 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.

A target covers everything beneath it, so the choice of target is what attenuates a grant. A capability on a Collection URL, as above, covers the Collection's listing, every Resource in it, and its Metadata object -- which is what "share this collection" means. To grant the Collection's metadata alone, without its Resources, target the meta URL instead; that grant also covers the governing history log beneath it (see 10.7 Collection Governing History Log). The same holds one level up for a Space.

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 G. 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 a container's Metadata object, whose update operation replaces the whole object -- so two concurrent recipient changes to a key-epoch roster (see D.4 Key Epochs) would otherwise silently clobber one another:

The Space Metadata object carries the same validator, on the same terms: the 9.2 Read Space operation response includes an ETag header over the object's monotonic version, the 9.3 Update (or Create by Id) Space operation request MAY carry If-Match or If-None-Match: *, and a server advertising the conditional-writes feature MUST evaluate either atomically with the write and reject a failed precondition with precondition-failed (412). The Create Space and Update Space responses carry the new ETag. The create-if-absent form is what lets two clients provisioning the same Space at once resolve the race at the server: the loser's replacement PUT would otherwise rewrite the winner's type array, which a server accepts at creation only (see 9.1 Space Metadata Data Model).

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.

A Collection's metaVersion is independent of every Resource's validator: writing a Resource never advances it, and writing the Metadata object never advances a Resource's. Unlike the Resource-level validators it is not gated on the conditional-writes backend feature -- a server implementing the Collection management operations MUST maintain it and MUST support both preconditions -- and it covers configuration and annotation writes alike; see 10.1.1 Versioning.

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 G. Error Type Registry for canonical examples):

  • invalid-request-body (400) -- the required controller property is missing from the request body, or is a DID of a method the server does not accept as a Space controller (see B. Space Controller DID Method Registry: did:key is mandatory to support, other methods are optional, and a server may admit them only on update).
  • 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 Metadata object (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 Metadata Data Model

A Space's description is its Metadata object, addressable at the reserved meta path segment under the Space URL (one of the C.1 Space-level reserved endpoints). It is what 9.2 Read Space operation returns and what 9.3 Update (or Create by Id) Space operation writes; the Space URL itself is the container of the Space's Collections (see 9.5 List All Collections operation).

Because the meta segment occupies the {collection_id} position, meta is a reserved Collection id: creating a Collection with the id meta is a reserved-id conflict (see C. Reserved Path Segment Registry).

PUT is not defined at the Space URL itself; the replacement write is a PUT of this object. A server MUST answer a PUT at the Space URL with 405 Method Not Allowed.

Space properties:

Space properties automatically added by the server:

A server that supports conditional writes (see 7.2 Conditional Requests) also keeps a server-managed version validator for each Space Metadata object: an opaque strong validator that changes on every write of the object and is never reused after the Space is deleted. It is not a member of the Space Metadata object; it is surfaced only as the ETag header of the Read Space response and of the Create Space and Update Space responses, and consumed only through the If-Match and If-None-Match: * preconditions of the Update (or Create by Id) Space operation.

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}/meta

Example request:

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

Example success response:

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

{
  "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 G. 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

A PUT of a Space's Metadata object is a full replacement of that object, and creates the Space when none exists under that id. The client specifies the id of the Space in the request URL.

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 Metadata 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.

A new controller written by an update is subject to the same method acceptance as one set at creation (see B. Space Controller DID Method Registry), plus the promotion rule of 5.1.2.2 Setting a controller to an optional DID method: a proposed controller of an optional method MUST resolve and verify before it is stored, and a server MAY admit such controllers only through this operation.

The request MAY carry a precondition (see 7.2 Conditional Requests): If-Match: "<etag>" performs the update only if the Space Metadata object's current ETag matches it, and If-None-Match: * performs the write only if no Space exists under that id yet. A server that supports conditional writes MUST evaluate the precondition atomically with the write, after authorization, and answer a failed one with precondition-failed (412). Both are opt-in: an unconditional PUT remains valid and remains last-writer-wins. A client that creates a Space it has just read as absent SHOULD send If-None-Match: * and, on a 412, re-read the Space instead of treating the response as a failure. The success response carries the Metadata object's new ETag.

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

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):

PUT /space/81246131-69a4-45ab-9bff-9c946b59cf2e/meta 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. The Location header names the Space that was created, not the Metadata object that was written:

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

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/meta 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
ETag: "z3fkq.2"

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

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 G. 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}/

Example request (a container URL, so it carries the trailing slash):

GET /space/81246131-69a4-45ab-9bff-9c946b59cf2e/ 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/",
  "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 G. 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 Metadata Data Model

A Collection's description is its Metadata object, addressable at the reserved meta path segment under the Collection URL (one of the C.2 Collection-level reserved endpoints). It holds everything recorded about the Collection: what it is called, how its Resources are stored and whether they are client-side encrypted, who created it, and whatever annotations the user keeps on it. The Collection URL itself is the container of the Collection's Resources (see 10.5 List Collection operation).

Because the meta segment occupies the {resource_id} position, meta is a reserved Resource id (see C. Reserved Path Segment Registry). A GET or PUT at this path is always a Collection metadata operation, so no Resource is ever addressed at the id meta. Methods this specification does not define at this path (DELETE in particular) are not Resource operations either; see C.4 Methods at Reserved Endpoints for how a server answers them. The same meta segment also roots the Collection's governing history log, a separate sub-resource at meta/log with operations and a validator of its own (see 10.7 Collection Governing History Log).

PUT is not defined at the Collection URL itself; the replacement write is a PUT of this object. A server MUST answer a PUT at the Collection URL with 405 Method Not Allowed.

Reading and writing this object are part of the OPTIONAL Collection management group (see 2.6 API Summary): on a server where Collections are pre-configured or implicit, they are not implemented, and such a server SHOULD answer requests to this path with an unsupported-operation (501) error.

Writable properties:

Properties automatically added by the server, and read-only: a server MUST ignore any of them supplied in a request body.

There is no writerId member. A Collection's Metadata object 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 D. 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 every other member of this object stays 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. On an encrypted Collection a present custom MUST be either a conforming envelope or an empty object; an empty object clears the stored annotations. Omitting custom clears them too, exactly as on a plaintext Collection, because a PUT of this object is a full replacement. That is the same omission-clears rule epoch follows.

Example Metadata object of a plaintext Collection (JSON representation):

{
  "id": "73WakrfVbNJBaAmhQtEeDv",
  "url": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e/73WakrfVbNJBaAmhQtEeDv/",
  "type": ["Collection"],
  "name": "Verifiable Credentials Collection",
  "createdAt": "2026-06-10T09:12:00Z",
  "updatedAt": "2026-06-12T13:25:00Z",
  "createdBy": "did:key:z6MkpBMbMaRSv5nsgifRAwEKvHHoiKDMhiAHShTFNmkJNdVW",
  "generator": "did:key:z6MkfriqYRX3JBqzsbVbKuBUxDR2nsjLTu6AbrxJZAmFmXWb",
  "generatorOrigin": "https://app.example.com",
  "linkset": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e/73WakrfVbNJBaAmhQtEeDv/linkset",
  "custom": {
    "tags": { "project": "demo" }
  }
}

10.1.1 Versioning

The Metadata object carries a monotonic version, metaVersion, surfaced as a strong ETag validator on read. It is the Collection's one validator: it covers configuration and annotation writes alike, so a change to backend and a change to custom advance the same counter. (The name is the same one the changes query profile reports per Resource; this is its Collection-scoped counterpart.) It is independent of every Resource's validator -- writing a Resource never advances it, and writing this object never advances a Resource's -- and of the governing history log's, which versions its own sub-resource (see 10.7.5 Versioning).

A server implementing these operations MUST maintain the version and MUST honor If-Match and If-None-Match: * preconditions on the 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 object's JSON representation. Because the object exists exactly as long as its Collection does, If-None-Match: * here means "write only if the Collection does not exist" -- the create-if-absent precondition of 10.4 Update (or Create by Id) Collection operation.

Carrying configuration and annotation on one validator means a configuration change invalidates an in-flight annotation write. That is intended. A change to encryption requires the custom envelope to be re-sealed in any case, and for anything else the client re-reads and retries. A client that wants to update custom alone still resends the configuration members it just read, because PUT is a full replacement.

Writes of this object are invisible to replication: the changes query profile is per-Resource and emits no entry for them, so a client mirroring the object re-reads it and compares the returned ETag.

10.1.2 Lifecycle

The Metadata object and its Collection share one lifecycle. The object comes into existence with the Collection, written by the POST or PUT that creates it, and there is no state in which a Collection exists without one: a GET of meta on an existing Collection always succeeds and always carries an ETag.

There is no DELETE at meta. Deleting the Collection removes its Metadata object with it. To clear the user's annotations alone, send a PUT carrying the configuration members unchanged and an empty custom object, or none at all; both forms clear on an encrypted Collection too (as above). Clearing is itself a write and advances metaVersion. A Collection later re-created under the same id starts over: its metaVersion restarts, so a client MUST NOT compare validators across a delete and a re-create.

10.2 Create Collection (Add Collection to a Space) operation

A Collection is added to a Space by POSTing its Metadata object (see 10.1 Collection Metadata Data Model) to the Space. The client can specify the id of the Collection in the body. 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}/

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

POST /space/81246131-69a4-45ab-9bff-9c946b59cf2e/ 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/ 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 G. Error Type Registry for canonical examples):

10.3 Read Collection Metadata operation

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

Example request:

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

Example response:

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

{
  "id": "73WakrfVbNJBaAmhQtEeDv",
  "url": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e/73WakrfVbNJBaAmhQtEeDv/",
  "type": ["Collection"],
  "name": "Verifiable Credentials Collection",
  "createdAt": "2026-06-10T09:12:00Z",
  "updatedAt": "2026-06-12T13:25:00Z",
  "createdBy": "did:key:z6MkpBMbMaRSv5nsgifRAwEKvHHoiKDMhiAHShTFNmkJNdVW",
  "linkset": "/space/81246131-69a4-45ab-9bff-9c946b59cf2e/73WakrfVbNJBaAmhQtEeDv/linkset",
  "backend": { "id": "default" },
  "custom": {
    "tags": { "project": "demo" }
  }
}

Errors (see G. 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.
  • unsupported-operation (501) -- the server does not implement the optional Collection management operations.

10.4 Update (or Create by Id) Collection operation

A PUT of a Collection's Metadata object is a full replacement of that object, and creates the Collection when none exists under that id. The client specifies the id of the Collection in the request URL; it MUST NOT collide with the list of C.1 Space-level reserved endpoints.

Full replacement means that a writable member the request omits is cleared, so a client that wants to change one member reads the object, edits it, and writes it back. Some members qualify that rule. The server-managed members are read-only: a server MUST ignore createdAt, updatedAt, createdBy, url, and linkset in a request body, so that a read-modify-write roundtrip needs no stripping. On a Collection whose encryption descriptor is governed by its history log, the server derives that member from the log's head and refuses a direct write of it (see 10.7 Collection Governing History Log). An update that omits plaintext leaves the stored member untouched (see its member definition). And an update that omits backend MUST keep the stored backend selection. Clearing it would repoint the Collection at the default backend. The Resources already stored in the selected backend would become unreachable, and later writes would land elsewhere. Only a create that omits backend is assigned the default.

The request MAY carry a precondition (see 7.2 Conditional Requests): If-Match: "<etag>" performs the write only if the object's current ETag matches it, and If-None-Match: * performs the write only if the Collection does not exist yet. A failed precondition is precondition-failed (412); an unconditional PUT remains last-writer-wins. The success response carries the object's new ETag.

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

Example successful "create" request. A PUT that creates the Collection and a PUT that reconfigures an existing one are the same operation:

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

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

The Location header names the Collection that was created, not the Metadata object that was written:

HTTP/1.1 201 Created
Location: https://example.com/space/81246131-69a4-45ab-9bff-9c946b59cf2e/73WakrfVbNJBaAmhQtEeDv/
ETag: "1"

Example request annotating an existing Collection. The configuration members are resent unchanged, because this is a full replacement, and the If-Match precondition makes the write conditional on nothing else having changed in the meantime:

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

{
  "id": "73WakrfVbNJBaAmhQtEeDv",
  "name": "Verifiable Credentials Collection",
  "type": ["Collection"],
  "backend": { "id": "default" },
  "custom": {
    "tags": { "project": "demo" }
  }
}
HTTP/1.1 204 No Content
ETag: "2"

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

10.5 List Collection operation

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

Example request (a container URL, so it carries 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 D.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 D. 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 G. 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:

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 Governing History Log

A Collection MAY carry one governing history log: an append-only, client-written log that the server stores at the sub-path /space/{space_id}/{collection_id}/meta/log and from whose head entry the server derives a member of the Collection's Metadata object. The clients that share the Collection write the log and read it. The server stores it, checks a minimal line contract on every write, and serves the derived member to every reader. Which members a log governs, what an entry is beyond its state, and how a reader verifies the log are defined by a governing profile, not by this specification. The encryption descriptor of the 10.1 Collection Metadata Data Model is the first governed member, under the resource log profile of [WAS-EC]; see 10.7.6 The first governed member: encryption.

Support is OPTIONAL and discoverable. A backend that implements the sub-resource advertises the governed-history-logs token in its Backend description (see 14.1 Backend Data Model). A server without the feature serves a client-written member instead, so a producer MUST consult the token before choosing which form to write. A server that does not implement the sub-resource SHOULD return an unsupported-operation (501) error for requests to its path, and MUST NOT treat them as Resource operations (the path lies under the reserved meta segment; see C.2 Collection-level reserved endpoints).

10.7.1 The sub-resource

The log is a sub-resource of the Collection, addressed beside its Metadata object under the reserved meta segment. It is neither a Resource of the Collection nor a member of the Metadata object:

Authorization follows the Collection. The log is readable under any capability whose invocationTarget covers the Collection URL, so a share grantee or an app reads it with the capability it already holds; a read is also served where the Collection's access-control policy grants reads. A write requires a capability that allows the PUT action at the log's URL; a policy grants no writes to it. As everywhere in this specification, a caller that is not authorized receives the merged not-found (404), per 6. Error Handling.

10.7.2 Line contract

The log body is JSON Lines [JSON-LINES]: one JSON object per line, lines separated by U+000A LINE FEED, with an optional trailing line feed. The whole of what a server requires of the content is:

  • each line is a JSON object carrying a state member whose value is a JSON object;
  • the last line is the head entry, whose state is the current state of what the log governs;
  • the first (genesis) line MAY carry a string parameters.method, the log format identifier the derived member's history.method echoes.

A server MUST refuse a write whose body breaks this contract with an invalid-request-body (400) error: an empty body, a line that is not a JSON object, a line without an object state, or a blank line other than the trailing line feed. A server MUST NOT require anything else of a line. Proofs, hash chaining, the type of a state, and any member name reserved inside state are defined by the governing profile (for the resource log format, [WAS-EC]) and are not checked here. The server stores the log; it does not verify it.

10.7.3 Declaration and the derived member

The guarded create of the log is the declaration. A PUT to the sub-resource carrying If-None-Match: * (see 7.2 Conditional Requests) on a Collection that has no log makes the Collection log-governed. Nothing is added to the Collection's Metadata object, and a Collection stays governed until it is deleted.

From that write on, the server derives the governed member from the head entry: the served value is the head's state with one member stamped on,

"history": {
  "method": "<the genesis entry's parameters.method>",
  "resource": "<the absolute URL of the log>"
}

Where the genesis entry carries no parameters.method, the server serves the head state without history. The derived member appears wherever the Metadata object is served; the stored object holds no copy of it. Derivation is last-line parsing, so the derived member is exactly what a verifying reader computes from the head after stripping history.

A direct write of a governed member through the 10.4 Update (or Create by Id) Collection operation MUST be refused; the member is read-only on that path, and the object's other members update normally. For the encryption descriptor the refusal is encryption-history-log-governed (409).

Declaring governance on a Collection whose Metadata object already holds a client-written value of the governed member MUST be refused. There is no conversion between the two forms in this version: a Collection is provisioned in one form or the other. For the encryption descriptor the refusal is encryption-immutable (409).

10.7.4 Trust

The log is the only authoritative serving of what it governs. The derived member is a projection the server computes by parsing the head line, for its own checks and for readers that do not verify; the server verifies neither entry proofs nor the chain. A verifying reader reads the log, verifies it under the governing profile, and compares the derived member to the verified head's state after stripping history. A derived member that differs is a stale projection, and the reader acts on the verified head instead (for the resource log format, the equality check of [WAS-EC]).

10.7.5 Versioning

The log carries its own monotonic version, exposed as a strong ETag validator on read, on the same terms as the Collection Metadata object (see 10.1.1 Versioning). A server implementing the sub-resource MUST maintain it, MUST honor If-Match and If-None-Match: * on write, evaluated atomically with the write, and does so regardless of the conditional-writes backend feature. A log that does not exist has no validator, and If-None-Match: * succeeds exactly then. Because a log write changes the derived member, it also advances the Collection's metaVersion: a client holding that ETag sees the change on its next conditional read. A Collection re-created under the same id restarts the log's version, so a client MUST NOT compare validators across a delete and re-create.

10.7.6 The first governed member: encryption

The encryption descriptor (see 10.1 Collection Metadata Data Model) is the first, and in this version the only, member a profile governs. On a log-governed Collection the head entry's state is an encryption descriptor, and the Collection is an encrypted Collection from the declaration onward: the envelope rule applies to its Resources and its Metadata object, and the plaintext member is excluded, exactly as when the descriptor is written on the Metadata object. The profile's state carries a type member naming its schema ([WAS-EC]); the server stores it with the rest of the state and does not interpret it.

On every log write the server runs, on the head state, the same checks the 10.4 Update (or Create by Id) Collection operation runs on a supplied descriptor: the shape validation of D.4.3 Server validation and the scheme recognition rule of D. Encryption Scheme Registry. On an append it also runs the transition invariants against the prior head's state: epochs is append-only, currentEpoch never moves backwards, hmac is permanent once present, and the descriptor's scheme and version are set-once, version-monotonic. A violating write MUST be refused with the error the Update Collection operation raises for the same change, and MUST leave the log unchanged. This is the one clause of this mechanism that is specific to encryption; a later governed member adds its own.

10.8 Read Governing History Log Operation

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

  • 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 whose target covers the Collection URL
  • Returns the log body verbatim, as text/jsonl, with an ETag header over the log's version (see 10.7.5 Versioning)
  • A request carrying If-None-Match with the current validator is answered 304 Not Modified with the ETag and no body (see 7.1 Caching)

Example request:

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

Example success response (one entry per line; the entry members beyond state are the governing profile's):

HTTP/1.1 200 OK
Content-type: text/jsonl
ETag: "z3fkq.2"

{"versionId":"1-...","versionTime":"2026-09-07T10:00:00Z","parameters":{"method":"resource-log:0.1","scid":"z..."},"state":{"type":"WasEpochConfiguration","scheme":"edv","currentEpoch":"urn:uuid:8d3f...","epochs":[...]},"proof":[...]}
{"versionId":"2-...","versionTime":"2026-09-08T09:30:00Z","parameters":{},"state":{"type":"WasEpochConfiguration","scheme":"edv","currentEpoch":"urn:uuid:c17a...","epochs":[...]},"proof":[...]}

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

  • not-found (404) -- the Collection does not exist, it carries no log, or the caller has missing or insufficient authorization; per 6. Error Handling a log 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 governing history log.

10.9 Write Governing History Log Operation

The body of a write is the whole log. A client appends by sending the stored bytes verbatim followed by the new line, under an If-Match precondition carrying the validator of the log it read. A client creates the log, and so declares the Collection governed, with If-None-Match: *. A client MUST carry one of the two preconditions on every write. A server evaluates the precondition it is given atomically with the write. On a Collection that already carries a log, a server MUST also check, atomically with the write, that the body fast-forwards the stored log: the stored bytes verbatim followed by exactly one new line. A body the stored log is not a prefix of (a stale read, or a rewritten prefix) MUST be refused with a precondition-failed error (412), whether or not the write carries If-Match; a body that extends the stored bytes by no line or by several MUST be refused with an invalid-request-body error (400). A write capability can therefore add to a log but not erase or rewrite it. The server also checks the line contract and, for a governed encryption descriptor, the head-state transition (10.7.6 The first governed member: encryption). It verifies nothing inside an appended entry: a bad proof or a broken hash link is a chain break that a verifying reader detects under the governing profile.

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

  • 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 target covers the log's URL
  • The request body is the whole log as text/jsonl
  • Carries If-None-Match: * (guarded create, the declaration) or If-Match: "<etag>" (compare-and-swap append); see 7.2 Conditional Requests
  • Does not create a Collection: a write to the log path of a nonexistent Collection returns a not-found (404) error
  • Returns a 204 success response, with an ETag header carrying the log's new validator

Example request (the guarded create, with a genesis entry):

PUT /space/81246131-69a4-45ab-9bff-9c946b59cf2e/73WakrfVbNJBaAmhQtEeDv/meta/log HTTP/1.1
Host: example.com
Content-Type: text/jsonl
If-None-Match: *
Authorization: ...

{"versionId":"1-...","versionTime":"2026-09-07T10:00:00Z","parameters":{"method":"resource-log:0.1","scid":"z..."},"state":{"type":"WasEpochConfiguration","scheme":"edv","currentEpoch":"urn:uuid:8d3f...","epochs":[...]},"proof":[...]}

Example success response:

HTTP/1.1 204 No Content
ETag: "z3fkq.1"

From this point the Collection Metadata object's encryption member reads as the entry's state plus history:

"encryption": {
  "type": "WasEpochConfiguration",
  "scheme": "edv",
  "currentEpoch": "urn:uuid:8d3f...",
  "epochs": [...],
  "history": {
    "method": "resource-log:0.1",
    "resource": "https://example.com/space/81246131-69a4-45ab-9bff-9c946b59cf2e/73WakrfVbNJBaAmhQtEeDv/meta/log"
  }
}

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

  • not-found (404) -- the Collection does not exist (this operation does not create one), or the caller has missing or insufficient authorization, per 6. Error Handling.
  • invalid-request-body (400) -- the body breaks the line contract (10.7.2 Line contract); or, for a governed encryption descriptor, the head state is malformed or the append violates an epoch invariant, as the Update Collection operation would report it.
  • unsupported-encryption-scheme (400) -- the head state declares a scheme (or a version of one) the server does not recognize.
  • encryption-immutable (409) -- the guarded create targets a Collection whose Description already holds a client-written encryption descriptor; or the append changes the scheme, moves the version backward, or changes or drops the hmac member.
  • precondition-failed (412) -- an If-Match / If-None-Match: * precondition evaluated false: another writer appended first, or the log already exists; see 7.2 Conditional Requests.
  • unsupported-operation (501) -- the server does not implement the optional governing history log.

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).

Server-generated documents. API documents generated by the server -- Metadata objects, listings, quota reports -- 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 D.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). The server generates the Resource id and returns it in the Location response header. A client that chooses the id uses 11.6 Update (or Create By Id) Resource Operation instead.

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 G. 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 G. 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 C.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 D.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 G. 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 G. 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 C.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.1 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 D. 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. An empty custom object is accepted as well and clears the stored value, and so does an omitted custom, here as on a plaintext Collection. 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 ({}); both forms clear on an encrypted Collection too.

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 Metadata 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 D.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 G. 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 D.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 G. 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 F. 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 D. 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 G. 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 G. 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 G. 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 G. 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 C. Reserved Path Segment Registry).

Chunk writes and deletes are invisible to the changes query profile (see E.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 C.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 C.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. Omitting it on a later update of the Collection's Metadata object keeps the backend already selected.

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 Metadata Data Model): a non-secret declaration any authorized reader discovers from the Collection's Metadata object, 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 Metadata 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 G. 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. Space Controller DID Method Registry

Note
Every method in this registry other than did:key is OPTIONAL to support. A server that supports only did:key Space controllers is conformant; a server that does support a registered method MUST follow that method's profile here.

This registry lists the DID methods a Space controller (and, by the 5.1.2.1 Current-key-set rule, any DID signing in a capability chain) may use, and the verification a server performs for each. See 5.1.2 Space controller and the Root of Trust for the method-neutral rules: the current-key-set rule, refusal of unsupported methods, and the resolve-before-store rule for a proposed controller.

Method Support Kind Self-hosted history layout Profile
did:key REQUIRED static n/a B.2 did:key controller profile
did:webvh OPTIONAL verified log /space/{space_id}/{collection_id}/did.jsonl B.3 did:webvh controller profile

B.1 Verified-log DID methods

A verified-log DID method is one whose DID document is derived from a self-certifying history (a log of entries, each bound to the one before it and to the DID's own identifier) rather than from the identifier alone. This subsection states the rules common to every such method in the registry; a method's profile adds the log layout and the verification its own specification requires.

A server that supports a verified-log method MUST resolve a DID of that method by reading its history and fully verifying it, as the method's specification defines, before using the resulting DID document. The history is verified rather than trusted. Once a Space names such a DID as controller, the writes that extend that history may themselves be authorized by the very document being resolved, so an unverified history would let whoever can write it rewrite the Space's root of trust.

B.1.1 Self-hosted histories

The history of a verified-log controller MAY be stored on the WAS server itself, as one or more Resources in a Collection of a Space on that server. The method's standard DID-to-URL transformation then lands on /space/{space_id}/{collection_id}/ followed by the method's history file name(s); each profile gives the exact layout. Such a controller is self-hosted. The Space hosting the history need not be the Space the controller controls, since the DID names the history's own Space and Collection.

A server that supports a verified-log method MUST resolve self-hosted DIDs of that method by reading the history out of its own storage. A network fetch of its own URL is not a substitute: it would pass through the history Collection's access-control policy and through any intermediary in front of the server. Resolution reads the history regardless of the Collection's policy, so a capability-gated history still resolves for authorization while remaining unreadable to clients that hold no capability for it. A server MAY accept only self-hosted controllers of a method and refuse every other DID of that method (one whose history lives on another host) with invalid-request-body (400), the same way it refuses an unsupported method.

Hosting a history under a Space's path is not an endorsement by that Space's controller: any party holding a write grant there can store a resolvable history. A verified-log DID is self-certifying through its own identifier and history. It acquires authority in WAS only by being referenced, as a Space's stored controller or as the controller of a delegated capability, and not by where its history is stored.

A server MAY cache a resolved document. It MUST drop the cached entry whenever the history the document was resolved from may have changed, so that a write to a self-hosted history (such as one adding or removing a key) is reflected by the next verification that resolves that DID.

B.2 did:key controller profile

The mandatory floor. A conformant server MUST accept a did:key using the Multikey encoding of Ed25519 elliptic curve keys, as specified in the Multikey section of the CID spec, as a Space controller. The DID document is derived from the identifier alone; there is nothing to resolve or verify beyond the key encoding, and the document never changes.

B.3 did:webvh controller profile

A verified-log method; the rules of B.1 Verified-log DID methods apply.

History layout. The history is the method's did.jsonl log. Self-hosted, the DID did:webvh:{scid}:{host}:space:{space_id}:{collection_id} resolves from the Resource at https://{host}/space/{space_id}/{collection_id}/did.jsonl under the method's standard DID-to-HTTPS transformation. The {collection_id} is any Collection id; WAS Collection ids are restricted to characters the method's path encoding leaves untouched, so the mapping is direct.

Verification. Before using the resolved document the server MUST verify the log as the method's specification defines: the SCID is checked against the log's first entry, the hash chain of entries is verified, pre-rotation commitments (nextKeyHashes) are enforced, and each entry's proof is verified against the update keys authorized by the entry before it.

C. Reserved Path Segment Registry

This appendix is normative.

C.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}/meta meta Space metadata; see 9.1 Space Metadata Data Model
/space/{space_id}/policy policy Access control policy
/space/{space_id}/backends backends Storage backends available
/space/{space_id}/collections collections Retired in v0.5. Listing and creating collections moved to the Space URL (see 9.5 List All Collections operation); a server MAY answer this path with a 308 Permanent Redirect to it. The segment stays reserved
/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.

C.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: its description together with the user's annotations; see 10.1 Collection Metadata Data Model
/space/{space_id}/{collection_id}/meta/log meta Governing history log, a sub-resource under the meta segment; see 10.7 Collection Governing History Log
/space/{space_id}/{collection_id}/query query Query resources within a collection (see E. 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.

C.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. meta appears in all three tables, holding the description of the thing it hangs under (see 2.2 Reading This Document), but it constrains id choice only where it occupies an id position: the Space-level row makes meta a reserved Collection id and the Collection-level row makes it a reserved Resource id, 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.4 Methods at Reserved Endpoints

This subsection governs requests to the reserved endpoints listed in the three tables above.

A request to a reserved endpoint with a method the server does not implement at that endpoint MUST be answered with 405 Method Not Allowed. The response carries an Allow header naming the methods the server does implement there, as [RFC9110] requires of a 405.

An endpoint can belong to an OPTIONAL group the server does not implement at all. Where that is so, the group's own rule governs the request instead, typically an unsupported-operation (501) error (see for example 10.1 Collection Metadata Data Model and 11.8 Resource Metadata Data Model).

HEAD counts as implemented wherever GET is. This subsection does not govern OPTIONS.

The 308 redirect this registry permits for the retired collections segment (see C.1 Space-level reserved endpoints) is the server's implementation of the redirected methods. Those methods are not refused.

The server MUST answer the 405 without regard to whether the Space, Collection, or Resource named in the path exists. So the answer reveals nothing the not-found rule protects (see 6. Error Handling). A server MUST NOT treat such a request as an operation on a Collection or Resource whose id is the reserved segment.

This refusal has no entry in the Error Type Registry. Its meaning is the HTTP status alone. A problem body, where a server sends one, uses [RFC9457]'s about:blank type.

D. 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 Metadata 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

D.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). An empty or omitted custom is a clearing write and is not validated.

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.

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

When a Collection create or update operation declares an encryption descriptor (see 10.4 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 Metadata 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.

D.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"] }
      ]
    }
  }
}

D.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.

D.4.1 Epoch data model

A Collection's encryption descriptor MAY carry the two key-epoch members below. It MAY also carry the hmac member of D.4.2 The blinding-key member, where its clients use the blinded-index query profile.

  • 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": [ "..." ]
    }
  ]
}

D.4.2 The blinding-key member

A Collection's encryption descriptor MAY also carry an hmac member: the public reference to the Collection's blinding key, the HMAC key under which a client blinds the attribute names and values it stores in an envelope's indexed member and sends in a blinded-index query (see E.2 blinded-index profile). The member is a JSON object:

  • id - A non-empty string identifying the blinding key, opaque to the server. It is the value an envelope's indexed[].hmac.id carries and the value a blinded-index query names as its index.
  • type - A non-empty string naming the key type (the [WAS-EC] profile uses Sha256HmacKey2019). Servers MUST treat the value as opaque.
  • recipients - A non-empty array of wrapped-key entries, one per reader, in exactly the recipients entry shape of an epoch (see D.4.1 Epoch data model). Each entry carries the blinding secret wrapped to one reader's key-agreement key, so one wrap construction serves epoch keys and the blinding key alike.

As with the epoch members, nothing secret appears here: the blinding secret travels only as per-reader wrapped ciphertext, and the server never unwraps it. How the key is minted, which readers hold it, and how its recipients are maintained alongside the epochs' are client-side matters defined by [WAS-EC]. One property of the member does matter to the server: the blinding key is permanent. Every blinded token in the Collection is computed under it, so replacing or dropping it would orphan every blinded index at once. The server therefore treats a stored hmac member as immutable in its id and type and never removable (see the server validation below). As with the epoch members, this validation is part of recognizing the edv scheme: a server that does not support encrypted Collections, or that stores descriptors it does not enforce (see D. Encryption Scheme Registry), treats hmac as an opaque member. The [WAS-EC] profile goes further and installs the key when the Collection is provisioned, since envelopes written before it carry no tokens. This specification leaves that timing rule to clients and does not require the server to enforce it.

Example descriptor carrying a blinding key (epoch members elided):

{
  "scheme": "edv",
  "hmac": {
    "id": "urn:uuid:6d4a2c3e-9b1f-4c8e-a0d2-7f5e1b3c9a44",
    "type": "Sha256HmacKey2019",
    "recipients": [
      {
        "header": {
          "kid": "did:key:z6MkappA...#z6LSkakA...",
          "alg": "ECDH-ES+A256KW",
          "epk": { "kty": "OKP", "crv": "X25519", "x": "..." },
          "apu": "...",
          "apv": "..."
        },
        "encrypted_key": "base64url..."
      }
    ]
  },
  "currentEpoch": "did:key:z6LSepoch1...#z6LSepoch1...",
  "epochs": [ "..." ]
}

D.4.3 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.

The same server MUST validate the hmac member (see D.4.2 The blinding-key member) when a create or update supplies it, on the same terms:

  • hmac MUST be an object with non-empty string id and type members and a non-empty recipients array; each recipients entry MUST have the shape required of an epoch's entries above (a header object with non-empty string kid and alg members, plus a string encrypted_key).

A server that does not recognize the scheme applies none of these checks; see D. Encryption Scheme Registry for what such a server does with the descriptor.

On an update of a descriptor that already carries epochs, two server-side invariants also 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.

A third invariant covers the hmac member, on an update of a descriptor that already carries one:

  • hmac is permanent. The member MUST remain present, with its id and type unchanged. Its recipients entries MAY change (a reader being added or removed). Changing either value, or removing the member, would orphan every blinded index in the Collection, just as a scheme change would corrupt its Resources. A violation is therefore rejected with the same encryption-immutable error (see 10.1 Collection Metadata Data Model). Introducing hmac on a stored descriptor that lacks it is not a server-side violation; whether a client may do so is a matter for the client-side profile (see D.4.2 The blinding-key member).

On a Collection whose descriptor is governed by its history log (see 10.7 Collection Governing History Log), the descriptor is not supplied on an update; it is the state of the log's head entry. The shape validation above then runs on the head state of every log write, and the invariants run on each append against the prior head's state, with the same errors.

Beyond this shape validation and these invariants the server MUST NOT interpret the members. In particular it MUST NOT attempt to check that the recipients of an epoch or of hmac match any set of capability grantees -- the two axes are deliberately independent, and keeping them in sync is the client's job.

D.4.4 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 10.4 Update (or Create by Id) Collection operation request MAY likewise carry a top-level epoch member, stamping the epoch the Collection Metadata object'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.1 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.

D.4.5 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 metadata 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 Metadata object 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 Metadata object, re-apply the recipient change to the fresh descriptor, and retry (bounded).

D.4.6 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 blinding key does not rotate with the epoch. Rotating the hmac reference on removal would invalidate every blinded index in the Collection, which is why the server treats the member as permanent (see D.4.2 The blinding-key member and D.4.3 Server validation). A removed reader retaining the ability to compute blinded index terms is harmless: the server gates the blinded-index query profile (see E.2 blinded-index 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.

E. 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) E.1 changes profile
blinded-index Matching over client-computed blinded (HMAC'd) attributes E.2 blinded-index profile

E.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,
      "etag": "3mJr7AoUXx2.1",
      "metaVersion": 3,
      "metaEtag": "3mJr7AoUXx2.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", "etag" } with no data member; the deletion bumps version and etag is the tombstone's validator. 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" }
}

E.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 D. 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
}

E.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 G. 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.

F. 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.

F.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 E.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 H.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.

F.2 The encryption descriptor

A Collection realizing this profile declares the encryption descriptor { "scheme": "edv", "version": 1 } in its Collection Metadata object (see 10.1 Collection Metadata 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 D. 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 D. Encryption Scheme Registry describes, or through the key-epoch indirection of D.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].

F.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 D. 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.

F.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.

F.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 E.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 E.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.

F.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.

G. 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 C. 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. Also returned when a write would violate a unique: true blinded-attribute claim (see E.2 blinded-index profile) or a unique: true plaintext index claim, and when a Collection update adds a unique: true plaintext index over Resources that already violate it (see 10.1 Collection Metadata Data Model). (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; or it tried to change the id or type of the descriptor's hmac member, or remove that member. 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, and replacing or dropping the blinding key would orphan every blinded index. See 10.1 Collection Metadata Data Model and D.4.2 The blinding-key member.
https://wallet.storage/spec#encryption-history-log-governed encryption-history-log-governed 409 A Collection update carried an encryption member on a Collection whose descriptor is governed by its history log. The member is read-only on that path: it is derived from the log's head entry, and changes by an append to the log at the Collection's meta/log sub-resource. See 10.7 Collection Governing History Log.
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 D. 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 D. 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 C. 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.

G.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 C. 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 Metadata 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-history-log-governed -- a Collection update carried an encryption member on a Collection whose descriptor is governed by its history log; append to the log instead (see 10.7 Collection Governing History Log):

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

{
  "type": "https://wallet.storage/spec#encryption-history-log-governed",
  "title": "Collection encryption descriptor is governed by its history log."
}

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 D. 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."
}

H. Goals and Requirements

This section is non-normative.

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

H.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.

H.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.

H.3 Stored data is opaque to the storage provider

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

H.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.

H.6 Data Portability

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

H.7 A Plurality of Data Formats and Protocols

H.8 Permissioned Query and Search functionality

H.9 Upgradeable and legislation-compliant cryptography

All cryptography has a half-life.

H.10 Anti-Goals

H.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.

I. 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.

J. References

J.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/
[JSON-LINES]
JSON Lines. Ian Ward. URL: https://jsonlines.org/
[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/
[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/
[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/

J.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/
[RFC9530]
Digest Fields. R. Polli; L. Pardue. IETF. February 2024. Proposed Standard. URL: https://www.rfc-editor.org/info/rfc9530/