> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cowboy.inc/llms.txt
> Use this file to discover all available pages before exploring further.

# CIP-7: Watchtower

> Watchtower — the canonical Cowboy stream protocol. Actor-native streaming with deterministic signatures, bounded replay, header filters, CBSS-routed payload encryption, CBY-native per-epoch key billing, and optional timer-driven ingestion

<Note>
  **Status:** Draft
  **Type:** Standards Track
  **Category:** Core
  **Created:** 2026-02-17
  **Requires:** CIP-2 (Off-Chain Compute), CIP-3 (Dual-Metered Gas), CIP-5 (Timers)
</Note>

**Normative conventions.** The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, and **MAY** in this document are to be interpreted as described in RFC 2119.

## Summary

This CIP defines **Watchtower**, the canonical stream protocol for Cowboy. Watchtower is the product name for any implementation of this specification; the terms "Watchtower," "Watchtower feed," and "StreamActor" are used interchangeably throughout.

A `StreamActor` is the Watchtower feed primitive. Publishers append ordered messages to a stream. Consumers use push, pull, or push-with-pull-fallback delivery. Filtering is deterministic and evaluated only on message headers. The protocol stores payloads inline with a bounded on-chain replay window.

For paid streams, encryption and decryption are off-chain SDK operations backed by CBSS (CIP-24) threshold proxy re-encryption, while key-access billing is a native platform event settled in `CBY` via the Stream Key Manager system actor. X25519 account keys serve as the CBSS recipient identity; one access purchase covers every downstream consumer the account holder delegates to. The protocol collects a configurable fee on every key-access purchase.

The `cowboy watchtower` CLI (see [CLI spec](/cli-specs/cowboy-watchtower)) is the reference toolchain for deploying, publishing to, and subscribing to Watchtower feeds. The `WatchtowerRegistry` system actor, when deployed, is a named-feed index built on top of this protocol and is the canonical discovery mechanism for public Watchtower feeds.

## Abstract

CIP-7 standardizes:

* One `StreamMessage` format for news, pricing, alerts, and other stream kinds
* Explicit message versioning for forward compatibility
* Strictly increasing per-stream sequence numbers with no gaps
* Subscription and delivery semantics for `PUSH`, `PULL`, and `PUSH_WITH_PULL_FALLBACK`
* A deterministic JSON filter DSL over headers/tags
* A bounded replay window (`10,000` messages) with explicit `CURSOR_TOO_OLD`
* Optional timer-driven ingestion through CIP-2 runners
* Single active publisher key with deterministic key rotation cutover
* Optional subscriber-paid access with CBSS-routed key custody, on-chain stream-access records, epoch keying, and native `CBY` billing

## Motivation

CIP-7 optimizes for:

* Fast deployment with one actor pattern
* Real-time multicast delivery
* Deterministic replay and filtering behavior
* Practical support for news feeds and higher-volume pricing via micro-batching
* Platform-managed encryption so actors never implement crypto
* Native `CBY` value capture from paid stream economics

## Design Goals

* One protocol, one actor model
* Deterministic behavior for sequence, signing, filters, and replay
* Push and pull both first-class
* Explicit resource bounds for storage and fan-out work
* Optional ingestion without changing core stream semantics
* CBSS-routed key custody for paid mode (no chain-wide master secret; no validator-decryptable content)
* Native per-key-epoch billing with protocol fee support
* Account-scoped X25519 keys (CBSS recipient identity) with sponsored purchases (`payer != beneficiary`)
* Rolling access windows

## Non-goals

* Permanent storage guarantees
* Exactly-once delivery
* Ack/retry protocol in core spec
* Payload-level query language
* External payload URI hosting guarantees
* Actor-defined custom cryptography for paid streams
* Custom billing assets beyond `CBY` in v1
* Off-protocol key marketplaces or resale
* On-chain key escrow or multi-operator key distribution (future CIP)
* Refundable prepaid balances (future CIP)

## Protocol Constants

* `MAX_INLINE_PAYLOAD_BYTES = 16_384` (16 KiB)
* `DEFAULT_RING_BUFFER_CAPACITY = 10_000`
* `MAX_GET_SINCE_LIMIT = 500`
* `DEFAULT_MAX_SUBSCRIBERS = 10_000`
* `DEFAULT_INGEST_INTERVAL_BLOCKS = 1`
* `DEFAULT_KEY_EPOCH_BLOCKS = 600`
* `BILLING_ASSET = CBY` (paid mode v1 REQUIRED)
* `MAX_PROTOCOL_FEE_BPS = 5_000` (50% cap)
* `CONTENT_CIPHER = XCHACHA20_POLY1305` (paid mode v1 REQUIRED)
* `NONCE_BYTES = 24`
* `TAG_BYTES = 16`
* `MAX_EFFECTIVE_PLAINTEXT_BYTES = 16_344` (16,384 - 24 - 16)
* `DEFAULT_MIN_PURCHASE_EPOCHS = 1`
* `MAX_ACCOUNT_KEYS_PER_ACCOUNT = 8`
* `MAX_EPOCHS_PER_ACQUIRE = 256` (per-tx cap on the SealRequest fan-out of a single `acquire_epoch_access` call)
* `REQUEST_FRESHNESS_BLOCKS = 384` (CBSS-defined; mirrored here for SealRequest expiration)

## Definitions

* **StreamActor**: Actor implementing this CIP's interface
* **StreamMessage**: Canonical message envelope with ordered sequence
* **Cursor**: Last consumed sequence used with `get_since`
* **Header filter**: Deterministic filter over `kind`, `tags`, `sequence`, `timestamp_unix_ms`
* **Head sequence**: Latest published sequence
* **Floor sequence**: Oldest retained sequence in ring buffer
* **Key epoch**: Time window (measured in blocks) during which a single content encryption key is active
* **Beneficiary account**: Account that receives stream access (the CBSS recipient identity for delivered per-epoch content keys)
* **Payer account**: Account charged in `CBY` for stream-access extension (may differ from beneficiary)
* **Account key**: Account-scoped X25519 key registration used as the CBSS recipient for committee-delivered per-epoch content keys
* **Rolling access window**: `active_until_key_epoch` upper bound per `(stream_id, beneficiary_account)`
* **Stream secret**: 32-byte symmetric master key generated by the publisher off-chain. Used only as the HKDF input to derive per-epoch content keys; **never wrapped to the committee, never shared, never on-chain**. If lost, the publisher rotates by generating a new stream secret and re-registering all upcoming content keys (past epochs remain decryptable by anyone who already paid for them).
* **Content key**: Per-epoch AEAD key derived as `HKDF(stream_secret, "cip-7-content-key-v1", chain_id || stream_id || u64_be(key_epoch))`. Used by `XChaCha20-Poly1305` for `payload_inline` encryption/decryption. The publisher IBE-encrypts each content key to the committee MPK and registers it on-chain as `WrappedContentKey`.
* **Generation**: Per-epoch monotonic counter at `0xD || 0x06 || keccak(stream_id) || u64_be(epoch)`. Starts at `1`, increments on every re-wrap of that epoch's content key. Bound into the IBE identity so a threshold signature obtained for one generation cannot decrypt a re-wrapped ciphertext under the same epoch.
* **SealRequest**: On-chain CBSS request for the committee to threshold-sign a per-(stream, epoch, generation) identity, enabling the recipient to unwrap the `WrappedContentKey` for that epoch. Minted by SKM during `acquire_epoch_access` — **one SealRequest per newly-covered epoch**.

## Stream Model

Each stream is represented by one actor with:

* `stream_id`
* `head_sequence` (starts at `0`; first publish becomes `1`)
* `floor_sequence` (starts at `1`; advances as pruning occurs)
* single active `publisher_key`
* key schedule history for signature verification across rotations
* `ring_buffer_capacity` (default `10,000`)
* `max_subscribers`
* `subscription_policy`
* push delivery work limits
* optional ingestion configuration
* optional paid-mode configuration for CBSS-routed key custody and key-access billing

## Platform Architecture (Paid Mode)

Paid stream confidentiality is delegated to **CBSS** (CIP-24 Secrets Manager at seed `0x04`), which provides identity-based threshold encryption over BLS12-381. CIP-7 introduces only the **Stream Key Manager** (SKM) system actor — a thin coordination layer that handles billing, access tracking, and SealRequest emission to CBSS. Encryption and decryption themselves are **off-chain** operations performed by publisher and subscriber processes, using on-chain wrapped secrets delivered via the CBSS committee.

This design has four consequences:

1. **Validators cannot decrypt paid content.** Per-epoch wrapped content keys are IBE-encrypted to the CBSS committee's master public key (MPK) and only recoverable via a t-of-n threshold of committee partial signatures.
2. **Per-epoch billing is enforced cryptographically.** The publisher wraps one content key per `(stream_id, key_epoch, generation)` triple. Subscribers paying for epochs `E+1..T` receive committee threshold signatures only for those specific epochs and cannot derive keys for unpaid epochs. The publisher's master `stream_secret` never leaves the publisher's keystore.
3. **No VM-side `stream_encrypt` / `stream_decrypt` host functions.** Encryption happens in publisher SDK; decryption happens in subscriber SDK. Both are bounded by audited library code (`cbss-client`, `cbss-crypto`).
4. **`acquire_epoch_access` is synchronous for billing, asynchronous for key material.** Billing settles in the same block; per-epoch wrapped-key delivery follows the standard CBSS partial-aggregation flow (typically 4–5 blocks per epoch's SealRequest; capped at `REQUEST_FRESHNESS_BLOCKS = 384`).

### Stream Key Manager System Actor

A new system actor is added at deterministic seed `0x000000000000000D` (see whitepaper §9 for the canonical system-actor allocation across all CIPs).

Note: CIP-2's `Entitlement Registry` (a separate system actor governing runner-job entitlements) is unrelated to this CIP's stream-access concept (see [StreamAccess](#5-streamaccess-paid-mode)). The two are deliberately disambiguated by name.

The Stream Key Manager is the single authority for:

* Per-epoch wrapped-content-key registration (publisher → CBSS committee)
* Account `X25519` key registration (subscriber recipient binding)
* Stream access tracking and `CBY` billing
* Per-epoch SealRequest emission to CBSS upon successful `acquire_epoch_access`
* Protocol fee collection

The SKM does **not** hold any decryption material. The CBSS committee is the sole party (in aggregate) that can produce the BLS signatures needed to unwrap per-epoch content keys.

### HostApi Extensions

Two new methods are added to the `HostApi` trait, exposed to actor Python code as VM host functions:

```rust theme={null}
fn acquire_epoch_access(
    &mut self,
    params: &[u8],  // canonical-CBOR AcquireEpochAccessParams
) -> HostResult<Bytes>;  // canonical-CBOR KeyAccessReceipt

fn register_account_key(
    &mut self,
    params: &[u8],  // canonical-CBOR RegisterAccountKeyParams
) -> HostResult<Bytes>;  // canonical-CBOR AccountKeyRegistration
```

Encryption and decryption are **not** VM host functions; they are SDK-side operations (see [SDK and Off-Chain Components](#sdk-and-off-chain-components)). All other SKM operations (`register_content_keys`, `revoke_account_key`) are invoked via standard actor message calls to `0x0D`.

All CBOR-encoded params and results use the canonical CBOR rules already established by [`cowboy_sdk.codec`](https://github.com/cowboyinc/node/blob/main/pvm/Lib/cowboy_sdk/codec.py) (RFC 8949 canonical form: sorted map keys, shortest integer encoding, no indefinite-length, float64-only).

### Storage Layout

The Stream Key Manager uses storage sub-prefix `0xD` under its system actor address:

```
0xD || 0x01 || keccak(stream_id)                                                          -> PaidStreamConfig
0xD || 0x02 || keccak(account)                                                            -> [AccountKeyRegistration...]
0xD || 0x03 || keccak(stream_id) || u64_be(key_epoch)                                     -> WrappedContentKey (IBE-encrypted to committee MPK)
0xD || 0x04 || keccak(stream_id) || keccak(account)                                       -> StreamAccess
0xD || 0x05 || keccak(stream_id) || keccak(account) || u64_be(account_key_id) || u64_be(epoch)  -> SealRequestStatus
0xD || 0x06 || keccak(stream_id) || u64_be(key_epoch)                                     -> u64 generation (incremented on re-wrap of that epoch)
```

All state is committed through the standard MPT path and included in `state_root`.

No raw `content_key` is stored anywhere on-chain. Each `WrappedContentKey` is the IBE ciphertext of `content_key_e = HKDF(stream_secret, "cip-7-content-key-v1", chain_id || stream_id || u64_be(key_epoch))`, where `stream_secret` lives only in the publisher's off-chain keystore.

No `actor_nonce` is stored: encryption nonces are generated client-side by the publisher (random 24-byte XChaCha20 nonces, freshness ensured by publisher-side RNG and bound by AEAD AAD).

`SealRequestStatus` is keyed per `(stream, beneficiary, account_key_id, epoch)` so that a beneficiary account holding multiple active X25519 keys can have an independent in-flight SealRequest per key for the same epoch without overwriting one another. This is load-bearing: previously, switching `account_key_id` mid-flight would silently drop the prior key's pending delivery (late partials would be ignored). With this layout, each key's request lifecycle is isolated.

### Content-key Identity (Normative)

CBSS IBE signs a G1 hash-to-curve point. CIP-7 defines the bytes layer, the domain separation tag (DST), the hash-to-curve mapping, and the AEAD AAD used by `WrappedDek` — each in exact form so cross-language and cross-implementation derivations are byte-identical.

**Domain separation tag (DST).** Used for IBE hash-to-curve. Follows the existing CBSS naming convention (cf. [`CIP9_VOLUME_DEK_IBE_DOMAIN`](https://github.com/cowboyinc/cbss/blob/main/crates/cbss-crypto/src/identity.rs)):

```
CIP7_CONTENT_KEY_IBE_DOMAIN = b"cbss/ibe/cip7-content-key/v1"
```

The chain MUST use this exact byte string. The hash-to-curve operation itself uses the BLS12-381 G1 implementation in `blstrs::G1Projective::hash_to_curve` (SSWU map, random oracle).

**Bytes form.** The pre-hash identity payload:

```
content_key_identity_bytes(chain_id, stream_id, key_epoch, generation) =
    b"cip-7-content-key-v1"           // 20-byte literal domain prefix
    || u64_be(chain_id)                 // 8 bytes
    || u32_be(stream_id.len())          // 4 bytes (stream_id is variable length)
    || stream_id                        // stream_id.len() bytes
    || u64_be(key_epoch)                // 8 bytes
    || u64_be(generation)               // 8 bytes
```

The length prefix on `stream_id` prevents collision between e.g. `("abc", epoch=42)` and `("abc\x00\x00\x00\x00\x00\x00\x00\x2a", epoch=0)`.

**Hash-to-curve.** The IBE identity point is computed via the BLS12-381 G1 hash-to-curve operation specified in [draft-irtf-cfrg-bls-signature](https://datatracker.ietf.org/doc/draft-irtf-cfrg-bls-signature/) §4.2:

```
identity_point = hash_to_curve_g1(
    msg = content_key_identity_bytes(...),
    dst = CIP7_CONTENT_KEY_IBE_DOMAIN,
)
```

Concretely the implementation calls `cbss_crypto::hash_to_curve_g1(msg, &CIP7_CONTENT_KEY_IBE_DOMAIN)`, reusing the same H2C primitive CIP-9 uses (different DST, so no cross-feature signature reuse). The `identity_point` is the input to both `ibe_encrypt(committee_mpk, identity_point, content_key, aad)` and the committee's partial-sign operation.

**WrappedDek AAD.** The AAD baked into the AES-GCM layer inside `WrappedDek` is a fixed-width byte concatenation (mirrors the CBSS [`aad_for_secret_version`](https://github.com/cowboyinc/cbss/blob/main/crates/cbss-crypto/src/identity.rs) convention — no CBOR, deterministic, no parser ambiguity):

```
base_wrapped_dek_aad(chain_id, stream_id, key_epoch, generation, committee_epoch,
                     committee_selector, release_key_material_hash) =
    b"cip-7-wd-v1"                  // 11-byte literal version prefix
    || u64_be(chain_id)              // 8 bytes
    || u32_be(stream_id.len())       // 4 bytes
    || stream_id                     // stream_id.len() bytes
    || u64_be(key_epoch)             // 8 bytes
    || u64_be(generation)            // 8 bytes
    || u64_be(committee_epoch)       // 8 bytes
    || selector_byte                 // 1 byte: 0x00 = default committee, 0x01 = override
    || override_hash_or_zero         // 32 bytes: committee override hash, or all-zero for default
    || release_key_material_hash     // 32 bytes: CBSS release-key material hash — binds mpk_g2 + committee/threshold/vss (COW-2924)
                                     // total = 112 + stream_id.len() bytes
```

`release_key_material_hash` is the 32-byte CBSS release-key material hash for the committee this content key is wrapped to — the same primitive CIP-9 / CIP-24 §9.3.1 bind: `cip9_hash("cowboy.cip-9.release-key-material.v1", chain_id ‖ account ‖ committee_epoch ‖ threshold ‖ committee ‖ mpk_g2 ‖ vss_commitments)`. It covers `mpk_g2` **and** the committee/threshold/VSS material in one field, and the SKM re-derives it from committed release-key state at registration (below), so a content key sealed under a substituted `mpk'` produces a `WrappedDek.aad` that does not match and is rejected. **Why the material hash here, not raw `mpk_g2` (as the account-secret AAD binds).** CIP-7 already binds `committee_epoch` and re-checks it at `acquire_epoch_access`, and it already has a re-wrap model (`generation`), so it is epoch-scoped by design and tolerates a commitment that rotates on every reshare; binding the full material hash costs nothing extra and covers more. Account secrets have no re-wrap path, so they must bind the reshare-*invariant* raw `mpk_g2` instead (CIP-24 §3.4.3). This is the family-specific application of one rule — *bind the strongest release-key commitment the family's re-wrap model can carry* — not the same field copied across families. **The version prefix stays `cip-7-wd-v1`:** appending `release_key_material_hash` changes the byte layout (base `80 + N` → `112 + N`), but CIP-7 was already at v1 and there is no live boundary carrying the prior layout across the wiping re-genesis, so the label is unchanged and the golden-vector byte-gate is the drift tripwire (mirrors the account-secret single-version decision, CIP-24 History).

This is the **base** AAD. The AEAD AAD actually baked into the v2 `WrappedDek` extends it with the Boneh-Franklin encryption ephemeral, exactly as in CIP-24:

```
aad = base_wrapped_dek_aad(...) || compress(U)      // U = r · G2_gen, 96-byte compressed G2
```

The publisher MUST pass the base AAD to `ibe_encrypt`, which samples a fresh `r != 0` per wrap, publishes `U` in the envelope's `ephemeral_u` field, and binds `compress(U)` into the AAD. SKM verifies at `register_content_keys` time that `ephemeral_u` is a canonical non-identity G2 point and that the submitted `WrappedDek.aad` equals `base || ephemeral_u` **by full equality — exact length included** (mismatch → `INVALID_WRAPPED_DEK_AAD`). Notation: `U` is the G2 group element; `ephemeral_u = compress(U)` is its stored 96-byte canonical compressed encoding, and formulas over the stored field use `ephemeral_u` directly. Subscriber unwrap recomputes and verifies the full AAD before decrypt. Cross-language Python and Rust implementations both emit byte-identical AAD without any CBOR codec involved.

**Why the layers stack.**

1. The DST + hash-to-curve isolate CIP-7 from CIP-9 even if their pre-hash bytes happen to collide.
2. The bytes-form length-prefixing prevents collisions between different `stream_id` values.
3. `generation` in bytes form means re-wrapping bumps the H2C point — old σs stop working for new wrapped material.
4. The `WrappedDek.aad` includes `committee_epoch`, so an attacker can't swap a `WrappedDek` from a prior committee into a new SealRequest path: the AEAD tag verification fails.
5. The per-wrap ephemeral `U` bound into the AAD is what makes the wrap key secret at all: `K = HKDF(serialize(e(I, MPK)^r))`, so `K` is not recomputable from the public `(I, MPK)` (CIP-24 confidentiality fix), and post-publish tampering with `ephemeral_u` is a hard AEAD failure rather than a silent mis-derivation.
6. The `release_key_material_hash` bound into the AAD closes **key substitution** (COW-2924): `committee_epoch` is only a *label*, so before this field the AAD carried no committee *key material* and a content key sealed under a substituted `mpk'` — with an honest `committee_epoch` — registered successfully, letting an attacker recover every pre-wrapped content key in a `pre_wrap_epochs` batch with zero committee interaction. Binding the release-key material hash, which the SKM re-derives from committed state and full-equality-checks, makes a substituted key a hard registration failure rather than a silent acceptance.

`generation` itself is the integer at `0xD || 0x06 || keccak(stream_id) || u64_be(key_epoch)`. It starts at `1` on first `register_content_keys` for that epoch and increments on every re-wrap.

### SDK and Off-Chain Components

Off-chain CIP-7 work splits across two layers. The **IBE wrap/unwrap** — the pairing and threshold-combine operations that seal a content key to the committee `MPK` and recover it from an aggregated σ — is implemented **only in Rust** (`cbss-client` / `ras`); the reference **Python SDK (`cowboy.stream`) does NOT provide the IBE wrap/unwrap.** What the Python SDK ships is (a) **byte-layout mirrors** of the CIP-7 identity and AAD, byte-identical to `cowboy-protocol-cbss-crypto`, and (b) the pure-crypto content-key AEAD layer (HKDF + XChaCha20-Poly1305, no pairing). A Python publisher/subscriber that must wrap or unwrap a content key invokes the Rust path (a native binding, or the in-PVM `cowboy_sdk.stream.Stream` actor surface), not a pure-Python primitive.

**Byte-layout mirrors (Python `cowboy.stream`, pure byte-concatenation — no crypto):**

* `content_key_identity_bytes(chain_id, stream_id, key_epoch, generation) -> bytes` — the pre-hash identity payload (§Content-key Identity), byte-identical to the Rust builder so a Rust-side `hash_to_curve` over the Python-produced bytes yields the same G1 point.
* `wrapped_dek_aad(chain_id, stream_id, key_epoch, generation, committee_epoch, committee_selector, release_key_material_hash) -> bytes` — the `base_wrapped_dek_aad` concatenation (including the COW-2924 `release_key_material_hash`), byte-identical to the Rust builder; used to construct or verify the AEAD AAD.

**Content-key AEAD layer (Python `cowboy.stream`, pure crypto — no pairing):**

* `derive_content_key(stream_secret, chain_id, stream_id, key_epoch) -> bytes(32)` — pure HKDF-SHA256.
* `encrypt(content_key, plaintext, aad) -> ciphertext_envelope` — XChaCha20-Poly1305 with a fresh CSPRNG nonce; returns `nonce(24) || ciphertext || tag(16)`.
* `decrypt(content_key, ciphertext_envelope, aad) -> plaintext` — XChaCha20-Poly1305 decrypt with mandatory AAD check.

**IBE wrap/unwrap (Rust `cbss-client` / `ras` — NOT the Python SDK):**

* `wrap_cip7_content_key(release_key, identity, content_key) -> WrappedDek` — IBE-wraps a content key to the committee `MPK` for a `(stream, epoch, generation)` identity; a `pre_wrap_epochs`-style batch derives + wraps a range of upcoming epochs, ready to submit via `register_content_keys`.
* `combine_verified_partials(...)` + `unwrap_cip7_content_key(σ, wrapped_dek, aad) -> content_key` — verifies the aggregated threshold σ and decrypts the WrappedDek to recover the per-epoch content key.

**Implementers MUST NOT roll their own IBE, AEAD, or HKDF.** The IBE wrap/unwrap MUST use the audited Rust primitives above; the Python AEAD library MUST support XChaCha20-Poly1305 natively (PyNaCl `crypto_aead_xchacha20poly1305_ietf_encrypt`). VM host-function wrappers (`acquire_access`, `register_account_key`) may be exposed by either surface for convenience.

**Publisher operational pattern (non-normative):** a publisher SHOULD run a wrap-ahead daemon (cron, systemd timer, or in-actor CIP-5 timer) that maintains a buffer of pre-wrapped epoch keys covering at least the next `K` epochs (e.g., `K=100`). On stream deploy, the publisher SHOULD pre-wrap an initial batch (e.g., 1,000 epochs ≈ 7 days at 10-min epochs) before exposing the stream to subscribers. SKM does not enforce a minimum pre-wrap depth in v1; subscribers calling `acquire_epoch_access` for epochs the publisher has not yet wrapped will receive `EPOCHS_NOT_WRAPPED(missing_epochs)`.

## Data Types

### 1. StreamConfig

Fields:

* `stream_id` (bytes32/string)
* `owner` (address)
* `publisher_key` (bytes): active ed25519 public key
* `current_signing_key_id` (uint64): key identifier for `publisher_key`
* `ring_buffer_capacity` (uint32): default `10,000`
* `max_subscribers` (uint32): default `10,000`
* `subscription_policy` (enum): `PUBLIC` | `PRIVATE_ALLOWLIST`
* `max_push_deliveries_per_block` (uint32)
* `max_push_cycles_per_block` (uint64)
* `access_mode` (enum): `OPEN` | `SUBSCRIBER_PAID`
* `paid_stream_config` (optional `PaidStreamConfig`)
* `ingestion` (optional `IngestionConfig`)

Rules:

* `ring_buffer_capacity` MUST be > 0
* `current_signing_key_id` MUST be >= 1; the constructor (`init`, below) establishes the initial value
* `max_subscribers` MUST be > 0
* Push limits MUST be finite and non-zero
* If `access_mode == SUBSCRIBER_PAID`, `paid_stream_config` MUST be set
* If `access_mode == OPEN`, `paid_stream_config` MUST be absent

### 2. PaidStreamConfig (optional)

Fields:

* `fee_per_key_epoch_cby` (uint64): publisher amount per newly covered key epoch, in CBY wei
* `protocol_fee_bps` (uint16): basis points applied on top of publisher amount
* `publisher_treasury` (address): where publisher revenue is credited
* `key_epoch_blocks` (uint32): default `600`
* `content_cipher` (enum/string): `XCHACHA20_POLY1305` (v1 REQUIRED)
* `min_purchase_epochs` (uint32): default `1`

Rules:

* `fee_per_key_epoch_cby` MUST be > 0
* `protocol_fee_bps` MUST be in `[0, MAX_PROTOCOL_FEE_BPS]`
* `key_epoch_blocks` MUST be > 0
* `content_cipher` MUST be `XCHACHA20_POLY1305` in this CIP version
* `min_purchase_epochs` MUST be >= 1; purchases covering fewer than `min_purchase_epochs` newly-charged epochs MUST fail with `MIN_PURCHASE_NOT_MET`
* `publisher_treasury` MUST be a valid account address

Protocol fees are credited to the platform-wide system Treasury at `0x0000000000000008` (CIP-2 §5.4). Publishers do not configure a per-stream protocol treasury; the Stream Key Manager credits `protocol_fee_cby` to the system Treasury at billing time.

### 2a. WrappedContentKey (paid mode)

The publisher-registered, CBSS-encrypted per-epoch content key. **One per `(stream_id, key_epoch)` pair**, registered by the publisher in advance of subscriber demand.

Fields:

* `stream_id` (bytes32/string)
* `key_epoch` (uint64)
* `generation` (uint64): starts at 1; increments on each re-wrap of this epoch
* `committee_epoch` (uint64): CBSS committee epoch at the time of wrap
* `wrap_block_height` (uint64): block at which the wrap was registered
* `wrapped_dek` (`WrappedDek`): IBE ciphertext envelope per [`node/types/src/cbss.rs`](https://github.com/cowboyinc/node/blob/main/types/src/cbss.rs) — fields `version: u8` (leading format tag, value 2, rejected on unknown values), `ciphertext: bytes`, `nonce: bytes(12)`, `ephemeral_u: bytes(96)`, `aad: bytes` (serialized format version 2; the earlier three-field envelope is not valid)
* `committee_override_hash` (optional bytes32): per-stream committee override, defaulting to the chain-wide committee

Rules:

* One row per `(stream_id, key_epoch)`. The publisher MUST register a row before any subscriber can acquire access to that epoch (else `EPOCHS_NOT_WRAPPED`).
* Re-registering an existing `(stream_id, key_epoch)` increments `generation`, generates a fresh IBE identity (which incorporates `generation`), and produces a new `wrapped_dek`. Any in-flight SealRequests for the prior generation are invalidated by the identity change; on-chain partials gathered against the old identity cannot decrypt the new wrapped key.
* `wrapped_dek` is opaque to the SKM; only the CBSS committee + holders of a valid threshold signature for the matching identity can decrypt.
* The publisher SHOULD batch-register many epochs via a single `register_content_keys` call (see [Actor Interface](#register_content_keysstream_id-entries-committee_epoch-committee_override_hash)).

### 3. StreamMessage

Fields:

* `version` (uint8): MUST be `1` for this CIP revision
* `stream_id` (bytes32/string)
* `sequence` (uint64)
* `timestamp_unix_ms` (uint64)
* `kind` (string): examples `news`, `price_batch`, `alert`
* `content_type` (string): payload media type, default `application/json`
* `tags` (`map<string, string | float64 | bool>`)
* `payload_format` (enum): `PLAINTEXT` | `CIPHERTEXT`
* `payload_inline` (bytes): MUST be ≤ 16 KiB
* `payload_hash` (bytes32): `SHA-256(payload_inline)`
* `key_epoch` (optional uint64): REQUIRED when `payload_format == CIPHERTEXT`
* `signing_key_id` (uint64): publisher-key identifier active at this sequence
* `publisher_sig` (bytes): ed25519 signature over canonical signing bytes

Rules:

* `version` MUST be `1`
* `payload_inline` is REQUIRED
* `payload_inline` size MUST be ≤ `MAX_INLINE_PAYLOAD_BYTES`
* `sequence` MUST be strictly increasing and contiguous (`prev + 1`)
* `payload_hash` MUST match `payload_inline`
* If `payload_format == CIPHERTEXT`, subscribers require committee-delivered stream-secret access to decrypt
* In `SUBSCRIBER_PAID` mode, stream MUST publish `CIPHERTEXT` messages
* In `SUBSCRIBER_PAID` mode, ciphertext payloads MUST be produced off-chain using `cowboy_sdk.stream.Stream.encrypt(...)` (or equivalent audited library)
* In `CIPHERTEXT` mode, `payload_inline` MUST be encoded as: `nonce(24 bytes) || ciphertext_with_tag`
* In `CIPHERTEXT` mode, the 16 KiB limit applies to the full encrypted envelope (`nonce + ciphertext + tag`)
* In `CIPHERTEXT` mode, `aad` for the AEAD MUST be the canonical-CBOR encoding of `{stream_id, sequence, key_epoch, kind, content_type}` (the same fields included in the signing payload). Subscribers MUST recompute and verify this AAD before accepting plaintext.
* For `XCHACHA20_POLY1305`, effective maximum plaintext size is `16_384 - 24 - 16 = 16_344` bytes
* The 24-byte nonce MUST be drawn from a cryptographically secure random source by the publisher SDK; nonce reuse with the same content key is forbidden

### 4. Subscription

Fields:

* `subscriber` (address)
* `mode` (enum): `PUSH`, `PULL`, `PUSH_WITH_PULL_FALLBACK`
* `filter` (JSON Filter DSL)
* `created_at_sequence` (uint64)
* `account_key_id` (optional uint64): account key used as the CBSS re-encryption recipient. REQUIRED for `SUBSCRIBER_PAID` mode at `acquire_epoch_access` time.
* `status` (enum): `ACTIVE`, `CANCELLED`

Rules:

* `subscribe` MUST validate filter schema before activation
* New subscription MUST fail with `SUBSCRIBER_CAP_REACHED` when full
* In `PRIVATE_ALLOWLIST`, non-allowlisted addresses MUST fail with `SUBSCRIPTION_NOT_ALLOWED`
* Subscription controls **delivery** (push/pull routing). In `SUBSCRIBER_PAID` mode, decryption access is controlled separately by `StreamAccess` records on the Stream Key Manager.
* `account_key_id` SHOULD be set at subscribe time for `SUBSCRIBER_PAID` streams; it specifies the X25519 recipient for CBSS-delivered per-epoch content keys. If unset at subscribe time, the subscriber MUST provide it at `acquire_epoch_access`. Subscription itself never triggers billing or CBSS activity.
* In `OPEN` mode, no payment or access record is required for subscription.
* If `start_cursor` is provided and is strictly less than `floor_sequence - 1` at subscribe time, the call MUST fail with `CURSOR_TOO_OLD`. Subscribers MAY retry with `start_cursor` omitted to attach at `head_sequence`.
* `status` transitions: a subscription is created `ACTIVE`. `unsubscribe` sets it to `CANCELLED`. `CANCELLED` is terminal; reactivation requires a new `subscribe` call.

### 5. StreamAccess (paid mode)

Tracks key-access rights per `(stream_id, beneficiary_account)`. Distinct from CIP-2 runner entitlements (see [`0x07 EntitlementRegistry`](#stream-key-manager-system-actor)).

Fields:

* `stream_id` (bytes32/string)
* `beneficiary_account` (address)
* `active_until_key_epoch` (uint64)

Semantics:

* Access is rolling and account-scoped
* Account has access for all key epochs `<= active_until_key_epoch`
* Purchasing access to epoch `T` when current access is `E` charges for epochs `E+1..T` inclusive
* Repeated calls for already-covered epochs are idempotent and free
* Past covered epochs remain accessible indefinitely within the window
* Access is per `(stream_id, beneficiary_account)` pair

### 6. AccountKeyRegistration (paid mode)

Fields:

* `account` (address)
* `account_key_id` (uint64): auto-incrementing per account
* `scheme` (string): `"X25519"` (v1 REQUIRED)
* `public_key` (bytes): X25519 public key — the recipient identity for CBSS-delivered per-epoch content keys
* `status` (enum): `ACTIVE` | `REVOKED`
* `registered_at` (uint64): block height

Rules:

* Keys are owned by the **account**, not by any actor
* An account MAY register up to `MAX_ACCOUNT_KEYS_PER_ACCOUNT` keys
* `account_key_id` is assigned sequentially starting at `1`
* Revoked keys MUST NOT be used as the recipient for new SealRequests
* In-flight SealRequests against a revoked key continue per CBSS rules (committee delivers; subscriber discards); future `acquire_epoch_access` calls referencing a revoked key MUST fail with `KEY_REVOKED`
* Key registration MUST be signed by the account holder
* The corresponding X25519 private key MUST be held off-chain (wallet keystore, runner sandbox, or operator process). It is never stored on-chain in any form.

### 7. KeyAccessReceipt (paid mode)

Synchronous billing receipt returned by `acquire_epoch_access`. Does **not** carry decryption material — the per-epoch wrapped content keys arrive asynchronously via the standard CBSS partial-delivery flow, one delivery per requested epoch.

Fields:

* `stream_id` (bytes32)
* `beneficiary_account` (address)
* `payer_account` (address)
* `from_key_epoch` (uint64): first newly-charged epoch (E+1)
* `to_key_epoch` (uint64): last charged epoch (T)
* `epochs_charged` (uint64): T - E (may be 0 on billing-idempotent calls)
* `publisher_amount_cby` (uint64)
* `protocol_fee_cby` (uint64)
* `total_amount_cby` (uint64)
* `seal_requests_minted` (`Vec<{epoch: uint64, seal_request_id: bytes32, generation: uint64}>`): one entry per SealRequest minted by SKM for this call. Empty when all requested epochs already have valid in-flight or delivered SealRequests for `(beneficiary, account_key_id)`. Subscribers track these IDs to correlate inbound committee deliveries.

### 8. IngestionConfig (optional)

Fields:

* `enabled` (bool)
* `interval_blocks` (uint32): default `1`
* `task_definition` (object): CIP-2 task request
* `result_schema` (object)
* `transform_method` (optional string)
* `num_runners` (uint8)
* `proof_type` (enum from CIP-2)

Rules:

* When enabled, actor MUST schedule recurring timer callbacks
* Actor MUST submit CIP-2 tasks using configured `num_runners` and `proof_type`
* Ingestion failures MUST NOT increment sequence

## Canonical Hashing and Signing (Normative)

This CIP fixes signature and encoding rules now.

* `signature_scheme`: ed25519
* `hash_alg`: SHA-256
* `canonical_encoding`: Deterministic CBOR (RFC 8949 canonical form)

Signing payload object keys and values:

* `stream_id`
* `version`
* `sequence`
* `timestamp_unix_ms`
* `kind`
* `content_type`
* `tags`
* `payload_format`
* `payload_hash`
* `key_epoch` (or `null` when `payload_format == PLAINTEXT`)
* `signing_key_id`

Procedure:

1. Compute `payload_hash = SHA-256(payload_inline)`
2. Build the signing payload object with exactly the keys above
3. Encode using deterministic CBOR
4. Sign bytes with ed25519 private key
5. Store signature in `publisher_sig`

Verification:

* Consumer MUST recompute `payload_hash`
* Consumer MUST rebuild deterministic-CBOR signing payload
* Consumer MUST verify `publisher_sig` against key schedule entry effective at that sequence
* Consumers MAY resolve key schedule via `get_key_at_sequence` or cached `get_key_history` output

Tag canonicalization for signing payload:

* `tags` keys MUST be CBOR text strings.
* String tag values MUST be CBOR text strings.
* Boolean tag values MUST be CBOR `true`/`false`.
* Numeric tag values MUST be finite IEEE-754 binary64 and encoded as CBOR float64 (major type 7, additional info 27).
* Producers MUST NOT encode numeric tags as CBOR integers in signing payload bytes.

Ciphertext nonce format:

* For `XCHACHA20_POLY1305`, nonce MUST be exactly 24 bytes.
* Nonce MUST be carried inline as the first 24 bytes of `payload_inline`.
* Nonce generation is performed off-chain by the publisher SDK using a cryptographically secure RNG. The 192-bit nonce space is large enough that random sampling is statistically nonce-collision-free for any practical publish volume.
* Nonce reuse with the same `content_key` is forbidden. Publishers MUST NOT cache or reuse nonces across messages.

AEAD AAD construction:

For `SUBSCRIBER_PAID` mode, the AEAD AAD is **mandatory** and MUST be the canonical-CBOR encoding (using [`cowboy_sdk.codec.encode`](https://github.com/cowboyinc/node/blob/main/pvm/Lib/cowboy_sdk/codec.py)) of:

```
{
  "stream_id":     <bytes>,
  "sequence":      <u64>,
  "key_epoch":     <u64>,
  "kind":          <text>,
  "content_type":  <text>,
}
```

Subscribers MUST recompute this AAD from the `StreamMessage` envelope before decryption; an AAD mismatch raises `DECRYPTION_FAILED` client-side. This binds ciphertext to its envelope and prevents reorder/replay attacks within a stream.

## Actor Interface

### Required Methods

#### `init(stream_id, initial_publisher_key, access_mode, paid_stream_config?, ring_buffer_capacity?, max_subscribers?, subscription_policy?, ingestion?)`

Constructor. Establishes initial state for a fresh StreamActor.

Behavior:

1. Set `owner = sender` (deploying account).
2. Validate `initial_publisher_key` is a 32-byte ed25519 public key.
3. Set `head_sequence = 0`, `floor_sequence = 1`.
4. Set `current_signing_key_id = 1`, append key-schedule entry `{signing_key_id: 1, publisher_key: initial_publisher_key, effective_sequence: 1}`.
5. Apply `access_mode` rules from [`StreamConfig`](#1-streamconfig); if `SUBSCRIBER_PAID`, `paid_stream_config` MUST be present and pass `PaidStreamConfig` validation.
6. Apply defaults for unprovided fields per Protocol Constants.
7. Emit `StreamInitialized`.

Rules:

* `init` MUST be called exactly once per StreamActor; subsequent calls MUST fail with `ALREADY_INITIALIZED`.
* `init` MUST NOT publish any message; first published sequence is `1` via subsequent `publish` calls.

#### `publish(kind, content_type?, tags, payload_format, payload_inline, key_epoch?, publisher_sig)`

Behavior:

1. Validate payload size ≤ 16 KiB
2. If `content_type` omitted, set `content_type = application/json`
3. Compute `payload_hash = SHA-256(payload_inline)`
4. Validate `payload_format` and `key_epoch` consistency
5. If `SUBSCRIBER_PAID` mode, require `payload_format == CIPHERTEXT`
6. Validate signature against active publisher key using signing payload that includes computed `payload_hash`
7. Set `next_sequence = head_sequence + 1`
8. Persist message at `next_sequence` with `version=1`, `content_type`, and `signing_key_id`
9. Update `head_sequence = next_sequence`
10. Prune ring buffer deterministically (see pruning section)
11. Emit `StreamMessagePublished`
12. Enqueue message for push delivery

Paid mode publish flow (publisher SDK, off-chain):

1. Publisher composes plaintext payload, metadata, tags, and kind.
2. Publisher computes `key_epoch = floor(block_height / key_epoch_blocks)`.
3. Publisher derives `content_key = HKDF(stream_secret, "cip-7-content-key-v1", chain_id || stream_id || u64_be(key_epoch))` locally, holding `stream_secret` in its off-chain keystore.
4. Publisher generates a fresh random 24-byte nonce.
5. Publisher builds canonical-CBOR AAD per [Canonical Hashing and Signing](#canonical-hashing-and-signing-normative).
6. Publisher encrypts: `ciphertext_envelope = nonce || XChaCha20-Poly1305-Encrypt(content_key, nonce, aad, plaintext) || tag`.
7. Publisher signs the StreamMessage and submits via `publish(...)`.

The reference helpers are `Stream.derive_content_key(...)` + `Stream.encrypt(content_key, plaintext, aad)`.

The publisher's `stream_secret` is **only** the HKDF input; it is never IBE-wrapped, never registered on-chain, never delivered to subscribers. Subscribers receive per-epoch `content_key` material via CBSS delivery and cannot derive other epochs.

#### `subscribe(mode, filter, start_cursor?, account_key_id?)`

Behavior:

* Validate `mode` and filter schema
* Enforce subscription policy and subscriber cap
* Create or update subscription
* If `start_cursor` omitted, set to current `head_sequence`
* Emit `SubscriberUpdated`

Re-subscribe / update semantics:

* `subscribe` is an upsert keyed by `subscriber`.
* On update, subscriber MAY change `mode`, `filter`, and `account_key_id`.
* `created_at_sequence` MUST remain unchanged on update.
* `start_cursor` applies only on create; on update it is ignored.

Note: In `SUBSCRIBER_PAID` mode, subscription controls delivery routing only. Billing occurs on key-access acquisition (via `acquire_epoch_access`), not on subscription creation or renewal. The `account_key_id` parameter records which X25519 key the subscriber wants CBSS to use as the recipient identity for delivered per-epoch content keys — it must reference an `ACTIVE` `AccountKeyRegistration` owned by the subscriber, but does not trigger payment.

#### `unsubscribe()`

Behavior:

* Set status to `CANCELLED`
* Emit `SubscriberUpdated`

#### `get_since(cursor, limit)`

Inputs:

* `cursor`: last consumed sequence
* `limit`: `1..500`

Behavior:

* Reject invalid limit with `LIMIT_EXCEEDED`
* If `cursor < floor_sequence - 1`, return `CURSOR_TOO_OLD`
* Return messages where `sequence > cursor`, ascending
* Return at most `limit`

Paid mode pull rule:

* `get_since` returns ciphertext messages regardless of access status.
* Stream access gates decryption, not ciphertext retrieval.

#### `get_head()`

Returns:

* `head_sequence`
* `floor_sequence`
* stream metadata required for consumers

#### `rotate_publisher_key(new_key)`

Behavior:

* Owner-only
* Allocate `new_signing_key_id = current_signing_key_id + 1`
* Set `effective_sequence = head_sequence + 1`
* New key is active from `effective_sequence` onward
* Append key schedule entry `{signing_key_id, pubkey, effective_sequence}`
* Emit `PublisherKeyRotated(old_key, new_key, old_signing_key_id, new_signing_key_id, effective_sequence)`

#### `get_key_at_sequence(sequence)`

Returns key schedule resolution for verification:

* `signing_key_id`
* `publisher_key`
* `effective_sequence`

#### `get_key_history(from_signing_key_id?, limit?)`

Returns ordered key schedule entries for batch verification use.

#### `set_subscription_policy(policy)`

Behavior:

* Owner-only
* Set `PUBLIC` or `PRIVATE_ALLOWLIST`

#### `allowlist_add(address)` / `allowlist_remove(address)`

Behavior:

* Owner-only
* Manage allowlist used when policy is `PRIVATE_ALLOWLIST`

### Platform Key Management Methods

Two methods are exposed as VM host functions backed by the Stream Key Manager. The remaining SKM operations (`register_content_keys`, `revoke_account_key`) are invoked via standard actor message calls to `0x0D`.

#### `acquire_epoch_access(stream_id, beneficiary_account, payer_account, target_key_epoch, account_key_id) -> KeyAccessReceipt`

Caller: Any actor (on behalf of `payer_account`). The transaction MUST be signed by `payer_account` or the payer must have delegated spending authority.

Synchronous behavior (settles in the calling block):

1. Validate `access_mode == SUBSCRIBER_PAID` for this stream.
2. Resolve `AccountKeyRegistration(beneficiary_account, account_key_id)`; require `status == ACTIVE`. Fail with `KEY_REVOKED` (revoked) or `INVALID_ACCOUNT_KEY` (missing).
3. Resolve current access: `E = active_until_key_epoch` for `(stream_id, beneficiary_account)`. If no access record exists, `E = current_key_epoch - 1` (no free retroactive access).
4. `target_key_epoch` MUST be >= `current_key_epoch`; else fail with `INVALID_TARGET_KEY_EPOCH`.
5. `target_key_epoch - current_key_epoch + 1` MUST be ≤ `MAX_EPOCHS_PER_ACQUIRE` (256); else fail with `ACQUIRE_RANGE_TOO_LARGE { requested: u64, max: u64 }`. Subscribers wanting more epochs must call `acquire_epoch_access` multiple times.
6. **Compute the full mint set first.** Collect `epochs_needing_delivery: Vec<u64>` = every epoch in `current_key_epoch .. target_key_epoch` for which delivery is needed under the current (beneficiary, account\_key\_id) pair. An epoch needs delivery iff it falls into ONE of:
   * newly billed (`current_key_epoch ≤ e ≤ target_key_epoch` AND `e > E`), OR
   * already billed (`e ≤ E`) but no `SealRequestStatus` exists at `0xD || 0x05 || keccak(stream_id) || keccak(beneficiary_account) || u64_be(account_key_id) || u64_be(e)`, OR
   * already billed AND status `EXPIRED` (i.e., `request_block + REQUEST_FRESHNESS_BLOCKS < current_block`), OR
   * already billed AND the stored `generation` \< the current `WrappedContentKey.generation` for that epoch.
7. **Pre-billing validation** (covers every epoch that will mint or re-mint): for every epoch `e` in `epochs_needing_delivery`, verify a `WrappedContentKey` exists at `0xD || 0x03 || keccak(stream_id) || u64_be(e)` AND its `committee_epoch` matches the current CBSS committee epoch (or registered override). Collect missing epochs; if non-empty, fail with `EPOCHS_NOT_WRAPPED { missing_epochs: Vec<u64> }`. If any present row has a stale committee epoch, fail with `COMMITTEE_EPOCH_MISMATCH { epoch: u64, found: u64, current: u64 }`. No CBY moves and no SealRequest mints until this check passes for all epochs that will mint (newly-billed AND already-billed-but-needing-re-mint).
8. **Billing path:**
   * Compute `epochs_charged = max(0, target_key_epoch - E)`.
   * If `0 < epochs_charged < min_purchase_epochs`, fail with `MIN_PURCHASE_NOT_MET`.
   * If `epochs_charged == 0`, set `publisher_amount = protocol_fee = total = 0` (billing-idempotent path). Do NOT enforce min-purchase.
   * Else compute:
     ```
     publisher_amount = epochs_charged * fee_per_key_epoch_cby
     protocol_fee     = floor(publisher_amount * protocol_fee_bps / 10_000)
     total            = publisher_amount + protocol_fee
     ```
   * If `total > 0`: transfer `total` CBY from `payer_account` (fail with `INSUFFICIENT_CBY_BALANCE` if insufficient); credit `publisher_amount` to `publisher_treasury` and `protocol_fee` to system Treasury (`0x08`).
   * Set `active_until_key_epoch = max(E, target_key_epoch)`.
9. **Delivery path (independent of billing):** for every epoch `e` in `epochs_needing_delivery`:
   * Mint a fresh CBSS `SealRequest` with `identity_bytes = content_key_identity_bytes(chain_id, stream_id, e, generation)` and `recipient = AccountKeyRegistration.public_key`. (See [Content-key Identity](#content-key-identity) for full hash-to-curve derivation.)
   * Persist as `SealRequestStatus` at `0xD || 0x05 || keccak(stream_id) || keccak(beneficiary_account) || u64_be(account_key_id) || u64_be(e)`, replacing any prior row. The row's `account_key_id` is implicit in the storage key, so different `account_key_id` values for the same `(beneficiary, epoch)` never overwrite each other.
   * Append `{ epoch: e, seal_request_id, generation }` to `seal_requests_minted` in the receipt.
10. Emit `EpochAccessPurchased` (if `epochs_charged > 0`) or `EpochAccessIdempotent` (if `epochs_charged == 0`). Emit one `SealRequestEmitted` per entry in `seal_requests_minted`.
11. Return `KeyAccessReceipt`.

This separation means a subscriber whose prior SealRequest expired (committee was down) can re-call `acquire_epoch_access` with the same `target_key_epoch`: step 8 sees `epochs_charged = 0` and skips billing; step 9 sees the expired row and mints a fresh SealRequest. The receipt's `seal_requests_minted` lists the new request ID(s); `epochs_charged` is `0`.

Crucially, step 7's pre-validation covers BOTH newly-billed and already-billed-but-re-minting epochs. After a committee rotation, a re-mint for a paid epoch whose `WrappedContentKey.committee_epoch` is stale will fail with `COMMITTEE_EPOCH_MISMATCH` before any SealRequest is created — forcing the publisher to `register_content_keys` (re-wrap under the new committee, bumping `generation`) before subscribers can re-deliver.

Asynchronous behavior (committee-driven, per epoch, follows CBSS partial-delivery flow):

* Committee proxies sign partials on each SealRequest's identity, deliver via the standard CBSS flow (HPKE to subscriber recipient + on-chain accountability receipts).
* Subscriber aggregates t-of-n partials off-chain per epoch and decrypts the corresponding `WrappedContentKey` to obtain `content_key_e`.
* `content_key_e` decrypts only that epoch's ciphertexts; the subscriber cannot derive other epochs' keys.
* SKM updates `SealRequestStatus` to `DELIVERED` on the corresponding `SealDelivered` event, or to `EXPIRED` after `REQUEST_FRESHNESS_BLOCKS`. SKM emits `SealRequestExpired` on transition to expired.

Gas cost (synchronous portion):

* Cycles: `5,000` fixed + `1,000 * epochs_with_new_seal_request` (one SealRequest mint per requested epoch that needs one) + CBSS SealRequest mint costs (charged separately per CBSS gas table)
* Cells: `500 + 200 * epochs_with_new_seal_request`

Rules:

* `payer_account` MAY differ from `beneficiary_account` (sponsorship). Stream access accrues to `beneficiary_account`; the payer never receives delivered key material.
* **Billing idempotency** (charged amount) and **delivery idempotency** (SealRequest minting) are independent. A repeat call for already-covered epochs charges `0` CBY but MAY still mint fresh SealRequests if prior delivery has expired or has a stale generation. This prevents the "stuck subscriber after committee failure" trap.
* A subscriber buying epochs E+1..T receives `epochs_charged = T - E` and up to `T - max(E, current_key_epoch)` SealRequests (one per epoch needing fresh material).
* This method is the **canonical and only billing point** for paid streams.

#### `register_account_key(account, scheme, public_key) -> AccountKeyRegistration`

Caller: Account holder.

Behavior:

1. Verify transaction is signed by `account`.
2. Validate `scheme` is `"X25519"` (v1).
3. Validate `public_key` length (32 bytes for X25519).
4. Check account has fewer than `MAX_ACCOUNT_KEYS_PER_ACCOUNT` active keys.
5. Assign `account_key_id = next_id` for this account (auto-increment).
6. Store `AccountKeyRegistration`.
7. Emit `AccountKeyRegistered`.
8. Return registration.

Gas cost:

* Cycles: `2,000`
* Cells: `200`

#### `register_content_keys(stream_id, entries, committee_epoch, committee_override_hash?)`

Caller: Stream owner.

`entries: Vec<{ key_epoch: u64, wrapped_dek: WrappedDek }>` — one entry per epoch to register or re-wrap. Batch size is bounded only by tx gas; publishers typically batch 100–10,000 entries per call (e.g., one week of 10-minute epochs = 1,008 entries).

Behavior:

1. Verify caller is the stream's `owner`.
2. Validate `stream_id` corresponds to an existing `SUBSCRIBER_PAID` stream.
3. Validate `committee_epoch` matches the current CBSS committee epoch (or the optional `committee_override_hash` committee's epoch). Else fail with `COMMITTEE_EPOCH_MISMATCH`.
4. For each entry:
   * Validate `wrapped_dek` shape (`{version(== 2), ciphertext, nonce(12), ephemeral_u(96), aad}`); reject any other `version` tag value, reject empty `ciphertext`, and reject `ephemeral_u` unless it is the canonical compressed encoding of a non-identity G2 point.
   * Read current generation at `0xD || 0x06 || keccak(stream_id) || u64_be(key_epoch)` (default `0` if missing); set `new_generation = current + 1`.
   * Compute the expected AEAD AAD `expected_aad = base_wrapped_dek_aad(chain_id, stream_id, key_epoch, new_generation, committee_epoch, committee_selector, release_key_material_hash) || entry.wrapped_dek.ephemeral_u` per [Content-key Identity](#content-key-identity-normative), where `release_key_material_hash` is **re-derived by the SKM from committed release-key state** for the resolved committee (default or override), **not** taken from the submitter. Verify `entry.wrapped_dek.aad == expected_aad` by **full equality — exact length included**; mismatch → `INVALID_WRAPPED_DEK_AAD { epoch }`. This binds the wrapped ciphertext to the exact (stream, epoch, generation, committee) tuple, the committee's exact key material (`mpk_g2` + committee/threshold/vss via `release_key_material_hash`), and the per-wrap encryption ephemeral — preventing cross-epoch / cross-stream wrap-swap, **key substitution** (a content key sealed under a substituted `mpk'` fails this check; COW-2924), and post-publish `ephemeral_u` tampering.
   * Persist `WrappedContentKey` at `0xD || 0x03 || keccak(stream_id) || u64_be(key_epoch)`. Replaces any prior row for the same epoch.
   * Write `new_generation` at `0xD || 0x06 || keccak(stream_id) || u64_be(key_epoch)`.
   * Emit `WrappedContentKeyRegistered { stream_id, key_epoch, generation: new_generation, committee_epoch }`.

Rules:

* The publisher derives `content_key_e = HKDF(stream_secret, "cip-7-content-key-v1", chain_id || stream_id || u64_be(key_epoch))` off-chain and IBE-encrypts `content_key_e` to the committee using `cbss-client::wrap_cip7_content_key` (a new helper analogous to `wrap_cip9_volume_dek`).
* On committee rotation, the publisher SHOULD re-register all upcoming-or-active epoch keys bound to the new committee. Subscribers with delivered partials for the prior committee may still decrypt their already-delivered material; but **future** `acquire_epoch_access` for those epochs will require the publisher to have re-wrapped under the new committee.
* Re-registering an existing epoch (publisher mistake, key compromise, voluntary rotation) bumps `generation`, which invalidates threshold signatures gathered against the prior identity. **Subscribers who already aggregated a threshold signature for an old generation still hold the prior `content_key_e` they decrypted** — re-wrap does not erase past material, only changes what new SealRequests can decrypt.
* v1 does NOT enforce a minimum pre-wrap inventory. Publishers SHOULD maintain a buffer per [SDK and Off-Chain Components](#sdk-and-off-chain-components).

Gas cost:

* Cycles: `2,000` fixed + `1,000 * entries.len()`
* Cells: size of serialized `WrappedContentKey` rows × `entries.len()`

#### `revoke_account_key(account, account_key_id)`

Caller: Account holder.

Behavior:

1. Verify transaction is signed by `account`.
2. Set `AccountKeyRegistration.status = REVOKED`.
3. Emit `AccountKeyRevoked`.

Gas cost:

* Cycles: `500`
* Cells: `50`

## Push Delivery Semantics

* Push is best-effort multicast
* Delivery is at-least-once
* No protocol-level ack or retry
* Push message includes full `StreamMessage`, including full inline payload
* Consumers MUST deduplicate by `(stream_id, sequence)`

Paid mode delivery rule:

* Push delivery is a transport concern and MAY deliver ciphertext regardless of key access.
* Decryption is entirely off-chain; the on-chain `StreamAccess` record (plus per-epoch CBSS committee delivery) gates whether a subscriber can recover the per-epoch content key needed to decrypt that epoch's payloads.

### Push Work Bounds and Economics

* Stream actor execution pays push fan-out costs
* Owner is responsible for funding actor operations
* Actor MUST enforce:
  * `max_push_deliveries_per_block`
  * `max_push_cycles_per_block`
* Undelivered subscribers remain pending for subsequent blocks
* Push delivery MUST never exceed configured per-block bounds

## `PUSH_WITH_PULL_FALLBACK` Semantics

`PUSH_WITH_PULL_FALLBACK` means:

* Actor behavior is same as `PUSH` (best-effort, no retries)
* Subscriber behavior additionally includes periodic pull catch-up using `get_since`
* Fallback is subscriber-driven, not actor-driven
* No automatic mode switching is performed by actor

## Pull Delivery Semantics

* Pull consumers track local cursor
* Consumers call `get_since(cursor, limit)`
* Stale cursors are explicit via `CURSOR_TOO_OLD`

Paid mode pull rule (restated):

* `get_since` returns ciphertext messages regardless of access status.
* Stream access gates key issuance/decryption, not ciphertext retrieval.

## Payments and Key Management (Normative)

This section defines how subscriber-paid monetization works with CBSS threshold-PRE custody and native `CBY` billing.

### On-chain stream access

* In `SUBSCRIBER_PAID` mode, stream access is tracked per `(stream_id, beneficiary_account)` via `active_until_key_epoch` on the Stream Key Manager.
* Key epoch is computed as `floor(block_height / paid_stream_config.key_epoch_blocks)`.
* Access window is rolling: check for epoch `k` succeeds iff `k ≤ active_until_key_epoch`.

### Payload confidentiality

* Stream publishes inline `CIPHERTEXT` payloads in `SUBSCRIBER_PAID` mode.
* Ciphertext is globally readable on-chain. Plaintext recovery requires the per-epoch `content_key_e`, which is IBE-encrypted to the CBSS committee MPK and recoverable only via a threshold of committee partial signatures (gated by the per-epoch SealRequest minted at `acquire_epoch_access` time).
* Validators cannot decrypt content unilaterally; they would need to collude as ≥ t-of-n CBSS committee members.
* **A subscriber's purchased epochs are cryptographically isolated.** The committee threshold-signs only the specific `(stream_id, epoch, generation)` identities for which a SealRequest was minted. Without a fresh SealRequest, no σ exists; without σ, the wrapped content key for that epoch cannot be decrypted; without that key, the payload's AEAD ciphertext is unintelligible.

### Per-epoch wrapped content key lifecycle

* Publisher generates `stream_secret` (32 bytes random) off-chain at stream deploy time. **`stream_secret` never leaves the publisher's off-chain keystore.**
* For each upcoming `key_epoch`, publisher derives `content_key_e = HKDF(stream_secret, "cip-7-content-key-v1", chain_id || stream_id || u64_be(key_epoch))` off-chain.
* Publisher IBE-encrypts `content_key_e` to the committee under identity `content_key_identity(chain_id, stream_id, key_epoch, generation)` and submits via `register_content_keys` (batched). One on-chain row per epoch.
* `StreamMessage.key_epoch` selects which `content_key_e` to use when decrypting `payload_inline`.
* **Forward + backward isolation.** Even a subscriber who decrypts `content_key_e` cannot derive `content_key_{e'}` for any other epoch `e'`, because the underlying `stream_secret` is the publisher's secret and never delivered. Each epoch's confidentiality is independent.
* **Re-wrap semantics.** Re-registering an epoch bumps its `generation` counter; the IBE identity changes; threshold signatures gathered against the prior identity stop working for the new wrapped key. Subscribers who already aggregated and unwrapped under the old generation retain their cleartext `content_key_e` — re-wrap does not retroactively revoke past unwraps, only invalidates pending/future SealRequests for that epoch.

### Key distribution flow

This is a normative summary of [`acquire_epoch_access`](#acquire_epoch_accessstream_id-beneficiary_account-payer_account-target_key_epoch-account_key_id---keyaccessreceipt). The detailed method-section is authoritative; implementers SHOULD work from it directly.

1. Publisher pre-wraps content keys for upcoming epochs via `register_content_keys` (out-of-band, ahead of subscriber demand).
2. Caller invokes `acquire_epoch_access(stream_id, beneficiary, payer, target_epoch, account_key_id)`.
3. SKM resolves subscriber's `AccountKeyRegistration` (fail `KEY_REVOKED` / `INVALID_ACCOUNT_KEY` if revoked or missing).
4. SKM bounds the request: `target_epoch - current_key_epoch + 1` MUST be ≤ `MAX_EPOCHS_PER_ACQUIRE`, else `ACQUIRE_RANGE_TOO_LARGE`.
5. SKM computes `epochs_needing_delivery` — the union of (a) newly-billed epochs `E+1..target_epoch` and (b) already-billed epochs whose `SealRequestStatus` at `0xD || 0x05 || keccak(stream_id) || keccak(beneficiary) || u64_be(account_key_id) || u64_be(epoch)` is missing, EXPIRED, or bound to a stale `generation`.
6. SKM pre-validates the **entire** `epochs_needing_delivery` set: every epoch MUST have a `WrappedContentKey` row whose `committee_epoch` matches the current CBSS committee. Failures yield `EPOCHS_NOT_WRAPPED { missing_epochs }` or `COMMITTEE_EPOCH_MISMATCH { epoch, found, current }`. No CBY moves and no SealRequests are minted until this passes for every epoch that would mint.
7. **Billing path:** `epochs_charged = max(0, target_epoch - E)`. If `> 0` and below `min_purchase_epochs`, fail. Else compute fees, transfer CBY, credit treasuries, set `active_until_key_epoch = max(E, target_epoch)`.
8. **Delivery path:** for each epoch `e` in `epochs_needing_delivery`, mint a CBSS SealRequest with `identity_point = content_key_identity_point(...)` and recipient `AccountKeyRegistration.public_key`. Persist `SealRequestStatus` at the per-(beneficiary, account\_key\_id, epoch) key — distinct `account_key_id` values for the same beneficiary/epoch never overwrite. Emit `EpochAccessPurchased` (or `EpochAccessIdempotent`) plus one `SealRequestEmitted` per mint.
9. **Asynchronous:** CBSS committee proxies sign partials per SealRequest and deliver via the standard CBSS flow. Subscriber aggregates t-of-n partials per epoch, decrypts each `WrappedContentKey` to recover `content_key_e`, and decrypts the corresponding ciphertexts locally.

### Async delivery semantics

* Typical end-to-end delivery latency: 4–5 blocks under healthy committee conditions, per SealRequest. A subscriber buying N new epochs has N parallel SealRequests in flight.
* Maximum SealRequest lifetime: `REQUEST_FRESHNESS_BLOCKS = 384` blocks. Expired requests yield no delivery; SKM emits `SealRequestExpired`; subscriber MAY re-call `acquire_epoch_access` to re-mint. **Billing idempotency** ensures the re-call charges `0` CBY; **delivery idempotency** ensures fresh SealRequests are minted for the still-needed epochs.
* Subscriber tracks delivery via `SealDelivered` events emitted by CBSS, correlated by the `seal_request_id` values in `KeyAccessReceipt.seal_requests_minted`.
* Publisher and subscriber MUST tolerate the latency window: subscribers polling `get_since` immediately after `acquire_epoch_access` will receive ciphertexts they cannot yet decrypt; decryption succeeds once committee delivery completes for the matching epoch.

### Account-scoped keys

* X25519 keys are registered per account (not per actor or per subscription).
* The account holder is responsible for delegating the corresponding X25519 private key to whatever process consumes the stream (a CLI, an off-chain worker, a CIP-2 runner, etc.). The chain does not enforce *which* process holds the key.
* Account-key revocation is immediate for future `acquire_epoch_access` calls. In-flight SealRequests (already on-chain, awaiting committee delivery) continue per CBSS rules; the subscriber MAY discard delivered material bound to a revoked key.
* One paid `acquire_epoch_access` purchase covers all consumers the account holder delegates to, since they all share the same X25519 private key off-chain.

### Sponsored purchases

* `payer_account` MAY be any funded account, including a third party
* `CBY` is debited from `payer_account`; stream access accrues to `beneficiary_account`
* The payer has no implicit access to decryption keys
* Use cases: employer sponsors employee access, DAO treasury funds member access, trial/promotional grants

### Protocol fee

* Configurable per-stream at creation within `[0, MAX_PROTOCOL_FEE_BPS]`
* Additive: total cost = publisher price + protocol fee on top
* Credited to the platform-wide system Treasury at `0x08` (CIP-2 §5.4). Publishers do not configure a per-stream protocol treasury.
* Protocol fee creates direct `CBY` demand proportional to paid stream usage across the ecosystem

### CBY value flow

```
Payer Account
    |
    |── publisher_amount ──> Publisher Treasury (stream owner revenue)
    |
    └── protocol_fee ──────> System Treasury 0x08 (CIP-2 §5.4, shared sink)
                                |
                                |── Burned (deflationary pressure)
                                |── Staking rewards
                                └── Development fund
                                    (distribution is governance-defined)
```

### Scalability guidance

* Use epoch keys (for example, 10-minute epochs at 1-second blocks = 600 blocks) rather than per-message keys
* Rolling windows and idempotent purchase semantics avoid duplicate charging
* Model supports large subscriber sets (100, 1,000, 10,000) with stable delivery and billing overhead

## Ring Buffer Pruning (Deterministic)

State:

* `head_sequence`
* `floor_sequence`
* `ring_buffer_capacity`

On every successful publish:

1. Append message at `head_sequence + 1`
2. Set `head_sequence = head_sequence + 1`
3. If `(head_sequence - floor_sequence + 1) > ring_buffer_capacity`:
   * delete message at `floor_sequence`
   * set `floor_sequence = floor_sequence + 1`

Empty-stream rule:

* When `head_sequence == 0`, stream has no retained messages.
* In that state, pruning condition MUST NOT be evaluated.
* `get_since` MUST return an empty result set for valid limits.

Stale cursor rule:

* `cursor` is stale iff `cursor < floor_sequence - 1`

## JSON Filter DSL

Filters are evaluated only on:

* `kind`
* `tags.<key>`
* `sequence`
* `timestamp_unix_ms`

Operators:

* `eq`, `ne`, `in`, `nin`, `gte`, `lte`, `exists`

Logical forms:

* `{"all": [ ... ]}` (AND)
* `{"any": [ ... ]}` (OR)
* `{"not": { ... }}`

Example:

```json theme={null}
{
  "all": [
    {"field": "kind", "op": "eq", "value": "price_batch"},
    {"field": "tags.symbol", "op": "in", "value": ["BTC", "ETH"]},
    {"field": "tags.venue", "op": "eq", "value": "coinbase"},
    {"field": "tags.confidence", "op": "gte", "value": 0.9}
  ]
}
```

Determinism constraints:

* Maximum depth: `4`
* Maximum predicates: `16`
* Unknown fields/operators MUST fail subscription validation

## Ingestion Flow (Optional)

When `ingestion.enabled == true`:

1. Timer fires every `interval_blocks` (default `1`)
2. Actor submits CIP-2 task using configured:
   * `task_definition`
   * `result_schema`
   * `num_runners`
   * `proof_type`
3. Runner callback returns result
4. Actor transforms result into publishable payloads
5. For `SUBSCRIBER_PAID` mode, publisher SDK encrypts each transformed payload off-chain using `Stream.encrypt(stream_secret, key_epoch, plaintext, aad)` before the actor invokes `publish(...)` with the ciphertext envelope
6. Actor calls `publish(...)` for each transformed payload
7. Actor reschedules next ingestion timer

Failure behavior:

* Increment `ingest_fail_count`
* Emit `IngestFailed(reason, timestamp_unix_ms)`
* Do not increment sequence on failed attempts

## High-Volume Pricing Guidance (Non-normative)

For 1-second block cadence, pricing streams SHOULD micro-batch:

* Publish one `price_batch` message per block
* Include multiple ticks in one inline payload
* Keep payload ≤ 16 KiB
* Use tags such as `symbol`, `venue`, `window_ms`, `count` for filtering

## Large-Payload Path (Non-normative, Future)

The 16 KiB inline cap is intentional for v1. For long-form payloads (news articles, encoded media, model outputs >16 KiB), a future extension may permit publishing a CBFS handle (see CIP-9) in `payload_inline` and dereferencing client-side. The signing payload would still cover only the inline bytes (i.e., the CBFS handle), keeping the canonical hashing path unchanged. v1 does not specify this and clients SHOULD NOT rely on out-of-band fetching.

## Events

### `StreamMessagePublished`

* `stream_id`
* `version`
* `sequence`
* `kind`
* `content_type`
* `payload_format`
* `payload_hash`
* `key_epoch` (nullable)
* `signing_key_id`
* `timestamp_unix_ms`

### `SubscriberUpdated`

* `stream_id`
* `subscriber`
* `mode`
* `status`

### `PublisherKeyRotated`

* `stream_id`
* `old_key`
* `new_key`
* `old_signing_key_id`
* `new_signing_key_id`
* `effective_sequence`

### `EpochAccessPurchased`

* `stream_id`
* `beneficiary_account`
* `payer_account`
* `from_key_epoch`
* `to_key_epoch`
* `epochs_charged`
* `publisher_amount_cby`
* `protocol_fee_cby`
* `total_amount_cby`

### `EpochAccessIdempotent`

* `stream_id`
* `beneficiary_account`
* `target_key_epoch`
* `active_until_key_epoch`

### `AccountKeyRegistered`

* `account`
* `account_key_id`
* `scheme`
* `block_height`

### `AccountKeyRevoked`

* `account`
* `account_key_id`
* `block_height`

### `WrappedContentKeyRegistered`

* `stream_id`
* `key_epoch`
* `generation`
* `committee_epoch`
* `wrap_block_height`
* `committee_override_hash` (nullable)

### `SealRequestEmitted`

* `stream_id`
* `beneficiary_account`
* `seal_request_id`
* `account_key_id`
* `key_epoch`
* `generation`

### `SealRequestExpired`

* `stream_id`
* `beneficiary_account`
* `seal_request_id`
* `account_key_id`
* `key_epoch`
* `generation`
* `expired_at_block`

### `StreamInitialized`

* `stream_id`
* `owner`
* `access_mode`
* `initial_publisher_key`
* `initial_signing_key_id`

### `IngestFailed`

* `stream_id`
* `timestamp_unix_ms`
* `reason`

## Error Codes

Actor method errors:

* `ALREADY_INITIALIZED`
* `INVALID_SIGNATURE`
* `PAYLOAD_TOO_LARGE`
* `INVALID_FILTER`
* `CURSOR_TOO_OLD`
* `LIMIT_EXCEEDED`
* `UNAUTHORIZED`
* `SUBSCRIBER_CAP_REACHED`
* `SUBSCRIPTION_NOT_ALLOWED`

Platform key management errors:

* `NOT_SUBSCRIBER_PAID_STREAM`
* `INSUFFICIENT_CBY_BALANCE`
* `INVALID_TARGET_KEY_EPOCH`
* `MIN_PURCHASE_NOT_MET`
* `INVALID_ACCOUNT_KEY`
* `ACCOUNT_KEY_LIMIT_REACHED`
* `KEY_REVOKED`
* `EPOCHS_NOT_WRAPPED { missing_epochs: Vec<u64> }`
* `COMMITTEE_EPOCH_MISMATCH { epoch: u64, found: u64, current: u64 }`
* `ACQUIRE_RANGE_TOO_LARGE { requested: u64, max: u64 }`
* `INVALID_WRAPPED_DEK_AAD { epoch: u64 }`

Note: `DECRYPTION_FAILED` and `STREAM_ACCESS_REQUIRED` are subscriber-side off-chain errors raised by the SDK; they are not VM error codes.

Error mapping:

* `init`: `ALREADY_INITIALIZED`
* `publish`: `INVALID_SIGNATURE`, `PAYLOAD_TOO_LARGE`, `UNAUTHORIZED`
* `subscribe`: `INVALID_FILTER`, `SUBSCRIBER_CAP_REACHED`, `SUBSCRIPTION_NOT_ALLOWED`, `CURSOR_TOO_OLD`, `INVALID_ACCOUNT_KEY` (when `account_key_id` references a missing or revoked registration in paid mode)
* `get_since`: `LIMIT_EXCEEDED`, `CURSOR_TOO_OLD`
* `rotate_publisher_key`: `UNAUTHORIZED`
* `set_subscription_policy`: `UNAUTHORIZED`
* `allowlist_add` / `allowlist_remove`: `UNAUTHORIZED`
* `acquire_epoch_access`: `NOT_SUBSCRIBER_PAID_STREAM`, `INSUFFICIENT_CBY_BALANCE`, `INVALID_TARGET_KEY_EPOCH`, `ACQUIRE_RANGE_TOO_LARGE`, `MIN_PURCHASE_NOT_MET`, `INVALID_ACCOUNT_KEY`, `KEY_REVOKED`, `EPOCHS_NOT_WRAPPED`, `COMMITTEE_EPOCH_MISMATCH`
* `register_account_key`: `INVALID_ACCOUNT_KEY`, `ACCOUNT_KEY_LIMIT_REACHED`, `UNAUTHORIZED`
* `register_content_keys`: `UNAUTHORIZED`, `NOT_SUBSCRIBER_PAID_STREAM`, `COMMITTEE_EPOCH_MISMATCH`, `INVALID_WRAPPED_DEK_AAD`
* `revoke_account_key`: `UNAUTHORIZED`, `KEY_REVOKED`

## Security Considerations

* Consumers MUST verify signature and payload hash before trusting data
* Push may duplicate deliveries; consumers MUST implement idempotent handling
* Filter validation limits reduce DoS risk from pathological expressions
* Subscriber caps and push work limits reduce fan-out abuse risk
* Deterministic key-rotation cutover avoids signer ambiguity at sequence boundaries
* Paid mode confidentiality MUST rely on CBSS threshold-PRE: per-epoch wrapped content keys are decryptable only by ≥ t-of-n committee members in aggregate. Validators alone cannot decrypt content.
* Subscribers receive only the per-epoch `content_key_e` material for which they have a delivered SealRequest. They CANNOT derive other epochs' content keys, because the publisher's `stream_secret` never leaves the publisher's keystore.
* Account-key (X25519 private key) compromise grants the attacker access to every paid epoch that account has already received delivered material for; accounts SHOULD rotate X25519 keys periodically and re-acquire access under the new key.
* Account-key revocation is immediate for future `acquire_epoch_access` calls; in-flight SealRequests already on-chain continue per CBSS rules. Re-wrapping the relevant epochs (via `register_content_keys`) to bump `generation` invalidates any pending or already-aggregated threshold signatures for the prior identity.
* Publisher `stream_secret` compromise exposes all past and future epoch keys. Publishers SHOULD treat `stream_secret` as long-lived high-value material and hold it in HSM-grade keystore; recovery requires re-keying the stream (new `stream_secret`, re-wrap all upcoming epochs with bumped generations).
* Compromise of one epoch's `content_key_e` does NOT expose other epochs (HKDF domain separation; publisher's master secret never leaves keystore).
* AEAD libraries MUST use constant-time implementations (RustCrypto `chacha20poly1305`, libsodium, or equivalent audited).
* Nonce uniqueness is achieved by the publisher SDK using a CSPRNG; the 192-bit nonce space makes collision probability negligible for practical publish volumes.
* Sponsored purchases do not leak key material to the payer (per-epoch content keys are delivered only to the beneficiary's X25519 recipient).
* Protocol treasury is the chain-wide system Treasury (`0x08`); its address is governance-controlled and compromise of any publisher treasury does not affect protocol fee collection.
* `StreamAccess` state is on-chain and verifiable; the CBSS committee enforces the off-chain key delivery contract.
* The CBSS committee is a trust assumption: a corrupt threshold of committee members could collude to decrypt every paid stream. Operators SHOULD monitor committee health and rotate via the standard CBSS RotateCommittee flow.

## Backwards Compatibility

This CIP is greenfield. Cowboy has no installed Watchtower base; the prior `stream_actor.py` and `watchtower_registry.py` reference actors at `node/cli/actors/` are pre-CIP-7 sketches and are **replaced**, not migrated.

For implementers building consumer applications:

* Ciphertext is delivered to all subscribers regardless of payment status. Decryption — not transport — is the access-control boundary.
* One `StreamAccess` record per `(stream_id, beneficiary_account)` covers all downstream consumers the account holder delegates to off-chain (the account-scoped X25519 private key gates delivery).
* Paid mode REQUIRES the CBSS committee to be live. If CBSS is not yet bootstrapped on a chain (e.g., devnets pre-DKG), only `OPEN` streams are usable; `SUBSCRIBER_PAID` `register_content_keys` calls will fail until the committee is established.
* Publishers MUST pre-wrap upcoming content keys (typically a daemon maintaining the next \~100–1,000 epochs) or subscribers will see `EPOCHS_NOT_WRAPPED` on `acquire_epoch_access`.
* Subscribers MUST be prepared for asynchronous decryption: ciphertext arrives via push or pull immediately, but decryption succeeds only after CBSS committee delivery completes per epoch (\~4–5 blocks per SealRequest, capped at `REQUEST_FRESHNESS_BLOCKS` = 384 blocks).
* An expired SealRequest does NOT force a re-payment: subscribers re-call `acquire_epoch_access`, and the billing/delivery idempotency split means CBY is charged only for genuinely new epochs while fresh SealRequests get minted for any still-needed deliveries.

## Reference Implementation Notes (Non-normative)

* SDK `StreamActor` should expose the full actor method set
* The Python SDK (`cowboy.stream`) should include the byte-layout mirrors (`content_key_identity_bytes`, `wrapped_dek_aad`) and the pure-crypto content-key AEAD helpers (`derive_content_key`, `encrypt`, `decrypt`), plus `acquire_epoch_access` (a VM host-function wrapper for convenience). The **IBE wrap/unwrap** (`wrap_cip7_content_key`, `pre_wrap_epochs`, `combine_verified_partials`, `unwrap_cip7_content_key`) is provided by Rust `cbss-client` / `ras`, **not** reimplemented in Python — the shipped Python SDK carries only the byte-layout mirrors and AEAD layer (see [SDK and Off-Chain Components](#sdk-and-off-chain-components))
* Pruning should be O(1) amortized
* Push scheduler should use deterministic round-robin over active subscribers
* `StreamAccess` lookup should be O(1) by `(stream_id, beneficiary_account)`
* Publisher and subscriber SDK helpers should reuse `cbss-client` (`wrap_cip9_volume_dek` analog, `combine_verified_partials`, `ibe_decrypt`) for all CBSS interactions; reimplementing IBE primitives is forbidden
* Canonical CBOR for params, results, and AAD MUST reuse [`cowboy_sdk.codec`](https://github.com/cowboyinc/node/blob/main/pvm/Lib/cowboy_sdk/codec.py) (Python) and the equivalent Rust helper at the host boundary; cross-language test vectors are required
* For sustained pull-only consumers, the SDK should advance cursors in chunks of up to `MAX_GET_SINCE_LIMIT` (500) per call; a fully-backed subscriber catching up from a 10,000-message buffer requires ≤ 20 calls
* Subscribers SHOULD persist `last_received_sequence` locally to resume after restart; on `CURSOR_TOO_OLD` they MAY reattach at `head_sequence` and accept the gap
* Subscribers SHOULD watch CBSS `SealDelivered` events filtered by `seal_request_id` returned in `KeyAccessReceipt`; aggregation of partials should follow the existing CIP-9 runner pattern in [`cbss/crates/cbssd/src/cip9_volume_seal.rs`](https://github.com/cowboyinc/cbss/blob/main/crates/cbssd/src/cip9_volume_seal.rs)

## Rationale

### Why CBSS-routed key custody?

Validators execute the VM deterministically and have visibility into all on-chain state. A purely "VM-managed" key model — where the platform holds a master seed used to derive content keys inside the VM — necessarily means any validator can decrypt every paid stream. CBSS threshold-PRE breaks this: per-epoch content keys are IBE-encrypted to a t-of-n committee MPK and recoverable only by aggregating committee partial signatures. No single validator (or even a sub-threshold subset) can decrypt content without colluding as a CBSS committee threshold.

### Why per-epoch wrapped content keys (and not per-stream secrets)?

The naïve design — wrap one master `stream_secret` per stream, deliver to paying subscribers, let them derive per-epoch content keys via HKDF — is broken: any subscriber who pays for a single epoch and receives `stream_secret` can derive every past and future epoch's `content_key`. The `active_until_key_epoch` access window becomes unenforceable once the secret leaves the publisher.

Per-epoch wrapped content keys fix this cryptographically: each epoch's content key is IBE-wrapped under a distinct identity, so committee threshold signatures gathered for `(stream_id, epoch_5)` only decrypt that epoch's wrapped key — they cannot be used to decrypt `(stream_id, epoch_6)`. Subscribers receive committee signatures exactly for the epochs they paid for and nothing else. The publisher's `stream_secret` stays in the publisher's keystore and is never delivered to anyone.

The cost is storage growth (\~110–150 bytes per epoch per stream — about 8 MB/year at 10-min epochs for a continuously-active stream) and an operational requirement that publishers pre-wrap upcoming epochs. Both are acceptable; both alternatives (per-stream secret with broken billing; per-acquire publisher-in-the-loop wrapping with broken scalability) are worse.

### Why include `generation` in the IBE identity?

If `content_key_identity` were stable across re-wraps of the same `(stream_id, epoch)`, a threshold signature obtained for an old wrapped key would also decrypt any future re-wrap of that epoch — defeating the purpose of re-wrapping after a key compromise or committee rotation. The `generation` counter in identity ensures that incrementing it produces a fresh identity, so old signatures stop working on new wrapped material.

### Why no `stream_encrypt` / `stream_decrypt` VM host functions?

In a CBSS-routed model, decryption requires the subscriber's X25519 private key — material that cannot live in the VM (deterministic execution would expose it to all validators). The natural place for both encryption (publisher knows the per-stream secret) and decryption (subscriber receives the committee-delivered wrapped secret) is off-chain in vetted SDK code. The trade-off — losing in-VM gas-metered crypto — is acceptable because the publisher already operates off-chain to assemble each message, and subscribers already need an off-chain consumer process to drive any non-trivial application.

### Why account-scoped, not actor-scoped keys?

An account is the natural billing entity. Actors are programs; accounts are economic agents. If account A delegates consumption to five different worker processes that all read the same price feed, they all use the account's X25519 private key off-chain and share one `StreamAccess` purchase. Actor-scoped keys would force separate purchases for what is one economic relationship.

### Why async key delivery?

CBSS is a t-of-n threshold scheme; committee proxies sign partials independently and aggregation happens off-chain. There is no synchronous "give me the key now" path that doesn't either (a) move trust onto a single party or (b) replace the threshold scheme with a TEE. The 4–5 block latency is the price of true threshold custody and matches the existing CIP-9 volume DEK delivery flow.

### Why rolling window, not static ranges?

Static ranges (`from_epoch..to_epoch`) create complexity around gaps, overlaps, and partial refunds. A rolling upper bound is simpler: one integer per entitlement, monotonically increasing, idempotent extension. It supports buy-as-you-go, sponsored top-ups, and continuous consumption without subscription lifecycle management.

### Why protocol fee on top (additive), not embedded?

An additive fee (`total = publisher_price + protocol_cut`) is transparent. Publishers set their price; the protocol adds its fee. There is no ambiguity about who gets what. An embedded fee (protocol takes X% of publisher's stated price) creates incentive misalignment where publishers inflate prices to offset the cut.

### Why separate subscription from stream access?

Subscription controls delivery routing (push/pull). `StreamAccess` controls decryption (key epochs). Separating them means a subscriber can receive ciphertext via push without paying (they just can't decrypt), and an account can pre-purchase epoch access before any actor subscribes. This is cleaner than coupling payment to subscription lifecycle, and is why this CIP intentionally **does not** define a `renew_subscription` convenience method: billing happens at `acquire_epoch_access` and nowhere else.

## Open Questions

* Should `protocol_fee_bps` be globally fixed or per-stream configurable within bounds?
* Should a future extension define bulk epoch purchase discounts?
* Distribution of the system Treasury (`0x08`) — burn vs. stake vs. fund — is set by governance (see CIP-2 §5.4); should CIP-7 protocol fees be earmarked or treated as fungible with runner-fee revenue?
* Should there be a maximum `key_epoch_blocks` to prevent publishers from setting excessively long epochs that reduce billing granularity?
* Should a future extension add optional delivery receipts without retries?
* Should a future extension define refundable prepaid balances or proration?
* Should there be a `StreamConfigUpdated` event emitted when `access_mode` or `paid_stream_config` is changed (in particular, `access_mode` mutability is not yet specified)?
* Should CBSS committee rotation auto-trigger a re-wrap notification for affected `WrappedContentKey` rows, or is publisher-driven re-registration sufficient? Auto-notification would surface stale-committee state proactively but adds SKM/CBSS coupling.
* Should the spec define a minimum pre-wrap depth that publishers MUST maintain (e.g., `K = 10` upcoming epochs) so subscribers have predictable acquire success? v1 leaves this to publisher operational discipline; a future CIP could add an on-chain SLA.
* v1 caps a single `acquire_epoch_access` at `MAX_EPOCHS_PER_ACQUIRE = 256` epochs to bound block-time cost of SealRequest fan-out. Should a future extension introduce a deferred-batch acquire that spans tx boundaries for larger purchases (e.g., a year's worth of epochs at once)?
* Should the spec define a v1.1 actor-attested decrypt path (e.g., authorized actors hold derived runner-X25519 keys via TEE attestation), restoring on-chain ActorAuthorization and finer-grained enforcement?
* Should SKM emit explicit `SealRequestExpired` events to help subscriber SDKs decide when to re-acquire, or rely on subscribers' own deadline tracking against `REQUEST_FRESHNESS_BLOCKS`?
* For sponsored purchases, should the payer optionally be notified of delivery completion (separate event vs. relying on the beneficiary)?
* The CLI spec at [`/cli-specs/cowboy-watchtower`](/cli-specs/cowboy-watchtower) currently predates this CIP and covers only `publish`/`subscribe`/`list`. It needs to be expanded to cover key rotation, account-key registration, `register_content_keys`, `acquire_epoch_access`, off-chain decrypt commands, and pull/`get_since`. Tracked separately.
* CIP-17 (stream-bridge) is referenced from `docs/docs.json` but no spec file exists. Out of scope for this CIP; flag for separate authoring.
