Streams

A stream is an append-only byte sequence addressed by a URL: /{bucket}/{stream}. Once data is written it cannot be modified or reordered. Only new data can be appended.

Naming

Stream IDs may contain any byte that is not /, \0, or ... Maximum stream ID length is 122 bytes, and the combined bucket-plus-stream path is also capped at 122 bytes. The literal name streams is reserved and cannot be used as a stream ID. A stream ID cannot start with $ either; that prefix is reserved for bucket-level subresources (400).

A workspace app might use /workspace-a/doc-123. An agent system might use /agents/run-2026-05-13-abc. Anything within the rules above is fair.

Content type

A stream's content type is set when it is created with PUT and defaults to application/octet-stream if no Content-Type header is supplied. POST never creates a stream: an append to a stream that does not exist answers 404. Subsequent appends must declare the same content type, or the server rejects them with 409. The content type rides along on reads so clients can dispatch on it without inspecting payloads.

Lifecycle

Every stream is in one of these states:

  • Open. Accepts appends. The default on creation.
  • Closed. Sealed by Stream-Closed: true on a POST. Readers receive an EOF signal. Further appends return 409. Close is irreversible.
  • Expired. Past its Stream-TTL or Stream-Expires-At deadline. Reads and writes return 404. The group's leader removes an expired stream within about a minute, and cold GC then deletes its cold objects.
  • Deleted. Removed by DELETE. Reads return 404.

Stream-TTL (seconds, relative) and Stream-Expires-At (RFC 3339 absolute) are mutually exclusive. Sending both yields 400.

Stream-TTL is a sliding window, not a deadline fixed at creation:

  • Every accepted append or close renews it; a deduplicated retry or a repeat close does not.
  • Reads renew it at most once per quarter of the TTL, so a stream that is only read can expire after about 0.75×TTL of idleness following the last read. HEAD does not renew it.
  • An SSE connection renews it when it opens, not while it waits for new data. Ursula does not close idle SSE connections (the base protocol expects servers to end them about every 60 seconds so readers reconnect), so an open subscription alone does not keep a stream with no writes alive. Each long-poll request renews like any other read.
  • Renewal never moves expiry earlier, even when the node that handled the request has a slower clock than the one that last renewed it.