# page.corvus.backchannelFrame

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

✓ This is the authoritative definition for this NSID.

## Description

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 DID and carries subscriptions to a dynamic set of blocks. After upgrade the connection has no active subscriptions; clients send `#subscribe { blockId, cursor? }` frames to start receiving ops for a block and `#unsubscribe { blockId }` to stop. No descent — each frame names exactly one block id; to follow a tree, the client reads child ids out of materialized data and sends another `#subscribe`.

**Why a backchannel.** Live typing wants the lowest possible latency. Rather than POST every keystroke, ops flow client→server on the open WebSocket as `#op` frames. The server appends each submitted op to the op log, assigns a server-side `cursor`, and fans the op out to every subscribed connection (including the publisher) as a `page.corvus.subscribeOps#op` frame.

**Client-side echo.** The same client also writes each op as a `page.corvus.edit` record into its own PDS via DPoP OAuth — WS-first then PDS, so peers see ops via the relay before the author's PDS is durably updated. The server does NOT write to the author's PDS on their behalf. The server's jetstream subscription is the backstop for ops the client wrote to PDS but never sent over the socket; opId dedup makes the two ingress paths interchangeable.

**Ack via echo.** There is no explicit `#ack` frame — every submitted op is broadcast back to the same WebSocket as a `page.corvus.subscribeOps#op` frame carrying the server-assigned `cursor` and `blockId`. Publishers identify their own ops by `op.id` and treat the echo as confirmation. Same-op redeliveries (network retries, or arrival via jetstream after a WS submit) dedupe at the server's opId primary key and re-echo with the existing cursor, so the client's match-by-opId logic is naturally idempotent.

**One DID per connection.** The WebSocket upgrade carries authentication identifying a single DID. Every `#op` on this connection must carry an op whose author is that DID; mismatches are rejected with a `page.corvus.subscribeOps#error` frame carrying `code: 'AuthorMismatch'` and the offending `opId`.

**ACL.** The authed DID's effective role under `page.corvus.aclGrant` (resolved against the most-specific scope on the parent chain of every block the op touches) is enforced at `#op` submission time:
- `write` → accepted as authored.
- `suggest` → accepted; the server forces `op.suggestion = true` unless the op targets a `#comment` block.
- no effective `write`/`suggest` grant → rejected with `#error { code: 'Forbidden', opId }` on the subscription.

Project roots (named in their DID docs) are implicit `write` + `grant` at the project root scope.

## Links

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

## Definitions

### `page.corvus.backchannelFrame#op`

**Type**: `object`

Client→server. Submit one op to the op stream

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `op` | `union` | Yes |  |
| `blockId` | `string` (at-uri) | No | The block id of the block that the op was applied to |

### `page.corvus.backchannelFrame#include`

**Type**: `object`

Client→server. Set the full collaborator allowlist for one block on this connection. Each frame is a complete replacement: the `dids` array becomes the active set for the named `blockId`, dropping anything sent previously for the same block. An empty `dids` array clears the filter for that block (no allowlist; all authors pass, matching the default when no `#include` has been sent). Filters are per-block — there is no global allowlist. Sending an `#include` for a block the connection is not currently `#subscribe`d to is permitted; the filter applies as soon as a matching `#subscribe` is active.

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `dids` | `array` | Yes | Full set of DIDs whose ops should be delivered for this block on this connection. Replaces any prior set. Empty array clears the filter for this block. |
| `blockId` | `string` (at-uri) | Yes | The block id this allowlist applies to. |

### `page.corvus.backchannelFrame#subscribe`

**Type**: `object`

Client→server. Start receiving ops for one block on this connection. The server validates that the authed DID has any effective `write`/`suggest` grant under `page.corvus.aclGrant` (or is a project root); failure is signaled by a `page.corvus.subscribeOps#error` frame carrying the offending `blockId` and no `opId`.

No descent — the server does not walk child references. To watch a tree, the client reads child block ids out of materialized data and sends another `#subscribe` per id.

Duplicate `#subscribe` for an already-active block is idempotent and may be used to advance the resume cursor.

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `cursor` | `integer` | No | Resume from cursor > N for this block. The server replays every op for this `blockId` with cursor strictly greater than `N`, then transitions to live tail. Omit to start at live tail with no catch-up. |
| `blockId` | `string` (at-uri) | Yes | The block id to subscribe to. |

### `page.corvus.backchannelFrame#unsubscribe`

**Type**: `object`

Client→server. Stop receiving the op stream for one project on this connection. The server stops emitting `#op` / `#info` / `#heartbeat` frames for the project. Idempotent — unsubscribing from a project that isn't subscribed is a no-op.

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `blockId` | `string` (at-uri) | Yes | The block id of the root block that you want to stop viewing items beneath |

## Raw Schema

```json
{
  "id": "page.corvus.backchannelFrame",
  "defs": {
    "op": {
      "type": "object",
      "required": [
        "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
        },
        "blockId": {
          "type": "string",
          "format": "at-uri",
          "description": "The block id of the block that the op was applied to"
        }
      },
      "description": "Client→server. Submit one op to the op stream"
    },
    "include": {
      "type": "object",
      "required": [
        "blockId",
        "dids"
      ],
      "properties": {
        "dids": {
          "type": "array",
          "items": {
            "type": "string",
            "format": "did"
          },
          "description": "Full set of DIDs whose ops should be delivered for this block on this connection. Replaces any prior set. Empty array clears the filter for this block."
        },
        "blockId": {
          "type": "string",
          "format": "at-uri",
          "description": "The block id this allowlist applies to."
        }
      },
      "description": "Client→server. Set the full collaborator allowlist for one block on this connection. Each frame is a complete replacement: the `dids` array becomes the active set for the named `blockId`, dropping anything sent previously for the same block. An empty `dids` array clears the filter for that block (no allowlist; all authors pass, matching the default when no `#include` has been sent). Filters are per-block — there is no global allowlist. Sending an `#include` for a block the connection is not currently `#subscribe`d to is permitted; the filter applies as soon as a matching `#subscribe` is active."
    },
    "subscribe": {
      "type": "object",
      "required": [
        "blockId"
      ],
      "properties": {
        "cursor": {
          "type": "integer",
          "description": "Resume from cursor > N for this block. The server replays every op for this `blockId` with cursor strictly greater than `N`, then transitions to live tail. Omit to start at live tail with no catch-up."
        },
        "blockId": {
          "type": "string",
          "format": "at-uri",
          "description": "The block id to subscribe to."
        }
      },
      "description": "Client→server. Start receiving ops for one block on this connection. The server validates that the authed DID has any effective `write`/`suggest` grant under `page.corvus.aclGrant` (or is a project root); failure is signaled by a `page.corvus.subscribeOps#error` frame carrying the offending `blockId` and no `opId`.\n\nNo descent — the server does not walk child references. To watch a tree, the client reads child block ids out of materialized data and sends another `#subscribe` per id.\n\nDuplicate `#subscribe` for an already-active block is idempotent and may be used to advance the resume cursor."
    },
    "unsubscribe": {
      "type": "object",
      "required": [
        "blockId"
      ],
      "properties": {
        "blockId": {
          "type": "string",
          "format": "at-uri",
          "description": "The block id of the root block that you want to stop viewing items beneath"
        }
      },
      "description": "Client→server. Stop receiving the op stream for one project on this connection. The server stops emitting `#op` / `#info` / `#heartbeat` frames for the project. Idempotent — unsubscribing from a project that isn't subscribed is a no-op."
    }
  },
  "$type": "com.atproto.lexicon.schema",
  "lexicon": 1,
  "description": "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`).\n\n**Multi-block.** A single WebSocket connection authenticates one DID and carries subscriptions to a dynamic set of blocks. After upgrade the connection has no active subscriptions; clients send `#subscribe { blockId, cursor? }` frames to start receiving ops for a block and `#unsubscribe { blockId }` to stop. No descent — each frame names exactly one block id; to follow a tree, the client reads child ids out of materialized data and sends another `#subscribe`.\n\n**Why a backchannel.** Live typing wants the lowest possible latency. Rather than POST every keystroke, ops flow client→server on the open WebSocket as `#op` frames. The server appends each submitted op to the op log, assigns a server-side `cursor`, and fans the op out to every subscribed connection (including the publisher) as a `page.corvus.subscribeOps#op` frame.\n\n**Client-side echo.** The same client also writes each op as a `page.corvus.edit` record into its own PDS via DPoP OAuth — WS-first then PDS, so peers see ops via the relay before the author's PDS is durably updated. The server does NOT write to the author's PDS on their behalf. The server's jetstream subscription is the backstop for ops the client wrote to PDS but never sent over the socket; opId dedup makes the two ingress paths interchangeable.\n\n**Ack via echo.** There is no explicit `#ack` frame — every submitted op is broadcast back to the same WebSocket as a `page.corvus.subscribeOps#op` frame carrying the server-assigned `cursor` and `blockId`. Publishers identify their own ops by `op.id` and treat the echo as confirmation. Same-op redeliveries (network retries, or arrival via jetstream after a WS submit) dedupe at the server's opId primary key and re-echo with the existing cursor, so the client's match-by-opId logic is naturally idempotent.\n\n**One DID per connection.** The WebSocket upgrade carries authentication identifying a single DID. Every `#op` on this connection must carry an op whose author is that DID; mismatches are rejected with a `page.corvus.subscribeOps#error` frame carrying `code: 'AuthorMismatch'` and the offending `opId`.\n\n**ACL.** The authed DID's effective role under `page.corvus.aclGrant` (resolved against the most-specific scope on the parent chain of every block the op touches) is enforced at `#op` submission time:\n- `write` → accepted as authored.\n- `suggest` → accepted; the server forces `op.suggestion = true` unless the op targets a `#comment` block.\n- no effective `write`/`suggest` grant → rejected with `#error { code: 'Forbidden', opId }` on the subscription.\n\nProject roots (named in their DID docs) are implicit `write` + `grant` at the project root scope."
}
```
