Skip to content

docs: write down what may change on the wire, and gate it - #169

Merged
puritysb merged 3 commits into
masterfrom
docs/wire-compatibility-contract
Aug 9, 2026
Merged

docs: write down what may change on the wire, and gate it#169
puritysb merged 3 commits into
masterfrom
docs/wire-compatibility-contract

Conversation

@puritysb

@puritysb puritysb commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Why

Three things this week broke, or nearly broke, rules that existed only in reviewers' heads:

  • feat(codex): surface persistent goals as read-only tiles #163 appended a new kind of row to sessions_list. It followed every written rule — optional fields only, generated mirrors regenerated, the Swift fold guard mirrored, appstore-feature-matrix.md updated in the same commit — and would still have consumed physical Stream Deck keys and produced untappable rows on iOS 1.0.4, plugin 1.0.2 and every flashed board. Nothing said that a new row kind is not an additive change, and pnpm verify-version calls all 1.0.x mutually compatible regardless.
  • fix(plugin): reject expired Claude usage windows #161 added a fourth freshness rule at a fourth layer with a 5-minute grace, next to the 1-hour grace that adjustUsagePercent and formatResetTime already share.
  • Closing Security feedback #145 needed a probe from a second host. Nothing recorded that, and the first attempt at it misread a correct result as a live vulnerability.

An external contributor followed every rule we had written and still shipped a fleet-breaking change. That says the written rules are read — and that this one was missing.

What this adds

docs/wire-compatibility.md — the contract for the daemon↔client surface:

  • Who is downstream, and which of them we cannot update (App Store 1.0.4 ×2, Elgato 1.0.2, Ulanzi 1.0.3, flashed firmware — only the npm CLI updates on our schedule)
  • What the X.Y version rule actually promises, and that a green verify-version is not evidence a wire change is safe
  • Change classes: safe-additive vs. breaking-though-it-looks-additive (new row kind, new id namespace, reinterpreting a field, removing a field)
  • The retain-on-absent merge rule (s.x = e.x ?? s.x) and its optional-boolean corollary — usageStale demonstrated both failure directions within eight days
  • The three freshness axes (window ended / reading age / plan void) and why none may collapse into "hide the gauge"
  • The two supported routes for a genuinely breaking change: capability negotiation via client_register, or a new event type
  • A review checklist ending on the question that matters: would a 1.0.x client render something wrong, or merely render nothing?

Auth is deliberately not restated. docs/daemon.md's LAN security model already covers it thoroughly and is already cataloged as arch.daemon; duplicating it would create exactly the drift the coverage gate exists to prevent. The contract links to it and carries only the three invariants that constrain message shape.

docs/daemon.md gains the one thing it was missing — how to verify the boundary:

You cannot test this from the daemon's own machine. isLocalConnection() trusts loopback and every address on this host's own interfaces, so curl http://<my-own-LAN-IP>:9120/health returns the full token-bearing payload, and that is correct behaviour.

…plus the second-host procedure via ADB, and the two traps in reading the result: a 101 WebSocket upgrade is not a failure (the daemon closes 4001 before sending state), and several e-ink Android images have no curl.

Verified, not asserted

Claims in the doc were checked against the code before writing them — BridgeEventParser.parse returning nil on unknown type (which is what makes route 2 safe), rawSessionId/sameSession still existing, client_register's actual shape.

bridge/src/__tests__/docs-wire-contract.test.ts keeps the prose bound to the code: the documented public /health body must equal buildPublicHealth(), must carry no credential/inventory/state field, and GET /health must remain the only route an unauthorized peer reaches.

The gate was negative-tested in both drift directions:

mutation result
doc sample regains pairingToken 2 tests fail
gate opens /setup-status to unauthenticated peers 1 test fails
restored 6/6 pass

docs:check (97 files), design-system:check (28 documents, coverage satisfied) and the security suite (22 tests across 4 files) all pass.

Note

catalog.json is +7/-0 — an earlier revision of this branch round-tripped it through a JSON writer and reformatted all 27 navigationLabels entries. That churn was removed; the entry is inserted textually beside spec.esp32-client, the other contract doc.

puritysb and others added 3 commits August 9, 2026 12:37
Three changes this week broke, or nearly broke, rules that were never
written down. #163 appended a new kind of row to `sessions_list` — every
field-level rule followed, generated mirrors regenerated, Swift guard
mirrored — and it would still have corrupted the deck on every shipped
1.0.x client, because nothing says that a new row kind is not an
additive change. #161 added a fourth freshness rule at a fourth layer
with a different grace constant. And closing #145 needed a probe from a
second host, which nothing recorded either.

Add docs/wire-compatibility.md as the contract for the daemon↔client
surface: who is downstream and which of them we cannot update, what the
X.Y version rule does and does not promise, the change classes (safe /
breaking-though-additive), the retain-on-absent merge rule and its
optional-boolean corollary, the three freshness axes, and the two
supported routes for a genuinely breaking change (capability
negotiation via `client_register`, or a new event type — verified that
unknown `type` values are dropped by shipped parsers).

Auth is deliberately NOT restated. docs/daemon.md's LAN security model
already covers it and is already cataloged; the contract links to it and
carries only the three invariants that constrain message shape.

What daemon.md was missing is how to verify the boundary: you cannot do
it from the daemon's own machine, because `isLocalConnection` trusts
every address on this host's interfaces, so curling your own LAN IP
returns the full token-bearing payload and that is correct. Record the
second-host procedure, and the two traps in reading it (a 101 upgrade is
not a failure — the close code is 4001; several e-ink Android images
have no curl).

Gate it: docs-wire-contract.test.ts asserts the documented public
`/health` body equals `buildPublicHealth`, that it carries no
credential/inventory/state field, and that `/health` is the only route
an unauthorized peer reaches. Verified the gate fails in both drift
directions — reintroducing `pairingToken` into the doc sample fails two
cases, opening a second unauthenticated route fails a third.

Cataloged as spec.wire-compatibility. docs:check, design-system:check
(28 documents) and the security suite all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The published viewer renders one collapsible rail group — Engineering —
and it is closed by default (`isProject`/`projectOpen` in
agentdeck-design-system/viewer/app.js). Design and Specs are always
open.

Every auth and protocol spec was inside that drawer: Bridge Protocol,
Gateway Protocol, and Daemon Hub, which is where the LAN security model
lives. Meanwhile the surface specs people browse — devices, ESP32, the
ESP32 client contract, Stream Deck, TUI — sit in the always-visible
Specs group. So the site showed hardware layouts by default and hid the
security boundary behind a chevron, and the wire-compatibility contract
added in this branch landed in the drawer too.

Move all four to `category: Specs`, next to the ESP32 client contract,
which is the same kind of artifact. No content changes, no id changes;
`navigationLabels` is keyed by id, so nothing else moves.

Gate it, reading the collapsible category out of the viewer so a rename
cannot silently re-bury them: the four docs must not carry that category
and must share the one the ESP32 client contract uses. Verified the gate
fails when any of them is put back.

design-system:check (28 documents), docs:check (97 files) and the suite
(11 tests) pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Catalog order drives rail order, so spec.wire-compatibility was landing
between ESP32 firmware and the ESP32 client contract. Move it up beside
Daemon / Bridge protocol / Gateway protocol so the four auth-and-wire
documents read as one block at the top of Specs.

Verified against the built manifest, not just the catalog.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@puritysb
puritysb merged commit cf8ddb7 into master Aug 9, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant