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:
--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).- Otherwise, the minter endpoint’s anchor subnet.
- 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.
- 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 TZin 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.