# 0054 — Sealed knock-codes: the cross-node invite is one subnet-sealed string

Status: accepted (ratified 2026-08-01, cross-node redemption grill — doyle + operator). Ships with the redemption wave; UNBUILT at ratification.

## Context

`spt knock redeem` ships local-only: the verb looks the code up in the redeeming node's own knock store and writes the grant into its own access store, so a code minted on one node is "unknown or expired" everywhere else — while the ratified glossary promises that presenting a code "reaches your endpoint even if it is undiscoverable." The wire vocabulary cannot carry a redemption at all (`KnockRecord` has no code field). Field failure: lia's code, unredeemable by ball-b on enlyzeam, 2026-08-01.

A cross-node redemption needs the redeemer's daemon to know **where to present the code**, and the target may be undiscoverable — discovery is what the code exists to bypass. The fork-surface rig finding (releases#76 grounds) proved the general hazard: any cross-node verb that resolves its target through registry replication inherits a hidden DISCOVER co-requisite. The redemption route must not pass through discovery.

## Decision

**The code string itself carries route and secret, sealed to the minting node's subnet membership keys.**

1. **Format**: `sptkc_` + unpadded lowercase base32 of a sealed payload. The prefix is the version marker (a future format mints a new prefix, never a flag day) and the standing agent-recognition hook (a NOW-SIGNAL-era hint can key on it). Legacy bare-hex codes stay local-redeemable.
2. **Payload**: the minting node's short key (4 B) + the code secret (10 B; 80 bits is ample under the receiver-enforced redemption rate limit and 24 h TTL). The redeemer resolves short→full node key against its own subnet node rows — node-tier replication, no endpoint DISCOVER involved.
3. **Seal**: AEAD under `HKDF(subnet seed_hex, "spt-knock-code-v2")`. Single-envelope for the common one-subnet case; `--subnet` selects when the minter belongs to several (multi-envelope only on request, ~+22 B each). SIV-style nonce derived from the single-use secret; 8 B tag. The redeemer try-decrypts with each of its subnets' derived keys — current and `prev_seed_hex`, so the code inherits the subnet's own one-deep rotation grace (Mesh-D7) instead of a private key schedule.
4. **Common-case length ≈ 36 base32 chars** — shorter than the shipped 32-hex codes.

### What the seal buys

A pasted code reveals **nothing** to a non-member: not the node, not the subnet, not which subnet — sympathetic to the anti-oracle stance the redeem refusals already take. The decode-set (subnet members) is exactly the population that can dial the minter over WAN anyway, so no capability is lost.

## Considered and refused — on the record

- **Plaintext route prefix** (`<node-short>-<secret>`): honest and workable; loses route confidentiality for a string designed to be handed around out-of-band. Refused in favor of the seal once (d-i) proved affordable.
- **Explicit `--node` flag at redeem**: turns one hand-off string into two facts with a silent-failure seam between them.
- **Subnet sweep** (try every peer): turns the rate limit into a subnet-wide probe. Disqualified.
- **Static shipped key** ("plain encryption key that ships with every installation"): a key everyone holds is obfuscation wearing encryption's clothes — decodes for anyone with the (public) binary while claiming confidentiality it does not have. Refused explicitly; if the seal were infeasible, honest plaintext beats it.
- **"Shrink by adding an encryption layer"**: no invertible transform emits fewer bits than the information it must carry — ciphers are length-preserving or length-expanding, so a layer can only grow the string. Length is bought with payload economy (short key, 10 B secret, derived nonce, truncated tag, single envelope), never with a layer. Recorded so this does not resurface looking clever.

## Consequences

- Redemption becomes a **kind-tagged wire family** (knockmsg precedent): an N-1 daemon drops it cleanly; extending `KnockRecord` instead was refused because unknown fields decode fine and an old minter would misread a redemption as an ordinary knock — accepted-but-wrong beats dropped.
- Redeemer CLI gains the two-silences outcome triple: `REDEEMED` / `REDEEM_REFUSED` (one anti-oracle message) / `REDEEM_UNCONFIRMED` (no answer ≠ refusal).
- Serve-side grant executes the **mint's standing intent** (owner-agent / engine-room authority recorded at mint), never `SameNodeUser`; subject binding mirrors `approval_form`'s split, which entails `--admit-node` arriving on `new-code` (persisted on the code record) and repairs the shipped inert-rule defect (a non-attributable surface redeemed today writes a sender-endpoint rule that can never match).
- `--mutual` stays redeemer-local, consumed once by the node-proven `REDEEMED` reply.
- KDF use must honor the seed's no-reveal invariant: derived keys only, domain-separated so the envelope can never become an oracle against the seed's TOTP use.

Reserve levers if the length budget ever needs re-spending: full node key in payload, multi-envelope by default, 16 B tag, longer secret.

## Amendment — 2026-08-01, post-build ratifications (doyle, DOORBELL W4 / releases#79)

Appended at the W4 build. The Decision section above is the pre-build sketch as ratified at the cross-node redemption grill; four of its clauses were superseded or sharpened by the recon ruling (releases#79 comment 5152757354) and the requirement texts minted with the build. The original text stands unedited for the record; where it conflicts with the following, the following governs:

1. **Two keys, never one.** "AEAD under `HKDF(subnet seed_hex, "spt-knock-code-v2")`" reads as a single derivation. Ratified and built: TWO domain-separated derivations — `spt-knock-code-v2/enc` for the cipher key, `spt-knock-code-v2/mac` for the authentication key — required by `REQ-HAZARD-CODE-SEAL-SEED-ORACLE`, whose unit fails exactly the one-key collapse this sentence could be read to permit.

2. **The nonce, precisely.** "SIV-style nonce derived from the single-use secret" is loose: the ratified form is `tag = truncate-8( HMAC-SHA-256(K_mac, plaintext) )` over the WHOLE plaintext (node-short ‖ secret), and that tag IS the SIV nonce, padded deterministically to the cipher's nonce width. The secret's per-code freshness is what makes the derived nonce single-use.

3. **Multi-envelope is a DESTINATION, not shipped behaviour.** As built, `--subnet` selects the sealing subnet; several memberships with NO `--subnet` REFUSE (`KNOCK_WHICH_SUBNET`) rather than emitting multiple envelopes. Multi-envelope remains ratified-on-request and UNBUILT. Marked rather than struck: the destination stands; reading it as shipped is the drift.

4. **"AEAD" names the construction loosely.** Built is the ratified SIV composition — ChaCha20 plus the separately-keyed truncated HMAC, encryption bound to the MAC-derived nonce — because a stock AEAD's fixed 16 B tag cannot express the ratified 8 B envelope. Recorded as deliberate at the dependency (W4 leg a) and in the recon ruling; the 8 B truncation's forgery bound is the receiver-enforced redemption rate limit, stated in `REQ-KNOCK-CODE-SEALED`'s text.

## Amendment — 2026-08-18, multi-envelope BUILT; clause 3 superseded (todlando, KEYSTONE W2 / releases#161)

Marked rather than struck, on the clause-3 and clause-5 precedent. Clause 3 above records multi-envelope as "ratified-on-request and UNBUILT" and `--subnet` as SELECTING one subnet. **That is now the old state.** The operator's restatement (2026-08-04) governs and post-dates it: `--subnet` takes a LIST, and OMITTING it seals for ALL memberships. `KNOCK_WHICH_SUBNET` is retired with its copy, which had begun teaching "a sealed code opens only for members of ONE subnet" as though it were the design rather than the shape of a deferred slice.

6. **One secret, N keys — never N secrets.** Every envelope seals the SAME secret, because the store record keys on it and a second draw would mint a code whose envelope opens to a secret no record holds. This is also what keeps the SIV construction of clause 2 safe here: the envelopes differ by KEY, never by a repeated (key, plaintext) pair, so the per-code freshness the determinism rests on is untouched. Envelopes are concatenated at the fixed 22 B width, so the count is derived from the length rather than declared in a header — nothing in the string says how many there are, for the same reason nothing in it says which subnets they are for.

7. **The membership COUNT is disclosed, and that is accepted rather than padded away** (ruled 2026-08-18). "What the seal buys" above says a pasted code reveals "not the node, not the subnet, not even *which* subnet". That sentence stays TRUE — but a concatenated code does reveal HOW MANY memberships the minter holds, to anyone who can count base32 characters. Padding every code to a fixed envelope count would restore the property and spend the length budget this whole ADR was written to defend, in order to hide a fact strictly weaker than the ones the format actually hides. Recorded here and qualified in CONTEXT.md's sealed-code entry, so a later reader meets the delta in the design rather than deducing it from a string.

8. **The N-1 arm is real, is undiagnosable by construction, and is answered at the MINT.** An older binary refuses a multi-envelope string on its single-envelope length check, and reports it through the same anti-oracle silence as a wrong subnet, a tampered byte and an unknown code — so no redeemer can ever learn that age was the reason. Clause 1's versioning lever does not rescue it: a NEW PREFIX fails the shape test instead, routes to the legacy local path, and misses just as silently. There is no spelling of this format change an old redeemer can diagnose, which makes the MINT the only place the fact can be told — and the minter is in any case the only actor who knows the code is multi-envelope. So a mint sealing N>1 envelopes PRINTS the consequence and names the narrowing escape (`--subnet <name>`). **No fleet version floor is claimed**: a floor asserted before the feature ships is a claim about an unshipped thing, and the R&D fleet upgrades as a unit.

9. **Two openings are now possible, and the winner is PINNED.** A redeemer holding two of the minter's subnets opens two envelopes. The winner is not cosmetic — it selects the registry that resolves the payload's short node key (REQ-KNOCK-CODE-ROUTE) — and the shipped first-match-wins rule would have made it an artifact of store iteration order. Ratified as a hole-fill (the ratified design, having one envelope, never had to consider simultaneous openings): the redeem route's exit condition is **ROUTED, not opened** — try every opening, prefer the first that resolves a route — with ties broken by **subnet-name lexicographic order**. Safe by construction: the short key is the MINTER's, so every opening names the same physical node, and preferring a routed one can only avoid a thin registry's `NoRoute`.

## Amendment — 2026-08-01, as-built scope of redeemer-side `--mutual` (deployah, DOORBELL golden-head assembly / releases#92)

Marked rather than struck, on the clause-3 precedent: the Consequences sentence "`--mutual` stays redeemer-local, consumed once by the node-proven `REDEEMED` reply" **stands as ratified intent**. What follows records where the build stopped short of it, so the sentence is not read as a description of v0.51.0.

5. **The consumption is wired on the LOCAL route only.** As built, `--mutual` on a redemption arms the presenter's own pre-authorization — and spends it in the same invocation — when the code's route resolves to this node (a legacy bare-hex code, or a sealed code minted here). On a **cross-node** redemption it arms nothing: the CLI refuses with `KNOCK_MUTUAL_NOT_YOURS` and the presenter's side never opens. The clause above has content only on the cross-node route — "node-proven" is a vacuous qualifier for an outcome that is immediate and local — so the route where the sentence actually bites is the one where it is unimplemented.

   Recorded as a **gap, not a narrowing** (doyle-ruled at the golden-head hand-off 2026-08-01): the consuming seam already exists on this route, since the node-proven `REDEEMED` reply arrives exactly where the pre-authorization would live and carries the proven counterpart identity, so closing it is wiring rather than new mechanism. This distinguishes it from the sibling `knock --mutual` gap (releases#87), where no answer-receipt seam exists at all and the matching refusal *is* honest. `KNOCK_MUTUAL_NOT_YOURS`'s wording overreaches for the same reason — it states a general rule ("`--mutual` arms the MINTER's side… presenting one cannot open your side") that the local route falsifies.

   **releases#92** tracks wiring the cross-node route and retiring or route-scoping that message. No head code moved for this amendment; the store-level machinery it will use is already present and pinned — the two owner-distinguished pre-authorizations sharing one code id are `REQ-HAZARD-MUTUAL-PREAUTH-SIBLING` (KNOWN-HAZARDS 7.63).
