# Wax seals: proof of user authority

A **wax seal** is a durable, citable proof that a human authorized **specific
content** — a decision text, or the exact body of a message. Minting one runs a
**human-presence ceremony** (a TOTP code entered at the operator's attached
controller); the result is a short token that binds `{content hash, minter,
timestamp}` into a record any member of the binding subnet can check, on any of
its nodes, at any later time.

That last part is the point: an agent cites a seal *later* to prove a decision
without re-asking. It is distinct from the in-the-moment origin authority of a
`user-msg` — a seal is durable and content-bound, and a reversed decision is
simply a **newer seal** (records are immutable; there is no expiry and no
revocation).

A seal is **evidence, never authorization**. Possessing a token grants nothing,
and nothing in spt-core branches on one — the only citable fact is a `BOUND`
verdict from `spt api seal verify` over the actual content. Forged or misquoted
tokens are harmless by construction, because verify recomputes the hash.

## Minting a decision seal

<!-- [doc->REQ-SEAL-MINT-VERB] the walkthrough: stdin → ceremony → bare token on stdout, exit 0 iff admitted, refusals verbatim -->

```text
printf '%s' "Ship v0.61.0 with the reduced default timeout" | spt seal mint
```

The text to seal arrives on stdin. The daemon runs the ceremony over **exactly
those bytes** (trimmed the same way `spt send` trims a message body, so a seal
minted for a text and a sealed send of that same text seal byte-identical
buffers), the overlay appears at the attached controller, and on admit the
minted **token prints alone on stdout** — exit 0 **only** when the ceremony
admitted. Every refusal (no surface, wrong code, throttled, cancelled, content
too long…) exits nonzero with the daemon's own named reason: the CLI never
re-words a refusal it did not decide.

The token is 8–10 characters from a narrow lowercase alphabet, made to be read
aloud and retyped.

<!-- [doc->REQ-SEAL-SHORTFORM-RESULT-TOKEN] -->
**From inside a turn:** an agent on an
[IO-compliant adapter](../harness-contract/manifest.md#io--io-funnel-compliance)
mints by wrapping text in `;;` markers in its own output — `;;we ship the parser
behind the manifest gate;;` — and that runs **this** ceremony, not a second one.
The turn does not wait for it (the ceremony is human-scale, so the mint is
handed off and the turn ends), and with no controller attached it refuses
immediately with the same `SEAL_NO_CEREMONY_SURFACE` this page's refusals come
from. The outcome is reported only through the now-signal's `DISPATCH_RESULTS`
category. An admitted mint includes its actual token:

```text
-> (seal): seal minted seal=n2czzem8hc
```

Each result appears once per session. Cite that token with the marked text and
verify the content before relying on it: a token remains evidence, never
authorization. Historical results written before token recording was added
still say `seal minted`; no token is reconstructed for those rows.

Grammar and edge cases — pairs, the empty `;;;;`, the odd trailing marker, and
the backtick/fence suppression that lets you write about all of it — are in
[the frame contract](../shells/frames.md#what-happens-after-you-type-it).

## Sealing a message

<!-- [doc->REQ-SEAL-SEND-SEALED] the sealed-dispatch walkthrough: one buffer end to end; the overlay names the destination; non-admit sends nothing -->

```text
printf '%s' "Approved: run the migration tonight" | spt send doyle --seal
```

`--seal` turns a send into **sealed dispatch**: the ceremony runs over the
**exact bytes that will be delivered** — one buffer end to end, never re-read
or re-encoded between ceremony and delivery — so the receiver's `verify` over
the delivered body reads BOUND by construction. The overlay names the
**destination** beside the binding subnet: the human consents to sealing *this
text*, *to this endpoint*, *under this subnet*. A ceremony that does not admit
sends **nothing** — not delivered, not spooled.

The **answer carries the token**:

```text
SENT:doyle seal=n2czzem8hc
```

so the sender can record or relay what it just minted. It rides every outcome a
sealed send can answer with — `SENT`, `QUEUED`, `QUEUED(idle-only)`,
`DEFERRED` — because the token is evidence of the **mint**, not of delivery:
the ceremony ran before any delivery attempt, so a message that only spooled
still minted a record you may need to cite. An unsealed send's answer is
unchanged, and no path prints an empty `seal=`.

Once the ceremony admits, the overlay comes down and says nothing: the token is
on the answer line, and the notice that used to print there only stranded in
the terminal. A ceremony that **fails or is cancelled** still prints its
sentence — nothing else would tell you.

The delivered message carries the token as the `seal="…"` envelope attribute —
see [the envelope contract](overview.md#the-event-wire-contract) for what a
receiving adapter must do with it.

## The ceremony

<!-- [doc->REQ-SEAL-CEREMONY-CONTENT-SHOWN] the UX contract: verbatim content + named subnet, 500-scalar cap refuses (never truncates), scrolling never gates submit -->

The overlay shows the human **the sealed content, verbatim**, and **names the
binding subnet** whose member-or-admin TOTP code it expects (plus the
destination, on sealed dispatch). What you see is what is hashed — the same
byte buffer, end to end.

- **Content is capped at 500 characters** (Unicode scalars, counted at the
  ceremony — not a record property). Longer content **refuses by name**
  (`SEAL_CEREMONY_CONTENT_TOO_LONG`); it is never silently truncated, because a
  seal over a truncation would attest text the human never saw. Content must
  also be valid UTF-8 (`SEAL_CEREMONY_CONTENT_NOT_UTF8`): bytes that cannot be
  displayed verbatim are bytes nobody can consent to.
- **The overlay scrolls.** Content within the cap can still overflow a small
  terminal grid; the surface adapts, the content never shrinks. Submitting is
  **never gated on scrolling to the end** — no consent theater.
- <!-- [doc->REQ-SEAL-CEREMONY-ESC-CANCEL] the cancel clause in user terms: clean abort, nothing minted, nothing spent, requester answered -->
  **Esc (or ctrl-c) cancels cleanly.** No record exists anywhere, nothing is
  spent against the attempt limit (a cancel is not a guess), a pending sealed
  send is dropped, and the requesting agent is answered with the named
  cancellation. Detaching the controller mid-ceremony aborts the same way.
- Wrong codes are **counted and bounded** on the seal ceremony's own attempt
  ledger (repeated failures back the gate off, loudly past the third) —
  separate from every other gate's ledger, so failed seal guesses can never
  shut a gate a human needs for something else.

<!-- [doc->REQ-SEAL-NO-CEREMONY-SURFACE] the no-surface refusal in user terms: fast, named, attach-and-retry; nothing parked -->

A mint with **no ceremony surface** — no live session for the minter, no
attached controller, or a controller too old to run the ceremony — refuses
**immediately** with `SEAL_NO_CEREMONY_SURFACE` and attach-and-retry guidance.
Nothing is parked waiting for a controller to appear; have the operator attach
(`spt rc <id>`) and ask again.

## The FIDO2 ceremony

<!-- [doc->REQ-SEAL-CEREMONY-FIDO2] the FIDO2 clause in user terms: offer iff enrolled, the OS prompt is the ceremony, tuple-bound signature, silent remote withdrawal, failed proofs spend nothing and fall back to TOTP, Hello-cancel distinct from Esc -->

Once this node × the binding subnet is
[enrolled](#enrolling-an-authenticator), the mint ceremony **offers FIDO2
first**: the overlay opens with the content as always, and your OS's own
presence prompt (Windows Hello) asks you to sign. Completing the gesture
**is** the ceremony — the signature ships to the daemon, which verifies it
against the enrolled pubkey and mints with ceremony kind `fido2` and the
signature on the record. No code to read off a phone, no code to type.

What the keypair signs is not the content alone but the whole **binding
tuple** — content hash, fully-qualified minter, mint timestamp, fixed before
the prompt appears — so every fido2 record is **self-authenticating from its
own fields**: the signature cannot be lifted onto different content, a
different minter, or a different moment.

The TOTP code stays the universal fallback, always on the same overlay:

- **Cancelling the OS prompt falls back to code entry.** The overlay stays up
  and you type the TOTP code instead. Distinct from **Esc**, which still
  aborts the whole ceremony — nothing minted, nothing spent, requester
  answered — exactly as before.
- **A failed signature spends nothing.** A proof the daemon cannot verify
  refuses by name (`SEAL_FIDO2_PROOF_REFUSED`) and never counts against the
  code-attempt ledger — a broken client must not shut the gate a human needs
  — and the overlay falls back to code entry.
- **Remote controllers degrade silently.** The signing key lives where it was
  enrolled, so availability in v1 is: **FIDO2 for a controller at the minter
  node; a remote controller gets the ordinary TOTP overlay** — no error
  chrome, no broken offer, just the universal ceremony. Under the hood the
  open names the enrolled node and the client compares it against its own;
  local and remote controllers are one code path.

<!-- [doc->REQ-SEAL-FIDO2-RC-CLIENT] the remote-controller sentence: client-side signing where the human is, a proof (never a verdict) up the attach wire, additive fields so old clients render plain TOTP -->

Signing happens **client-side, where the human is**: the rc client signs the
daemon-composed tuple and ships the **signature up the attach wire — a
proof, never a verdict**; verification, the attempt ledger, and the mint all
stay daemon-side. The offer rides the existing ceremony records as additive
fields, so an older `spt rc` simply renders the plain TOTP overlay — nothing
hangs, nothing to update before the next mint.

### `E` — enroll and seal in one ceremony

<!-- [doc->REQ-SEAL-ENROLL-SHORTCUT-E] the E clause: offered iff not enrolled, one code writes enrollment + seal both-or-neither, every TOTP clause carries unchanged -->

When this node × subnet is **not** yet enrolled, the TOTP overlay offers
**`E`**: press it and the OS prompt mints (or fetches) the authenticator
keypair; the one TOTP code you then enter both **enrolls the node and mints
the seal — both or neither**, under the daemon's one apply lock. A failed
enrollment mints nothing and a failed mint enrolls nothing, so one code
entry never yields a half-state. Every TOTP clause carries unchanged: wrong
codes count and re-prompt, Esc cancels everything spending nothing. An
enrolled pair's overlay never shows `E`.

## Which subnet a seal binds to

<!-- [doc->REQ-SEAL-SUBNET-BINDING-DEFAULT] the user-terms rule: --subnet override → anchor → shared-lex-first → named refusal; why the audience rule holds -->

Seal records replicate to the nodes of **one subnet** — the *binding subnet* —
and that replication scope **is the verification audience**: only that
subnet's members can `verify` or `describe` the seal. So the binding subnet is
chosen to keep the audience able to check the citation, by one deterministic
rule:

1. **`--subnet <name>` wins** — but a subnet the minter is not a member of
   refuses fast (the ceremony could never verify a code for a subnet the
   minter holds no keys to).
2. Otherwise, the minter endpoint's **anchor subnet**.
3. On sealed dispatch where the destination does not share the anchor: the
   **alphabetically first subnet both endpoints share** — deterministic, so
   the same pair always binds the same subnet.
4. **No shared subnet refuses by name** (`SEAL_NO_SHARED_SUBNET`). A seal the
   receiver structurally cannot verify would be worse than no seal, so there
   is no silent fallback.

The overlay always names the chosen subnet — the human sees which key they are
being asked for before typing it.

## Checking a seal

<!-- [doc->REQ-SEAL-VERIFY-CONTENT-BOUND] the verify walkthrough: content on stdin, BOUND/NOT-BOUND, exit 0 iff BOUND, bare verify refuses toward describe -->

```text
printf '%s' "Approved: run the migration tonight" | spt api seal verify k7mn4wq2vx
```

`verify` is **content-bound**: the content arrives on stdin, its hash is
recomputed over the exact bytes received, and the verdict is **BOUND** or
**NOT-BOUND**, with the record's minter and mint time printed alongside. The
exit contract is strict so a script can never misread a non-verdict: **exit 0
if and only if BOUND**. NOT-BOUND, unknown token, and malformed token each
answer distinctly and none of them exits 0.

Your shell's trailing newline is not a difference. `seal mint` seals the
trimmed text, so `echo` and friends would otherwise present bytes that could
never match the seal they just minted. The exact bytes are hashed **first**;
only if they miss does verify try the same trim the mint applied. Content
deliberately sealed *with* its whitespace still binds exactly as before, and a
difference in anything but leading or trailing whitespace is still NOT-BOUND.

The verdict block is deliberately narrow: `token`, `content_hash` and
`ceremony_kind` do not surface here. The verdict line already carries the
token, and on a mismatch it carries both hashes — a field repeated beside its
own verdict reads as a second, weaker answer. `describe` is where the full
record lives.

Verifying with **no content refuses** and points you at `describe` — a verify
that reads nothing is not a verify, and the question "what does this token
attest?" has its own verb:

<!-- [doc->REQ-SEAL-DESCRIBE] the describe walkthrough: record fields, line-oriented, quotable -->

```text
spt api seal describe k7mn4wq2vx
```

`describe` prints the record's fields — token, content hash, fully-qualified
minter, mint time, ceremony kind, and **whether a signature is present** —
line-oriented, one `key: value` per line.

`describe` is the **human** surface, and it renders two of those fields for a
reader rather than for a parser:

- the minter's node half is **named** where this node can name it —
  `SPT_DEV:lia@HFENDULEAM (14efb80c…)`, the name beside the key prefix it
  resolved from. A record stores that node as a short key prefix, so the lookup
  is a prefix match and it answers only when the prefix identifies **exactly
  one** known node. If nothing matches, or more than one does, the prefix stays
  as it is: a name guessed from an ambiguous prefix would attribute a decision
  to a machine nobody named.
- the mint time renders `YYYY-MM-DD HH:MM TZ` in **your host's** timezone.

Both renders come from *this* node — its roster and its clock — so two nodes
may legitimately render one record differently, and the block is not something
to parse. The record itself is the machine-readable answer, and nothing
rendered here reaches verification: the signature check recomposes its tuple
from the record's stored spellings, untouched.

Both verbs are read-only, take no auth gate, and answer on **any member node**
of the binding subnet.

<!-- [doc->REQ-SEAL-FIDO2-SIG-VERIFY] the signature-verify clause: fido2 BOUND = hash AND signature, strict (node, subnet) enrollment lookup, missing signature NOT-BOUND, unknown backend refuses by name, describe shows signature presence -->

For a **fido2 record**, BOUND is stricter: the content hash must match **and**
the record's signature must verify. The verifier recomposes the signed tuple
from the record's own stored fields — any divergence is NOT-BOUND, never a
second canonical form — and checks it against the pubkey enrolled for
**exactly** the record's minter node × binding subnet in the replicated
enrollment records, which is why it answers on any member node: the enrollment
replicated there on the same feed as the seal. No enrollment at that slot
refuses by name (`SEAL_VERIFY_NO_ENROLLMENT`) rather than pretending a
verdict; an unknown backend kind refuses by name
(`SEAL_VERIFY_UNKNOWN_BACKEND` — a newer spt knows that backend); and a fido2
record **missing its signature is NOT-BOUND** — a self-declared FIDO2 mint
that cannot be checked is not evidence. Non-fido2 records verify exactly as
before.

Exact-bytes discipline matters when quoting: a trailing newline is a different
content. Both `seal mint` and sealed dispatch trim the way `spt send` trims, so
sealing and delivering the same text agree — but verify what was **delivered**
(the `<EVENT>` body), not a retyped approximation.

## What arrives on the other side

The receiver sees the seal on the delivered envelope:

```text
<EVENT type="msg" from="reavo" seal="k7mn4wq2vx">Approved: run the migration tonight</EVENT>
```

To act on it as evidence: `describe` the token to see what it attests, or pipe
the delivered body into `verify` for the BOUND verdict. Treat the attribute as
a **citation to check, never a credential** — its presence or absence must
never move an authorization decision on its own.

## Enrolling an authenticator

<!-- [doc->REQ-SEAL-ENROLL-VERB] the enrollment walkthrough: ordered pre-checks, TOTP-gated, per node x subnet, immutable v1, refusals verbatim -->

The TOTP code is the universal ceremony; an **enrolled platform
authenticator** is the faster one — [the FIDO2 ceremony](#the-fido2-ceremony):
a keypair gated by your OS's own presence check signs the seal payload,
verifiable by any member node. Enrollment is what turns it on, per node ×
subnet:

```text
spt seal enroll-authenticator [--subnet <name>]
```

Enrollment is **per node × subnet** and gated by the **same TOTP ceremony** a
seal mint runs — the overlay shows an enrollment brief (the node's key short
form, the subnet, the backend, and the pubkey's SHA-256 fingerprint) and a
member-or-admin code of the binding subnet admits it. The subnet resolves the
way a plain mint's does: `--subnet` wins (refusing a subnet this endpoint is
not a member of), otherwise the endpoint's anchor.

The verb refuses fast, by name, **before any ceremony opens** when the
enrollment could not complete anyway: no platform authenticator on this OS
(`SEAL_AUTHENTICATOR_UNAVAILABLE` — Linux names libfido2 as its future
backend and refuses honestly this milestone), or the node × subnet pair
already enrolled (`SEAL_ENROLL_ALREADY_ENROLLED`). Enrollment records are
**immutable in v1** — like seal records there is no expiry and no revocation,
so re-enrolling a node needs an operator ruling, not a retry. Every ceremony
refusal rides through verbatim; exit 0 means enrolled, and the record prints
line-oriented for citation.

<!-- [doc->REQ-SEAL-AUTH-BACKEND-SEAM] the per-OS backend story in user terms: Hello first, Linux refuses naming libfido2, one seam -->

**Backends.** One seam, per-OS backends. Windows Hello is the first backend
(`hello-kcm-rs256`): the keypair lives in the Hello key store, creation and every
future signature are gated by your Hello gesture, and the private key never
leaves the store. Linux is a named refusal until its libfido2 backend lands —
run the enrollment on a Windows node today.

<!-- [doc->REQ-SEAL-ENROLL-RECORD-SUBNET-MATERIAL] the records surface: fields, subnet security material, member-verifiable, first-enrolled-wins -->

**The record.** An enrollment writes `{pubkey_hex, node, subnet, enrolled_at,
backend_kind}` into the subnet's security material — the node half is the
node-key short form (never a hostname), and the record replicates
**subnet-scoped on the same feed as seal records**, so every member node
holds the pubkey it will verify FIDO2 seals against. Records are grow-only
and **first-enrolled wins everywhere**: a conflicting claim for an enrolled
slot is dropped with the existing record kept.
