docs: write down what may change on the wire, and gate it - #169
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Three things this week broke, or nearly broke, rules that existed only in reviewers' heads:
sessions_list. It followed every written rule — optional fields only, generated mirrors regenerated, the Swift fold guard mirrored,appstore-feature-matrix.mdupdated 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, andpnpm verify-versioncalls all 1.0.x mutually compatible regardless.adjustUsagePercentandformatResetTimealready share.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:X.Yversion rule actually promises, and that a greenverify-versionis not evidence a wire change is safes.x = e.x ?? s.x) and its optional-boolean corollary —usageStaledemonstrated both failure directions within eight daysclient_register, or a new event typeAuth is deliberately not restated.
docs/daemon.md's LAN security model already covers it thoroughly and is already cataloged asarch.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.mdgains the one thing it was missing — how to verify the boundary:…plus the second-host procedure via ADB, and the two traps in reading the result: a
101WebSocket upgrade is not a failure (the daemon closes4001before sending state), and several e-ink Android images have nocurl.Verified, not asserted
Claims in the doc were checked against the code before writing them —
BridgeEventParser.parsereturningnilon unknowntype(which is what makes route 2 safe),rawSessionId/sameSessionstill existing,client_register's actual shape.bridge/src/__tests__/docs-wire-contract.test.tskeeps the prose bound to the code: the documented public/healthbody must equalbuildPublicHealth(), must carry no credential/inventory/state field, andGET /healthmust remain the only route an unauthorized peer reaches.The gate was negative-tested in both drift directions:
pairingToken/setup-statusto unauthenticated peersdocs:check(97 files),design-system:check(28 documents, coverage satisfied) and the security suite (22 tests across 4 files) all pass.Note
catalog.jsonis +7/-0 — an earlier revision of this branch round-tripped it through a JSON writer and reformatted all 27navigationLabelsentries. That churn was removed; the entry is inserted textually besidespec.esp32-client, the other contract doc.