# JSON output shapes

<!-- [doc->REQ-DOC-DELIVERY-VOCAB] the machine-consumer reference: send-outcome vocabulary (canonical home cross-linked to Messaging), the endpoint-digest --json schema, the shell relay MAC-stamped frame prefix + api poll auth, and the full --json shapes catalog (seed #3) -->

Every read/status command takes a global **`--json`** flag and prints one
pretty JSON value to **stdout** (status lines stay on stderr — see the
[`api` output discipline](../harness-contract/api.md)). This page is the
machine-consumer's reference: the send-outcome vocabulary you classify by, the
session-digest schema, the shell relay's MAC-stamped frames, and the catalog
of `--json` shapes.

> A JSON value on **stdout**, a status tag on **stderr**, the truth in the
> **exit code**. A program reads whichever it needs and never has to scrape a
> human line.

## Send outcomes

The closed set of `spt send` outcome lines — `SENT`, `SENT(WAN)`, `QUEUED`,
`QUEUED(idle-only)`, `DEFERRED`, `NO_PERCH`, and the WAN failure tags — is
documented with its exact conditions in
[Messaging → Send outcomes](../messaging/overview.md#send-outcomes--the-closed-set).
The one rule a caller must encode: **classify by exit code** (`0` = every
delivered/spooled outcome, non-zero = every failure), and treat **`QUEUED` as
success** — the message is durably spooled and drains when the target next
comes online; never retry on it.

## Session digest — `endpoint digest --json`

<!-- [doc->REQ-DIGEST-JSON-SELF-CONTAINED] the self-contained --json contract: top-level integer version cursor, stderr-clean under --json, the complete entry-kind enum with per-kind agent-produced vs spt-injected provenance, and the seq/cursor asymmetry -->

`spt endpoint digest <id> --json` prints the endpoint's activity digest as a
projection of its session logs (not a PTY scrape). The top-level object:

<!-- [doc->REQ-DIGEST-CROSS-NODE-PULL] -->
> **Reaching an endpoint on another machine.** The snapshot pull accepts a
> qualified address — `spt endpoint digest <id@node> --json`, `--last` and
> `--after` included. The endpoint's own node projects and answers, so the
> content is identical to what someone standing on that machine would see, and
> the same access rules apply as for every other qualified address. `--follow`
> is **local-only**: a delta subscription is not available across nodes, so poll
> the snapshot with `--after <seq>` instead. A qualified address that resolves
> to the machine you are already on is answered locally, not over the network.

```json
{
  "turns": [ /* Turn, oldest → newest */ ],
  "version": 42
}
```

**`version`** *(top-level integer, always present)* is the digest's
**snapshot version** — a monotonic counter that bumps each time the projected
digest *changes* (an unchanged projection does not bump it). Use it to detect
"anything new since my last pull" by comparing against the last `version` you
saw. It is **not** an entry `seq` and is **not** valid `--after` input — the
two are different number spaces (`--after` takes the entry-level `seq`
described below; a `version` passed as `--after` would predate the window and
trigger a full-window refresh every time).

`version` is **process-lifetime**: monotonic within one daemon run, held in
memory and **reset when the daemon restarts**. A version *lower* than one you
already saw therefore means the daemon restarted — treat it as changed,
re-pull the full window, and reset your comparator. Never compare with `>`
alone: a restart makes the counter go backwards, so a `>` test reports
"unchanged" forever and stalls.

**Version gating is an optimization, never a correctness boundary.** Because
the counter restarts, a fresh daemon run can land back on a value you have
already seen, and an equality test would then report "unchanged" across two
genuinely different digests — a *silent* skip, which is worse than a stall
because nothing surfaces. Build the correctness argument on content, not on
the counter:

1. **Compare the pair** `(version, highest agent-produced seq)` — computed at
   a **constant window depth** on every pull. `max seq` is taken over what
   the window returned, so comparing a pull made under `--last 1` against one
   made at the default depth manufactures a false "unchanged". Unlike
   `version`, `seq` is derived from the on-disk transcript ledger and
   **survives a daemon restart**, so a fresh run that coincidentally matches
   your last `version` will not also match your last `max seq` — unless the
   content really is identical, in which case there is nothing to skip.
2. **Know what the pair can and cannot see.** It detects only changes that
   move a **committed agent-produced `seq`**. Two whole classes of real
   change move no such number, so the pair is blind to both: entries on an
   **open turn** (they carry no `seq` until the turn closes) and
   **spt-injected entries** (`Boundary`/`Context` never carry `seq` at all —
   see the asymmetry note below, and note this holds on a *closed* turn as
   readily as an open one). A restart-collision that coincides with either
   matches on *both* halves while the content differs.
3. **Therefore also poll unconditionally on a slow cadence.** A pair is not
   an identity, and it is specifically blind to injected-only and open-turn
   changes — the unconditional pull is what covers those seams, bounding any
   residual collision to one interval instead of forever. A consumer that
   treats the pair as total and drops this pull re-opens the silent skip:
   injected entries signalling that something happened while every
   agent-produced number sits still is exactly the shape of a real fleet
   incident. **This poll is a correctness belt only — it cannot establish
   liveness.** It reads the same surface as everything else here, so it
   cannot distinguish a wedged daemon from an idle one from a torn-down
   endpoint. Liveness comes from endpoint state (`endpoint list` / `api
   endpoint-info`) — see *When the stream ends*, below, for the full case.

The `--json` object is **self-contained** — under `--json`, stderr carries
**no** status trailer, so the output parses identically whether you read
stdout alone or merge `2>&1`. (The human, non-`--json` path keeps its
`DIGEST:<id> version=N` stderr trailer instead — the version is never part of
the human stdout rendering.)

### Window and cursor flags

| Flag | Effect |
|---|---|
| `--last <N>` | Return the last `N` turns instead of the default window depth (`--last 1` is the latest turn — the turn-end view). **Snapshot only — ignored under `--follow`.** |
| *(default depth)* | Absent `--last`, the window is the last **3** user turns — but an adapter's `[digest]` config can change that default and there is no surface to query the effective value, so do not hard-code it. |

> **Programmatic consumers: use the default depth. `--last` is a human /
> one-shot flag.**
>
> A non-default `--last` is not a per-call presentation knob — it is a
> **write into shared daemon state** for that endpoint. The daemon holds one
> projected digest per endpoint, and *every* request publishes its own
> projection into it, so a `--last 1` pull collapses every concurrent
> `--follow` client's view to one turn (they receive it as a `from == 0` full
> replace) and bumps `version` for everyone — then the next default-depth
> request flips it back. `version` therefore oscillates with no content
> change at all.
>
> This is a property of the current implementation and may change; do not
> build a design that depends on the contamination either. Pinning
> programmatic consumers to the default depth also satisfies the
> constant-depth requirement of the version discipline above **by
> construction** — the two rules are one rule.
>
> **If you need a different depth, change the default rather than the call.**
> An adapter declares its endpoints' window as `window_turns` in its manifest
> `[digest]`, and that becomes the effective default every request resolves
> against — so an adapter author who needs a deeper window sets it there and
> then pulls *without* `--last`, getting the depth they need while writing
> nothing foreign into shared state. This is also the only way to deepen a
> `--follow` stream, which always uses the effective default.
>
> **Check the profile overlay, not just the base manifest.** The effective
> default can come from *two* places: `[digest]` on the base manifest, or
> `[profiles.<name>.digest]` on a profile overlay. Endpoints running under a
> profile resolve against the overlay, and the overlay **wins** (profiles are
> sparse leaf-replace). An author who sets `window_turns` on the base while
> their endpoints run under a profile that also declares it will see **no
> change at all** — and the natural next move for someone who still needs
> depth is to reach for `--last`, straight back into the operation this rule
> exists to prevent.
>
> **Determine the layer per endpoint, not per adapter.**
> `spt api endpoint-info <id>` reports the endpoint's `adapter` as
> `<adapter>[:profile]` — the suffix is the profile it resolves against, and
> its absence means base. The answer **differs between endpoints of the same
> adapter**: one endpoint may run under base while its siblings run under a
> profile. So if a profile declares `window_turns` and base does too, raising
> base fixes only the base-layer endpoints and silently misses the rest — a
> **partial** no-op, which is worse than the total one above, because partial
> success reads as "it worked" on whichever endpoint you happened to test.
>
> **Checking existing endpoints is a point-in-time audit; `window_turns` is
> durable.** Resolution happens per request against whatever layer *that*
> endpoint runs under, and endpoints created later resolve the same way — so
> auditing today's endpoints protects only today's endpoints. Bring up one
> new endpoint under a profile that declares `window_turns` and it silently
> takes that profile's depth. Rather than auditing once, make **every layer
> your endpoints can resolve against** declare the depth you need — or verify
> that none of them declares it, so base governs uniformly. Otherwise the
> endpoint that breaks you is the next one someone creates.
>
> **A consumer that is not the endpoint's adapter cannot do any of this.**
> There is no per-consumer depth today, so for them the default-depth rule is
> a hard constraint rather than a preference — no flag makes a non-default
> depth safe. The honest options are to **ask the endpoint's adapter author
> to raise `window_turns`**, or to **work within the default**. Do not try to
> accumulate turns client-side across pulls as a substitute: that is only
> sound while you poll faster than turns age out of the window, and nothing
> tells you when you have fallen behind.
| `--after <seq>` | Return only entries newer than that **entry seq** (see the asymmetry note below). A cursor that predates the retained window returns the full window plus `"after_predates_window": true`. **Snapshot only — ignored under `--follow`.** |
| `--follow` | Stream deltas instead of one snapshot — a different output shape, documented below. A follow subscription always uses the default window depth and starts from a full base, regardless of `--last`/`--after`. |

A `--after <seq>` cursor that predated the retained window adds one top-level
field, `"after_predates_window": true`, so a consumer knows it missed rows.

**`Turn`** — one user-opened turn:

| Field | Type | Notes |
|---|---|---|
| `input` | string \| null | The input that opened the turn; `null` for a preamble turn. |
| `entries` | array of entry | Agent/tool/boundary/context entries in stream order. |
| `input_seq` | number | Omitted when absent. |
| `partial` | bool | Omitted when false; `true` on the trailing turn while it is still being worked. |

**Entry** — an externally-tagged variant (the tag key names the kind). This
is the **complete** set — there are exactly four kinds, and each is either
**agent-produced** (derived from the harness transcript: the agent did this)
or **spt-injected** (spt's own bookkeeping merged into the timeline: the
agent did *not* produce it):

| Variant | Provenance | Fields |
|---|---|---|
| `Agent` | **agent-produced** — the agent's own output text | `text` (string); `seq`, `ts` optional |
| `ToolSprint` | **agent-produced** — tools the agent invoked; consecutive uses collapse into one sprint | `tools` (array of `{name, arg}`); `seq`, `ts` optional |
| `Boundary` | **spt-injected** — a context reset (`/clear`, `/compact`, boot) spliced between spanned sessions; never emitted by the agent or the adapter | `kind` ∈ `clear` \| `compact` \| `boot`; `ts` optional |
| `Context` | **spt-injected** — context spt fed *to* the agent (psyche download, owl message) or produced for it (echo commune); adapters never emit these | `kind` ∈ `psyche_download` \| `echo_commune` \| `owl_message`; `body` (string); `ts` optional |

**`seq` and the `--after` cursor are asymmetric across kinds:** only the
agent-produced kinds (`Agent`, `ToolSprint`) are transcript records and carry
a `seq` (optional — blanked on an open/partial turn). The spt-injected kinds
(`Boundary`, `Context`) are **not** transcript records and never carry `seq` —
they order by `ts` only. Consequences for a poller: cursor `--after` on the
**highest `seq` among agent-produced entries** (that is the number space
`--after` filters on), use the top-level `version` to cheaply detect that a
new pull is worth making, and never key "newest thing seen" on the presence
of injected entries — a `Boundary`/`Context` can appear in a window without
moving any `seq`.

**Open turns re-deliver, by design.** The trailing turn is marked
`"partial": true` while it is still being written, and its entries carry **no
`seq`** until they are committed. `--after` can only filter what carries a
`seq`, so a poller **re-sees the partial turn's content on every pull** until
the turn completes and its entries take seqs — at which point your cursor
advances past them. Expect it and de-duplicate on your side (or ignore
entries in a `partial` turn until it closes); the cursor is not stuck, it is
correctly refusing to skip content that has no committed position yet.
Meanwhile `version` keeps bumping, because the projection really is changing.

<!-- [doc->REQ-DIGEST-SEAL-ON-IDLE] -->
**A turn closes when the endpoint goes idle — not when the next input
arrives.** The moment the endpoint reports it stopped working, the trailing
turn is finished: `partial` drops away and its entries take their seqs, with
no further prompting needed. This matters if you scan for something in the
**latest** turn: you no longer have to wait for (or manufacture) another user
input before that turn has a stable cursor. A later input still closes the
previous turn as it always did — that path simply is not the only one any
more.

**Anchor your cursor on `input_seq`.** A turn's `input_seq` is fixed once the
turn exists and never moves — that is the number to remember. A turn still
being worked has **no `input_seq` key at all** (absent, not null), so "ignore
turns with no `input_seq`, anchor on the ones that have it" is safe by
construction: an unfinished turn cannot be mistaken for an anchored one, and
the reseal blink below cannot disturb a cursor that has already moved past the
turn. The seqs on
entries *inside* a turn may still advance **forward** while that turn is
gaining records: a reply that reaches the log late folds into the trailing
tool sprint and carries it to a higher seq. A forward move re-delivers, never
gaps — you may see a sprint a second time with more in it, but nothing you
have already read is skipped or renumbered downward. That holds on this
machine and across the network alike.

**One blink to know about.** Closing on idle is a live reading, not a latch: if
the endpoint goes busy again *before* the next input record reaches the log,
the trailing turn briefly reads as `partial` once more, then recloses with
**the same seq** as soon as the record lands. Nothing you already read moves,
and `--after` cursors are unaffected — the numbers are computed from log
position, so a turn that recloses reproduces exactly the values it had. The
blink is only visible to a poller that keys on *"the latest turn currently has
a seq"*; if that is you, treat a seq you have already seen as still valid
rather than as withdrawn. This cannot happen while the owner stays idle — it
takes a new prompt to open the window at all.

**De-duplicating repeated content.** Within a turn, entries are **append-only
in position** — never reordered or removed — and the **trailing** entry is
**replace-in-place**: a run of tool calls collapses into one `ToolSprint`
that *grows*, taking the latest record's `seq`/`ts` each time. A content hash
is specifically the wrong key, because a growing sprint hashes differently on
every tool call and reads as a brand-new entry — counting one sprint many
times over.

Which key you use depends on how you are reading:

- **Following** (`--follow`): `(turn index, entry index)` is the stable key.
  Indices are window-relative, but that is safe here because a window slide
  forces a full replace (`from == 0`), which tells you to rebuild.
- **Polling snapshots**: **do not key on turn index.** Nothing announces a
  window slide between two pulls — turns age out and every index silently
  renumbers, so index-keyed state mis-keys with no error. Key on the durable
  ids instead: a turn that has an `input_seq` is identified by it, and `seq`
  identifies an agent-produced entry.

**Two things have no durable id, in any reading mode.** Say them out loud
rather than discover them:

- **A leading preamble turn** (`input: null`) carries no `input_seq` — there
  is no input record to take one from. It is identifiable only as *the
  leading turn* of the window. This is not a corner case: a preamble turn is
  where `Boundary` entries live.
- **Injected entries** (`Boundary`, `Context`) never carry `seq` — and unlike
  open-turn entries, they never will: they are not transcript records, so
  there is nothing for them to commit to. A snapshot poller can only identify
  them by **content within their enclosing turn** (`kind` + `ts` + `body`),
  which is a weaker key than everything else on this page gets: it cannot
  distinguish two genuinely identical injections, and it breaks if any field
  is absent (`ts` is optional).

That weakness lands on exactly the class the version discipline flags as most
dangerous — injected-only change, invisible to the pair. If you must not
double-count a `Boundary` as new activity, prefer `--follow`, where position
within the delta is meaningful, over snapshot polling.

`ts` is an RFC3339-UTC ordering key.

### `--follow --json` — the delta stream

`--follow --json` does **not** stream the snapshot object above. It prints one
compact JSON object **per line**, one per change (only when the digest
actually changed — there are no heartbeats). **Only the follow stream is
line-delimited**: the snapshot path prints a single pretty-printed object
spanning many lines, so a line-oriented (NDJSON) reader built for `--follow`
will not parse a snapshot.

```json
{ "version": 12, "from": 3, "turns": [ /* Turn, from index `from` onward */ ] }
```

| Field | Type | Meaning |
|---|---|---|
| `version` | integer | The digest version *after* this change — the same counter the snapshot carries. |
| `from` | integer | The window index where `turns` begins. |
| `turns` | array of `Turn` | The changed turns, starting at `from`. |

**Applying an update:** truncate your view to `from`, then append `turns`.
`from == 0` is a **full replace** — either the first (base) update, or a
window slide that invalidated the old indices. `Turn` and its entries are the
same shapes documented above, so provenance and the `seq` rules are unchanged.
A follow stream carries no `after_predates_window` field: that signal belongs
to the `--after` snapshot path.

**Subscribing and resubscribing.** Every subscription opens with a **full
base update** (`from == 0`) carrying the current window, then streams
changes. So a consumer that lost its stream simply resubscribes and rebuilds
from that base — there is no cursor to carry across a reconnect, and no
partial-state resume to get wrong.

**When the stream ends.** The stream ends **observably**: if the daemon stops
or restarts, the connection drops and `--follow` terminates. Idle and dead
are therefore not indistinguishable in the ordinary case — silence means
idle, and a dead daemon ends the stream rather than leaving you on it. Two
consequences to encode:

- **Treat "follow exited" as "resubscribe" — with backoff.** The command
  currently exits **0** on a dropped connection, exactly as it does on a
  clean end, so the exit code is not a verdict on *why* it ended: do not
  branch on it. Because a down daemon drops the subscription immediately, a
  literal respawn loop becomes a hot loop precisely when the daemon is least
  able to absorb it — back off between attempts. A subscription that ends
  **before its base update** never established; count that as a failed
  attempt (and back off), not as a normal end.
- **A wedged-but-alive daemon still looks idle** — it holds the connection
  open and publishes nothing. That narrow case is the one where a consumer
  with a hard freshness requirement needs its own liveness timer; it is not
  the general rule. **That timer cannot be a digest pull.** The slow
  unconditional snapshot poll above is a *correctness* belt and nothing more:
  it reads this same surface, so it can no more distinguish wedged from idle
  from torn-down than the stream can. Liveness comes from endpoint state
  (`endpoint list` / `api endpoint-info`), never from digest activity.
- **A dead or torn-down *endpoint* is not an error either — and it does not
  look empty.** The digest is projected from **on-disk records**, so tearing
  an endpoint down does not empty it. A torn-down endpoint that has history
  keeps serving its **real last-known content, indefinitely**; only one with
  *no* records serves an empty digest. The selecting condition is
  **records-on-disk, not liveness.**

  So a watchdog polling a torn-down endpoint does not see silence — it sees
  plausible, real, permanently-frozen content, and `--follow` against it
  subscribes successfully, delivers that content as its base, and then
  streams nothing forever. Neither the content nor the silence tells you
  anything about whether the endpoint is alive. Establish endpoint liveness
  out-of-band (`endpoint list` / `api endpoint-info`) and never infer it from
  this surface.

> This is the digest **read** shape. It is distinct from the digest **record**
> an adapter *pushes* via `spt api digest-entry` / a `[digest]` extractor —
> that ingest contract (`role`/`text`/`tool`/`ts`) is documented in the
> [manifest digest-record reference](../harness-contract/manifest.md#session-digest--the-digest-record-contract).

## The shell relay — MAC-stamped frames

Two poll surfaces authenticate differently:

- **`spt api poll <id>`** — the agent hook-channel drain. Reads the caller's
  own perch spool; the manifest `[inject]` set must include the hook method.
  Each row prints as one whole `<EVENT …>` envelope on stdout.
- **`spt api poll <shell-id> --link <token>`** — the shell relay drain. The
  **link token is the credential**: it resolves the `(owner, shell)` pair and
  is refused (exit 1, `AUTH_REFUSED`) if no instance holds it. Rows are emitted
  **raw, one per line — deliberately not `<EVENT>`-wrapped** (the shell child
  parses its own vocabulary).

Shell-relay frames are **MAC-stamped**. The on-wire form is:

```text
<mac> <frame>
```

— a **64-hex-char HMAC-SHA256** over the frame bytes, one ASCII space, then the
raw frame. The key is `SHA-256(link_token)`; a frame with no valid MAC is
dropped, never processed. Agent-perch surfaces (`spt ready`, `api listen`,
`api poll <id>`) **never** emit stamped frames — a consumer of agent traffic
only ever sees `<EVENT>` / `<EVENT-PART>` lines.

## `--json` catalog

Commands that emit `--json`, and the top-level shape each prints. Fields marked
optional are omitted when empty.

<!-- [doc->REQ-STATUS-LIVE-SUPERVISOR-PID] -->
`node status` (also `daemon status`) obtains its service PID from the live
service reply, alongside its compiled version. JSON `pid` remains a string,
or `null` when the service is unreachable or too old to report its PID; it is
never populated from `daemon.pid`. The human view reports a differing file
record as `recorded pid <N> is stale (daemon.pid)`. With no live PID to compare,
it says `daemon.pid records <N>, not verified`; an unreachable service is
reported as `daemon: not running`, never as running from the file alone.

| Command | Top-level shape |
|---|---|
| `endpoint list` | `{ self, subnets[], local[] }` — `self`: `{id, status, ready, alive, unbound, description, psyche_host_error, translation_fault?, host_error?}` (note the `?` convention holds literally here: `psyche_host_error` carries no `?` because the key is ALWAYS emitted, `null` when there is no fault, while its two sibling fault fields are omitted when absent); `subnets[]`: `{name, endpoints[]}` where each endpoint is `{id, node, node_label, status, resources, endpoint_type?, project?}`; `local[]`: `{id, state, address, ready, alive, unbound, project?, activity?}`. *(Since v0.33.0 the local `project` field reads the daemon-maintained project index — answers are immediate and may lag a just-changed project by moments; absent while the index has never been built.)* <!-- [doc->REQ-ACTIVITY-LIST-JSON] --> `activity` is `busy` or `idle` — the endpoint's current state for surveying many endpoints at once. **Local rows only**: a remote row is gossiped and carries no activity sentinel, so the key is omitted there rather than guessed; it is also omitted for an unbound perch, which has no endpoint to be busy. |
| `whoami` | `{ id, state?, ready?, alive?, unbound?, description? }` — identity-only *(since v0.33.0; previously the `endpoint list` shape)*: the calling session's own endpoint, or `{"id": null}` + exit 1 when the session owns none. Never derives projects — the bounded-time identity verb for hooks. |
| `endpoint digest` | `{ turns[], version, after_predates_window? }`; `--follow` streams `{ version, from, turns[] }` per change — see [above](#session-digest--endpoint-digest---json) |
| `endpoint description show` | `{ id, description }` |
| `endpoint role` | `{ id, role }` |
| `api endpoint-info [<id>]` | `{ id, endpoint_type, adapter, local_node:{label,key}, attached_node:{label,key}\|null, controlled, project, cwd, subnets[] }` (always JSON) *(since v0.33.0 `project` is index-fed — bounded time, safe on hook paths)* |
| `daemon status` | `{ running, pid, net_up, pump_heartbeat_ms, managed_by, managed_active, subnets[], local_endpoints[], broker_image?, broker_stale?, stall_evict_count?, stall_evict_last_ms?, project_index? }` — `project_index` *(since v0.33.0)* is the index writer's health block: `{generated_ms, source_generation, pending_refresh, last_run_ms, last_duration_ms, last_error?, endpoints, projects, cwds, cwd_cache_hits, cwd_cache_misses, stale_reads, repairs, last_cycle:{branch_enumerations,tree_scans,derivations}, cumulative:{…}}`; absent when no writer has ever run on the home |
| `subnet status [--nodes]` | `{ daemon_running, subnets[] }` — each `{name, node_count, endpoint_count, nodes[], declared_mode?, captured_mode?, pending_declared_mode?, pending_seen_ms?}`. <!-- [doc->REQ-SUBNET-STATUS-MODES-EVERY-VIEW] --> The mode facts ride **every** row of the bare view, not only a named subnet's: `declared_mode` is the posture the subnet declares as this node knows it, `captured_mode` what this node actually enforces as its fallback, and the `pending_*` pair a declared change seen but not adopted. Each is **omitted when absent**, and absent is a real state to read for: a row carrying `declared_mode: "closed"` with no `captured_mode` is enforcing **open** here — see [Viewing access rules and posture](../networking/access-viewing.md#subnet-modes-spt-subnet-status). |
| `subnet show-code` | `{ subnet, code, otpauth_uri? }` |
| `notif list` | `{ notifs[] }` — each `{notif_id, subnet, kind, state, from_id, head}` |
| `access list` | `{ entries[] }` — each `{endpoint, nodes[], locked}` |
| `grant list` | `{ grants[] }` — each `{capability, agent, node, qualifier}` |
| `adapter list` | `{ adapters[] }` — each `{name, kind, mode, version, source_dir, active}` |
| `adapter version <option>` | `{ adapter, version }` |
| `shell list` | `{ owner, shells[], instantiable[] }` — each shell `{id, alias, adapter, status}` |

All shapes are **additive-forever**: new keys may appear, existing keys keep
their meaning. Parse tolerantly (ignore unknown fields) and a newer daemon
never breaks an older consumer.
