Status: Draft
Type: Standards Track
Category: Core
Created: 2026-07-27
Requires: CIP-2 (off-chain compute), CIP-3 (fee handling), CIP-9 (CBFS), CIP-10 (runner workloads), CIP-12 (governance), CIP-24 (CBSS), CIP-31 (CBFS rent schedule, at the 2,905 nano-CBY rate), CIP-36 (§6.6 administered CBY rate)
Related: CIP-7 (public streams), CIP-15 (workload ingress), CIP-29 (actor events), CIP-38 (persistent runners)
Abstract
This proposal introduces the Cowboy Queue System (CBQS), a durable off-chain messaging service for workloads running on different Cowboy Runners. CBQS provides one ordered stream primitive from which applications construct work queues, publish/subscribe, chat rooms, request/reply, and replayable event logs. Broker-side lanes let many related rooms or documents share one chain-created stream without weakening its single total order. The Cowboy chain is the control plane. It records stream ownership, provider assignment, authorization and encryption generations, configuration, and prepaid rent. A new off-chain service,cbqsd, is the data plane. It stores
records, manages consumer groups and delivery leases, pushes records to
connected clients, and issues signed receipts. No chain transaction is required
to append, deliver, acknowledge, or replay a message.
CBQS encrypts message contents end-to-end by default. The chain, CBSS, and the
broker still expose communication topology and operational metadata. This CIP
does not claim communication-graph secrecy.
Background
Cowboy actors and actor-to-actor messages are consensus execution. Their inputs, state transitions, and outputs are part of the public replicated state machine. They are the correct path for finality-bearing application state, but not for private, high-frequency coordination between off-chain workloads. Cowboy Runners execute jobs and persistent workloads away from consensus, but there is no shared messaging backplane between them. A coordinator that talks to several specialist agents must currently implement persistence, retry, fan-out, cursors, leases, reconnection, authorization, and crash recovery as application infrastructure. Chat and collaborative applications repeat the same work. CBFS solves durable object storage and CBSS solves authorized secret release. Neither supplies queue delivery semantics. CBQS is the corresponding messaging service.Goal
CBQS MUST provide:- cross-runner message exchange with no consensus write on the data path;
- ordered replay and at-least-once work delivery;
- push delivery with bounded, explicit backpressure;
- encrypted payloads whose keys are unavailable to the broker;
- owner recovery and revocation through chain authority;
- receipts that make provider equivocation and acknowledged-data loss detectable;
- a lower-overhead class for replayable application data that can tolerate and repair a bounded crash-loss window;
- cheap logical room or document creation inside one shared access-and-encryption domain.
Specification
The key words MUST, MUST NOT, REQUIRED, SHALL, SHOULD, SHOULD NOT, and MAY in this document are to be interpreted as described in RFC 2119.1. Terms
2. Trust model
CBQS has a chain control plane and an off-chain data plane. The chain is authoritative for stream ownership, provider assignment, provider epoch, authorization generation, encryption generation, stream status, configuration, pricing basis, provider base-rate reservations, and escrow.cbqsd is authoritative for the records and consumer state it has accepted,
subject to the signed-receipt rules in this CIP.
For an encrypted stream:
- clients encrypt and decrypt payloads;
- the broker stores ciphertext and MUST NOT receive a data key;
- CBSS escrows the stream root for an owner-authorized rotation workload; and
- members receive per-generation data keys in HPKE-wrapped envelopes.
- chain: stream existence, owner, configuration, provider, billing, pricing basis, provider base-rate caps and reservation totals, authorization-generation changes, encryption-generation changes, and linked key-stream existence;
- CBSS: secret policy and release activity for the rotation workload; and
- provider: connecting workloads, stream and group identifiers, message sizes, timing, traffic volume, delivery state, and key-envelope recipients; and
- member: a member requesting lane proofs learns, for its own lanes,
whether they carried records and what those records were. About other lanes it
learns only an occupancy pattern: each set
bitmapbit says that some sibling prefix subtree along its own query path is non-empty, and carries that subtree’s opaque hash.popcount(bitmap)is therefore a lower bound on the number of other active lanes, and the set bits indicate which regions of thelane_idspace are occupied. No other lane’s identifier, exact count, payload, or activity window is disclosed.
3. Architecture
The chain MUST NOT receive message payloads, group operations, delivery leases, acknowledgements, or cursor advances. The Stream Registry system actor is allocated at0x17. CBQS uses one
top-level system-instruction opcode:
CbqsInstruction. This preserves the
remaining one-byte top-level opcode space.
cbqsd exposes the data-plane protocol, verifies finalized Stream Registry
state, stores records and delivery state, enforces quotas, and signs receipts.
It MUST bind its durable store to one chain instance and refuse startup when
the connected node reports a different chain instance. Reusing broker data
after a re-genesis requires an explicit export-and-import operation that
creates and verifies target-chain streams, imports application payloads under
their new stream ids and receipt lineages, and writes a new chain-instance
binding. Old-chain signatures and anchors remain evidence for the source
history but are never accepted as target-chain objects. Changing only
configuration or an environment variable is insufficient.
Clients connect directly to a provider endpoint. The Cowboy Gateway is not on
the message data path.
4. Chain objects
All CBQS signing public keys carry an explicit algorithm tag. v1 accepts Ed25519 for CBQS object keys. HPKE keys carry their suite tag:provider_epoch field in a v1 chain object, request, receipt, frame, or
signed payload MUST equal zero. Encoders MUST emit zero and verifiers MUST
reject a nonzero value.
The chain instance id is the chain’s domain-separated genesis-configuration
fingerprint:
genesis_source_bytes are the exact raw config bytes used to initialize
production state. A node started without a genesis file uses the canonical
serialization of its default genesis config. Nodes MUST derive this value from
the same captured bytes they parse for initialization; a second file read is
invalid because it creates a state/identity time-of-check-to-time-of-use race.
Genesis initialization MUST persist the derived value in the Stream Registry’s
consensus singleton. Every CBQS execution path MUST read the chain instance
from that singleton rather than node-local configuration. A missing or
malformed singleton, or a record whose embedded chain instance does not match
it, is invalid consensus state and MUST fail closed; execution MUST NOT derive
or substitute a default at read time.
The node MUST expose the finalized chain instance, finalized height, and
finalized block hash through one atomic RPC read:
effective_rent_height needed for one broker decision in the same atomically
constructed response as FinalizedViewV1, or support exact
block-hash-pinned reads for every one of those values. The atomic unit is the
broker-state response, of which FinalizedViewV1 is one field:
effective_rent_height is deliberately not inside the view — the view is a
consensus identifier that must mean the same thing to every reader, while the
effective rent height is a derived registry quantity — but it is carried in
the same response and inherits the same atomicity. A node MUST reject a pinned
read it can no longer serve.
Returning a view and then answering follow-up reads from whatever state is
current at that later instant is invalid.
Every signed CBQS object carries context: CbqsSigningContextV1. A v1 verifier
MUST reject any other version, numeric chain id, or chain instance before
performing authorization or mutating state.
4.1 StreamRecord
chain_instance_id is the genesis-configuration fingerprint defined in §3.
Genesis initialization MUST seed it into a Stream Registry singleton in
consensus state before CBQS instructions can execute. Handlers MUST read that
singleton rather than node-local configuration, MUST reject an absent or
malformed singleton, and MUST reject any stored provider or stream record whose
carried chain_instance_id differs from it. A re-genesis therefore seeds a
different value even when chain_id is reused.
The Stream Registry MUST reject a CreateStream whose (owner, owner_nonce)
has already been used, including after the earlier stream closes or expires.
An owner nonce is never reusable.
An encrypted stream is created atomically with a key stream:
Standard, unencrypted at the stream layer, owned by
the same account, assigned to the same provider, and linked bidirectionally to
the main stream. Its records are HPKE ciphertext, not plaintext keys.
The encrypted main stream’s pricing basis and escrow cover both records. The
key stream mirrors the main stream’s pricing_basis, has rate_per_block = 0,
cannot be funded, suspended, or closed independently, and mirrors
the main stream’s lifecycle atomically.
delivery_class, purpose, encrypted, the stream-id linkage, and provider
assignment are immutable for the stream’s lifetime. Immutable assignment keeps
chain state, receipt lineage, and provider admission accounting single-owner.
Moving an application to another provider is an application migration: create
a new stream, replay or copy through a verified durable anchor, regrant and
rekey, redirect clients, and close the old stream. Section 16.1 applies the
same flow to confidentiality-domain migration.
The owner MAY change bounded retention, quota, group defaults, admin key, and
other mutable configuration. An admin-key change MUST increment
authorization_generation.
pricing_generation is 1 at creation on both pricing bases and increments
exactly once for every accepted change to a priced capacity field: a
ProviderQuote stream stores the accepted quote’s bound generation (§4.4),
and a GovernanceBase stream increments alongside its §5 schedule
re-derivation. It never decrements and never skips a value.
4.2 StreamConfigV1
StreamConfigV1 is validated at creation and at every update, field by field,
against the source named below. The source is not uniform across the record,
so it is enumerated rather than stated as a rule: two fields are admission
bounds checked against the LIVE governance scalar, and the rest are
configuration ceilings checked against the IMMUTABLE §18 Maximum. This table
is what later sections mean when they call a snapshotted stream configuration
already validated.
max_message_bytes, max_lanes, retention_ms, default_max_attempts and
both fast batch fields MUST be nonzero. Standard-only fields are validated
only for Standard streams and fast-only fields only for Fast streams.
One cross-field rule joins the table’s per-field checks: for a Fast stream,
max_message_bytes × fast_batch_max_records MUST NOT exceed 268,435,456
(256 MiB), checked without overflow at creation and at every update. The bound
is an IMMUTABLE protocol constant, not a governance parameter, and it is what
makes the §13 replay admission bound finite: a batch’s maximum legal
materialization is this product, so the §18 per-field maxima — whose product
would be 64 GiB — are deliberately not jointly reachable. A configuration at
both per-field maxima is rejected by this rule, not admitted by the table.
retained_bytes_limit, max_append_bytes_per_sec, and
max_delivered_bytes_per_sec carry no §18 row and are bounded instead by
the pricing path — a signed PriceQuoteV1 or the governance-base admission
counters of §4.3 — so they are deliberately absent from this table. That
bound is concrete, not aspirational: every priced capacity field feeds the
§4.5 rate derivation with checked arithmetic, and CreateStream must pay
the §6 creation charge and any escrow against the derived rate in the same
transition, so a capacity large enough to distort the rate produces a
charge no balance can pay and the creation — with its record and
reservations — fails atomically. An absurd cap cannot create a frozen
reservation.
The two LIVE rows are admission bounds in §18’s sense: they gate what may be
created and the accepted record never consults them again, so lowering one
stops new work without re-examining anything already accepted.
At every configuration change of either linked stream, key-stream retention
MUST cover every encryption generation that remains replayable under the main
stream’s retention policy. The Stream Registry MUST reject any update that
would violate this invariant.
4.3 ProviderRecordV1
WebSocketTls uses wss:// and
Https uses https://. The URI MUST be nonempty and MUST NOT contain ASCII
control or whitespace bytes. A decoder MUST reject an unknown transport tag,
invalid UTF-8, a mismatched scheme, or an overlong URI before allocating or
using the endpoint.
Decoding alone does not admit an endpoint. RegisterProvider and
UpdateProvider MUST additionally validate every carried endpoint at
consensus admission with the following algorithm, rejecting the instruction
on the first failure. The algorithm is consensus-critical: two conforming
implementations MUST accept and reject the identical URI set.
- The endpoint list is nonempty and at most
cbqs.max_provider_endpointslong, and each URI is at mostcbqs.max_provider_endpoint_uri_bytesbytes. - The URI begins with the transport’s scheme prefix —
wss://forWebSocketTls,https://forHttps— compared ASCII-case-insensitively. - The URI parses under the WHATWG URL Standard’s basic URL parser; a parse failure rejects. The parsed scheme MUST equal the transport’s scheme exactly, the URL MUST NOT be cannot-be-a-base, and the parsed username MUST be empty and password absent.
- The raw authority — the URI bytes between the scheme prefix and the
first
/,?, or#, or the end of the URI — MUST NOT contain@. This is checked on the raw bytes because a parser cannot distinguish absent userinfo from an explicitly empty@hostform it normalizes away. - The host MUST be present, and:
- a domain host MUST NOT be
localhostand MUST NOT havelocalhostas its final label, compared ASCII-case-insensitively after stripping at most one trailing dot — the special-use namespace that deterministically resolves locally; - an IPv4 literal — including a host the parser canonicalizes to one,
such as a bare decimal like
2130706433— MUST NOT fall in0.0.0.0/8,10.0.0.0/8,100.64.0.0/10,127.0.0.0/8,169.254.0.0/16,172.16.0.0/12,192.0.0.0/24,192.0.2.0/24,192.88.99.0/24,192.168.0.0/16,198.18.0.0/15,198.51.100.0/24,203.0.113.0/24, or224.0.0.0/3; - an IPv6 literal MUST fall inside currently allocated global unicast
2000::/3and MUST NOT fall in2001::/23,2001:db8::/32,2002::/16,3ffe::/16, or3fff::/20. Every address outside2000::/3is rejected as a blanket rule — loopback, unspecified, IPv4-mapped (::ffff:0:0/96), unique-local, link-local, and multicast alike — so no per-range list outside global unicast exists to drift.
- a domain host MUST NOT be
Draining cannot accept new
streams. A provider cannot enter Deregistered while any live stream record
still assigns it. The registry maintains live_assigned_streams across both
pricing bases, incrementing once for each created main stream and decrementing
exactly once on its first terminal transition. A linked key stream does not
increment the counter separately because its lifecycle is atomic with the main
stream.
base_rate_admission is the provider’s standing consent to accept new
governance-base-priced streams without a per-stream quote. The provider sets
its four caps through its signed registration or UpdateProvider
instruction. The Stream Registry, not the provider, maintains
base_rate_reserved. An update MAY set accepts_new = false at any time, but
MUST NOT lower a cap below its corresponding current reservation. Re-enabling
acceptance consents to the genesis base-rate schedule, which v1 never
changes, until the provider opts out again.
Changing a provider signing key is prospective. Every StreamRecord snapshots
the key that signs its receipts. Historical receipts remain verifiable against
that snapshotted key; an UpdateProvider MUST NOT reinterpret an existing
stream under a new key.
The owner chooses an active provider at stream creation. Automatic provider
selection is outside v1.
4.4 PriceQuoteV1
quote_digest stored in PricingBasisV1::ProviderQuote is exactly that
32-byte signing digest:
quote_key, exact owner, bounds, expiry, and the requested stream
configuration. In particular, retained_bytes, append_bytes_sec, and
delivered_bytes_sec MUST equal the requested configuration’s
retained_bytes_limit, max_append_bytes_per_sec, and
max_delivered_bytes_per_sec, respectively. The quoted rate_per_block
MUST be at least CBQS_MIN_RATE_PER_BLOCK (§6), and is
snapshotted into the stream record.
CreateStream carrying a quote records
PricingBasisV1::ProviderQuote { quote_digest }. A quote is single-use
because it binds the exact stream and pricing generation it may admit, not
because consensus remembers digests: stream_id MUST equal the id of the
stream the accepting instruction creates or names, and pricing_generation
MUST equal 1 at CreateStream and exactly the record’s
pricing_generation + 1 at UpdateStream, which stores the accepted
quote’s generation inside the same atomic transition, together with all
admission accounting. A replayed quote fails the generation equality, a
quote for one stream cannot admit another, and no consumed-quote marker
exists to store. An explicit create/update action tag would be redundant: a
record’s pricing_generation is never below 1, so a creation quote can
never satisfy an update, and an owner nonce is never reusable (§4.1), so
the stream_id a creation quote binds — derivable by both parties before
creation from the §4.1 formula — can be created at most once, ever.
The validity height bounds when that one admission may occur; it does not make
the quote reusable. A request without a quote is valid only through the
governance base-rate path below.
4.5 Base-rate schedule
Rates are denominated in nano-CBY. Escrow is a native-balance position. USD figures quoted here are the administered CBY rate defined in CIP-36 §6.6 applied to these constants, not properties of the constants; §6.6 is that rate’s only definition and this section does not restate it. At the current rate the schedule below prices retained bytes at ~1.95x the CIP-31 storage rate this document requires (2,905 nano-CBY/MiB/epoch), and one append-throughput unit alone at roughly half the monthly price of a Kinesis shard; a symmetric 1 MiB/s in-and-out configuration, including the delivered dimension, is ~2.8x a shard. Genesis seeds schedule version 1, and v1 fixes it for the life of the chain; the frozen semantics and their rationale are stated at the end of this section:PricingBasisV1::GovernanceBase.schedule_digest and every
atomically returned active-schedule view MUST use this derivation.
For the requested delivery class, the Stream Registry derives:
max_delivered_bytes_per_sec prices egress, which the first two dimensions do
not reach. max_append_bytes_per_sec bounds ingress only, while every consumer
group reads the whole stream and cbqs.max_groups_per_stream defaults to
4,096 — so without this term one stream can draw up to 4,096x its provisioned
bytes in egress at no additional rent, against a provider whose dominant cost
is precisely that egress. CIP-31 §2 already meters the same quantity for CBFS
as TRANSFER_FEE_PER_MIB; this is the queue-side equivalent. It is provisioned
rather than measured, so no trusted per-byte meter is introduced.
The same three configuration fields are charged by this formula and reserved
against the provider’s base-rate admission counters, which carry a matching
max_total_delivered_bytes_per_sec bound and a delivered_bytes_per_sec
reservation total. Pricing a dimension the provider cannot bound would let it
be committed past capacity, which is what the admission counters exist to
prevent. Unit sizes MUST be
nonzero and every multiplication and addition MUST be checked. Every
governance-base rate derivation — at CreateStream and at every
UpdateStream repricing alike — MUST reject a configuration whose three
priced capacity fields are all zero, and MUST otherwise clamp the derived
rate upward to the §6 floor:
DataExpired are
unreachable, only the owner can ever close it, and each one consumes a
reserved_streams unit the provider can neither shed nor shrink beneath.
The clamp handles every other cheap shape: a retained-only configuration
whose derived rate would be 67 nano-CBY per block pays the 8,000 floor
instead. A stream with accepted rate r stays Active for
escrow_balance / r blocks; escrow is attacker-chosen, so that duration is
attacker-chosen, but the burn rate is floor-bounded.
Two economics bound that funded phase. First, the floor: the cheapest
possible stream pays 8,000 nano-CBY per block, about 20.7 CBY/month at
one-second blocks, and the cheapest append-capable configuration — which
must reserve at least matching delivered throughput — pays 80,000 nano-CBY
per block for Standard or 74,700 for Fast, about 207.36 or 193.62
CBY/month. Second, the §6 creation charge: every creation burns
rate_per_block × CBQS_CREATION_CHARGE_BLOCKS (at least ~4.84 CBY), so a
record’s existence costs unrecoverable capital before its first block of
rent. The pinned grace deadline governs Suspended streams only; throughout
a funded window the record is Active with both suspension fields absent,
and what bounds that window is the funded escrow itself, burning at or
above the floor into the rent split. Provider base-rate admission caps are
a count bound on top of those economics, not a substitute for them — §4.3
forbids a provider lowering a cap below its current reservation, so caps
are consent given before exposure, not a lever during it. There is no
free-stream path: a quote below the floor is invalid (§4.4), and the
zero-escrow creation §6 permits still pays the creation charge.
Genesis seeds the schedule, and in v1 it is frozen. The twelve
cbqs.base_rate.* rows in §18 are genesis-validation values, not live
governance parameters: no §5 instruction and no governance parameter write
re-derives the stored schedule, they sit outside §18’s governance-bounds
read, and a parameter-store write to those paths would change nothing a
CBQS handler reads. CBQS’s retained-byte cost basis tracks the CBFS rate
(cip31.cbfs.storage_fee_per_mib_per_epoch, which is live Tier-0 under
CIP-31) in practice, and nothing reconciles the two automatically:
repricing CBQS to follow a CBFS change is a coordinated-release protocol
change specified by a later CIP, exactly like the surcharge constants, and
that CIP is where a mutable-schedule mechanism with notice semantics
belongs. Until then a CBFS rate increase narrows CBQS provider margins for
the life of the schedule — a disclosed, accepted v1 exposure.
The 0x03 record stores the canonical encoding of
BaseRateSchedulesV1 { active: BaseRateScheduleV1, pending: Option<BaseRateScheduleV1> }, the container the protocol codec defines.
The genesis schedule MUST carry schedule_version = 1 and
activates_at_block = 0 with pending absent, and genesis validation MUST
reject any other values, so the stored record is exactly 162 canonical
bytes — 161 for active plus one absent-option byte — and the record, the
genesis state root, and every GovernanceBase schedule_digest derived
from it are identical on every conforming node. The schedule_digest
preimage covers the 161-byte BaseRateScheduleV1 alone, never the
container, so the pinned 187-byte digest preimage and the 162-byte stored
record are different byte strings by construction. The active schedule at
height H is the container’s active_at(H) selection: pending when
present and H >= pending.activates_at_block, else active. v1 has no
writer of pending, so the switch is unreachable — but an implementation
MUST select through active_at rather than read active unconditionally,
so the later CIP that populates pending does not fork implementations
that hard-coded the v1 reachability argument. Streams keep the
schedule_digest they snapshotted at creation until an UpdateStream
priced-capacity change replaces it per §5. CreateStream snapshots the
active schedule_version, digest, and derived rate in
PricingBasisV1::GovernanceBase, so the schedule a stream was admitted
under stays derivable from finalized state.
5. Chain instructions
CbqsInstructionV1 contains the following variants:
The canonical v1 payload schemas are:
CbqsInstructionV1 encodes the v1 version byte, then the one-byte action tag
from the table, then that action’s fields in declaration order. The account
authorized by the containing Cowboy transaction is the provider for provider
actions, the owner for owner actions, and the payer for top-up; those
identities MUST NOT be repeated in the CBQS payload. Chain instance, block
height, initial record status, registration height, reservation totals, and
created/settled heights are derived from consensus execution context.
Every height STORED in a stream record — created_at_block,
last_settled_block, suspended_at_block,
suspension_grace_deadline_block — is an effective rent height, never a
physical block height, so the two are never subtracted from each other and a
pause can never make an elapsed-block difference negative.
CreateStream atomically debits initial_escrow from the owner account and
credits the new stream escrow; the transition MUST fail without creating the
stream when that debit cannot be completed. Stream escrow is real custody,
not a bookkeeping field: CreateStream and TopUpStream move native CBY
from the payer into the Stream Registry account, and settlement, close, and
expiry move it out of that account to the provider, the Platform Fee Account,
and the owner. The escrow_balance in each stream record is the per-stream
liability against that pooled balance, and every debit MUST assert the
registry account holds the amount before any recipient or record write,
failing closed on absent or underfunded custody. A defect in a credit path
therefore trips the custody assertion rather than minting native CBY, and the
sum of live escrow_balance fields is always backed by an equal or greater
registry balance.
Wire amounts are u128 wei while the account layer stores u64 wei
balances. Every debit MUST checked-convert and reject an amount exceeding
u64 as insufficient balance, never truncate. Every credit — provider rent
share, platform rent share, creation-charge burn, close refund, expiry
residual — MUST use checked u64 addition; because total supply fits u64,
a legitimate credit cannot overflow, so an overflow is state corruption and
MUST fail the instruction rather than truncate or strand value silently.
A zero initial_escrow is valid, and later funding remains available through
TopUpStream; creation is nevertheless never free, because CreateStream
also debits the §6 creation charge.
A SettleStreamRent MUST be rejected as a no-op exactly when its transition
would leave the stream record byte-identical. Keying the rejection on accrued
due instead would miss the case that matters: a materialized Suspended
record has last_settled_block = suspended_at_block, a past block, so due is
positive at every height while the recomputed paid-block count stays zero and
the write is identical — unboundedly repeatable inside one block for the
whole grace window. A transition that advances last_settled_block is real
work and remains valid even when it pays nothing. The rejection is scoped to
the standalone instruction: settlement performed inside TopUpStream,
UpdateStream, and CloseStream is a no-op-tolerant subroutine that skips
its write rather than failing its caller, so a griefer cannot land one
settlement per block to block every top-up on a stream inside its grace
window.
An UpdateProvider with every field absent MUST be rejected. UpdateStream
MUST be rejected as a no-op exactly when config, admin_key, and
price_quote are absent, rotation_key = Keep, and
bump_authorization_generation = false. A revocation-only update with the
bump flag set is therefore valid. RotationKeyUpdateV1 is three-way so
Keep and Clear cannot share one optional encoding. Changing admin_key
always bumps authorization generation regardless of the explicit bump flag.
A replacement quote is accepted only where §5 requires repricing. A
PriceQuoteV1 is single-use through its §4.4 generation binding:
UpdateStream MUST reject a quote whose stream_id is not the named stream
or whose pricing_generation is not exactly the record’s
pricing_generation + 1, and MUST store the accepted quote’s generation
inside the same atomic transition, so a superseded quote can never be
replayed to reprice a stream.
RegisterProvider and UpdateProvider use the canonical Cowboy transaction
account signature already verified before system-instruction dispatch. They do
not define a second provider-operator signing layout. CreateStream performs
at most one CBQS Ed25519 verification for an optional PriceQuoteV1;
UpdateStream performs at most one for a replacement quote; and
ActivateKeyGeneration performs exactly one for KeyBatchReceiptV1.
Every instruction MUST use compute-then-commit semantics. Failure MUST leave all
registry, balance, and linked-stream state unchanged.
Closed and DataExpired are absorbing: every instruction that names a
stream MUST reject one whose status is terminal, so a permissionless
settlement can never re-suspend a closed record, resurrect it through
top-up, or decrement admission and reservation counters that its one
terminal transition already released.
Creating, modifying, suspending, reactivating, and closing an encrypted stream
and its linked key stream are atomic operations. A partially created or
partially transitioned stream pair is invalid.
For a governance-base-priced creation, CreateStream MUST verify
accepts_new, add the requested stream, retained-byte, append-throughput, and
delivered-throughput reservations with checked arithmetic, reject any result
above a provider cap, and commit the counters atomically with the stream. A
quoted stream does not consume base-rate reservation counters because its
quote is explicit per-stream admission. An encrypted main/key pair consumes
one stream reservation using the main stream’s three priced configuration
fields.
A base-rate reservation is acquired exactly once by CreateStream, retained
while its stream is Active or Suspended, and adjusted only by the checked
delta in UpdateStream. The first terminal transition, either CloseStream or
the transition to DataExpired, releases the reservation exactly once.
When UpdateStream changes retained_bytes_limit,
max_append_bytes_per_sec, or max_delivered_bytes_per_sec:
- a
GovernanceBasestream MUST settle the old rate through the update block, derive a new rate from the then-active schedule, replace its snapshotted schedule version and digest, and atomically apply the reservation delta; - a positive reservation delta requires
accepts_new = trueand MUST fit all provider caps, while a negative delta releases capacity; and - a
ProviderQuotestream MUST carry a fresh quote authorizing the new capacity and rate.
6. Rent and suspension
CBQS charges fixed reserved-capacity rent rather than provider-reported per-message usage. This keeps message activity off chain and avoids trusting a provider usage meter. For a stream with rater, a settlement at block H computes:
escrow_balance >= due, the chain deducts due and sets
last_settled_block = H.
Two protocol constants price the existence of a stream, as distinct from its
service:
rate_per_block MUST be at least CBQS_MIN_RATE_PER_BLOCK
on either pricing basis. A governance-base derivation clamps upward to the
floor (§4.5); a provider quote MUST carry a rate at or above it as signed —
a quote is a stated price, never clamped — which rejects the zero-or-token
rate that would otherwise price registry state at nothing. A linked key
stream keeps rate_per_block = 0; its lifecycle, escrow, and creation
charge are the main stream’s (§4.1).
CreateStream MUST debit the owner account a nonrefundable creation charge
of rate_per_block * CBQS_CREATION_CHARGE_BLOCKS (checked u128), in the
same atomic transition that debits initial_escrow, and MUST fail without
creating the stream when either debit cannot be completed. The charge is
burned — credited to the zero address like slashed relay stake — not split
as rent: it is an economic-security bond against permanent-state growth,
not payment for service, and routing any share of it through the rent split
would let a self-providing owner recover that share of it. It is
deliberately a charge and not an escrow floor: escrow returns to the owner
at close, so any floor expressed through escrow is recoverable and prices
permanent state at zero. The charge is the capital a registry record costs
no matter how quickly its stream closes. It is sized to the grace window a
record may occupy while unfunded — CBQS_CREATION_CHARGE_BLOCKS equals the
genesis cbqs.suspend_grace_blocks default — so at the rate floor the
cheapest possible permanent record costs 8,000 × 604,800 wei ≈ 4.84 CBY. An
encrypted pair pays once, on the main stream’s rate. Both constants are
compile-time protocol constants with no live governance parameter — the
charge does not read cbqs.suspend_grace_blocks, so a governance rewrite
of the grace window neither cheapens nor breaks creation — and retuning
them is a coordinated-release protocol change, per the same rule as the
handler surcharge constants.
Creation always initializes the stream as Active, including when
initial_escrow = 0. Suspension is materialized only by a settlement that
finds accrued rent underfunded; a zero initial deposit is not itself a
suspension predicate. The two rules compose without contradiction: a
nonzero-rate stream created with zero escrow is chain-Active but already
economically suspended at the broker one block later, serves no mutations
until a top-up covers accrued rent, and enters the same grace window as any
other underfunded stream. Zero-escrow creation therefore buys — at the
price of the creation charge — a registry record and an id, not service;
funding it before the grace deadline is what starts service.
The broker MUST nevertheless derive effective availability from the same
finalized view used for authorization. For a record whose materialized status
is Active, it computes the checked accrued amount through the
effective_rent_height the broker-state response reports beside that view —
never served_at.height, which
is physical and would over-accrue by the whole pause coverage, rejecting
service on streams the chain considers funded — and MUST reject mutations
when escrow_balance < accrued. When the record carries a pinned
suspension_grace_deadline_block, the broker MUST also reject mutations at
or past it. A broker MUST NOT compute a grace deadline of its own from live
governance parameters: the pinned field is the only deadline that exists, and
a broker that invented one would disagree with the chain the moment
cbqs.suspend_grace_blocks was rewritten. A finalized height below
last_settled_block or any arithmetic overflow fails closed. The materialized
status flag alone is not sufficient: permissionless settlement may lag behind
economic suspension, so a stream reading Active may already owe more than it
holds, and the accrued check above — not the flag — is what withholds service.
6.1 The settlement transition
Settlement is defined exactly once, here. Every other section — §7’s authorization rules, §19’s event ordering and gating, and each instruction in §5 — refers to this function rather than restating it, and where an earlier draft restated it those restatements were the source of the divergence.settle(record, H), with H an effective rent height, is the only transition
that moves a stream between Active and Suspended:
status == Active guard on the pinning branch is load-bearing. A partial
payment on an already-suspended stream advances last_settled_block to the
newly paid-through point and leaves both pinned fields alone, so repeated
small top-ups cannot walk a grace window forward; without the guard the same
transaction would produce different record bytes on two implementations and
fork state_root.
Grace expiry is exactly status == Suspended and H >= suspension_grace_deadline_block. It is only ever evaluated against a
materialized record, and each instruction reaches that guarantee one of two
ways. There is no universal settle-first rule: requiring every instruction to
settle would make an administrative update mutate status, last_settled_block
and the emitted events on an economically underfunded record, which is neither
what this protocol intends nor what a conforming node does.
Each instruction MUST follow exactly the discipline named for it:
The last two carry an
Active-only gate, and that gate is what makes them
safe: they refuse to operate on any record that is Suspended or carries
either pinned field, so neither ever consults a deadline and neither needs one.
ActivateKeyGeneration never settles at all. UpdateStream settles only on
its priced-capacity path, and that path may take settle’s underfunded branch
and leave the stream Suspended with both fields freshly pinned — which is
consistent, because the Active-only gate is an ENTRY condition. It
establishes that no deadline exists to consult when the instruction begins; it
does not claim the instruction cannot create one. An administrative
UpdateStream that changes no priced capacity performs no settlement and
touches neither escrow nor status.
Both may run against a stream that is economically underfunded but not yet
materialized. That is deliberate and forgives no rent: the next settlement
still accrues from the unchanged last_settled_block, so the gap costs the
provider nothing.
SettleStreamRent is the one instruction that evaluates expiry before its
own settlement, because materializing DataExpired is precisely the case
where settling first would be wrong.
An economically suspended stream can sit unmaterialized indefinitely, since
SettleStreamRent is permissionless and nobody is obliged to submit it. Two
things follow, and neither is that such a stream is safe.
The provider’s data-deletion right is not evidenced until materialization —
the chain shows no deadline for anyone to check against — so a provider that
wants to exercise it submits SettleStreamRent itself, and if the stream is
already past grace, a further call to materialize DataExpired. This is not
“starting a clock”: settle pins suspended_at_block to the historical
paid-through height, so the deadline it writes may already be in the past at
the moment it is first written.
Nor does the unmaterialized window make such a stream revivable. TopUpStream
settles before it credits, computing that same historical deadline and
rejecting against it. An overdue stream is refused a top-up whether or not
anyone had settled it first — which is the property the ordering exists to
produce.
A rejected TopUpStream persists NOTHING. Its pre-deposit settlement is
evaluated to determine the deadline, not to commit one: on rejection the
record, the escrow balance, the custody account, the provider and platform
shares, and the payer’s balance are all exactly as they were, and no event is
emitted.
A rejected instruction has no effect, which is the only rule under which two
implementations agree — one that persisted the settlement and then returned an
error would produce different StreamRecordV1 bytes and different balances in
the same block. The consequence is deliberate: the provider is not paid, by
this instruction, for blocks it served before the escrow ran out. It collects
that rent by submitting SettleStreamRent, which is the instruction whose
whole purpose is to commit a settlement, and which is why a provider wanting
to act on an overdue stream submits one itself rather than waiting for a
top-up to do it.
Settlement distributes each paid amount using governance parameters:
provider_bps + platform_bps = 10000 MUST hold after any parameter write —
the same sum guard CIP-31 §4 states for the CBFS fee split, enforced on the
proposal, so neither share is ever a silent complement of a stale partner.
The platform_bps share is credited to the Platform Fee Account, system
actor 0x18, rather than burned. Burning is irreversible and crediting is
not: a credited share can still be burned later from that account, while a
burned one cannot be recovered, and the credit preserves an on-chain record of
accrued platform revenue that a burn destroys. The address is fixed, so no
governance write can redirect the share and settlement needs no state read to
find it. Withdrawal authority is genesis-defined for the account under
COW-2915 — 0x18 sits in the keyless reserved system range, so it needs an
explicit genesis-configured owner or multisig rather than a recoverable key,
and this document defines accrual here while COW-2915’s resolution defines
the exit path before mainnet genesis. This is the same account and the same
rule as CIP-31 §4.
All elapsed-block quantities in this section — accrued rent, paid-through
positions, suspension points, and grace deadlines — are computed on the
effective rent height: the physical block height minus the registry’s
cumulative CIP-12 pause coverage, exposed to brokers as the broker-state
effective_rent_height and never greater than the finalized height. While
the registry has never been paused the two are equal and this section reads
as plain block arithmetic.
For each paid amount, execution uses checked u128 arithmetic:
platform_share is exactly the platform_bps complement plus that
remainder. The two split parameters are read live
at each settlement and apply prospectively from their parameter-write block:
already-settled rent never moves, but future rent on existing streams follows
the new split, which is why both carry §18 rows and the same governance
change path as every live scalar rather than silent prose defaults.
The provider share credits the assigned provider account. The platform share
credits the Platform Fee Account (0x18), per the rule that opens this
section. A self-host quote MUST still carry at least
CBQS_MIN_RATE_PER_BLOCK: no main stream accrues zero rent, so every stream
either stays funded or walks the §6.1 suspension path to a terminal state,
and the open-ended zero-rate exit pin earlier drafts disclosed cannot be
constructed. A provider still accepts the one-sided exposure §Security
Considerations discloses for funded streams by signing any quote.
While suspended, the broker MUST reject appends, new groups, redrives, and
administrative mutations. It SHOULD permit read, replay, ack, and key recovery
during the grace window.
suspended_at_block and suspension_grace_deadline_block are written only by
§6.1’s settle, which pins them together on the Active -> Suspended
materialization using the grace parameter in force at that block and preserves
them unchanged on every later partial payment. There is no second way to
obtain a deadline and no formula that reproduces one from a live parameter, so
a governance change can neither shorten nor extend a window that is already
pinned. That is the guarantee, and it is narrower than “governance cannot
affect grace at all”.
settle anchors the deadline on the historical paid-through height but reads
cbqs.suspend_grace_blocks live at the settling block, and §6.1 allows a
stream to sit unmaterialized indefinitely. Two streams that exhausted escrow
at the same height can therefore receive different deadlines, depending on
which side of a governance write each was settled. This is deterministic — the
settling block fixes the parameter, so every node computes the same deadline
and there is no fork — but it is a race: after a reduction a provider gains by
settling promptly, and before an increase an owner gains by delaying. Once
pinned the value is fixed for that stream forever. Removing the race entirely
would require snapshotting the grace parameter into the record at creation,
which v1 does not do.
inverted means exactly suspension_grace_deadline_block < suspended_at_block; equality is valid, since a zero-length grace parameter is
permitted, and expiry applies at H >= suspension_grace_deadline_block.
Reactivation and every terminal transition clear both fields together; an
encrypted stream mirrors both to its linked key stream and rejects divergence,
a missing or inverted pair fails closed with no mutation, and an active record
carrying stale suspension fields is rejected.
Grace is available only while the stream is Suspended and H < suspension_grace_deadline_block, evaluated per §6.1 under the discipline that
section assigns to the evaluating instruction. At that height or beyond, the
provider MAY delete data and a subsequent SettleStreamRent MUST materialize
DataExpired. TopUpStream is the only instruction that can reactivate a
stream, and §6.1 requires it to settle before it credits, so it applies this
check against a deadline it has just computed: an economically suspended
stream has its deadline derived by the very instruction attempting to revive
it, and is then rejected on that deadline. The rejection persists nothing, per
§6.1 — the settlement is evaluated to find the deadline, not committed. Top-up
revives a suspended stream only before that deadline. TopUpStream is permissionless only while the stream’s assigned
provider is Active; when the provider status is not Active, the payer MUST
be the stream owner. Third-party sponsorship therefore works in normal
operation but cannot keep an abandoned stream funded against a provider whose
deregistration requires every assigned stream to reach a terminal state.
TopUpStream MUST reject amount = 0 and MUST apply §6.1 in this order:
settle at the pre-deposit escrow through the effective rent height, reject
if the resulting record is Suspended at or past its now-pinned deadline,
then atomically credit the deposit, then settle again through the same height.
Settling before the credit is what makes the deadline check meaningful — a
deposit applied first can cover the accrued window and erase the very
suspension the check exists to catch, admitting a top-up on a stream whose
data the provider was already entitled to delete. The second settlement
restores Active only when the credited escrow covers the full accrued
window; a partial payment advances the paid-through block, preserves both
pinned fields per §6.1, and leaves the stream Suspended.
Materializing DataExpired refunds the residual escrow_balance to the owner
and sets the record balance to zero. The residual is less than one rent block
and is not earned by the provider. The same atomic transition mirrors
DataExpired to a linked key stream, releases any governance-base reservation,
decrements the owner’s active-stream count, and decrements the provider’s
live_assigned_streams exactly once.
Changing pricing basis or priced capacity settles the old rate through the
activation block before installing the new rate.
7. StreamGrant authorization
CBQS does not reuse CBFSOwnerCapTokenV1. The implemented CBFS token is an
owner-issued credential with access-mode and path restrictions, but it does not
bind a grantee holder key or express CBQS verbs and group limits.
The stream’s current admin key issues a directly signed StreamGrantV1. The
owner controls that key through UpdateStream; an owner account signature is
not itself a valid StreamGrant signature.
grant_nonce, but not to a transport serialization or a locally
invented identifier.
The verb bits are:
Unknown bits MUST be rejected. A key stream does not accept ordinary
APPEND;
it accepts only KeyRotationBatchV1 under §9.
The admin signature is over:
- the signature under the stream record’s admin key from the request’s
FinalizedViewV1; - exact signing context and
stream_id; - exact current
authorization_generation; - the time window, allowing at most
CBQS_MAX_CLOCK_SKEW_MS; - the requested verb, lane scope, and group scope; and
- the intersection of stream, grant, provider, and account quota limits.
max_lane_creates_per_min MUST NOT exceed the finalized
cbqs.max_lane_creates_per_min protocol bound.
The broker MUST fail closed when its finalized chain-state view is older than
CBQS_MAX_CHAIN_STALENESS_BLOCKS. It MUST acquire one FinalizedViewV1 before
processing a request and evaluate the stream record, provider record, status,
effective escrow availability under §6, keys, generations, schedule, and
applicable bounds from that same immutable snapshot. A request MUST NOT combine
values read at different heights.
The broker’s observed finalized cursor MUST advance monotonically. After it
observes a finalized status, provider-key, provider-epoch, authorization-
generation, or encryption-generation change, it MUST NOT admit a later
mutation under an older view. Existing sessions MUST be revalidated against
the newest observed finalized view before their next mutation and terminated
when their grant no longer matches. The activation boundary is the finalized
block containing the change.
7.1 Proof of possession
On connection, the broker sends a random 32-byte challenge and a uniquesession_id. The client proves possession of the grant’s holder key by signing:
signature is carried in the canonical proof object and is excluded from the
signing preimage. It MUST NOT be supplied through a transport-only side field.
The holder signs:
CbqsRequestMethodV1 is a canonical u16 registry:
Unknown method tags are invalid. Every proof-bound method defines one canonical
request-body type that excludes
RequestProofV1. Its body_hash is:
RequestProofV1 MUST equal the tag used in the body-hash
preimage. Proof bytes, signatures, request-correlation ids, WebSocket framing,
and HTTP framing never enter body_hash. Implementations MUST use the named
cowboy-protocol-codec::cbqs body-hash function for the method and MUST NOT
select fields locally.
The holder signs
keccak256(request_proof_signing_bytes_v1(request_proof)).
Counters start at one and MUST increase strictly within a session. The broker
MUST reject duplicate, skipped-backward, or cross-session proofs. A captured
grant without the holder private key is therefore insufficient.
authorization_generation is coarse revocation. Owners SHOULD also issue short
grant expiries. v1 has no delegation chain or third-party attenuation.
7.2 Lane scope
Every main-stream grant carries:Any permits its verbs on every lane, Exact permits one lane, and Set
permits the listed, sorted, unique lane ids. A set is bounded by
cbqs.max_lane_ids_per_grant. One lane-set grant therefore covers a user’s
workspace sidebar without minting one grant per page. A group scope never
widens lane scope. The broker MUST evaluate both.
Lane authorization is a transport boundary, not a cryptographic boundary. All
lanes in an encrypted stream share its data-key generation. Applications MUST
therefore place only lanes in the same cryptographic confidentiality domain in
one parent stream. Within that domain, narrower lane grants are broker-enforced
routing isolation, not protection from another holder of the parent data key.
A page or room that needs a genuinely different confidentiality boundary MUST
use a separate parent stream or independent application-layer encryption
outside this CIP.
8. Lanes
The all-zerolane_id is the default lane and exists for every main stream.
Additional lanes are broker-side state:
LANE_ADMIN creates a lane by choosing a random lane_nonce:
max_lanes, retained-byte limit, and throughput quota bound the aggregate. The
broker MUST also enforce the grant’s lane-creation rate caveat. A closed lane
rejects new appends but remains replayable through the parent retention floor.
Closed lane ids MUST NOT be reused.
max_lane_creates_per_min is enforced per (stream_id, grant_id) across all
sessions using a rolling 60-second window. At time T, a broker admits a lane
creation only when fewer than the caveat value have been admitted in
(T - 60_000 ms, T]; a value of zero denies all lane creation. Requests
rejected before mutation admission, including an already-used lane_id or an
exhausted max_lanes limit, do not consume the caveat. A broker clock
regression MUST fail closed for lane-creation admission.
max_lanes counts the implicit all-zero default lane. A stream configured with
max_lanes = 1 therefore permits no additional lanes.
Every lane create and close atomically appends a provider-originated LaneEventV1
to the default lane, carrying the affected lane_id, new status, and metadata
hash. The operation succeeds only after the event and its ordinary
AppendReceiptV1 are durable, including on a fast parent. That append receipt
is the authoritative lane-mutation receipt. On a fast parent it carries a
FastRecordIdV1, including the broker epoch; a receipt MUST NOT identify that
record using only the broker-epoch-local sequence.
LaneRecordV1, but the mutation’s
AppendReceiptV1.lane_id MUST be the all-zero default lane where the event was
appended. The broker
also exposes bounded, paginated lane enumeration filtered by the caller’s lane
scope and capped by cbqs.max_lane_list_page, so a consumer can recover after
the default-lane event falls behind retention. ListLanes requires at least
one of CONSUME, REPLAY, or LANE_ADMIN; an unrelated verb such as
APPEND alone does not authorize topology discovery. after_lane_id is an
exclusive cursor. The broker applies the grant’s lane scope before the page
limit, and returns next_after_lane_id only when another authorized lane
remains.
The cleartext lane_id is carried in each record header. The provider MUST
maintain an index that serves exact-lane subscriptions without transmitting
unrelated records. This is opaque routing-metadata filtering, not
payload-semantic filtering.
A stream retains one global append order and one global receipt chain. It also
maintains a receipt predecessor per lane so a lane-only consumer can verify
that the provider did not rewrite or omit a record within that lane. Lanes are
not partitions: they have no independent provider, rent, retention,
authorization generation, encryption generation, or ordering authority.
There are no per-lane quota carve-outs in v1; a hot lane consumes the parent’s
shared quota and may backpressure its siblings.
9. Encryption and key rotation
9.1 Algorithms
Main-stream payload encryption uses XChaCha20-Poly1305 with a random 24-byte nonce. The associated data is the canonical record header throughencryption_generation, including stream_id and lane_id but excluding
payload bytes and provider-assigned sequence. A ciphertext therefore cannot be
moved between lanes without authentication failure.
Member envelopes use HPKE base mode with suite
X25519-HKDF-SHA256/ChaCha20Poly1305 (RFC 9180). Each grant for an encrypted
main stream MUST bind the holder signing key and the member’s HPKE public key.
Signing keys and HPKE keys are distinct.
Every Ed25519 verification defined by this CIP uses pure Ed25519 from RFC 8032
with an exactly 32-byte public key and exactly 64-byte signature. Verification
MUST use strict semantics equivalent to ed25519-dalek 2.2
VerifyingKey::verify_strict: canonical point and scalar encodings are
required, weak or small-order public keys and R values are rejected, and a
noncanonical S is rejected. Consensus code MUST verify each signature
individually and MUST NOT use a batch-verification result. ZIP-215 or a
permissive Verifier::verify result is not valid for a CBQS object.
SigningPublicKey canonically encodes the one-byte numeric algorithm tag
followed by the fixed-width public-key bytes. v1 defines only Ed25519 = 1.
Every signing key id in this CIP is:
PriceQuoteV1.signing_key_id MUST equal
signing_key_id(ProviderRecordV1.quote_key) for the finalized provider record
used to verify that quote. holder_signing_key_id means
signing_key_id(StreamGrantV1.holder_signing_key) everywhere it appears,
including deduplication and delivery leases.
9.2 Root escrow
The owner creates a random 32-bytestream_root, stores it as a CBSS secret,
and records the derived rotation public key in both linked stream records. The
v1 rotation actor is the sole actor in that secret’s CBSS read policy.
The rotation signing seed and each data key are derived:
9.3 KeyRotationBatchV1
For encryption generationg, the rotation workload publishes:
sorted_envelope_digests is sorted by
keccak256(canonical(recipient_hpke_key)). The rotation key signs:
batch_digest referenced by envelopes and receipts is exactly the
32-byte signing digest above:
sorted_envelope_digests is the canonical envelope digest:
membership_set_digest commits the generation’s complete membership — the
recipient HPKE keys in strictly ascending order of
keccak256(canonical(recipient_hpke_key)), duplicates forbidden:
u32
member count, then each recipient key’s §14 struct encoding concatenated in
order with no per-element or vector length prefix; the explicit count is the
only cardinality in the preimage. The envelope-root preimage in §9.3 uses the
same convention for its ordered digest list.
The batch chain has an explicit per-key-stream anchor and tail, distinct from
the append-receipt chain. With no committed batch, the tail is:
previous_batch_digest MUST equal the current batch tail — the genesis
anchor for the first batch, the prior committed batch’s batch_digest
afterwards, including supplemental batches — and each committed batch
atomically advances the tail to its own batch_digest inside the §14.3
key-batch commit set.
Sealing MUST NOT depend on digests of sealed envelopes, so associated data
binds the pre-envelope batch identity rather than batch_digest:
previous_batch_digest, compute membership_set_digest and
batch_context_digest, seal every envelope, compute envelope digests, then
sign the batch, whose batch_digest exists only after sealing.
Each envelope is HPKE single-shot seal for
X25519HkdfSha256ChaCha20Poly1305 with info = "cbqs/key-envelope-hpke/v1". The associated data is the bare concatenation,
with no length prefixes or vector headers, of: the 32-byte stream_id, the
8-byte big-endian encryption_generation, the 32-byte recipient order key,
and the 32-byte batch_context_digest. The recipient order key is exactly
the §9.3 sort key keccak256(canonical(recipient_hpke_key)), where
canonical is the recipient key’s §14 struct encoding (suite tag then key
bytes). The
plaintext is exactly the generation’s 32-byte symmetric data key.
hpke_ciphertext is the 32-byte X25519 encapsulated key followed by the
AEAD output; anything shorter than one sealed data key is invalid.
The broker MUST reject
a batch whose generation is lower than or more than one greater than the
chain-active generation, whose predecessor does not match the current batch
tail defined above,
or whose signature is invalid.
Committing a staged batch materializes it as exactly 1 + envelope_count
contiguous standard records on the key stream’s default lane in one atomic
§14.3 commit. The first record, at first_sequence, has
kind = KeyBatchHeader, payload = the canonical signed KeyRotationBatchV1
bytes, and client_message_id = batch_digest. It is followed by one
kind = KeyEnvelope record per envelope in sorted_envelope_digests order,
each with payload = the canonical KeyEnvelopeV1 bytes and
client_message_id = that envelope’s envelope_digest, so
last_sequence = first_sequence + envelope_count. All of these records use
origin = ProviderEvent, zero visible_after_ms and expires_at_ms, a zero
nonce, and encryption_generation = the batch generation; their payloads are
HPKE ciphertexts and signed objects, not stream-encrypted content, and
ordinary append receipts chain across them. final_append_receipt is the
Keccak-256 digest of the last materialized record’s append-receipt signing
bytes. The receipt’s envelope root is:
envelope_count MUST be nonzero and within the batch bound,
envelope_root and batch_digest MUST be nonzero, and last_sequence MUST
equal first_sequence + envelope_count exactly (the header record plus the
envelopes). ActivateKeyGeneration verifies the receipt signature and these
structural invariants only; recomputing the envelope root or membership
digest requires the key-stream records and is client and auditor work, by
design.
The provider MUST reserve enough key-stream capacity for the
stream-configuration membership maximum. Ordinary main-stream backpressure
MUST NOT consume that reserve. ActivateKeyGeneration carries the
KeyBatchReceiptV1; the Stream Registry verifies its signature against the
StreamRecord’s snapshotted provider key and epoch before advancing the main
stream. It also verifies that the receipt context matches the executing chain,
that served_at.chain_instance_id matches that context, and that the
served_at.height is lower than the executing block height. Before submitting
the instruction, the owner or its client MUST verify through the finalized-view
RPC that the signed served_at height and block hash identify one canonical
finalized view. The consensus handler MUST NOT query a validator’s local block
archive or marshal state: served_at.block_hash remains provider-signed
evidence, while chain-instance replay protection and receipt authorization are
enforced by the signed context, generation, stream record, and provider key.
9.4 Membership changes
Adding a member MAY append a supplemental batch for the current generation. The supplemental batch MUST extend the prior batch digest and MUST NOT remove an existing envelope. A valid batch has at least one envelope: a rotation that would leave zero members is invalid, soenvelope_count is never zero and removing the final
reader of a stream is expressed by CloseStream, not an empty rotation.
Membership entries are HPKE keys and the admin signing key is not itself a
member; an owner who wants continued read access registers an HPKE key like
any other member.
Removing a member requires:
- produce generation
g + 1envelopes for the retained membership; - durably append the batch to the key stream;
- submit
ActivateKeyGenerationwithbump_authorization_generation = true; and - issue new StreamGrants only to retained members.
g + 1.
A failure before step 3 leaves generation g active and g + 1 inert. A
failure after step 3 cannot strand retained members because the chain verified
the durable key-stream receipt first.
Removed members may retain plaintext and keys they previously received. CBQS
provides forward exclusion, not retroactive erasure.
10. Record model
The client submits anAppendRequestBodyV1; the transport carries its
RequestProofV1 as a sibling field rather than inside the hashed body:
body_hash MUST equal
append_request_body_hash_v1(AppendRequestBodyV1), using method tag Append = 1 and the §7.1 request-body domain.
The provider assigns order and emits a broker-visible envelope:
origin = Client, kind = Application, and copy
client_created_at_ms to created_at_ms. Clients MUST set
visible_after_ms = 0; the broker MUST reject any other v1 value.
A provider event MUST use origin = ProviderEvent, a lane-event kind, the
all-zero default lane_id, and
keccak256(canonical(LaneEventV1)) as its synthetic client_message_id. Its
created_at_ms is provider time, it is visible immediately
(visible_after_ms = 0), it has no application deadline
(expires_at_ms = 0), its payload is the canonical LaneEventV1,
encryption_generation = 0, and nonce is zero. Provider events are cleartext
delivery mechanics even on an encrypted stream; a consumer MUST inspect
origin and kind before deciding whether to decrypt the payload.
Provider events do not enter the client deduplication table and require no
holder request proof. They receive ordinary AppendReceiptV1 receipts, consume
the parent’s append, retained-byte, and retention quotas, and are evicted only
under the same retention rules as application records.
A committed standard record is:
sequence is monotone within a stream. A fast record id is:
max_message_bytes or the protocol maximum MUST be rejected.
The SDK SHOULD store larger encrypted objects in CBFS and place the CBFS
reference inside the encrypted payload.
11. Standard delivery
Astandard append is successful only after the record and its append-receipt
predecessor are durably committed. The provider MUST NOT acknowledge it from
memory or an operating-system page cache without its configured durable-commit
boundary.
11.1 Idempotent append
The deduplication key is:holder_signing_key_id is derived by §9.1; no raw-key-only, JSON, or
transport-specific alternative is valid.
Deduplication is stream-scoped even when a request names a lane. On first
acceptance, the broker records its own received_at_ms. It MUST reject a new
key when client_created_at_ms is later than received_at_ms + CBQS_MAX_CLOCK_SKEW_MS or older than received_at_ms - (idempotency_horizon_ms + CBQS_MAX_CLOCK_SKEW_MS). The broker retains the
dedupe entry for at least idempotency_horizon_ms after first acceptance; the
client clock does not shorten that retention. Within the retained horizon:
- an identical retry returns the original append receipt;
- reuse with different canonical request bytes, including a different
lane_id, returnsIdempotencyConflict; and - a retry after the horizon returns
IdempotencyExpired.
11.2 Consumer groups
Groups are broker-side objects and are not chain state.(stream_id, lane_id, group_id). Creating,
updating, or deleting a group requires GROUP_ADMIN scoped to both the group
and its lane. A group created at Head sees only later records in that lane. A
group created at an explicit retained id begins with the first lane record at
or after that id. A stale start returns CursorTooOld with the latest signed
lane retention-floor anchor.
cbqs.max_groups_per_stream counts active groups across the parent stream. Deleting a group
decrements that active count but leaves a durable deletion tombstone and
receipt-chain tail. The exact (stream_id, lane_id, group_id) tuple MUST NOT be
reused; a new logical group uses a new random group_id. This prevents a
stale consumer from confusing a replacement group with the deleted group’s
start cursor or receipt history.
Each record is delivered at least once to each applicable group while the
provider is honest and available, until one terminal state occurs:
ACK, NACK,
EXTEND, and REJECT MUST name that lease. A stale lease cannot mutate a later
delivery cycle.
ACKtransitions the cycle toACKED.NACKreleases it for retry after a requested bounded delay, unless the completed attempt exhaustsmax_attempts, in which case it transitions directly toDEAD_LETTERED.EXTENDadvances expiry but never beyondmax_visibility_ms.REJECTtransitions directly toDEAD_LETTERED.- exhaustion of
max_attemptstransitions toDEAD_LETTERED. - passage of
record_deadline_mstransitions toEXPIRED.
group_queue_sequence when a delivery cycle first enters its queue. Initial
cycles follow the group’s applicable lane-record order. A redriven successor
receives the next sequence at the then-current tail. Sequences are immutable
and never reused, including after terminality or group deletion.
StrictFifo exposes only the lowest nonterminal record. A terminal state fills
the cursor hole. Concurrent may lease later records up to max_in_flight.
Two in-flight limits apply together, and a lease requires room under both.
GroupConfigV1.max_in_flight bounds the group’s total outstanding leases
across every holder, counted per group. StreamGrantV1.max_in_flight bounds
the leases held concurrently by ONE holder, counted per
holder_signing_key_id across every group, subscription, and connection on
that stream, so a holder cannot multiply its signed allowance by opening more
subscriptions or spreading across groups. The two counters therefore have
different scopes on purpose — one per group, one per holder per stream — and a
lease is admitted only when both have room.
A grant whose limit exceeds the group’s is capped by the group, so a holder’s
effective ceiling in any one group is min(grant.max_in_flight, group.max_in_flight); a group whose limit exceeds a grant’s still admits
other holders up to the group total. When several grants name the same
holder_signing_key_id, each request is evaluated under the grant it
presents, against the one stream-wide count for that holder. The holder’s
reachable ceiling is thus the maximum over its own valid grants, which is
intended: the owner signed each of those allowances, and taking the minimum
instead would let a later narrow grant silently revoke a broader one still
inside its validity window.
Enforcing only the group limit would make the grant’s field decorative and let
a holder issued one concurrent lease take the whole group’s capacity.
The per-holder count MUST be durable and MUST be released in the same atomic
write as the lease it accompanies; a broker that reconstructs it MUST do so by
scanning live leases, never by trusting a client. It MUST be checked after
lease preparation rather than before, so that leases expiring during the
operation are released first — a pre-check against a stale count wedges a
capped holder permanently once its leases have expired.
Undecryptable or malformed application payloads SHOULD be nacked or rejected
by the consumer; the broker cannot inspect ciphertext.
Live records MUST NOT be evicted under capacity pressure. The broker instead
returns Backpressure. A stalled group pins retention only until its configured
pin deadline and within its stream byte quota. When the deadline expires,
affected records become EXPIRED for that group and retention may advance.
Deleting a group returns a signed group-state receipt and releases its
retention pins.
12. Fast delivery
fast is for repairable application logs such as CRDT operations, presence,
typing state, telemetry, and cursor broadcast. It does not support consumer
groups, leases, dead-letter lanes, redrive, or an at-least-once work-queue
claim.
The provider may acknowledge an append provisionally before durable commit. It
MUST commit a batch when either fast_batch_max_ms or
fast_batch_max_records is reached. The provider publishes a checkpoint
receipt after every durable batch.
Two watermarks exist:
- provisional watermark: highest record emitted in the current broker epoch; and
- durable watermark: highest record covered by a valid checkpoint.
12.1 Broker epochs
Before accepting records after startup, the provider MUST durably increment the stream’sbroker_epoch. Sequences begin at zero in the new epoch and MUST NOT
be reused within it. Every increment MUST produce one signed transition:
Clean is valid only when the broker durably recorded, before shutdown, that
the provisional and durable watermarks were equal;
last_provisional_sequence and last_durable_sequence MUST both be present
and equal, or both absent for an empty genesis lineage. Void means every
record in the prior lineage above last_durable_sequence is void, and its
last_provisional_sequence MAY be absent because a crashed process may not
know its exact emitted upper bound.
previous_lineage identifies the preceding same-provider
(provider_epoch, broker_epoch). It is absent only for a newly created stream.
The stream starts at broker epoch zero with a Clean transition anchored to
the checkpoint-genesis digest in §14.1.
The broker MUST durably publish the transition before accepting new appends.
The first CheckpointReceiptV1 of the replacement broker epoch MUST set
previous_checkpoint to the transition digest. Because the transition itself
names last_durable_checkpoint, it is a link in the checkpoint chain rather
than an unaudited side record. Conflicting transitions for the same predecessor
and replacement lineage are provider-equivocation evidence. A client that
observes a broker-epoch change without a valid transition MUST treat it as an
unclean restart and MUST NOT advance beyond its last verified durable
checkpoint.
Clients compare their locally observed watermark with the transition, discard
void records, and re-send application operations from local state.
Applications using fast MUST either carry stable application operation ids
or use an idempotent merge model with a defined resynchronization procedure.
For a CRDT such as Yjs, the SDK recovery path MUST compare watermarks, discard
the void suffix, materialize state through the durable checkpoint, compute a
state-vector diff from the client’s local state, and re-append that diff. The
SDK MUST surface clean and void transitions and the current durable watermark;
it MUST NOT silently reconnect past either an unexplained epoch change or a
void.
When either fast-batch threshold is reached, the provider MUST stop issuing
additional provisional acknowledgements for that stream until the batch
commit completes. Under honest stable storage, the provisional loss window is
therefore bounded by both configured thresholds rather than merely targeted by
them.
12.2 CheckpointReceiptV1
BrokerEpochTransitionV1; later checkpoints link to
the prior checkpoint. A snapshot manifest may bind only to a stream checkpoint
receipt, never to a provisional watermark.
The partition is required rather than optional because §7.2 directs applications
to subscribe with Exact or Set lane scope, and lane authorization is a
transport boundary rather than a cryptographic one. A flat commitment over the
whole batch can only be checked by a subscriber holding every record in it, so
it would force every verifying subscriber to Any — which, since all lanes in
an encrypted stream share one data-key generation, would hand every verifying
subscriber the plaintext of every lane it is not authorized for. The partition
is what lets record-level verification and lane-scoped delivery coexist.
The canonical fast-record commitment is:
record_digest[i] be the
fast-record commitment for sequence first_sequence + i, in increasing
global sequence order.
Two commitment shapes are used, and the difference is deliberate.
Ordered lists use merkle_root_v1: for a non-empty leaf list it pairs
adjacent nodes left to right at each level as
keccak256("cbqs/merkle-node/v1" || left || right); a trailing odd node is
promoted unchanged to the next level and MUST NOT be duplicated. A
single-leaf list roots to that leaf.
An ordered root is not by itself a commitment to its leaf list, and MUST NOT
be used as one. Promotion makes a single-leaf list root to that leaf, so the
two-leaf list [a, b] and the one-leaf list [node(a, b)] share a root by
construction — the ambiguity duplication would have introduced is removed, but
length ambiguity remains. Every structure consuming an ordered root therefore
commits the leaf count alongside it, which is what makes the pair unambiguous.
lane_sub_root is only ever consumed through lane_payload_digest, which
commits lane_record_count with it.
Lane membership uses sparse_root_v1, a fixed-depth-256 tree keyed by
lane_id rather than a sorted list, so that a lane’s absence can be proven
without naming any other lane. The leaf for lane_id sits at the path given by
its bits, most significant first. Empty subtrees are canonical constants:
bitmap whose bit d is set when the
sibling at depth d differs from EMPTY[d+1], followed by exactly those
non-default siblings in increasing depth order. A verifier reconstructs the
remaining siblings from the constants.
Descent is canonical. At depth d, the key bit is lane_id[d / 8] bit
7 - (d % 8) — most significant bit of the most significant byte first. A zero
bit descends left and the sibling is the right child; a one bit descends right
and the sibling is the left child. bitmap bit d refers to that same depth,
and siblings holds the set-bit siblings in increasing d.
Typical proofs carry about log2(active lanes) hashes, but the protocol bound
is 256, not the typical case: a valid map built from sufficiently distinct
lanes can place a non-empty sibling at every depth along one query path. Decode
limits and the scope bound in §12.2.1 are sized against that worst case, never
against an expected one.
Any sibling supplied that equals its EMPTY[d+1] constant MUST be rejected, so a
proof has exactly one canonical encoding. A present leaf whose
fast_lane_payload_digest_v1 equals EMPTY[256] MUST also be rejected, so
presence and absence cannot collapse into each other except by a hash
collision.
Each lane present in the batch commits its own records:
lane_id and holds lane_payload_digest directly, so a
proof about one lane carries no information about any other: absence is proven
against the empty-leaf constant at the lane’s own key, not against neighbours.
lane_id appears in the leaf preimage as well as the key, and the redundancy is
load-bearing: it makes a payload digest valid at its own key and nowhere else.
Position alone cannot provide that. A leaf whose preimage omitted its key would
verify at any key, and a provider could then move a lane’s entry to a key
that is no lane’s lane_id — the batch-global sums would still balance, and the
lane’s own subscriber would receive a perfectly valid absence proof at its
key while its records sat re-keyed elsewhere in the same signed map. With
lane_id in the preimage, an absence proof at L’s key means L’s payload is
committed nowhere in the map, because it cannot verify anywhere else. A
verifier recomputing fast_lane_payload_digest_v1 MUST use the lane id of the
key being proven, never a lane id carried in the payload material. The binding
closes leaf relabelling, not record mis-partitioning — a leaf naming the
wrong key over another lane’s records remains a FullStream-detectable
falsehood, not a scoped-detectable one; §Security Considerations states the
exact division. Absence proofs carry no leaf, so their key binding rests on a
different mechanism: verification by sparse_proof_root_v1 is driven by the
key being queried, so a proof accepted at key K is a statement about K
alone. Proof bytes are not themselves key-bound — two absent keys whose paths
meet only default (empty) siblings share identical proof bytes — and a proof
lifted from another key can only verify at K when K is genuinely absent.
Verifiers MUST NOT reject an absence proof because its bytes were previously
seen for a different key.
The canonical ordered range root is:
record_count MUST be at least 1, MUST NOT exceed the protocol maximum of
65,536, and MUST equal last_sequence - first_sequence + 1 without integer
overflow. A provider MUST NOT commit a batch whose record_count exceeds the
fast_batch_max_records snapshotted for that batch under the batch-boundary
rule below; that is a commit-time obligation on the signer, not a verification
condition — a verifier checks only the immutable bound, per the
historical-verification rule below. Each lane appears
at most once in the map. The sum of every lane_record_count MUST equal
record_count, and the union of all lanes’ sequences MUST be exactly the
contiguous range first_sequence..=last_sequence.
durable_high_water MUST equal last_sequence. ordered_range_root and
previous_checkpoint MUST be nonzero valid commitments. Verification MUST
reject a missing, duplicated, out-of-order, wrong-lineage, or non-contiguous
record before accepting the root.
The sum and union rules are dischargeable only by a verifier holding every
lane. They bind the provider at commit and a FullStream verifier at replay,
and the codec’s full-stream verification enforces them. A ScopedProofs
subscriber holds nothing about lanes outside its scope, so it can evaluate
neither; to it the receipt’s record_count and range contiguity are signed
claims, authenticated as to signer but not checked against the map. What a
scoped subscriber does verify is stated in §12.2.1: its own lanes’ counts,
sequences and sub-roots, and — because lane_id is in the leaf preimage — that
each of its lanes is present or absent at its own key and nowhere else. A
binding MUST NOT report the batch-global properties as verified for a
ScopedProofs subscription.
A configuration change takes effect at a batch boundary, never inside one.
StreamConfigV1 fields that govern batching — fast_batch_max_records,
fast_batch_max_ms and max_message_bytes — are snapshotted by a stream and may
be changed by UpdateStream while a broker holds an open buffer. A provider MUST
flush any buffered batch under the configuration and finalized view it was
opened with, then install the new configuration for subsequent records; if that
flush fails it MUST stop accepting new records rather than proceed. Adopting a
change mid-batch would sign a batch against a configuration it was not built
under, and caching the configuration at first use — never re-reading it — would
sign every later batch against a configuration the stream no longer has.
Verifying a historical checkpoint MUST NOT consult mutable present state.
record_count is bounded by the immutable protocol maximum of 65,536, never by
the stream’s currently configured fast_batch_max_records. A stream’s
configuration may be updated, so checking an old receipt against today’s value
would let a configuration write retroactively invalidate checkpoints that were
correct when signed — bricking replay and retention for history that has not
changed. This is the same retroactivity §18’s class partition exists to prevent,
and the same failure the pinned suspension-grace deadline avoids: a signed
historical artifact must be verifiable from itself and immutable constants alone.
A provider MAY record the configured value in force at commit time for its own
audit, but verification MUST NOT depend on it, because a remote verifier does not
have it.
Contiguity is also required across checkpoints: within one lineage a
receipt’s first_sequence MUST equal the prior receipt’s last_sequence + 1,
and the first receipt of a broker epoch MUST start at zero. Within-batch union
contiguity alone would let a provider reorder records across a batch boundary
while each batch stayed internally well-formed; the cross-checkpoint rule plus
previous_checkpoint, which digests the entire prior receipt, closes that.
The codec helpers fast_record_digest_v1, merkle_root_v1, sparse_root_v1,
sparse_proof_root_v1, fast_lane_sub_root_v1, fast_lane_payload_digest_v1
and fast_ordered_range_root_v1 are the sole canonical implementations of
these bytes. The record digest keeps its v1 name because its preimage is unchanged;
only the range root’s shape moved.
12.2.1 Lane verification and omission detection
A verifying subscription is served durable-only, one batch at a time. A lane sub-root does not exist until its batch closes, so no delivery carries proof material and none is defined. Per batch, the provider MUST emit every in-scope durable record in ascending global sequence under ordinary delivery credit, then that subscription’sCheckpointFrameV1, and only then begin the next batch. The
checkpoint is therefore an unambiguous batch-completion signal — which is what
lets a subscriber reject an incomplete batch rather than wait indefinitely, and
what bounds the proof metadata it must hold to one batch.
When the cursor names a checkpoint in the immediate predecessor lineage, the
provider MUST first serve the predecessor’s owed batches in this same
per-batch order, then the adopted BrokerEpochTransitionV1, then the adopted
lineage’s batches. Serving the transition first unconditionally would strand a
client whose durable anchor predates a broker restart: a CBFS snapshot names the
checkpoint that was current when it was taken, and a restart moves the lineage
under it. Resuming at the current boundary instead is not a substitute, because
records already committed in the replacement epoch below that boundary would
never be served.
A binding MUST NOT report record-set verification for records above the last
verified checkpoint; their assurance is AEAD authenticity alone until then.
Should a future revision push provisional records to proof subscriptions, it
MUST specify a separate retroactive buffer rather than reusing this ordering:
provisional push and batch-completion ordering are not compatible guarantees.
A verifying subscriber holds every record of its own lanes, so it does not
need per-record inclusion paths: it recomputes lane_sub_root from the records
it received for that lane, in ascending sequence, and compares against the
committed value. Per-record paths would only serve a subscriber holding a subset
of a lane, which §12.2.2 forbids for proof subscriptions.
For every lane L in scope S the provider MUST supply a proof at L’s own key
in lane_map_root: a presence proof carrying L’s payload fields, or an
absence proof resolving to the empty-leaf constant. Both are sparse_root_v1
proofs at the same position and differ only in the leaf value, so a proof about
L names no other lane: no neighbouring identifier, no ordering rank, and no
count of how many lanes the batch touched. It does carry the prefix-occupancy
pattern inherent to a sparse path, which §Security Considerations states.
The signed receipt is the anchor, and every proof MUST fold into it. A
verifier MUST reconstruct one candidate lane_map_root from all the frame’s
LaneProofV1 entries — every proof in a frame MUST fold to the same candidate,
and a frame whose proofs disagree MUST be rejected — then recompute
fast_ordered_range_root_v1 over that candidate together with the receipt’s
range fields, and compare against the signed ordered_range_root. Verifying
individual proofs against a root taken from the frame would authenticate the
proofs against themselves; only the provider’s signature over the receipt makes
the check meaningful. A None subscription receives no checkpoint frame and
therefore no signed checkpoint chain at all.
Given a presence proof the subscriber MUST verify that it received exactly
lane_record_count records for L, that their global sequences are strictly
increasing, that the first and last equal lane_first_sequence and
lane_last_sequence, and that its recomputed lane_sub_root matches. A lane’s
sequences are not contiguous: lanes interleave in the global sequence space, so
one lane may validly hold 1 and 3 while another holds 2. Gaps inside a lane are
expected; the union rule in §12.2 is what accounts for them globally, and a
per-lane contiguity requirement would reject well-formed batches.
Because the count and the sub-root are both committed inside the signed root, a
provider can neither withhold a record from an authorized lane nor inject an
extra one: either makes the recomputed root and the committed count disagree.
This is what makes silent per-lane omission detectable, which a flat batch
commitment cannot offer a filtered subscriber at all.
Proofs are requested per subscription. A subscription MAY decline them, and
SHOULD do so for lanes whose content is disposable — presence and cursor state,
where a lost record is superseded by the next one and repair is meaningless.
Choosing None is a delivery-cost and latency choice. It preserves AEAD
authenticity, which travels with every record regardless of mode, but it forgoes
the signed checkpoint chain entirely — a None subscription receives no
checkpoint frame, so there is no chain to verify — and with it any omission
detection, because nothing pins the lane’s record set. A FullStream
subscription detects omission without lane proofs by recomputing lane_map_root
from complete batches. Bindings MUST expose the distinction in the delivery
assurance they report, and MUST NOT report record-set verification for a
subscription that requested None.
12.2.2 Verifying subscriptions start at a checkpoint boundary
Both verifying modes require anafter position at a checkpoint boundary. A
provider MUST reject any other combination with NotCheckpointBoundary.
The boundary is provider-validated, never client-asserted. In a verifying
mode, after = Some(id) MUST name a last_sequence of an actual checkpoint in
the adopted or immediate-predecessor lineage, and the provider MUST reject an
id that does not — a client claiming to have verified a position is not
evidence that the position is a boundary. A cursor older than the immediate
predecessor is CursorTooOld; the client re-enters from a snapshot.
after = None in a verifying mode means the current durable checkpoint boundary,
which is checkpoint-genesis only on an empty lineage; this differs from None
under None verification, which tails the current durable head wherever it
happens to fall.
The reason for the restriction is arithmetic: a subscriber entering mid-batch
legitimately receives only a suffix of a lane, so it would fail a committed count
that describes the whole batch — a correct provider would look like a
withholding one. Requiring a boundary makes every covered batch complete for the
subscriber, so the count means what it says.
ScopedProofs on Any scope is rejected rather than ignored, because silently
accepting it would report an assurance the wire never carried. FullStream is
the mode an Any subscriber asks for instead: it needs no lane proofs, since it
holds every record and recomputes lane_map_root directly, but it inherits the
same boundary condition — an Any subscriber that tails from mid-batch holds a
partial first batch and can verify nothing about it, exactly as a filtered one
cannot.
This costs applications nothing that matters. Homestead’s ops lanes resume from
the checkpoint position bound to a CBFS snapshot, which is already a boundary;
its presence lanes tail from mid-batch and verify nothing anyway.
13. Transport and flow control
The canonical persistent transport is a bidirectional TLS WebSocket using binary frames. It carries the versioned protocol objects and error codes defined by this CIP. Canonical signing bytes live incowboy-protocol-codec::cbqs; the WebSocket binding MUST NOT invent its own
signing layout.
13.1 Proof-bound request bodies
After session establishment, every client operation is:request_id is an opaque correlation id echoed by the server and is not an
idempotency key. CbqsRequestBodyV1 starts with the method’s canonical u16
tag from §7.1. Decode MUST reject the request before dispatch when
proof.method differs from the body tag or proof.body_hash differs from
cbqs_request_body_hash_v1(body).
The non-append request bodies are:
retention_pin_until_ms = 0 is the ordinary “no pin” value and MUST be
accepted. Writing the bound as an addition on the right rather than a
subtraction on the left is what makes that automatic: 0 satisfies the
inequality trivially, whereas the subtractive form underflows u64 into a
value exceeding every ceiling.
The addition MUST saturate. A checked addition that rejects the configuration
on overflow would refuse retention_pin_until_ms = 0 for a sufficiently
distant now_ms, contradicting the MUST above — the horizon is a ceiling, and
a ceiling that cannot be represented is u64::MAX, not an error. With
saturation no implementation needs a special case for zero, and one that adds
a == 0 disjunct has written dead code.
Group configuration is validated by the broker, not the chain — CreateGroup
and UpdateGroup are §7 grant verbs, not §5 chain instructions, and no
consensus state records a group. now_ms is therefore the broker’s own clock.
A stream has exactly one assigned provider serving it, so a single broker
evaluates this inequality for a given group and there is no second party to
disagree with; the clock needs no consensus treatment and none is implied by
its appearance here.
The ceilings are deliberately not the live governance scalars.
cbqs.standard.visibility_ms and cbqs.standard.max_attempts are DEFAULTS a
client MAY adopt when it does not choose its own, and binding a group to them
would let a parameter write retroactively invalidate running groups — the
same hazard the pinned grace deadline exists to prevent. The per-stream
max_visibility_ms is snapshotted at creation and validated there against the
immutable §18 maxima per §4.2, and the attempts ceiling is the immutable §18
maximum, so both bounds are stable for a group’s lifetime.
Each inequality closes a configuration that would otherwise be accepted and
then be unsatisfiable or unbounded: max_attempts = 0, which no delivery can
ever satisfy; visibility values at u64::MAX, which pin a record’s in-flight
slot for the life of the stream; and dead_letter_ttl_ms or
retention_pin_until_ms at u64::MAX, which pin storage the owner is billed
for indefinitely and defeat §15 retention. A group is a consumer of the
stream’s retention budget and may never enlarge it: every group-held pin
expires no later than the stream’s own retention horizon.
Three of these ceilings are per-stream fields rather than protocol constants,
and UpdateStream can lower retention_ms or max_visibility_ms beneath a
value an existing group already holds. Existing groups are NOT re-validated
and continue to operate on their snapshotted configuration — the same
principle that keeps a parameter write from redefining a running group applies
to an owner’s own write. The narrowed ceiling binds the next UpdateGroup,
which MUST bring the group within the current stream configuration to be
accepted. An owner that shrinks a stream beneath its groups has therefore
frozen those groups’ configurations rather than invalidated them, and the
remedy is in the owner’s own hands.
Group update is full replacement of the mutable fields above. stream_id,
lane_id, group_id, and the original start are immutable. Changing mode is
permitted only when the group has no live leases. All other updates apply to
new delivery cycles; they do not rewrite a lease already emitted.
Subscription sources are:
VerificationModeV1 selects what the provider owes and is rejected rather than
silently downgraded whenever the request cannot supply it. None is the ordinary
subscription and may start anywhere. ScopedProofs requires a Fast source with
Exact or Set scope; FullStream requires a Fast source with Any scope.
Both verifying modes require an after at a checkpoint boundary (§12.2.2) and
both receive the one-batch completion ordering of §12.2.1; only ScopedProofs
carries lane proof material, because a FullStream subscriber holds every record
and recomputes lane_map_root directly. A provider MUST reject either verifying
mode on a standard source or on the wrong scope with
VerificationModeIncompatible, and at a mid-batch position with
NotCheckpointBoundary (§17).
The mode is explicit rather than a boolean because the two verifying modes differ
in scope, in what travels on the wire, and in what a binding may claim — and
because a subscriber that wants full-stream verification has no way to ask for it
otherwise.
StandardGroup is exact-lane group delivery and requires CONSUME.
StandardReplay requires REPLAY. Fast requires CONSUME, and resuming
before the live head additionally requires REPLAY. DeadLetters requires
CONSUME; moving one of its records back to delivery additionally requires
REDRIVE. The requested source scope MUST remain inside the grant’s lane and
group scopes.
after_group_receipt is valid only for StandardGroup and DeadLetters.
It names the Keccak-256 digest of
delivery_state_receipt_signing_bytes_v1(receipt). When absent, the provider
replays self-verifying group-state and delivery-state updates from group
genesis before live frames. When present, it is an exclusive cursor and replay
starts with its successor. An unknown or no-longer-retained cursor returns
CursorTooOld with the latest retained signed group anchor. Other
subscription sources MUST set it absent.
Lease mutation bodies are:
reason_hash is application-defined audit metadata; the all-zero value means
unspecified. The broker does not interpret it.
Delivery-cycle and lease identifiers are canonical. The first cycle for one
record in one group is:
previous_group_receipt is the group receipt-chain tail immediately
before the redrive transaction. Replaying the same committed redrive returns
the original response and identifier; it does not create another cycle.
Lease attempts are one-indexed. The first lease in a cycle has attempt = 1;
each lease issued after expiry or NACK increments the attempt exactly once.
EXTEND changes the active lease expiry without changing its attempt or
identifier. A redriven successor is a new cycle, so its first lease restarts at
attempt one. The identifier for an attempt is:
BeginKeyBatch creates a staging namespace keyed by
(key_stream_id, batch_digest) and requires total_chunks > 0.
PutKeyBatchChunk accepts indices in [0, total_chunks), at most
cbqs.max_key_envelopes_per_chunk envelopes, and at most
cbqs.max_key_batch_chunk_bytes canonical envelope bytes. An identical chunk
retry is idempotent; reusing an index with different bytes is
InvalidKeyBatch. CommitKeyBatch succeeds only when every index is present,
the concatenated envelope ordering and digests exactly match the signed batch,
and the complete §14.3 key-batch commit set crosses one durable transaction.
Staged chunks are not records, are never delivered, and produce no receipt.
An incomplete staging namespace may be garbage-collected after the provider’s
documented staging timeout.
13.2 Canonical transport frames and results
One WebSocket binary message contains exactly one canonical frame. A frame starts withversion: u8 = 1, then a one-byte frame tag, then the canonical
variant body.
The lane-partitioned checkpoint commitment changes bytes that earlier drafts of
this specification signed, and does so in place rather than behind a version
bump. CBQS is reset-only and pre-launch: activation wipes chain state, and every
deployment is replaced at deploy time, so no peer or store predating this
document survives to be misread. A provider MUST reject a store or a peer it
cannot decode rather than attempt a partial decode.
That reset scope explicitly includes §15’s CBFS-resident snapshot bundles. A
pre-amendment bundle would be the one artifact this argument otherwise misses:
CheckpointReceiptV1’s byte layout is unchanged, so an old bundled receipt
passes every structural check while its ordered_range_root commits the earlier
flat shape. The environments this in-place change ships to wipe owner CBFS
volumes at the same deploy boundary that wipes chain state, so no such bundle
exists to hydrate from. An operator of any environment that preserves CBFS
content across this change MUST delete or regenerate pre-amendment snapshot
generations; a reader has no in-band way to distinguish the two meanings.
The client tags are:
OpenSession.stream_id MUST equal grant.stream_id. The server frame tags are:
served_at.chain_instance_id MUST equal
context.chain_instance_id.
CbqsResponseV1 echoes the request correlation id and contains a response-body
enum whose u16 tag is the same method tag as the corresponding request:
receipt.action MUST match.
A staged key-batch result MUST have total_chunks > 0 and
accepted_chunks <= total_chunks. Lane pages are strictly sorted by
lane_id, contain no duplicate lane id, and are bounded by
cbqs.max_lane_list_page. When next_after_lane_id is present it MUST equal
the final returned lane id; it is absent on a terminal or empty page.
Delivery-state receipt actions MUST match their response method (GroupCreate,
GroupUpdate, GroupDelete, Ack, Nack, Extend, Reject, or Redrive)
or decode fails.
A standard append result MUST contain the matching standard append receipt and
no fast watermark. A fast append result MUST contain no append receipt and MUST
contain the watermark for the same provider/broker lineage. Create/close lane
receipts are standard provider-generated lane-event records. Their stream,
lane, status, origin, and record kind MUST agree with the lane result.
checkpoint_anchor is the digest of the latest CheckpointReceiptV1, or the
stream’s checkpoint-genesis digest before the first checkpoint. It is never an
unconstrained zero digest. last_durable_sequence is absent exactly while the
anchor is checkpoint genesis.
Delivery frames contain:
LaneProofV1 proves one lane at its own key. payload present means the lane
carried records in this batch and the leaf is
fast_lane_payload_digest_v1(payload); payload absent means the leaf is
EMPTY[256]. Both cases verify through the identical sparse path, so presence
and absence are indistinguishable in shape and neither discloses anything about
another lane.
A CheckpointFrameV1 for a ScopedProofs subscription MUST carry proofs for
exactly the lanes in that subscription’s scope — every in-scope lane exactly
once, no lane outside the scope, sorted ascending by lane_id. A
FullStream subscription carries an empty lanes, and a None subscription
receives no checkpoint frame at all. A subscriber MUST reject a frame whose scope
coverage is incomplete, duplicated, misordered, or extraneous. A missing lane
is indistinguishable from a withheld one, which is precisely the condition the
proofs exist to remove; an extra lane, even one inserted in correct ascending
position, discloses an out-of-scope lane’s identifier, count, and activity
window — exactly what §2 promises a member never learns about another lane —
so accepting it would convert the proof channel into the disclosure channel
the sparse construction exists to avoid.
Decode bounds are hard: lanes is bounded by
cbqs.max_proof_lanes_per_subscription, and siblings by 256. The siblings
length MUST equal the number of set bits in bitmap, and no supplied sibling may
equal the EMPTY constant for its depth, so a proof of any other shape fails
before verification rather than during it.
A verifying subscription’s scope is bounded separately from a grant’s, at
cbqs.max_proof_lanes_per_subscription, and a provider MUST reject a
ScopedProofs request whose scope exceeds it with ProofScopeTooWide. The bound is sized against the
worst case a valid map can produce, not the typical one: a single
LaneProofV1 may legally carry 256 non-default siblings, so it reaches
8,313 bytes (about 8.12 KiB), and 512 of them come to 4,256,256 bytes
(about 4.06 MiB) — inside a
cbqs.max_wire_frame_bytes of 5,242,880 with room for the receipt and envelope.
A larger scope can produce a legal frame the transport cannot carry, which is why
the bound is a hard decode limit rather than guidance. Canonical multiproofs
would share sibling paths across a frame and are the way to raise it later if an
application needs a wider verified scope in one subscription. Until then a client
needing more opens additional subscriptions, which costs nothing it was not
already paying — subscriptions are per-connection and cheap, and the limit binds
open pages rather than granted pages.
A standard delivery MUST carry its matching append receipt, MAY carry a lease
only for consumer-group delivery, and MUST NOT carry a fast watermark. A
consumer-group delivery with a lease MUST carry the matching LeaseIssue
delivery-state receipt; standard replay and dead-letter delivery carry neither.
A fast delivery MUST carry no append receipt, lease, or delivery-state receipt
and MUST carry the watermark for the same stream and provider/broker lineage.
Receipt, header, record, lease, and delivery-state identities MUST agree
exactly or decode fails. The decoder reconstructs the LeaseIssue
DeliveryResultStateV1 from the lease fields and requires its canonical digest
to equal the receipt’s resulting_state_digest.
Errors preserve unknown future codes:
request_id is absent only when the error cannot be attributed to one client
request. An error that terminates a subscription MUST carry its
subscription_id, whether or not it answers a request. A provider may close a
subscription unprompted — retention overtaking a partially delivered verified
batch yields CursorTooOld with no request to attribute it to — and a client
that cannot tell which subscription died has no way to release that
subscription’s verification state, pending records, or reserved bytes. It would
treat a terminal condition as a generic error and diverge from the provider
silently, holding state for a subscription the provider has already discarded. A
provider that cannot name the affected subscription MUST close the session
instead, because an unattributable terminal error leaves the client no correct
action.
Closing the session is a last resort, not an alternative. An attributed
terminal frame MUST take precedence over further delivery when output is bounded:
naming the dead subscription lets a client release that subscription’s state
precisely, whereas closing the session discards every subscription’s state and
explains nothing. A provider MUST therefore prefer deferring an already-encoded
attributed terminal frame to the next response over dropping it, and MAY
invalidate the session only when no path can carry it. This matters most on
long-poll, where a client cannot learn of a closure until its next request and
would otherwise treat a dead subscription as live in the meantime.
Message and details are bounded before allocation. Unknown frame
versions/tags, invalid UTF-8 messages, oversized frames/fields, semantic
identity mismatches, and trailing bytes are decode errors.
Append, subscription, credit, delivery, ack, and administrative frames may
share one connection. Every lane-bearing frame carries the exact lane_id.
Each stream session performs the proof-of-possession handshake in §7.1.
Every subscription names:
Any is therefore permitted only by lane_scope = Any; Exact may be
covered by an identical exact scope, a containing set, or Any; and Set
requires a containing set or Any. Any and Set deliver matching records in
the parent’s global sequence order and every delivery frame carries its
record’s lane_id. Each subscription uses one cursor and one credit window,
so snapshotters, archivers, search workloads, and auditors do not need one
subscription per lane. Standard consumer-group subscriptions remain
exact-lane; multi-lane forms are for replay and fast cursor delivery, not a
cross-lane work queue.
Subscription sets MUST be sorted, unique, and bounded by
cbqs.max_lane_ids_per_grant; the provider MUST reject a noncanonical or
oversized set before allocation.
Every subscription begins with zero transport credit. The consumer grants both
record and byte credit:
standard, a delivery lease starts only when the
delivery frame is emitted and counts against both transport credit and
max_in_flight.
The broker MUST enforce cbqs.max_subscriptions_per_connection; opening a
subscription beyond the cap returns SubscriptionLimitExceeded.
The provider MUST schedule active subscriptions with deficit round robin or an
equivalent starvation-bounded algorithm. It MUST NOT allow one stream or group
to consume all connection send capacity.
A subscription with no credit for CBQS_SLOW_CONSUMER_TIMEOUT_MS MAY be
closed. Closing a subscription does not ack records; outstanding standard
leases expire normally.
Reconnect resumes from durable group state (standard) or from a supplied
durable checkpoint plus local provisional watermark (fast). Push is an
optimization over those states, not a second consistency model.
Every fast append response and delivery frame MUST surface the current durable
watermark so an SDK can distinguish “sent” from “durably saved.” A provider
MUST NOT imply that a provisional acknowledgement is durable.
HTTPS long-poll is a required fallback. It MUST use the same authorization,
leases, cursors, receipts, and credit-equivalent response limits. Consumers
connect outbound; CBQS requires no inbound workload route. An HTTPS request
body is exactly one canonical CbqsClientFrameV1. Its response body is:
u32 frame_count, followed for each frame by
u32 frame_byte_length || canonical_server_frame. The count and total response
bytes are bounded before allocation. An empty batch is a successful long-poll
timeout. The WebSocket transport sends individual server frames and does not
wrap them in this batch.
A long-poll virtual session admits at most one in-flight request. A second
concurrent request MUST be rejected with LongPollBusy without advancing any
cursor, consuming credit, or consuming a proof counter. Long-poll otherwise has
no frame ordering of its own — order is the sequence of served requests — so
concurrent requests against one session would interleave batches and break the
§12.2.1 batch-completion ordering that proof subscriptions depend on. Rejecting
is what keeps the two transports semantically equivalent, as §13 requires.
A verified replay MUST bound the bytes it materializes, not only the record
count. A batch may legally hold fast_batch_max_records records of
max_message_bytes each; §4.2’s cross-field rule caps that product at
268,435,456 bytes, so a count-only bound still admits a materialization roughly
51× cbqs.max_wire_frame_bytes (5,242,880) — and a provider holding a global
lock across it converts a legal replay into a denial of service against every
other session on the broker. A provider MUST apply a total-byte admission bound
of 268,435,456 bytes per batch replay — the immutable §4.2 product constant,
deliberately not the stream’s current configuration: no legal batch exceeds
the constant, and reading today’s configuration against a historical batch is
exactly the retroactive-invalidation §12.2 forbids, since a later tightening
would oblige the provider to refuse a legal, verified, in-retention batch it
signed itself. The provider MUST NOT hold a global storage lock
for the duration of one.
A long-poll provider MUST bound its output at admission, never by truncating
afterwards. A single request can legally require more frames than a batch
carries: OpenSubscription returns its response plus up to
cbqs.max_groups_per_stream replay follow-ons, against a
cbqs.max_server_frames_per_batch of 256. A provider MUST therefore decide how
much work to schedule before mutating any state, and MUST leave whatever does
not fit deliverable on a subsequent poll with its cursors unadvanced. Truncating
a batch after the state behind it has already moved would silently destroy
frames — the client would see a well-formed short batch and no indication that
anything was dropped, which is precisely the failure this transport’s
equivalence requirement exists to prevent.
The WebSocket transport sends individual frames and is not subject to the batch
frame cap, so this bound is long-poll-specific. That asymmetry is permitted
because it is invisible to semantics: both transports deliver the same frames in
the same order, and only the packaging differs.
Undelivered output MUST also be bounded across requests, not only within one.
Admission-time bounding alone still permits unbounded growth: each
OpenSubscription may queue up to cbqs.max_groups_per_stream follow-on
controls while a single poll drains at most cbqs.max_server_frames_per_batch,
so a caller that repeatedly opens subscriptions outruns its own draining and the
provider’s pending queue grows without limit. A provider MUST therefore cap the
undelivered controls a virtual session may hold — retaining at most one open
request’s follow-ons is sufficient — and MUST reject a further OpenSubscription
with ControlBacklogExceeded before authenticating it, consuming any counter,
or mutating state, until earlier controls drain. Pending controls MUST NOT be
observable on an unauthenticated path.
The distinction matters because the two rules fail differently. Overflowing one
batch loses frames; accumulating across batches exhausts the provider. An
implementation can satisfy the first completely and still be trivially
memory-exhausted by an authenticated caller doing nothing but opening
subscriptions.
14. Receipts
The provider signing key snapshotted in the StreamRecord from the receipt’sserved_at finalized view signs every provider receipt. Every receipt binds
its signing context, stream id, provider epoch, served finalized view, receipt
kind, and domain separator.
All CBQS canonical bytes are emitted only by cowboy-protocol-codec::cbqs.
Integers use unsigned big-endian encoding. Fixed byte strings and addresses are
their raw fixed-width bytes. A variable byte string is u32 length || bytes.
An option is tag 0 for absent or tag 1 || value for present. A vector is
u32 element_count || canonical(element_0) || .... Struct fields appear in the
order listed in this CIP. Enum tags are the numeric tags listed by the
corresponding definition; unknown tags are invalid. The wire and state enums
tag themselves at their definitions — ProviderEndpointTransportV1 (§4.3),
ProviderStatus (§4.3), PricingBasisV1 (§4.1), and RotationKeyUpdateV1
(§5). RotationKeyUpdateV1 is deliberately 0-based, Keep = 0, matching
the reference codec: Keep is the default-shaped no-op and shares the
option convention’s absent tag. The 1-based table below covers signed
objects only and is not a convention the other enums inherit. Maps and
serialization-framework field names never enter canonical bytes.
The signed-object enum tags are:
For every signed type, the named codec function returns:
Implementations MUST NOT reconstruct a signing preimage from JSON, CBOR,
WebSocket frames, or locally repeated field concatenation. The table above is
the complete v1 signing registry.
For every provider receipt,
served_at.chain_instance_id MUST equal
context.chain_instance_id; served_at MUST be the immutable finalized view
used for all authorization reads for that operation. After the provider
observes a finalized status, provider-key, provider-epoch, authorization-
generation, or encryption-generation change for a stream, it MUST NOT sign a
later receipt for that stream from a view older than the observed change.
escrow_balance and last_settled_block are deliberately NOT in that list:
permissionless settlement can change them every block, so change-conditioning
on them would let anyone serialize a provider’s every operation behind the
newest view for the price of a settlement transaction. Economic availability
instead binds to the per-operation snapshot that served_at identifies, under
the cbqs.max_chain_staleness_blocks bound, so enforcement of economic
suspension lags finalized state by at most that bound and no further. It binds
to that snapshot, not to served_at.height: the rent and grace arithmetic
inside it uses the effective_rent_height carried in the same broker-state
response, per §6, so a paused registry does not make the broker and a receipt
verifier disagree about whether the provider was entitled to serve.
Unrelated finalized-height advancement does not serialize otherwise
independent operations or lanes; the change-conditioned monotonicity rule in
§7 is authoritative.
14.1 Append receipts
Receipt chains use the following empty-history anchors:header_hash is:
record_header_bytes_v1 and
record_header_hash_v1. The hash binds the complete delivered header,
including created_at_ms and nonce; payload_hash independently binds the
delivered payload. A decoder MUST reject a standard delivery when either hash
does not match or when any duplicated receipt/header identity field differs.
The provider signs:
record_id uses the tagged RecordIdV1 union. A standard record MUST carry
StandardRecordIdV1 { provider_epoch, sequence }; a receipt for a durable
provider event on a fast stream MUST carry
FastRecordIdV1 { provider_epoch, broker_epoch, sequence }. Normal provisional
fast application appends are checkpoint-covered and do not receive an
AppendReceiptV1.
The first global receipt of a newly created stream uses
stream_receipt_genesis. The first receipt in a newly created lane uses
lane_receipt_genesis. An implementation MUST NOT encode absence as an
unconstrained zero digest.
14.2 Delivery-state receipts
EveryACK, NACK, EXTEND, REJECT, redrive, group mutation, and group
deletion returns a signed receipt containing the prior group-state receipt
digest. The provider maintains one receipt chain per group.
keccak256("cbqs/group-genesis/v1" || canonical(context) || stream_id || lane_id || group_id || provider_epoch_u64_be) as
previous_group_receipt.
For group mutations, resulting_state_digest is canonical:
GroupCreate and GroupUpdate require config present with matching lane and
group identities. GroupDelete requires it absent. Recomputing the applicable
group state digest above MUST reproduce receipt.resulting_state_digest.
For record delivery mutations, resulting_state_digest commits the complete
absolute post-mutation state:
receipt.resulting_state_digest.
Every action requires a nonzero group_queue_sequence; for every record-state
action except Redrive it is the current cycle’s immutable position, while
for Redrive it is the successor cycle’s newly allocated tail position. Every
option not listed for an action below MUST be absent. Relative request values
such as retry_after_ms and extend_by_ms are not result state; the provider
commits their checked absolute outputs.
issued_at_ms <= lease_expires_at_ms. For LeaseExpire,
available_at_ms >= lease_expires_at_ms. For dead-letter terminal states,
terminal_at_ms <= terminal_expires_at_ms; exhaustion cannot precede the last
lease expiry. stream_id, group_id, delivery_cycle_id, present
lease/holder ids, and successor cycle ids MUST be nonzero; lane_id MAY be the
all-zero default lane. record_id.provider_epoch MUST be the v1 epoch. The
codec helper delivery_result_state_digest_v1 is the sole canonical encoder
and rejects every other option combination.
GroupCreate, GroupUpdate, and GroupDelete receipts MUST have absent
record_id and lease_id. Ack, Nack, Extend, and Reject receipts MUST
have both fields present and matching the mutated lease. Redrive MUST have
the redriven record_id present and lease_id absent. LeaseIssue,
LeaseExpire, and AttemptsExhaust MUST have both fields present and matching
the issued or completed lease. RecordExpire MUST have its record_id
present; lease_id is present only when expiry also terminates an active
lease.
Provider-driven delivery-state changes are receipt-chain mutations, not hidden
local bookkeeping. Lease issuance, lease expiry, attempt exhaustion, and
record-deadline expiry each append the corresponding signed receipt in the
same atomic transaction as the state change. A consumer-group subscription
receives the LeaseIssue receipt with the leased delivery. Authorized group
and dead-letter subscribers receive group mutations as GroupStateUpdateV1
server frames and subsequent provider-driven record mutations as
DeliveryStateUpdateV1 server frames. A provider MUST retain and replay those
frames from the subscription cursor so that a disconnected verifier can
restore receipt-chain continuity.
A receipt proves what a provider signed. It does not prove that the provider
served every authorized consumer or retained data it refused to serve.
14.3 Atomic commit sets
An acknowledgement is valid only after the complete operation-specific commit set below crosses one atomic durable transaction boundary:
The broker MUST NOT persist a dedupe success before its record, acknowledge a
record before its dedupe result and receipt tails, or expose a new durable
watermark before the checkpoint receipt commits. Recovery MUST replay or roll
back an incomplete transaction as a unit.
ActivateKeyGeneration remains a
separate chain transaction and can succeed only after the complete key-rotation
commit set is durably readable and its receipt verifies.
15. Retention and CBFS
The provider stores the bounded hot window. Retention applies only after every group has reached a terminal state or its pin deadline.fast retention is
cursor-independent.
Behind the retention floor, the broker returns CursorTooOld whose error
details are exactly the canonical bytes of the governing signed anchor:
fast cursor behind the floor is governed by FastRetentionAnchorV1, not by a
checkpoint receipt. A receipt attests that a batch was committed and what it
contained; it says nothing about what has since been deleted, so it cannot
state a floor. Without a signed floor a client receiving CursorTooOld cannot
distinguish a provider that genuinely pruned from one that is simply refusing to
serve — the withholding problem §12.2.1 exists to remove, reappearing at the
retention boundary. broker_epoch is carried because fast sequences restart per
epoch (§12.1), so a floor is meaningless without the lineage it belongs to.
A standard anchor states the scope’s floor: everything before
first_retained_sequence is pruned, and receipt-chain verification restarts
from the embedded predecessor digests (lane_id and previous_lane_receipt
are present together for an exact-lane anchor, absent together for the
stream-global floor). A group anchor states the latest retained group
receipt; a stale after_group_receipt replays from it. Anchors are signed
by the provider signing key over the §14 registry layouts, carry the
provider epoch every receipt binds (zero in v1) so evidence is
distinguishable across provider lineages, and floors are
monotone within one lineage and one anchor scope — two anchors compare
only when their full comparison keys are equal:
- standard:
(stream_id, provider_epoch, lane_id?), wherelane_id = Noneis the stream-global floor and each exact-lane floor is its own scope; - group:
(stream_id, provider_epoch, lane_id, group_id); - fast:
(stream_id, provider_epoch, broker_epoch).
retention_ms is one time window, so a uniform time
floor maps to wildly different per-lane sequence floors — a 1,000/s lane and
a 1/hr lane at the same boundary legitimately anchor near 900,000 and 10 —
and comparing across lanes, or a lane floor against the stream-global floor,
would convict an honest provider in the ordinary steady state of any
multi-lane stream. The broker_epoch component exists because fast
sequences restart at zero each broker epoch (§12.1): a floor of 10 in epoch
N+1 after 900,000 in epoch N is correct behaviour, not regression. A provider
MUST NOT sign an anchor whose floor regresses an earlier one with the same
comparison key, so any same-key pair whose floors regress is portable
equivocation evidence; a pair whose keys differ is evidence of nothing. Garbage
collection is a §14.3 commit set: the new floor, its signed anchor, and the
pruned records cross one durable transaction, so a served anchor is always
attested state rather than an in-progress deletion. Applications that need
older history archive to owner-controlled CBFS
volumes.
A CBFS snapshot manifest SHOULD contain:
application_signature uses the canonical signing layout in §14, but its
verification key and authorization policy are application state, not the
provider key or Stream Registry state. A broker does not validate or rely on
that signature for retention.
A fast snapshot MUST cover a durable checkpoint. Its content MUST be
materialized exclusively from records at or below that checkpoint; a
snapshotter MUST maintain or reconstruct a durable-only application replica
and MUST NOT serialize its live view when that view includes provisional
records. A lane snapshot includes only records for its lane at or below the
parent-stream checkpoint.
provider_anchor is the signing digest of the provider receipt the snapshot
is bound to, and it MUST be nonzero for both stream classes. For a fast
snapshot it is the signing digest of the bound CheckpointReceiptV1, and
covered_through MUST be that same digest. For a standard snapshot it is
the signing digest of the AppendReceiptV1 whose record id equals
covered_through — the receipt the snapshotter already holds for the last
record the snapshot covers, serving exactly the anchoring role the checkpoint
receipt serves for fast. The canonical signed receipt MUST be published
with the manifest — bundled or content-addressed together, so that
retrieving one retrieves the other.
That requirement exists because of when the manifest is used. A reader hydrates
precisely when the hot window no longer holds its records, so the broker can no
longer supply the receipt naming that digest. A manifest carrying only a digest
would therefore be unverifiable at the one moment it matters — the reader could
not tell a real checkpoint from an invented one.
What the bundled receipt does and does not establish. It authenticates the
anchor: the named checkpoint really existed, at that position, signed by the
provider. It does not authenticate the snapshot bytes. The provider signs
an ordered range of records; it never sees the application’s materialized state
and holds no key with which to attest it. An application signer can therefore
bind any bytes — including bytes derived from provisional records — to an
otherwise valid receipt, and no reader can detect it cryptographically.
Snapshot content fidelity is consequently an application trust property, not a
protocol-verified one. Hydration trusts whoever holds the snapshotter’s signing
key, and the protocol bounds only the anchor. Applications SHOULD treat that key
with the custody its role implies, and SHOULD structurally prevent accidental
contamination — materializing from a private replica mutated only by verified
durable batches, so that serializing provisional state is impossible by
construction rather than merely prohibited. Neither measure makes the fidelity
claim verifiable by a reader; they reduce the chance the honest signer is wrong,
not the power of a dishonest one.
snapshot_hash is Keccak-256 over the exact stored snapshot bytes, matching
every other digest in this CIP. encryption_generation names the data-key
generation the snapshot content is encrypted under, so a reader knows which key
to request and can refuse a snapshot from a generation it is no longer a member
of; authorizing and rotating the key that protects snapshot content is
application policy and outside this specification, but an application MUST state
its rule rather than leave it implicit.
Lane snapshots published together MUST share one parent checkpoint. A Set
subscription carries one cursor over the stream-global sequence space (§13), so
lanes resumed together must resume from a single position. Publishing a
generation whose lane snapshots name different checkpoints would leave no cursor
that resumes all of them without either replaying delivered records or skipping
undelivered ones. An application SHOULD therefore publish a generation
atomically: content-addressed snapshot blobs, one bundled receipt, sorted
per-lane manifests, and a head promoted only once every part is committed.
A reader MUST verify, before hydrating: the application signature and its
authorization; that context, stream_id, scope, cbfs_reference and
encryption_generation are the ones it expects; that the Keccak-256 hash of the
snapshot bytes equals snapshot_hash; that the bundled receipt’s own context
and stream_id equal the manifest’s — a digest match alone accepts a receipt
the manifest signer took from any stream or lineage, so the cross-binding is a
check of its own, not a corollary; the provider signature on the bundled
receipt, against the provider signing key snapshotted in the StreamRecord at
the receipt’s own served_at finalized view — the same key-resolution rule
§14 states for every receipt. Resolving the key at the reader’s current view
instead would make one bundle verify or fail depending on when it is read: after
a key rotation, two readers at different heights would disagree about the same
bytes at exactly the hydration moment the bundle exists for. And finally that
the receipt’s own signing digest equals provider_anchor; and that
covered_through is bound to that same receipt — for a fast snapshot it
MUST equal the receipt’s signing digest (the same value as provider_anchor),
and for a standard snapshot the bundled append receipt’s record_id MUST
equal covered_through. The last check is not a corollary of the digest
equality: for standard, provider_anchor and covered_through are two free
variables, and a manifest bundling a genuine receipt for record N while
claiming covered_through far beyond it would otherwise pass every listed
check and make the reader resume past records the snapshot never contained —
silently and unrecoverably, since hydration happens precisely when retention
no longer holds them. The record_id equality also pins the standard
snapshot’s lineage, since the epoch travels inside it. For a fast
snapshot, the bundled checkpoint receipt’s provider_epoch and broker_epoch
are then the lineage the reader resumes against under §12.2.2. A reader MUST
refuse an anchor it
cannot verify rather than hydrate from a plausible-looking one.
An application supporting reconnect after the fast retention window SHOULD
publish its latest verified manifest pointer in a designated standard
discovery stream or in actor state. An actor-state pointer is public consensus
metadata even when the referenced snapshot is encrypted. Snapshot cadence MUST
be shorter than both the configured retention window and the application’s
declared offline-recovery objective.
The broker does not create or trust application snapshots and does not delete
retained data solely because a client claims to have archived it.
16. Actor and application integration
CBQS does not directly invoke a consensus actor. An SDK actor-bridge helper MAY consume a stream, derive a caller-selected projection, and submit an ordinary actor transaction. The projection and its timing become public consensus data. The helper MUST document that boundary and MUST NOT imply that encrypted CBQS content remains private after projection. Finality-bearing actor communication remains on chain. Public, auditable event distribution remains the role of actor events and the public-stream design in CIP-7. CBQS is the private-content, off-chain complement.16.1 Application migration
An application that moves data to another provider or confidentiality domain MUST create a new stream, copy or replay data through a verified durable source anchor, issue new grants and key envelopes, redirect clients, and close the old stream only after verifying the target’s durable anchor. This flow is also v1’s only remedy when a provider disappears or stops serving: there is no on-chain dispute, slashing, or forced-reassignment path, so recovery depends on whatever durable anchor the application maintained elsewhere (a CBFS snapshot, another stream, or client-side state). The exposure is disclosed in §Security Considerations; applications with availability requirements SHOULD maintain such an anchor continuously. The target stream has an independent stream id and receipt history. SDKs SHOULD expose this sequence as one migration flow.16.2 Homestead application boundary
Homestead is Cowboy’s collaborative team-wiki application and is used here as an application-integration fixture, not as a protocol component. Its synthetic acceptance fixture contains 463 page lanes; 463 is a fixed scale-test input, not a normative product page count. Homestead SHOULD use one parent stream per uniform cryptographic membership, retention, provider, and billing domain, with one opaque lane per page. The fixture then requires one chain-created stream for the team wiki rather than 463 stream records, and a member’s lane-set grant can cover the pages in their sidebar. Lane grants within an encrypted parent are only broker-enforced. When a page first needs a materially different confidentiality boundary, the application MUST use the migration flow in §16.1 for a new parent stream and close the old lane only after the target anchor is verified. SDKs SHOULD provide this migrate-on-share flow. Ordinary wiki ACL churn MAY deliberately remain one workspace encryption domain; highly sensitive pages use separate streams; search-heavy deployments MAY choose the explicit unencrypted class. For encrypted deployments, search MUST run client-side or in an explicitly authorized search workload that is a member of the encryption domain and stores its index in a private CBFS volume. Authorizing such a workload reveals plaintext to that workload and expands the confidentiality boundary. The CBQS broker never performs semantic search over ciphertext. CBQS replaces relay transport state: WebSocket fan-out, room-log persistence, reconnect cursors, push backpressure, transport authorization, and crash-loss signaling. A Homestead application service remains responsible for Google identity to ACL mapping and grant minting, share links, metadata, seeding, snapshotting, search, and walletless-client attachment upload to CBFS. That service is a CBQS client; it is not a second queue. Yjs presence SHOULD use afast lane with a short application expiry and
awareness timeout. v1 has no broker-generated subscriber-departure event, so
abrupt disconnects can leave ghost presence until that timeout. The SDK MUST
document this behavior.
Homestead’s document and comment lanes SHOULD use ScopedProofs: a withheld
document operation is real data loss and the §12.2.1 count commitment is what
makes it detectable. Its presence lanes SHOULD use None — a dropped cursor
update is superseded by the next one, so verification would pay delivery cost
and batch-ordering latency for a guarantee with nothing to protect. This split
is the reason the mode is per-subscription rather than per-stream.
17. Typed errors
All bindings expose stable typed errors. v1 reserves:GroupConfigInvalid is returned when a CreateGroup or UpdateGroup
violates any inequality in §13’s group-configuration block, with details
naming the offending field. HolderInFlightLimitExceeded is returned when a
lease is refused by the per-holder StreamGrantV1.max_in_flight of §11 rather
than by the group total, which Backpressure continues to report; the two are
distinguished
because the first is a property of the caller’s own signed grant and will not
clear as other holders drain, so a client that retries it indefinitely is
wrong where the same retry on Backpressure is correct.
The three chain-state codes are separated by REMEDY, not by internal error
variant, because a client’s correct response differs across them and a single
code forces every client to guess:
ChainStateStale(3925) means the broker holds a valid finalized view that is older thancbqs.max_chain_staleness_blocks. The condition is expected under normal lag and clears on its own; a client SHOULD retry with backoff.ChainUnreachable(3941) means the broker cannot read finalized state at all — the node is unreachable, returned an error status, or returned a response exceeding the transport bound. Retrying will not help until an operator intervenes, so a client SHOULD surface the failure rather than spin, and a broker SHOULD fail readiness.ChainViewInconsistent(3942) means the chain state the broker did read cannot be trusted: a finalized view that regressed in height, a height-identical view with a different block hash, a chain-instance change, or a response that failed canonical decode. A client MUST NOT retry the request as though it were transient; the node has contradicted itself or the broker’s own state is corrupt, and continuing risks acting on a fork.
VerificationModeIncompatible(3943) rejects a verifying mode the request cannot carry —ScopedProofsorFullStreamon aStandardsource, or on the wrong lane scope (§13.1), withdetailsnaming which. The request is malformed for the source; retrying it unchanged is wrong.NotCheckpointBoundary(3944) rejects a verifying-modeafterthat names no checkpoint boundary in the adopted or immediate-predecessor lineage (§12.2.2). The client re-enters at a boundary or from a snapshot. A cursor older than the immediate predecessor remainsCursorTooOld.ProofScopeTooWide(3945) rejects aScopedProofsscope exceedingcbqs.max_proof_lanes_per_subscription. The remedy is to split the scope across additional subscriptions (§12.2.1), which no amount of waiting or backoff accomplishes.LongPollBusy(3946) rejects a second concurrent request on a long-poll virtual session (§13.2). The caller has violated the one-in-flight protocol; the remedy is to await the outstanding request, and blind retry only collides again.ControlBacklogExceeded(3947) rejects a furtherOpenSubscriptionwhile earlier follow-on controls remain undelivered (§13.2). The remedy is to poll and drain, after which the same request is accepted. §13.2 mandates this reject before per-request authentication; the specific code adds no information that branch does not already carry.
Backpressure continues to mean what §11 and §13 define — transient credit
or capacity exhaustion that clears as the system drains, where retry with
backoff is the correct response. None of the five conditions above clears
that way, which is why each carries its own code rather than borrowing 3914.
Unknown future errors MUST be preserved by clients as (code, message, details) rather than mapped to success or retry.
18. Parameters and bounds
All decode-time collection and byte lengths MUST be capped before allocation. The following are v1 defaults with their ceilings. For the governance-writable rows the Maximum is the ceiling a governance write may not exceed; for the twelvecbqs.base_rate.* rows, which are genesis values frozen for the life
of the chain (§4.5), and for the seven codec-bound rows, which are protocol
constants rather than governance parameters, it constrains genesis
validation only. The classes below the table say which is which:
The maxima are per-field, and one pair is bounded jointly below its per-field
product:
cbqs.max_inline_message_bytes and cbqs.fast.batch_max_records
combine under §4.2’s cross-field rule, so a Fast configuration’s
max_message_bytes × fast_batch_max_records never exceeds 268,435,456 even
though the table rows alone would permit 64 GiB.
The actual duration represented by a block count depends on network block
time. Governance changes are prospective, and which bound an operation reads
follows from what the value governs:
The four classes below partition the governance-writable rows exactly: 15 + 7
- 2 + 1 = 25, which is precisely §18.1’s set of 25 live governance scalars.
The twelve
base_rate.*rows and the seven codec bounds are not governance-writable at all; both are classified after the list. The assignment is given by name rather than by example, because an example list leaves the unlisted rows undetermined and implementations then differ on them.
- Admission bounds are read LIVE and checked per operation. The test is
not whether the operation creates something lasting but whether the accepted
object thereafter behaves by the bound:
cbqs.max_lanes_per_streamis an admission bound even though aLaneRecordV1persists and its id is never reused, because the bound limits how many lanes may be created and the lane itself never consults it again. Lowering one stops new work immediately and re-examines nothing already accepted. Rows (15): the 14 rows fromcbqs.max_streams_per_ownertocbqs.max_chain_staleness_blocksthat §18.1 lists among its 25 live scalars, pluscbqs.slow_consumer_timeout_ms. The seven codec bounds in that span —max_key_envelopes_per_chunk,max_key_batch_chunk_bytes,max_wire_frame_bytes,max_server_frames_per_batch,max_error_message_bytes,max_error_details_bytes,max_proof_lanes_per_subscription— are NOT in this class and are not governance-writable at all; see below. - Configuration ceilings bound a value a record snapshots at creation and
then behaves by. They are bounded by the IMMUTABLE table maxima, never by
the live default; the record’s own validated snapshot is thereafter its
ceiling. Reading these live would let a parameter write silently redefine
how existing streams and groups behave. Rows (7):
cbqs.standard.retention_ms,cbqs.standard.idempotency_horizon_ms,cbqs.standard.visibility_ms,cbqs.standard.max_attempts,cbqs.fast.retention_ms,cbqs.fast.batch_max_ms,cbqs.fast.batch_max_records. The two fast batch rows are ceilings, not admission bounds:StreamConfigV1storesfast_batch_max_msandfast_batch_max_records, and the stream behaves by its own snapshot thereafter. §4.2 is the authority on which source each config field takes. - Settlement parameters are read LIVE at each settlement and apply to
future accrual only. Already-settled rent never moves; rent accruing after
the parameter write follows the new value. This is prospective in the sense
that matters — no completed accounting is revisited — even though existing
streams are affected going forward, which is why these are their own class
rather than an awkward fit in either of the two above. Rows (2):
cbqs.rent.provider_bps,cbqs.rent.platform_bps. - Materialization parameters are read exactly once, at the transition that
pins a field into a record, and never again by any later decision. Row
(1):
cbqs.suspend_grace_blocks, read by §6.1’ssettlewhen it pinssuspension_grace_deadline_blockand never afterward — which is what makes a governance write unable to shorten a running grace window.
cbqs.base_rate.* rows sit outside the partition with the codec
bounds: they are genesis-validation values, frozen for the life of the
chain (§4.5). They are not governance-writable, are NOT part of the
25-scalar governance-bounds read, and cost no governance state read — a
rate derivation reads the active schedule record, never a parameter store.
Their Maximum column constrains genesis validation only.
The seven codec bounds named above likewise sit outside the partition: they
are protocol constants that decode paths enforce, are not
governance-writable, and cost no governance state read. Their Maximum
column constrains genesis validation only.
cbqs.max_proof_lanes_per_subscription belongs here rather than among the
governance scalars because it is a wire-safety bound derived from
cbqs.max_wire_frame_bytes: it is enforced entirely by the broker’s decode and
admission paths, never read by a chain handler, and adding it to the 25-scalar
governance set would change the §18.1 state-I/O reservations for every handler
that reads the bounds set.
A stream snapshots its pricing basis and rate, and a governance reduction MUST
NOT invalidate it: an already-accepted stream record is never re-examined
against a later parameter write, and v1 has no transition-height mechanism
because it needs none. Grants are the deliberate exception. A StreamGrantV1
is not a stored record but a credential presented per request, and §7 checks
its max_lane_creates_per_min against the live
cbqs.max_lane_creates_per_min on every presentation, so lowering that bound
does narrow outstanding grants. That is intended — the bound exists to cap
instantaneous load, which a credential signed before the write would otherwise
escape — and it is safe in a way record re-validation is not, because the
grant carries no accrued economic position that a narrowing could destroy.
Base-rate unit sizes and unit rates MUST be at least one and no greater than
the table maxima. Unit-rate values are denominated in the chain’s smallest CBY
unit per block. These are genesis values, fixed for the life of the chain;
repricing the base rate is a coordinated-release protocol change by a later
CIP, as §4.5 states.
18.1 State-I/O reservation bounds
Each instruction reserves exactly this many state reads and writes before its first state operation. The pairs are protocol constants: they do not vary with stream shape, encryption, pricing basis, or any other observed state, and changing one is a consensus change requiring a new document version.
Reservations that read the full governance bounds set include its 25
parameter reads in the totals above. That set is exactly the 25 live
governance scalars:
cbqs.max_streams_per_owner,
cbqs.max_provider_endpoints, cbqs.max_provider_endpoint_uri_bytes,
cbqs.max_inline_message_bytes, cbqs.max_groups_per_stream,
cbqs.max_lanes_per_stream, cbqs.max_lane_ids_per_grant,
cbqs.max_key_envelopes_per_batch, cbqs.max_lane_creates_per_min,
cbqs.max_lane_list_page, cbqs.max_subscriptions_per_connection,
cbqs.max_grant_ttl_ms, cbqs.max_clock_skew_ms,
cbqs.max_chain_staleness_blocks,
cbqs.standard.retention_ms, cbqs.standard.idempotency_horizon_ms,
cbqs.standard.visibility_ms, cbqs.standard.max_attempts,
cbqs.fast.retention_ms, cbqs.fast.batch_max_ms,
cbqs.fast.batch_max_records, cbqs.slow_consumer_timeout_ms,
cbqs.suspend_grace_blocks, cbqs.rent.provider_bps, and
cbqs.rent.platform_bps — one read each — plus one read of the registry’s
pause-accounting record, which every admitted handler performs so rent and
grace arithmetic can use the pause-adjusted height. Instructions that move
escrow additionally read and write the Stream Registry custody account,
which the per-instruction pairs above already include. CreateStream
additionally reads and writes the zero address, which the nonrefundable
creation charge (§6) credits; its pair above includes that read and write.
The remaining §18 rows
are protocol
constants, codec bounds, or per-record schedule fields, which cost no
governance read. After the transition commits, the
handler refunds the unreserved remainder before transaction fee
computation; the post-refund actual cycles and cells are what settle the
fee and what feed block-usage demand for the basefee update, and the
meter’s over-limit clamp survives only on rejected over-limit paths. The
actual operation counts are those of the canonical plan, pinned per
instruction and per path by the normative gas vectors published beside this
document as cip-39-gas-vectors-v1.json (schema
cowboy.cbqs.gas-vectors.v1): thirteen vectors covering all nine instructions
plus the maximum-size variable-length payloads and the two additional
TopUpStream paths §6.1 made normative — the rejection past a grace deadline
and the partial payment that preserves the pinned pair. Each records payload
bytes, reservation counts, actual read/write counts, charged read/write
counts, hash preimage lengths, signature count, and exact cycles and cells. A
conforming implementation MUST reproduce every vector exactly, measured
against this artifact, whose SHA-256 is
8bfaf1d47b6f312980c828a0b37d6c4550b97e375d2b3320aa30a72d3f388da2. Any change
to a metered cost changes that digest, which makes the hash the coordination
point between this document and an implementation: a node whose fixture
hashes differently is not running the schedule specified here.
The vectors are CBQS-dispatch scoped. Each covers the decode surcharge this
document defines plus its handler’s hash, signature, and state-I/O work, and
excludes charges the platform levies on every transaction regardless of
instruction family: the transaction base, intrinsic calldata cells, and the
outer system-instruction dispatch. A conforming implementation reproduces the
vector for the CBQS portion and adds its platform charges on top. Hash
charging is max(1, ceil(len / 32)) words, so an empty preimage costs one
word rather than zero — a distinction no vector’s magnitudes reveal, and
therefore stated here rather than left inferable.
19. Chain events
Events are consensus data: emitter, topic, payload bytes, and order commit throughlogs_root into receipt_root, so every field below is canonical.
The topic is the exact UTF-8 byte string shown; the payload is exactly the
one fixed-length identifier listed — no encoding envelope, no additional
fields. Registry details behind an event (generations, status, balances,
epoch) are read from 0x17 state at the event’s block rather than duplicated
into the log. Events MUST NOT contain record payloads, data keys, StreamGrant
bytes, or member envelopes.
suspended and reactivated are emitted on the NET status edge of an
instruction — the difference between the status the record held when the
instruction began and the status it holds when the instruction completes —
and always AFTER that instruction’s primary event. At most one such event is
emitted per instruction, and none is emitted when the two are equal.
The net rule is normative because §6.1 gives TopUpStream two settlements
around its deposit, and a reviving top-up passes through Suspended between
them: settle #1 finds the pre-deposit escrow underfunded and suspends, then
settle #2 finds the credited escrow sufficient and restores Active. Those
interior transitions are not observable. Emitting one event per interior edge,
or emitting reactivated alone, would make event count and order depend on
which settlement an implementer instrumented, and both commit through
logs_root into receipt_root. rent_settled is reported by exactly one
instruction, the standalone SettleStreamRent. TopUpStream and
CloseStream always settle internally per §6.1, and UpdateStream does so
only when priced capacity changes, but that settlement is part of the operation
rather than a separately reported event: TopUpStream emits topped_up then
any status edge, UpdateStream emits updated then any status edge, and
CloseStream emits closed alone. Exactly one reporting instruction is required because event count and
per-event topic commit through logs_root into receipt_root: were more than
one instruction to report an internal settlement, the emitted sequence would
vary with which rule an implementer applied. SettleStreamRent emits
rent_settled then any status edge, except that an expiry-materializing
settlement emits data_expired alone; it is the only instruction that
materializes DataExpired. TopUpStream applies §6.1’s ordering — settle,
check the deadline, credit, settle — so at or past the deadline it is rejected
before the deposit is debited and never materializes a terminal state. CloseStream
settles and refunds directly to Closed even past the deadline and emits
closed alone, so a close is never also an expiry. UpdateStream never
materializes a terminal state. No instruction therefore emits two terminal
topics or a terminal topic in an unenumerated position.
RegisterProvider, UpdateProvider, and SetProviderStatus each
emit exactly their own topic once — and a status write that does not change
the status is a rejected no-op, so status_changed always marks an actual
edge. CreateStream emits created alone, CloseStream emits closed
alone — its internal settlement is part of the close, not a separate
rent_settled — and ActivateKeyGeneration emits
key_generation_activated alone. An encrypted main/key pair transitions
atomically but emits exactly
one event per transition, on the main stream_id; the linked key stream
never emits separately. Event order within an instruction is consensus data
like the events themselves. v1 base-rate
schedules are fixed at genesis: no instruction writes them, so they emit no
event, and changing them is a coordinated-release protocol change by a later
CIP. Every GovernanceBase record carries its schedule_version, so the
schedule such a stream was admitted under is always derivable from finalized
state; a ProviderQuote record carries its quote digest instead.
Security Considerations
Content and metadata
End-to-end encryption protects payload contents only. Stream records, provider assignment, CBSS release activity, member-envelope recipient keys, lane and group identifiers, lane-scoped membership, sizes, timing, and connection metadata can reveal relationships. Applications requiring traffic-analysis resistance need a separate design. Provider-event record headers and payloads are intentionally cleartext operational metadata. They MUST NOT carry application payloads, member keys, or grants. The provider MUST redact payloads, grants, request proofs, envelopes, and derived keys from logs. Diagnostic logging SHOULD use hashes truncated to a documented length and MUST avoid linkable recipient-key material.Authorization
A StreamGrant is powerful until expiry or generation bump. Holder keys SHOULD be workload-scoped and kept outside application logs. Brokers MUST use one immutable finalized view per request, fail closed on stale state or a chain- instance mismatch, reject old authorization generations, and bind each session to provider epoch zero. A lane-set grant restricts what an honest broker serves, but it does not create a cryptographic boundary among lanes sharing a data key. The cleartext lane id also exposes finer activity and membership topology to the provider. Coarse generation revocation invalidates every outstanding grant. Applications must be prepared to refresh all retained participants after a removal.Key management
Compromise ofstream_root compromises every deterministically derived
generation. CBSS escrow, the rotation actor, and its job environment are
high-value targets. A root-compromise recovery MUST create a new root and new
stream; rotating only the generation is insufficient.
Envelope signatures prevent broker substitution and forked membership batches.
They do not hide the membership list from the broker. Removed members retain
old keys and plaintext.
Provider equivocation
Receipt chains make conflicting signed histories provable. They do not force a provider to respond. Clients SHOULD retain their latest append, group, and checkpoint anchors outside the provider. A nonzero provider epoch is invalid in v1 and MUST be rejected rather than treated as a new lineage. Loss of the assigned provider requires the application migration flow in §16.1. CBQS receipts identify the verified durable boundary but do not make the old provider’s unavailable state recoverable.Delivery
At-least-once delivery can repeat application effects. Consumers MUST make effects idempotent or transactionally deduplicate in their own state. A broker deduplication receipt does not make downstream effects exactly once. Strict FIFO allows a poison record to block later records until the consumer rejects it, attempts exhaust, or its deadline expires. Group deadlines and dead-letter inspection are required operational controls.Fast mode
fast acknowledges data before durable commit. A crash may lose every
provisional record after the last checkpoint. The bound is a provider
operational promise under honest storage, not a cryptographic guarantee.
Applications that cannot re-send or tolerate a gap MUST use standard.
Within a checkpointed batch, the §12.2.1 lane commitment does make withholding
detectable: a provider that drops a record from an authorized lane cannot
produce a proof consistent with the signed lane_record_count, and — because
lane_id is committed inside lane_payload_digest, not only as the map key —
it cannot relocate the lane’s own payload to a different key and serve the
lane’s subscriber a valid absence proof: a payload digest verifies at its own
key and nowhere else, so absence at L’s key means no committed payload
names L.
What that binding does and does not establish must be stated exactly, because
the two failure modes it leaves are different in kind. A provider can still
commit a batch that mis-partitions records — a leaf naming some other key
over records whose headers name L. Every FullStream verifier catches that
by recomputing the partition from the records themselves; L’s scoped
subscriber alone cannot see it, because its absence proof is genuine. Scoped
omission detection is therefore honest-partition detection: it is inherited
from the batch-global rules and the standing possibility of full-stream audit,
not established by the absence proof itself — and the batch-global rules
(lane_record_count sum, sequence-union contiguity) are dischargeable only by
a FullStream verifier, as §12.2 states; to a ScopedProofs subscriber they
are signed claims. Separately, a provider can decline to assign sequences
to a lane’s records at all. The resulting batch satisfies every §12.2 rule,
batch-global ones included, because a checkpoint claims only what it covers;
no verifier of any mode detects it. That is refusal to serve rather than an
inconsistency — the censorship residue below, whose remedy remains §16.1.
Three limits are
worth stating plainly. It bounds only what a checkpoint covers: verification
completes when a batch closes, so records already delivered in an unclosed batch
carry AEAD authenticity alone, and records lost while still provisional at a
crash are governed by the void statement rather than by any proof. A None
subscription keeps AEAD authenticity but receives no checkpoint frame at all, so
it forgoes both the signed chain and omission detection — the correct trade only
where a lost record is superseded rather than lost. And detection is not
prevention: a provider may still withhold, and the remedy remains the §16.1
migration flow.
Lane proofs disclose a prefix-occupancy pattern and nothing more. Absence is
proven at the lane’s own key against the empty-leaf constant rather than against
neighbouring entries, so no other lane’s identifier, ordering rank, exact count,
payload, or activity window appears in a proof, and presence and absence proofs
are identical in shape.
What remains is structural and should not be described as nothing: a sparse path
carries opaque sibling hashes and a bitmap marking which of them are non-empty.
A member therefore learns that certain prefix subtrees of the lane_id space
along its own query path are occupied, and popcount(bitmap) gives a lower bound
on how many other lanes were active in that batch. Since lane_ids are opaque
and the disclosure is a lower bound over prefixes rather than a set of
identifiers, this is accepted for v1 — but it is a residual leak, not an absence
of one, and a claim of zero cross-lane disclosure would be false.
The sparse keying is load-bearing and MUST NOT be simplified to a sorted lane
map with bracketing absence proofs. That construction is easier to implement and
leaks: proving n absent lanes reveals up to 2n unauthorized lane identifiers
per checkpoint — thousands at a legal scope — along with the batch’s active-lane
cardinality and each proven lane’s relative rank. The leak scales with scope
rather than being bounded by it, and the sparse form is also the smaller wire.
Denial of service
Message size, append rate, stream bytes, lane count, lane-list page size and creation rate, lane ids per grant, group count, subscriptions per connection, in-flight leases, retention pins, envelope count, transport credits, base-rate reservations, and decode allocations are bounded. Base-rate counter changes use checked arithmetic and fail atomically at provider caps. Capacity pressure rejects new work; it never silently evicts a livestandard record.
Permanent registry keys are additionally priced in capital, not only gas:
every creation burns the §6 creation charge, and the §6 rate floor keeps a
provider quote from zeroing it.
Every consensus-side decode, hash, signature verification, state read, state
write, and index update is charged by §18. The fixed
one-signature-per-action limit and root-only key-batch verification prevent a
caller from converting an apparently constant-cost instruction into
unmetered linear cryptographic work.
SetProviderStatus is free, notice-less, reversible, and fee-orderable
within a block, and provider status also gates who may fund a stream: a
provider can front-run a sponsor’s pending TopUpStream with a
higher-fee flip to Draining, burn the sponsor’s gas, and flip back next
block, invisibly to block-boundary snapshots. v1 discloses this rather than
adding status-change notice or rate machinery. The bound is that the gate
never binds the owner’s own funding or CloseStream, so sustained abuse
only starves the provider of the rent it would have earned; sponsors of
owner-absent streams remain exposed until notice machinery exists.
All nine instructions MUST be classified under the CIP-12 emergency-pause
circuit breaker the same way existing system-instruction families are: while
the breaker covers 0x17, every CBQS instruction is rejected before state
reads with no event, and read paths (/cbqs/broker-state) stay available so
brokers keep serving already-authorized traffic against their last finalized
view. Because the only remedies for an underfunded stream — settle, fund,
close — are exactly the instructions a pause rejects while heights keep
advancing, blocks during which the breaker covers 0x17 MUST NOT count
toward rent accrual or grace deadlines. The mechanism is a cumulative
pause-accounting record under the governance actor, keyed
"system:gov:pause-accounting:" || actor address, holding
completed_pause_blocks plus an optional active [paused_at_block, expires_at_block) interval: pausing folds any auto-expired prior interval
and starts a new one, extension refreshes the expiry, and explicit unpause
folds elapsed coverage capped at expiry instead of erasing it.
Coverage is derived at READ time, not only at write time, because pause
expiry is lazy — no transaction runs at the expiry block, so an expired
interval can sit unfolded indefinitely:
min with the expiry is
mandatory in both directions: omitting the active interval entirely would
lurch the clock forward by a whole pause at its expiry and mass-expire the
streams the mechanism protects, while omitting the cap would freeze the rent
clock permanently and pin every provider in unpaid service. Only the
expression above is conforming.
effective_rent_height is a derived quantity, not attested state: it is a
pure function of the finalized height and the pause-accounting record, both
consensus state, so any party recomputes it from a finalized view and no
signing preimage changes. It is therefore NOT a field of FinalizedViewV1,
which appears as served_at in nine §14 signing preimages: adding it would
break every golden vector and, worse, would put inside a signature the very
value two honest brokers can compute differently before a lazy pause expiry
is folded. Brokers obtain it from the same atomic broker-state read as the
registry records, per §4, so excluding it from the view costs no atomicity;
a disagreement is resolved by recomputation rather than by comparing
signatures. Reading the record is the one extra metered read in every §18.1
reservation. Every prior height-coupled
system actor required exactly this kind of isolation before its pause
classification. Adding the classification, the pause-interval record, and
the exclusion to the deployed allowlist is a
required node change.
State-operation pricing intentionally differs from the flat per-instruction
constants of existing system-instruction families: a TopUpStream balance
movement meters cheaper on the cycles axis than a bare Transfer, while its
cells charge — the axis that prices state growth — is higher. CBQS is the
first dynamically metered family, and its congestion contribution is
dominated by cells; re-basing the flat legacy constants is out of scope here.
Rationale
One stream primitive
One append-only stream plus groups covers the required patterns:
Lanes are indexed views over one attested global order, not partitions. They
reduce chain records and grants only when their rooms share a confidentiality,
retention, provider, and billing domain. Partitions and exchange/binding
algebra are omitted from v1. One total order is easier to specify, verify, and
use for agent coordination.
Dedicated broker
Client-facing RabbitMQ, Redis, NATS, or Kafka cannot directly verify Cowboy StreamGrants, track finalized chain generations, issue CBQS receipt chains, or implement CBQS terminal delivery cycles.cbqsd is the protocol layer that
supplies those semantics.
Its storage engine is pluggable. An embedded segmented log or RocksDB-class
engine is the v1 default. A provider MAY use a managed engine internally only
when every externally observable CBQS rule is preserved. Clients never speak
the internal engine protocol.
Two pricing paths
Provider quotes are the market path. They let a provider price and admit one stream explicitly, down to the §6 floor for a self-host discount. Governance base rates are the simple path: a provider publishes bounded standing capacity, owners consume that capacity at a deterministic active schedule, and the chain enforces admission counters. The base path does not remove provider consent. Consent moves from a per-stream quote to a signed provider admission record, bounded by streams, retained bytes, and throughput. A provider opts out by clearingaccepts_new in that record, which refuses further governance-base
admissions without disturbing anything already accepted: every existing stream
keeps its snapshotted rate until its owner changes priced capacity. Opting out
is the whole of the mechanism — v1 has no schedule transition to be given
notice of, since the base schedule is seeded at genesis and repricing it is a
coordinated-release protocol change rather than a governance write.
CBSS root escrow
CBSS releases are currently bound to a job, its assigned Runner, and an actor manifest, and secret policies cap actor ACLs. Making CBSS release one room key to every chat member would not match that contract. CBQS instead gives one rotation job access to one root and performs member fan-out with HPKE envelopes.Rejected alternatives
- Actor mailboxes: consensus-visible, gas-metered, and block-latency; retain them for finality-bearing state.
- Per-runner sidecars: not a cross-runner backplane.
- Validator data path: adds consensus cost without making off-chain agent chatter consensus-relevant.
- Strict topology privacy: complex provisioning and blind billing still do not hide timing from a single broker.
- Verbatim CBFS owner tokens: useful precedent, but no holder binding or CBQS verb/group model.
- CBSS member fan-out: wrong release shape and scale.
- Redis-style general service: expands into caches, locks, counters, and ambiguous durability outside this proposal.
- Exactly-once claims: cannot include arbitrary consumer side effects.
- In-place provider reassignment: adds mutable assignment and cross-provider lineage machinery; applications instead migrate through a new stream and a verified durable anchor.

