# page.corvus.subscribeOps

> Published by [corvus.page](https://lexicon.garden/identity/did:plc:3qc4cbzcriye72qqqodeda26)

✓ This is the authoritative definition for this NSID.

## Description

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.

## Links

- [View on Lexicon Garden](https://lexicon.garden/lexicon/did:plc:3qc4cbzcriye72qqqodeda26/page.corvus.subscribeOps)
- [Documentation](https://lexicon.garden/lexicon/did:plc:3qc4cbzcriye72qqqodeda26/page.corvus.subscribeOps/docs)
- [Examples](https://lexicon.garden/lexicon/did:plc:3qc4cbzcriye72qqqodeda26/page.corvus.subscribeOps/examples)

## Definitions

### `page.corvus.subscribeOps#op`

**Type**: `object`

Server→client. One CRDT op was just added

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `op` | `union` | Yes | the op |
| `cursor` | `integer` | Yes | server assigned sequence number for the op |
| `editor` | `string` (did) | No | The did of the editor that applied the op |
| `blockId` | `string` (at-uri) | Yes | The block id of the block that the op was applied to |

### `page.corvus.subscribeOps`

**Type**: `subscription`

```json
{
  "type": "subscription",
  "errors": [
    {
      "name": "InvalidAuth",
      "description": "Invalid auth credentials on the WebSocket upgrade."
    }
  ],
  "message": {
    "schema": {
      "refs": [
        "#op",
        "#heartbeat",
        "#error"
      ],
      "type": "union"
    }
  },
  "parameters": {
    "type": "params",
    "properties": {
      "cursor": {
        "type": "integer",
        "description": "The server-assigned sequence number to resume from."
      },
      "blockIds": {
        "type": "array",
        "items": {
          "type": "string",
          "format": "at-uri"
        },
        "description": "The 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."
      }
    }
  }
}
```

### `page.corvus.subscribeOps#error`

**Type**: `object`

Server→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.

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `code` | `string` | Yes | Stable machine-readable code. Implementations should treat unknown codes as opaque. |
| `cursor` | `integer` | Yes | The server-assigned sequence number at the time of the error. |
| `message` | `string` | No | Human-readable description; not stable across server versions. |

### `page.corvus.subscribeOps#heartbeat`

**Type**: `object`

Server→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.

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `ts` | `string` (datetime) | Yes | ISO 8601 instant the heartbeat was emitted. |
| `cursor` | `integer` | Yes | Latest seq the server has assigned. |

## Raw Schema

```json
{
  "id": "page.corvus.subscribeOps",
  "defs": {
    "op": {
      "type": "object",
      "required": [
        "cursor",
        "blockId",
        "op"
      ],
      "properties": {
        "op": {
          "refs": [
            "page.corvus.block#create",
            "page.corvus.block#insert",
            "page.corvus.block#delete",
            "page.corvus.block#set",
            "page.corvus.block#increment",
            "page.corvus.block#add",
            "page.corvus.block#remove"
          ],
          "type": "union",
          "closed": true,
          "description": "the op"
        },
        "cursor": {
          "type": "integer",
          "description": "server assigned sequence number for the op"
        },
        "editor": {
          "type": "string",
          "format": "did",
          "description": "The did of the editor that applied the op"
        },
        "blockId": {
          "type": "string",
          "format": "at-uri",
          "description": "The block id of the block that the op was applied to"
        }
      },
      "description": "Server→client. One CRDT op was just added"
    },
    "main": {
      "type": "subscription",
      "errors": [
        {
          "name": "InvalidAuth",
          "description": "Invalid auth credentials on the WebSocket upgrade."
        }
      ],
      "message": {
        "schema": {
          "refs": [
            "#op",
            "#heartbeat",
            "#error"
          ],
          "type": "union"
        }
      },
      "parameters": {
        "type": "params",
        "properties": {
          "cursor": {
            "type": "integer",
            "description": "The server-assigned sequence number to resume from."
          },
          "blockIds": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "at-uri"
            },
            "description": "The 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."
          }
        }
      }
    },
    "error": {
      "type": "object",
      "required": [
        "code",
        "cursor"
      ],
      "properties": {
        "code": {
          "type": "string",
          "description": "Stable machine-readable code. Implementations should treat unknown codes as opaque.",
          "knownValues": [
            "Forbidden",
            "Malformed"
          ]
        },
        "cursor": {
          "type": "integer",
          "description": "The server-assigned sequence number at the time of the error."
        },
        "message": {
          "type": "string",
          "description": "Human-readable description; not stable across server versions."
        }
      },
      "description": "Server→client. Non-fatal error report. The server closes the WebSocket on fatal errors instead of sending this frame.\n\nFor 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."
    },
    "heartbeat": {
      "type": "object",
      "required": [
        "cursor",
        "ts"
      ],
      "properties": {
        "ts": {
          "type": "string",
          "format": "datetime",
          "description": "ISO 8601 instant the heartbeat was emitted."
        },
        "cursor": {
          "type": "integer",
          "description": "Latest seq the server has assigned."
        }
      },
      "description": "Server→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."
    }
  },
  "$type": "com.atproto.lexicon.schema",
  "lexicon": 1,
  "description": "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.\n\n**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.\n\n**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.\n\n**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.\n\n**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.\n\n**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.\n\n**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.\n\n**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.\n\n**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."
}
```
