Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

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.

From inside a turn: an agent on an IO-compliant adapter 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. 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.

Sealing a message

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:

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 for what a receiving adapter must do with it.

The ceremony

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.
  • 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.

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

Once this node × the binding subnet is enrolled, 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.

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

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

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

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:

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.

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:

<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

The TOTP code is the universal ceremony; an enrolled platform authenticator is the faster one — 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:

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.

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.

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.