page.corvus.subscribeOps
Subscribe to CRDT op streams from a corvus server over a single WebSocket connection. **Connection-scoped, multi-block**: one connection authenticates one DID and may carry subscriptions to many block sets. Subscribes and unsubscribes happen mid-connection via `page.corvus.backchannelFrame#subscribe` / `#unsubscribe` — the upgrade itself takes no parameters and starts with no active subscriptions. **Subscriptions name an explicit block set.** A subscribe frame carries the `blockIds` the client wants ops for. The server does NOT descend into children, references, or any other graph relation — the client decides which blocks to watch and adds/removes from the set as it discovers (or stops caring about) new ids. Sharing a block across projects is just sharing its id across subscriptions. **Same socket, bidirectional.** Atproto's `subscription` lexicon type only describes server→client messages. Client→server frames (subscribes, op submits, cursor advances) are defined in `page.corvus.backchannelFrame` and flow over the same WebSocket. **Per-op cursor.** The server stamps every op with a monotonic `cursor` as it ingests (whether the op arrived via `#opSubmit`, `page.corvus.submitOps`, or jetstream from a member PDS). The cursor is strictly increasing per server, and is NOT op identity (use `page.corvus.core#opId` for that). Clients track the highest cursor they've seen *per block* to drive resume; the wire `cursor` parameter on `#subscribe` is the floor across all subscribed blocks. **Cursor / resume.** `#subscribe` carries an optional `cursor`; the server replays every op whose cursor is strictly greater than that value, scoped to the subscribed `blockIds`, before transitioning to live tail. Omitting `cursor` starts from live tail without replay. After reconnect, a client either re-subscribes with `cursor: <min lastCursor across blocks>` or sends a `#cursor` backchannel frame to advance. **Server role: app view + relay, not source of truth.** Edit records live in the **author's** PDS — clients write them via DPoP OAuth themselves (client-side echo, WS-first then PDS). The server ingests via two paths: live `#opSubmit` on the WS (fastest peer relay) and jetstream from each subscribed DID's PDS (backstop, catches ops the client wrote to its PDS but never sent over the socket). The server dedupes by opId across both paths and assigns a single cursor per op. The server's op log + materialized snapshot are caches over PDS-stored truth; backfill from PDSes can rebuild them. **Echo as ack.** A client's own submitted ops are echoed back as `#op` frames carrying the server-assigned `cursor` and `blockId`. Publishers recognize their own by `op.id` ending in their authed DID. There is no separate ack frame. **Per-op errors.** Rejections of a specific `#opSubmit` (`AuthorMismatch`, `Forbidden`, `MalformedSubmit`) flow back as `#error` frames carrying the offending `opId` AND `blockId`. Subscribe-level errors (`UnknownBlock`, `Forbidden`) carry `blockId` and omit `opId`. Connection-level errors omit both. **Auth.** The WebSocket upgrade carries authentication identifying one DID; that DID is the author of every `#opSubmit` on this connection and the subject of ACL resolution on each block subscribed. The auth mechanism is described separately and may evolve.
Details · subscription
| Kind | subscription |
|---|---|
| Authority | corvus.page |
| Indexed |
Subscription
subscription · WebSocketParameters
query stringblockIds
arrayThe explicit set of block ids to subscribe to. The server delivers ops only for these blocks — it does not walk children, registers, or any other ref relation. Clients that want a tree should subscribe to the root, read child ids from its data, then add those to the subscription.
cursor
integerThe server-assigned sequence number to resume from.
Messages
DAG-CBOR frames#op
objectServer→client. One CRDT op was just added
#heartbeat
objectServer→client. Liveness ping for one active subscription; carries the latest seq at the time of emission so clients can detect a stuck cursor when no ops are flowing.
#error
objectServer→client. Non-fatal error report. The server closes the WebSocket on fatal errors instead of sending this frame. For errors triggered by a specific `#opSubmit` (e.g. `AuthorMismatch`, `Forbidden`, malformed op), `project` and `opId` are both set so the client can correlate the rejection to the submitted op. For subscribe-level errors (e.g. `UnknownProject`, project-level `Forbidden`), `project` is set and `opId` is omitted. Connection-level errors omit both.
Errors
InvalidAuth
Invalid auth credentials on the WebSocket upgrade.
#error
objectServer→client. Non-fatal error report. The server closes the WebSocket on fatal errors instead of sending this frame. For errors triggered by a specific `#opSubmit` (e.g. `AuthorMismatch`, `Forbidden`, malformed op), `project` and `opId` are both set so the client can correlate the rejection to the submitted op. For subscribe-level errors (e.g. `UnknownProject`, project-level `Forbidden`), `project` is set and `opId` is omitted. Connection-level errors omit both.
Properties
#heartbeat
objectServer→client. Liveness ping for one active subscription; carries the latest seq at the time of emission so clients can detect a stuck cursor when no ops are flowing.
#op
objectServer→client. One CRDT op was just added
Properties
The block id of the block that the op was applied to
server assigned sequence number for the op
editor
string · didThe did of the editor that applied the op
Similar lexicons
semantic matchLexicons whose schemas describe something close to this one. Check them before publishing a new one that overlaps.
-
Wire format for messages a client sends server-bound over the same WebSocket opened by `page.corvus.subscribeOps`. Atproto's `subscription` lexicon type is server-push only — every server→client frame is defined under `page.corvus.subscribeOps`. The client→server side of the duplex socket is described here as a separate frame union. Clients dispatch on each message's `$type` discriminator (e.g. `page.corvus.backchannelFrame#op`). **Multi-block.** A single WebSocket connection authenticates one
-
page.corvus.publishOps
procedureHTTP alternative to streaming `page.corvus.backchannelFrame#op` frames over a `page.corvus.subscribeOps` WebSocket. Each entry is a `page.corvus.backchannelFrame#op` and is validated, ingested, and broadcast by the same code the backchannel uses, so the wire shape and semantics are identical. No response body — the server-assigned cursor is observed via `page.corvus.subscribeOps#op` echo, same as the backchannel. Ops are processed in order; the procedure stops at the first validation failure (op
-
page.corvus.getOps
queryPaginated forward read of an op log scoped to an explicit block set. Pull-based alternative to opening a `page.corvus.subscribeOps` WebSocket at `cursor: 0` — same op shape, same cursor semantics, just delivered as a query page instead of a live tail. Each entry is a `page.corvus.subscribeOps#op` so live and historical paths feed the same applier. Like `subscribeOps`, this query does NOT walk graph relations. Callers fetch ops for the blocks they care about and follow refs explicitly with subse