Durable Streams Extensions
Document: Durable Streams Protocol Extensions Status: Draft Date: 2026-07-18
Abstract
This document defines extensions to the Durable Streams Protocol. It adds bucket namespacing, snapshot and bootstrap semantics, multipart bootstrap delivery, a JSON message text rule, and read path parameters.
The snapshot and bootstrap design in sections 2 and 3 is based on a proposal by Loro. Ursula implements part of it: there is no latest-snapshot redirect, no snapshot deletion and no conditional publish.
All base protocol semantics remain in effect. Where this document is silent, the base protocol governs.
Terminology
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119] [RFC8174].
Table of Contents
- JSON Message Text
- 1. Buckets
- 2. Snapshots
- 3. Bootstrap
- 7. Ordinary SSE Compatibility
- 8. URL Encoding
- 9. Read Path
JSON Message Text
This section defines the "normalized message" that the base protocol's JSON mode refers to. It is a storage rule, not an optional extension, and has no extension token. A server MUST apply it to every stream whose content type is application/json (with or without parameters).
It applies to every JSON write: the body of a creating PUT and the body of POST. It applies to every read representation: the default NDJSON view, SSE, and bootstrap parts (Section 3).
- The server MUST validate each request body as JSON text per [RFC8259].
\uXXXXescapes that encode unpaired surrogates are valid. - After top-level array flattening, the server MUST store each message as the original text with insignificant whitespace removed, followed by one LF (U+000A). Insignificant whitespace is U+0020, U+0009, U+000A and U+000D outside string literals. The server MUST NOT reorder object members, drop or merge duplicate member names, or rewrite number or string literal text.
- The nesting depth of a scalar is 0. The depth of an array or object is 1 plus the maximum depth of its elements or member values; an empty array or object has depth 1. A message whose depth exceeds 127 MUST be rejected with
400 Bad Request. Depth is measured per message, after flattening, so a top-level array body may be one level deeper than its deepest message. - A body with invalid JSON or invalid UTF-8 MUST be rejected with
400 Bad Request, and no message from that request is committed. - Every read representation MUST carry the stored message text unchanged. Where a representation embeds the message in a larger JSON document, the embedded text MUST be the stored text.
- Offsets and ETags refer to the stored text.
Messages stored before a server adopted this rule are not rewritten. Clients MUST NOT assume that numbers survive storage as any particular binary floating-point value: a literal such as 1.50e3 or 1e400 is stored and returned as written.
Compatibility note (informative). Earlier Ursula releases re-serialized JSON messages: they sorted object members, kept only one of several duplicate members, rewrote numbers through a binary double, and rejected lone-surrogate escapes and numbers outside the double range. This rule removes these behaviors for new writes. The depth limit is unchanged for a bare message; a message inside an array body gains one level.
A JSON message boundary is an offset whose preceding stored byte is LF, plus the stream's first offset. Stored messages contain no other LF, so the boundaries are exactly the ends of the stored messages.
Client Event Time
The server does not assign a timestamp to messages. Applications that need event time SHOULD include one client-supplied timestamp in each JSON value, for example a captured_at field in a telemetry event.
Client event times MAY be out of order and MUST NOT change committed message order, stored bytes, or offsets. Their HTTP metadata representation and any secondary index over them are outside this document; Ursula's experimental event-time indexer is one such index.
1. Buckets
Every stream belongs to a bucket. The stream URL takes the form {base_url}/{bucket_id}/{stream_id}.
1.1. Identifier Constraints
bucket_id: MUST match^[a-z0-9_-]{4,64}$. Globally unique within the service.stream_id: Any UTF-8 string. MUST NOT exceed 122 bytes. MUST NOT contain/,\0, or... On the bucketed/surface, the combined{bucket_id}/{stream_id}key MUST also not exceed 122 bytes.- The literal local stream ID
streamsis reserved, and astream_idMUST NOT start with$, which is reserved for bucket-level subresources.
1.2. Bucket Lifecycle
In Ursula, buckets are implicit namespaces. A bucket exists once a stream has been created in it; PUT /{bucket_id}/{stream_id} needs no earlier bucket create. PUT /{bucket_id} is kept for clients that create the bucket first: it validates bucket_id, changes no state, and answers 201 Created for a valid ID or 400 Bad Request for an invalid one. The client surface has no bucket metadata read, stream listing or bucket delete. Removing a bucket with everything in it is an operator action, the admin-plane bucket purge, which permanently fences the bucket ID: later stream creates in it answer 404. It is not a protocol operation.
This section describes Ursula's server profile. Earlier drafts of this document required explicit bucket creation and defined bucket metadata, stream listing and bucket deletion in Sections 1.3 to 1.5. Those section numbers stay unused so that later section numbers do not change.
1.6. Stream Operations
All stream operations defined in the base protocol apply under {base_url}/{bucket_id}/{stream_id}. A path segment after {stream_id} names a stream subresource (Sections 2 and 3), never another stream. Ursula answers 404 Not Found for any other path with three or more segments under a bucket (paths under /__ursula/ are operational endpoints, not buckets).
The names $transaction, append-batch, attrs, bootstrap, retention and snapshot are reserved for any future co-location form and stream subresource; apart from the $ rule in Section 1.1 they remain valid stream IDs. Earlier drafts defined a three-segment path-affinity form and a group-local $transaction endpoint in Sections 1.7 and 1.8; those features are removed and the section numbers stay unused.
2. Snapshots
A snapshot is a materialized representation of a stream's content from offset -1 (inclusive) to snapshot_offset (exclusive). Snapshots enable clients to skip full replay and resume from a compacted state.
2.1. Offset Conventions
- Offsets are opaque tokens as defined in the base protocol. Clients MUST use server-returned
Stream-Next-Offsetvalues.
The messages of a JSON stream are its LF-terminated messages (JSON Message Text); servers MUST use the same message boundaries for snapshot and retention offset validation and for bootstrap parts. A non-JSON stream has no message boundaries that this extension exposes: any committed offset is a valid snapshot or retention offset, and a bootstrap part MAY contain several consecutive messages. This extension does not require any particular binary framing format.
2.2. Publish Snapshot
PUT {stream_url}/snapshot/{snapshot_offset}
Creates a snapshot at the specified offset. The snapshot represents the materialized result of folding all updates in the range [-1, snapshot_offset).
Request Headers:
Content-Type: The content type of the snapshot blob. Servers MUST store this value and return it on subsequent reads. Defaults toapplication/octet-stream.
Response Codes:
204 No Content: Snapshot published successfully.400 Bad Request:snapshot_offsetis invalid or, for a JSON stream, not a message boundary.404 Not Found: Stream does not exist.409 Conflict:snapshot_offsetexceeds the current tail, or the server cannot produce a consistent view at that offset.410 Gone:snapshot_offsetis older than the current earliest retained offset.413 Payload Too Large: Snapshot exceeds the server's size limit.503 Service Unavailable: For a JSON stream, the server could not verify thatsnapshot_offsetis a message boundary. It MUST NOT publish an unverified offset; the client MAY retry.
Note (informative). Ursula keeps snapshot bodies of up to 32 MiB in replicated state. With a cold backend, it stores bodies of at least its external-payload threshold (1 MiB by default) as cold-tier objects instead and accepts up to 1 GiB; only the reference is replicated, and the digest is the same as for an inline body.
Idempotency:
The server MUST return Stream-Snapshot-Digest for a successful publish. Repeating a publish at the same offset with the same content type and body MUST succeed and return the same digest. Publishing different content at an already-published offset MUST return 409 Conflict.
Retention Effect:
Publishing a snapshot MUST NOT change the earliest retained offset. Retention is a separate, explicit operation:
PUT {stream_url}/retention/{retained_offset}
Like every request that addresses a stream, a retention request MAY carry the Stream-Incarnation precondition (Section 2.4); its success response carries Stream-Incarnation. The server MUST reject a retention boundary that is not aligned to committed data (for a JSON stream, not a message boundary: 400 Bad Request), moves backward, or is newer than the latest published snapshot. As for a snapshot, a JSON retention boundary the server cannot verify MUST NOT be applied (503 Service Unavailable). After success, offsets less than retained_offset MUST return 410 Gone. A server MAY retain less than requested, at a committed boundary at or below retained_offset; the success response's Stream-Retained-Offset then reports the effective boundary, and offsets below it MUST return 410 Gone. Ursula retains exactly the requested offset.
Note (informative). Ursula checks a JSON boundary at apply time when the byte before the offset is still in memory. Otherwise it reads that byte from the leader (as a
consistency=leaderread), and applies the operation only if the stream was not deleted and recreated in between; a failed read answers503.
Publishing a new snapshot replaces the previously visible snapshot immediately. The superseded snapshot MAY be garbage-collected asynchronously after the new snapshot becomes visible. Clients MUST NOT depend on older snapshots remaining readable after overwrite.
Concurrency:
Snapshot creation MUST NOT block concurrent appends. The snapshot's consistency boundary is snapshot_offset. Updates at or beyond that offset MUST NOT be folded into the snapshot.
2.4. Snapshot Metadata on HEAD and Stream Incarnation
HEAD {stream_url}
Servers MUST include Stream-Snapshot-Offset and Stream-Snapshot-Digest when a latest visible snapshot exists for the stream. Apart from bootstrap (Section 3), HEAD is the only way to learn which snapshot is the latest.
Response Headers:
Stream-Snapshot-Offset: The latest visible snapshot offset.Stream-Snapshot-Digest: A digest of the latest visible snapshot's content type and body, equal to the digest its publish returned.Stream-Retained-Offset: The earliest retained offset (Section 2.2, Retention Effect).Stream-Incarnation: An opaque token that identifies this incarnation of the stream.
Semantics:
- Absence of
Stream-Snapshot-Offsetmeans the stream has no visible snapshot. - To read the latest snapshot, a client takes
Stream-Snapshot-OffsetfromHEADand fetches it with Read Snapshot (Section 2.5). - A server implementing this extension MUST send
Stream-Retained-Offseton every successfulHEAD. Reads at offsets below it MUST return410 Gone. Before any retention advance it is the offset of the stream's first byte. Like every offset, it is opaque: clients pass it back asoffsetor compare it lexicographically with other offsets of the same stream. - A server implementing this extension MUST send
Stream-Incarnationon every successfulHEAD. It MUST change when the stream is deleted and recreated at the same URL, and MUST NOT change otherwise. Clients MUST compare it only for equality. Offsets do not identify an incarnation, so a client that stores offsets and snapshot offsets across a possible recreate SHOULD store the incarnation with them and send it as a request precondition (below). An operator restore from backup keeps each stream's incarnation although it can rewind the stream to the backup's tail; offsets read after the backup was taken are not valid on the restored stream.
Stream-Incarnation on responses:
A server implementing this extension MUST send Stream-Incarnation on every successful response of a request that addresses a stream: create (201 and 200), append and close, every read (catch-up, the long-poll 204, and the SSE response), HEAD, bootstrap, snapshot publish and read, and retention. A client learns the token from its first request, without a separate HEAD.
Stream-Incarnation as a request precondition:
Requests address streams by URL only, so a client holding state from a deleted incarnation (offsets, producer state, a local cache) would otherwise act on a stream recreated at the same URL, including one recreated by TTL expiry and a later create, without any delete. Every request that addresses a stream MAY carry Stream-Incarnation: create (PUT), append (POST, including close), every read (catch-up, long-poll, SSE), HEAD, DELETE, bootstrap, snapshot publish and read, and retention.
- Absent: the request behaves as the base protocol and this extension define it, unchanged.
- Present: the server compares the value for equality with the stream's current incarnation, atomically with the operation. A write is compared when it is applied: a write that was accepted before a delete and recreate but applied after it is refused. A read is compared against the same stream state that serves it. A live read (long-poll or SSE) ends when the stream it was opened against is deleted or recreated: a long-poll answers
404or412, and an SSE stream ends with anerrorevent. - Mismatch:
412 Precondition Failed. A refused write applies nothing; a refused read returns no data, though, like any request that reaches a stream, it may still renew that stream's sliding TTL. The response carries the currentStream-Incarnationwhen the stream exists.412, not409, so it never mixes withStream-Seqand producer conflicts. - Evaluation follows RFC 9110 §13.2: a request that would answer
404without the header still answers404, except that aPUTwith the header for a stream that does not exist answers412and creates nothing. Likewise a read that would answer410or416without the header still does; only a request that would otherwise succeed (or aPUTthat would create) is answered412. - Malformed: a value that is not a token the server could have issued, or more than one value, answers
400 Bad Request.
2.5. Read Snapshot
GET {stream_url}/snapshot/{snapshot_offset}
Returns the snapshot blob at the specified offset.
Response Headers:
Content-Type: The content type stored at publish time.Stream-Snapshot-Offset: The snapshot offset.Stream-Snapshot-Digest: The snapshot digest returned at publish time.Stream-Next-Offset: The next offset after the snapshot.Stream-Up-To-Date: true: Present when no update follows the snapshot (the snapshot offset is the stream's tail); absent otherwise. Clients then read on fromStream-Next-Offset.
Response Codes:
200 OK: Snapshot exists.404 Not Found: Snapshot does not exist or has been garbage-collected.
3. Bootstrap
Bootstrap provides single-request initialization: a snapshot (if any) plus retained updates after the snapshot point, returned as a single ordered response that preserves per-message content types. A server returns either every retained update after the snapshot point, or a contiguous prefix of them and an honest continuation offset from which the client reads the rest through ordinary reads (Section 3.3).
3.1. Request
GET {stream_url}/bootstrap
Query Parameters:
/bootstrapis a one-shot initialization endpoint. It does not define any query parameters of its own.- Servers SHOULD reject any
livequery parameter on/bootstrapwith400 Bad Request.
3.2. Response
Response Headers:
Content-Type: multipart/mixed; boundary=<token>Stream-Snapshot-Offset: The snapshot offset, or-1if no snapshot exists.Stream-Next-Offset: The offset immediately after the last returned update byte. When the response contains no update parts, it is the snapshot point (defined below).Stream-Up-To-Date: true: Present whenStream-Next-Offsetequals the stream's tail offset at the time the response was produced. Otherwise the server MUST either omit the header, as for ordinary reads in the base protocol, or sendfalse. Clients MUST treat an absent header asfalse.
The snapshot point S is the visible snapshot's offset when a snapshot exists, and otherwise the earliest retained offset.
Completeness:
A server MUST return one of the following:
- Complete: update parts holding every retained byte in
[S, tail), withStream-Next-Offsetequal to the tail andStream-Up-To-Date: true. - Partial: update parts holding the retained bytes in
[S, P)for some offsetPwithS ≤ P < tail, withStream-Next-Offset: Pand noStream-Up-To-Date: true.PMAY equalS, in which case the response contains the snapshot part and no update parts.
In both cases the update parts MUST hold the retained bytes starting exactly at S, contiguous and in stream order: a server MUST NOT skip or duplicate any byte between S and Stream-Next-Offset. For a JSON stream each part MUST be exactly one message. For a non-JSON stream a part MAY contain several consecutive messages, and a part boundary carries no meaning. Stream-Next-Offset MUST be an offset that ordinary reads accept, and MUST NOT be below S.
A server MAY choose the partial form for any reason, for example because some messages after S are held only in a storage tier that does not keep per-message boundaries in memory. Serving /bootstrap never requires a server to read such a tier. A client MUST NOT treat a partial response as an error.
Note (informative). Ursula never reads cold storage for update parts while serving
/bootstrap(a snapshot body stored in the cold tier is streamed from it as the first part), and it omitsStream-Up-To-Datefrom a partial response. For a non-JSON stream it returns[S, tail)as one part whenSis at or above the stream's cold frontier, every byte of that range is still hot and the range is at most 8 MiB; otherwise it returns the partial form withP = S(the snapshot part only). For a JSON stream, under the same hot-range conditions, it splits[S, tail)on LF into one part per message, and otherwise returns the partial form withP = S; it caps the update parts of one response at 8 MiB, returning the partial form withPat the last message boundary that keeps the parts within the cap, and always includes at least one message, even when that message alone exceeds the cap. Each response uses a fresh 128-bit random multipart boundary.
Response Body:
The body is an RFC 2046 multipart/mixed entity. The multipart boundary, not any outer binary framing, delimits the parts. Servers SHOULD choose an unpredictable boundary for every response, so that no stream content can contain it.
- First part: Snapshot message. If a snapshot exists, the part body MUST be the raw snapshot bytes and the part
Content-TypeMUST equal the snapshot blob's stored content type. - Subsequent parts: Zero or more parts of retained updates starting at the snapshot point, as required by the completeness rules above: one message per part for a JSON stream; one or more consecutive messages per part for any other stream. The part
Content-TypeMUST be the content type of the update. For streams with a fixed stream-level content type, all update parts will normally share that value.
If no snapshot exists, the server MUST still emit an empty first part, Stream-Snapshot-Offset MUST be -1, and that empty part's Content-Type MUST be application/octet-stream. Update parts, if any, then begin at the earliest retained offset.
Servers MUST NOT wrap the entire bootstrap response in an outer binary framing container, and they MUST NOT reinterpret or reframe binary payloads when constructing bootstrap parts.
Example:
HTTP/1.1 200 OK
Content-Type: multipart/mixed; boundary=rr-bootstrap-9f1c2e7a
Stream-Snapshot-Offset: 0000000000000012
Stream-Next-Offset: 0000000000000026
Stream-Up-To-Date: true
Cache-Control: no-store
--rr-bootstrap-9f1c2e7a
Content-Type: application/octet-stream
<snapshot-bytes>
--rr-bootstrap-9f1c2e7a
Content-Type: application/json
{"op":"set","path":["title"],"value":"hello"}
--rr-bootstrap-9f1c2e7a
Content-Type: application/json
{"op":"insert","path":["body",0],"value":"world"}
--rr-bootstrap-9f1c2e7a--
A partial response that returns only the snapshot. The client continues with GET {stream_url}?offset=0000000000000012:
HTTP/1.1 200 OK
Content-Type: multipart/mixed; boundary=rr-bootstrap-4b7d01c3
Stream-Snapshot-Offset: 0000000000000012
Stream-Next-Offset: 0000000000000012
Cache-Control: no-store
--rr-bootstrap-4b7d01c3
Content-Type: application/octet-stream
<snapshot-bytes>
--rr-bootstrap-4b7d01c3--
Response Codes:
200 OK: Bootstrap data returned.404 Not Found: Stream does not exist.
3.3. Follow-Up Live Reads
Bootstrap is one-shot only:
/bootstrapreturns an ordered initialization payload once./bootstrapdoes not supportlive=sseorlive=long-poll.- After applying the bootstrap response, clients MUST continue tailing through the ordinary stream read APIs using the returned
Stream-Next-Offset. - When the response does not carry
Stream-Up-To-Date: true, the ordinary catch-up reads fromStream-Next-Offsetreturn the remaining updates; clients MUST apply them before treating their state as current. - Clients SHOULD prefer
GET {stream_url}?offset=<offset>&live=sseand fall back toGET {stream_url}?offset=<offset>&live=long-pollwhen SSE is unavailable.
3.4. Compatibility
- Regular
GET {stream_url}?offset=...MUST NOT return snapshot bytes. Snapshots are only delivered through/bootstrap. - When a server returns
410 Gonefor a read (because retention has advanced), clients SHOULD call/bootstrapto rebuild state, then continue tailing from the returnedStream-Next-Offsetthrough the ordinary read APIs. - If a server has begun returning
410 Gonefor a stream (i.e., earliest retained offset >-1), the server MUST ensure/bootstrapis available and can provide a snapshot covering that earliest retained offset. - Clients MUST parse
/bootstrapas an ordered sequence of parts. For a JSON stream each update part is one message; for any other stream, clients MUST NOT treat a part boundary as a message boundary.
7. Ordinary SSE Compatibility
Ordinary SSE behavior is defined by the base protocol. Binary SSE: see Section 9.5.
- Extension endpoints MUST NOT redefine the payload shape of ordinary binary SSE
event: dataframes unless they document an explicit endpoint-local contract.
8. URL Encoding
snapshot_offset values originate from Stream-Next-Offset and MAY contain characters requiring URL encoding. Clients and servers MUST apply standard URL encoding/decoding when using offset values in path segments.
9. Read Path
This section collects read parameters and read representations that apply to every stream and that the base protocol leaves to the server.
9.1. Long-Poll Timeout
timeout_ms={t} bounds how long a live=long-poll read waits for new data before the server answers 204 No Content. The default is 1000. An unparseable value means the default, and a parsed value is clamped to 1..60000. It applies only to live=long-poll reads; every other read, including live=sse, ignores it.
9.2. Leader Consistency
consistency={c} selects how fresh a catch-up read must be.
local, the default: any replica MAY serve bytes it has applied. Offsets are immutable, so those bytes are correct, but a replica that lags MAY omit appends that were already acknowledged.leader: the read MUST be linearizable. It MUST reflect every append acknowledged before the request was received, including appends acknowledged by a newer leader.- Any other value MUST be rejected with
400 Bad Request.consistency=leadercombined withliveMUST be rejected with400 Bad Request; live reads follow the leader's watchers already.
A server that cannot prove the read is linearizable MUST NOT serve it. It answers 307 Temporary Redirect to the current leader when it knows one, and otherwise 503 Service Unavailable with Retry-After. Clients SHOULD follow the redirect and retry the 503 after the advertised delay.
Note (informative). Ursula forwards a
consistency=leaderread from a follower to the group's leader. The leader confirms with a majority of voters that it still leads (Raft ReadIndex) and waits until it has applied everything committed up to that point. Concurrent reads to the same Raft group share confirmation rounds. A leader cut off from the majority, or one that cannot confirm in time, answers307or503rather than its own state. Ursula applies the same confirmation toHEAD,/bootstrap, snapshot reads and the registration of a long-poll or SSE reader.
9.3. Response Size
A server MAY cap how many stream bytes one response returns. Ursula caps every read response, and every SSE data event, at 8 MiB (8,388,608 bytes).
max_bytes={n} on an offset read asks for at most n bytes. A value above the server's cap is clamped to it. Ursula ignores a value that is not a decimal integer and applies the cap alone; clients SHOULD send a positive integer. On a live read, max_bytes bounds each response or SSE data event; Ursula raises a smaller value to 1 byte, and to 4 bytes on a text SSE read so an event can hold a whole UTF-8 character.
A response that max_bytes or the cap shortened is partial: it MUST NOT carry Stream-Up-To-Date: true, and its Stream-Next-Offset is the offset just past the returned bytes. Clients continue from Stream-Next-Offset until a response carries Stream-Up-To-Date: true.
9.4. JSON Stream Reads
Reads of an application/json stream return the stored messages (see JSON Message Text) in their stored order, each followed by one LF, as Content-Type: application/x-ndjson. HEAD reports the configured content type, application/json. SSE responses for such a stream carry Stream-Data-Content-Type: application/x-ndjson.
max_bytes and the server's cap (Section 9.3) count bytes of this representation, so a response or SSE data event MAY end inside a message. Clients MUST resume from Stream-Next-Offset and buffer an incomplete trailing line until the rest arrives. SSE data: lines are transport framing, not message boundaries.
9.5. Binary SSE
A live=sse read of a stream whose content type is neither text/* nor application/json carries binary data:
- The response MUST include
Stream-Sse-Data-Encoding: base64, andStream-Data-Content-TypeMUST be the stream's content type. - Each
event: datapayload MUST be the standard base64 encoding [RFC4648] of a contiguous byte range of the stream, with no JSON envelope. The server MAY split the text across severaldata:lines; the client joins them, removes the line breaks and decodes. - Each data event is followed by an
event: controlevent whosestreamNextOffsetis the offset just past the decoded bytes.
There is no client opt-in or opt-out: the stream's content type decides the encoding.