A worked example of a message arriving at a node, then the trust model, the load-bearing invariants, and the channel AKE, collected in one place.
Part of the seedkernel spec. Section numbers are global across the doc set, so a
(§X.Y)reference points to whichever file below holds that section:README §1 · PROTOCOL §2–§5, §16 · RUNTIME §10–§12 · SECURITY §13–§14
A chat text message arriving at a fully bootstrapped node, traced through every boundary. Assume nodes A and B are already linked over an authenticated channel (the AKE handshake of §12.6 has completed, so the link is encrypted and bound to each end's channel key), and B has the chat v1 app installed from a signed bundle (§12.4, §11): a guest plus its chat module, claiming the protocol id chat (§12.10). A sends the text "hello, world".
Pipeline trace at B:
- The link opens an AEAD record. The transport bundle's guest program (§12.6) decrypts the inbound ChaCha20-Poly1305 record under the receive key and the next expected per-direction counter. A bad tag or an out-of-order counter tears the link down; a good one yields the plaintext frame and the authenticated peer key
_from = <A pubkey>. There is no per-message signature to check: the AUTH exchanged once at handshake already bound this channel to A's identity, so every frame it carries is attributed to A by construction. - The host resolves the protocol. After its kind and correlation fields, the transport's req frame carries
[protocolId "chat"][chatType 0x00][body "hello, world"](§16.1). The transport hands the host the claim, the attribution and the payload throughlink/deliver; the host resolves the protocol claim directly to the complete slot B installed (§12.10). A protocol no installed bundle claims reaches no guest and is answered with an empty body. A names only the protocol; which verified code runs is settled entirely by B's installed claim map. - The host assembles the guest input. It prepends the authenticated sender key:
input = <A pubkey 32> ‖ 0x00 ‖ "hello, world". The identity comes from_from, never from anything inside the frame body: a peer cannot claim to be someone else, because the bytes it can choose don't include the attribution. - Guest → entrypoint. The slot's realm invokes its guest's
handleentrypoint with the assembled input, under the execution budget (§12.3).host.call("chat", …)resolves only in the private module set captured by that slot; no app label or cross-app namespace is involved (§12.2). - Invoke the module. The host copies
inputinto the module'sscratchregion and callshandle(input_len). The v1 module reads[pk 32][type][body], writes the render bytes[type][pk_len][pk][body]back atscratch, and returns their length (§11). It imports nothing from the host, reaches nothing, and asks no one who sent this: everything it needs is in the input (§4.2). - Render. The guest returns the render bytes from its
handle; the chat page reads them off its load'sonInbound(§12.10) andpostMessages them to the sandboxed iframe, which draws the line attributed to A. Done.
That is the entire steady-state pipeline: one AEAD open, one routing lookup, one guest entrypoint call, one module call, one copy each way. It is pure data movement. Cryptography runs only per link and per install (§10.1). A relayed OFFER (§11) re-runs the install check on the receiving node, which is why a bundle authenticates against its original author across any number of relays while a chat message authenticates only its single hop (§5).
The security properties are introduced where they arise (§3.1, §4.2–§4.3, §12.2–§12.6); this section collects the trust assumptions and the load-bearing invariants in one place so an implementer or auditor can see the whole model at once.
Trust boundaries depend on the protected property. Anyone with host-process access controls slot construction, claim routing, and admission, so bundle signatures constrain an author, never the operator. The host, its platform and sandbox engines, bundled host crypto, and shipped host JS are trusted foundations. A bundle's confinement limits its powers; its replaceability says how it can be updated. Neither determines whether a particular security property depends on its behaviour.
| Protected property | Trusted components and boundary |
|---|---|
| Code admission and guest confinement | The host's verifier, install path, operator policy, sandbox engines, and guest seam enforce which code runs and what it can reach. Admitted guests and their private modules remain confined. A valid signature identifies the author and commits to the bytes; it does not establish that the code is safe. |
| Node identity-key custody and signing-scope separation | The host keeps the private identity key and selects the domain and scope for each signing call. A guest can misuse its permitted signing oracle, but cannot extract that key or select another slot's scope through the seam. |
| Channel confidentiality, record authenticity, and peer attribution | In addition to the host and crypto it uses, the transport guest and its private crypto modules are trusted. The transport holds session keys, sees plaintext, and supplies attribution through link/deliver; the host does not independently authenticate that attribution. A compromised transport can disclose channel data or fabricate attributed input while remaining within its granted services. |
| Confidentiality and integrity of application exchanges | The host's claim routing, the operator's choice of claim owners, and the receiving app are trusted for data delivered to that app and the answers it returns. Protocol claims select recipients of decrypted peer input; service claims select recipients of local call arguments. The transport is also trusted for exchanges carried over its channels. |
The transport is replaceable signed content and remains in the trusted computing base for channel security. Host confinement still protects other slots' private state and signing scopes from direct access by that transport; it cannot protect plaintext or session keys already entrusted to it. The same holds for the peer lint and network separation it enforces (§12.6.3): a malicious transport can bypass the lint, fabricate attribution, or ask the host to sign another network's transcript under the node's identity; honest peers still enforce their own contact secrets and lints.
Admitted apps are trusted with what they declare. The host admits a bundle on the operator's say, confines its guest to the services in guest.requires, scopes its storage and signing by label (§12.2), and holds its sends to the protocol ids it claims (§12.10). It does not contain an app within a service it was granted: every app with fs shares one backend's space, and every app that reaches the transport shares its send queues, so one can crowd out another. The model is a browser extension's: admitting a bundle is the permission, and its manifest says what was permitted. An embedder that admits bundles of mixed trust, such as ones a peer offers, shows guest.requires and protocols where the user decides.
Authenticity is the channel's, per hop. The runtime carries no per-message signature: the transport attributes every frame to the immediate peer (§12.6). That is end-to-end for a direct exchange like chat, and not across relays: an app that forwards messages authenticates the original author itself (§5).
Signing has three distinct contexts. Offline authoring produces the hybrid bundle-manifest signatures that install code (§12.4); a running host verifies those signatures but does not author manifests. At runtime, the host-held Ed25519 identity signs (1) the channel AUTH transcript and (2) statements requested through an ordinary app's scoped node/sign. Both runtime uses are domain- and slot-scoped by the host, the private key never enters a realm, and neither lies on the steady-state message path.
Domain-separated signature contexts. Every signature the runtime verifies or makes has a context-specific prefix: DOMAIN_manifest for bundles (§12.4), DOMAIN_guest ‖ app for an ordinary app's node/sign, DOMAIN_link_scope for the slot holding raw links (§12.2). The prefixes are disjoint and never transmitted, so a signature harvested in one context cannot verify in another, and the family lives in one file so disjointness is checked globally (§16.1). Sub-separation inside a scope is the occupant's own: the transport's DOMAIN_channel tag is content, which lets a bundle update change the handshake format without changing the host domain.
The channel is an AKE that conceals both identities. The construction is §12.6 and the reasoning is CHANNEL. The trust-relevant facts: each side signs the transcript so far under the host's DOMAIN_link_scope, so a signature induced on one exchange is useless on another; the receiver's key never crosses the wire, since the caller dialed it, and the caller's travels under ephemeral X25519 + ML-KEM secrets, only to a receiver that proved the dialed key, so seizing a node later does not deanonymise the peers that dialed it; and no long-term DH or KEM key exists, so the identity key stays signing-only (§12.6.2b). The channel does not rely on TLS or Noise underneath, though WSS and WebRTC add TLS/DTLS. What concealment does not hide (traffic metadata, a stable host:port, the cleartext suite byte) is CHANNEL §9.
The cryptographic root is the host, not a bundle module. The runtime fixes Ed25519, BLAKE2b-256, and ML-DSA-65 as the manifest trust root (§16.1). Ed25519 and BLAKE2b come from the shared libsodium core; ML-DSA comes from a separate mldsa65.wasm host artifact shared byte-for-byte by all targets. This is not an optimization: a verifier cannot be delivered through the format it verifies. A PQ verifier admitted by a classical verifier would be only as strong as that classical admission. The trust anchors are therefore the host artifact and the operator's policy author set (§12.5). App labels, like claim names, are literal manifest strings rather than hash-derived public namespaces (§5).
What that root may grow, it may grow only under a test. Every host name is vocabulary every target must implement identically, so an addition is admitted only when one of three things holds:
- The TCB already calls it for work the host itself must perform.
- It cannot be delivered as a module at the endpoints. The manifest verifier is the canonical example, because it cannot be admitted through the format it verifies.
- It is a migration the host itself must call. Provisioning a transform merely so some future bundle can utter its name does not qualify.
A pure function of bytes the guest already holds is computation, not a grant, and belongs at the endpoint unless the endpoint cannot implement it correctly. Raw Ed25519 verification remains on SeamCrypto because the host calls it for scoped node/verify; it is not a second guest-visible name. Seedstore reaches HOST_TRANSFORM_NAMES only for hashing and record encryption, and uses scoped node/verify for signatures; the transport carries ML-KEM in its own mlkem module. The host-transform table is not a primitive catalog or a promise that more algorithms will be provisioned there.
The same test is why the residual table does not shrink further. It gates additions; read backwards as a removal mandate it would trade tested primitives for vendored ones to delete a name. blake2b is test 1 outright (the host hashes bundles, derives author ids and subkeys with it, and it is the one system hash), so the guest name is a second caller of code the TCB carries regardless; on the native target it is additionally a Go primitive on the storage hot path. chacha20poly1305-ietf/{seal,open} is the channel's per-frame record layer, not a twice-per-handshake step like ML-KEM, so relocating it buys a scratch copy per record against a library the host already links. x25519/dh is the one name that fails the test on its merits, but no X25519 exists in the vendored PQ trees (WASM/pq), so dropping it means vendoring a second, unreviewed, handshake-critical implementation; the libsodium core is 217 KB with or without it. And a bundle module cannot borrow the host's copy: a module runs in its own isolate under exactly three imports (abort, seed, trace; host/module-table.ts), and reaching libsodium would mean exporting the linear memory that holds the node secret key on every node/sign. The seam is that import in its safe form. The honest remaining win is narrowing what the host's loadCrypto publishes, which is an export surface, not this ABI.
Freshness is not revocation, and the difference is the whole exposure of a stolen key. version refuses downgrades, read at admission and again in the commit window so a slow candidate cannot land over a newer one (§12.4); it cannot say the key is still its author's. An attacker holding the admitted hybrid author key set signs version + 1 and lands as an ordinary upgrade, now and for every later release, with signature verification working exactly as specified throughout. The remedy is the written-off author set, checked ahead of the version on every load, whose one action also uninstalls what the key landed (§12.5; why it is local operator state rather than a signed object is DESIGN §12.5). The residual is the window between the compromise and the operator noticing it, which no host-side mechanism shortens; detection is out of scope.
Guest signatures are domain-confined; the node's raw key never signs guest bytes. Because the host chooses both domain and scope (§12.2), no app-obtainable signature verifies in any protocol context (not as a manifest, not as a channel AUTH) nor in any other app's scope, since the slots on a node hold distinct labels. The scope is the label's, so whatever holds the label next signs in it (§5); who holds a label is the operator's replaces. The link slot's DOMAIN_link_scope is conferred only by boot selection or by replacing the link owner (§12.5), and even that occupant cannot sign a manifest, sign in an app's namespace, or obtain the key.
What remains is deliberate: within its own scope an admitted app speaks for the node: a compromised guest can sign arbitrary statements in its namespace (a storage app's chunk descriptors, say). That is the authority the operator granted by running the bundle, and the oracle form contains it: the key can be used but never exfiltrated, so unloading the bundle revokes it instantly, where an app-held key would outlive its own compromise. Apps SHOULD sub-separate their object types under their scope (a distinct leading tag per signed format), the same prefix-family discipline one level down, exactly what the transport does with DOMAIN_channel.
Replay and ordering are settled where the bytes live, never in routing. Routing holds no state beyond the claim → slot maps, and a module's private state is disposable (§4.3), so neither supplies durable replay history. Each layer that owns bytes closes its own replay: the transport's strict per-direction counter rejects a replayed frame within a session (§12.6); bundle freshness refuses an older install (§12.4). What neither covers is a relayed message reaching a node over a different link than it originated on, which is precisely the case a relayed-message app takes on itself with per-message signatures and a backlink chain (§5). Installation touches no replay state because it is an operator call into the host, not a self-authenticating message anyone could resend.
Ownership is structural. A slot holds exactly the realm, modules and scopes derived for it and a claim points at the slot (§3), so no ownership register or module lineage can drift from the code it describes. There is no install log either: version supplies the ordering successive slots need, per (author, app), and the install path is not a replicated history verifier. An app needing verifiable lineage across relays builds it on top from signatures and backlinks (§5), for instance a replicated append-only log of its signed manifests (Bamboo is the design to borrow), so lineage lives where it can be verified.
Claims select data recipients without granting host services. The signed protocols and services lists (§12.10) cannot make unadmitted code run, widen guest.requires, or let an app act in another's signing scope (§12.2). They do determine which admitted app receives input and supplies a response. Delivering decrypted messages or sensitive local call arguments to the wrong app is a confidentiality breach; trusting its answers can compromise application integrity. Uninstalling that app stops future delivery but cannot retract disclosed data. Collision checks prevent a different slot from taking an occupied claim; they do not reserve free names, including names released by uninstall or an update. Operators should pin sensitive claims to approved owners (an author and an app label) in the existing admission predicate (§12.5; code in CLIENT.md), for ordinary app admission; transport candidates are authorized through explicit transport selection. Channel authentication identifies the immediate peer node, not the author or app serving a protocol on that node; any stronger remote-app assurance belongs to the application's protocol.
Structural sandbox. A module has no host imports (§4.2); a guest reaches exactly its guest.requires, with ungranted backends unwired (§12.1). Its modules, filesystem namespace, and signing oracle are private values on its slot, so cross-app access is unrepresentable rather than checked by label on each call.
Resource bounds. Confinement answers what untrusted code can reach; the bounds answer what it can consume. The rules are §4.1, §4.3 and §12.3; in summary:
| Memory | Compute | |
|---|---|---|
| WASM module | Declared in the module, refused at admission (§4.1) | Killed at the engine when the calling guest's remaining segment runs out, armed by default on every target (§4.3); the costs are below |
| JS guest | Realm heap cap (§12.3) | Execution and handoff deadline (§12.3, §16.1) |
Beyond the table, boundedness follows the three laws of §12.3. Network-originated roots arrive only over mutually authenticated links, but authentication is attribution rather than trust, and until the transport schedules that ingress, peer- and host-initiated roots are bounded per invocation, not in aggregate: there is no node-wide CPU guarantee.
Module CPU is bounded at the engine, not in the realm. QuickJS's interrupt handler ticks between bytecode executions and a WASM call is one bytecode, so it cannot land inside the call. What lands instead is the deadline the call carries (§4.3). The worker or wazero context kills the module when that remainder is gone, and the actual module burn is billed back to the guest's compute account when the call settles. Both halves are needed: the absolute deadline covers the parked caller and the engine lever stops the core actually running the module. A guest therefore cannot renew time by sequencing await host.call(…) turns.
On the JS targets the lever is the worker kill. Each module runs in its own worker, instantiated once while the candidate slot is built. A spinning module burns at most one core for one budget, and the host thread never blocks. That round trip measures (this machine, Node 20, WASM/tests/bench-module-call.mjs, on ws.wasm) as a fixed ~25 µs hop on a small call and ~110 µs on a 64 KiB transfer both ways. Standing a worker up costs ~20 ms per module at slot construction and once per kill-and-respawn; native instead pays wazero checks during compute.
On the native target the lever is wazero's WithCloseOnContextDone, armed for app modules. Each call context carries the calling guest's live remaining segment. Construction also runs under the shared default guest deadline because instantiation itself can execute a start section.
The native binary uses arj03/wazero, branch inline-termination-check, pinned by commit in native/go.mod. It checks the module's Closed word inline at each loop back-edge and on entry to every function that makes calls, and calls the termination trampoline when set. The entry check is what bounds a module that never loops: a call tree makes 2^depth calls at a stack depth of only depth. Measured execution time with checks enabled is 1.21× on RS encode, 1.15× on RS decode and 1.07× on XChaCha20 (native/module_bound_bench_test.go); overhead depends on the number of loop back-edges and calls per byte.
Why it is a default and not a flag. A bound nobody turns on bounds nothing, and the price is small enough not to ask: ~1.1–1.2x on app modules, nothing at all on the TCB's, plus a fixed ~80 ns per call, with no allocation, to arm and disarm the deadline (one shared timer, re-armed per call; BenchmarkBoundCallOverhead). That fixed part is the one to watch, because unlike the checks it does not scale with the work, but it is noise against the ~400 µs RS calls it sits in front of, and it buys not handing a spinning module the node's only thread.
The host TCB modules (libsodium and the ML-DSA verifier) run on a separate, unarmed runtime: a wedged trust-root module is a host bug, not a confinement breach. ML-KEM is instead a private module of the signed transport bundle and uses the ordinary bounded module path. And the check still exits to Go every 256th time, which it must: Go cannot asynchronously preempt a goroutine running compiled wasm, so that exit is a spinning module's only safepoint, and an inline check alone would deadlock rather than merely slow GC. A stop-the-world waits on the spinning goroutine while freezing the very goroutine that would write Closed, so the deadline could never fire at all. 256 bounds that wait while GC pause and throughput come back to what exiting on every check gave, so raising it buys nothing.
A permissive policy (§12.5) multiplies this exposure across many installs. Deployers exposed to untrusted installs can pre-validate module bytecode in the admission policy (a bounded loop checker); the module-call bound itself is already armed on every target, so a wedged module costs one rejected call on a bounded budget, not the node.
Both targets enforce the guest budget by the same mechanism: QuickJS's interrupt handler, so exceeding the budget throws inside the guest and the realm survives it. On the JS/browser target that is setInterruptHandler (safe-js.ts); on the native target it is QJS_SetDeadline in the engine shim (native/qjs/csrc/shim.c), armed by qjs.Runtime.Budget and consulted by the interpreter once every few thousand bytecodes. A locally unbounded realm still honours a finite incoming handoff; with neither a local nor incoming bound the handler remains dormant.
The native lever needs the in-repo QuickJS build. QuickJS offers its interrupt handler only to C, so native/qjs/build-qjs.sh builds the engine with native/qjs/csrc/shim.c, which installs the handler QJS_SetDeadline arms. Without an interpreter-level lever the only native bound is an outside-in wazero deadline over the whole engine call, which compiles a termination check into every loop of QuickJS itself: 2.3× on guest realm dispatch and 2.05× on a network round trip, since the transport is a bundle and the entire data path runs inside a realm. The same build keeps the engine's stack limit, which quickjs-ng switches off for WASI: a patch turns it back on, so deep recursion throws a RangeError inside the guest instead of running off the wasm stack into a trap that leaves the realm's engine unusable.
On both targets a realm that is stopped mid-flight must settle whatever it still owes: a guest promise can only be resolved from inside the realm, so a kill that lands in a continuation leaves the caller waiting on something nothing can settle. safe-js fails its outstanding callers explicitly (and on dispose() too); the native realm does the same from within, then nudges the event loop, since rejecting a promise from Go only queues a microtask that the loop must still pump. It has to be host business rather than the guest's own promises finishing the job: the throw rejects the entrypoint's promise, but delivering that rejection is more queued guest work, which would run under the budget just exhausted and be interrupted in turn. So the host observes the interrupt (QJS_TakeInterrupted) to break the circle; a job's exception is consumed by the job loop and never surfaces to the caller that pumped it. A bound that converts a runaway guest into a hung host is not a bound.
A guest that overruns therefore fails the live work the realm still owes: callers get an error and the realm stays usable. The exhausted deadline remains in force while the interrupted frame unwinds, so leftover jobs do not buy a fresh allowance each time the loop pumps; the next independently admitted entrypoint uses its own deadline.
Channel confidentiality, live authentication and long-lived application signatures have different exposure to future cryptographic breaks. Bundle admission and channel establishment use the fixed suites below; guest-obtainable signatures have application-defined lifetimes (§14.2).
Recorded channel confidentiality depends on both ephemeral secrets. Suite 0x03 derives every key from msg2 onward from X25519 and ML-KEM-768 (§12.6). Breaking X25519 alone is insufficient to decrypt a recording: the recorder must also break ML-KEM-768 or recover an endpoint's ephemeral KEM secret, and both ephemeral secrets are erased once session keys are derived and again at teardown. Endpoint compromise while secrets or plaintext are live remains outside that protection.
Live-verified authenticity is not harvest-vulnerable. Bundle manifests already require hybrid Ed25519 + ML-DSA-65 signatures (§12.4), while a live channel AUTH remains Ed25519 (§12.6). A recording of either verification buys an attacker nothing: a future ability to forge cannot retroactively install a bundle or complete a past handshake. This reasoning does not extend to app signatures kept as long-lived records (§14.2).
The hash is already fine. BLAKE2b-256 is the one system hash (§5). Grover gives at most a quadratic speedup on preimage search (256 bits down to a ~128-bit work factor), and the serial-depth requirements make even that unrealistic. Content-addressed blocks (genesisHash(bytes) == block_id) and author ids (§12.4) also need collision resistance, and at 256 bits the conclusion is unchanged. The app label is not another trust use of the hash: it is a literal manifest string (§5), hashed only to fit its fs prefix into a filename (§12.1).
What the runtime does today to keep the path open. Both places the runtime uses public-key cryptography carry a suite byte, and both follow one discipline: the byte is the first field of the structure it governs, and it is part of what that structure's signature covers.
- Channel (§12.6): first field of the initiator's opening message, and folded into the transcript hash both ends sign. An in-path attacker who flips it makes the two ends sign different transcripts, so verification fails.
- Manifest (§12.4): first byte of the envelope, and part of the signed preimage under it. An attacker who rewrites it invalidates the manifest. It lets a verifier read the field widths before it can verify anything, and reject an unsupported id with its own error, since "this bundle wants a host I am not" is a different problem from tampering.
Neither is a negotiation: one suite per link, one per manifest, unknown ids refused. The byte deliberately buys one thing: the format becomes self-describing, so a future suite can change every field width without the two formats being ambiguous. That is the difference between a rollout and a flag day where everything must cut over simultaneously. And because each byte must be read before verification (another suite's keys and signatures are other widths) while being covered by that verification, it is legible up front yet not editable underneath, so a suite is chosen by an endpoint and never forced by an attacker. Algorithm confusion between two suites is unrepresentable rather than merely unlikely.
The two ids are independent namespaces, which is why they are named apart (SUITE_CHANNEL_CONCEALED, SUITE_MANIFEST_HYBRID_PQ) rather than sharing one constant. The manifest id is the host's because the host reads it before anything is trusted; the channel id is the transport bundle's because the AKE that reads it is entirely that program.
Channel suite 0x03 is a hybrid key exchange: every key from msg2 onward derives from both the X25519 and the ML-KEM-768 secret, which keeps the classical half load-bearing while the PQ half is young. The KEM is the transport's own private module; where its secret enters the schedule and what it costs are CHANNEL §11.
Manifest suite 0x02. Hybrid Ed25519 + ML-DSA-65 signatures cover DOMAIN_manifest ‖ suite ‖ ed_pk ‖ ml_dsa_pk ‖ BLAKE2b-256(body) (§12.4). Four properties carry it:
- Both must verify, not either. "Either" is exactly as strong as the weaker algorithm. Requiring both means a flaw in the young half fails closed (valid bundles refused, an operator's recoverable problem) rather than open, and the bundle stays no weaker than a classical-only signature while ML-DSA is new. The ~5.3 KB of header (against 97 bytes) is paid once per install, off the message path (§13), which makes the manifest the cheapest place in the system to absorb it.
- The author id binds the whole key set. The id is
genesisHash(DOMAIN_manifest_author ‖ suite ‖ ed_pk ‖ ml_dsa_pk), not the Ed25519 key. Otherwise an attacker who breaks Ed25519 forges that half, supplies a fresh ML-DSA key for the other, and is admitted as the real author, pin and lineage included. Hybrid signing would buy nothing at exactly the moment it was supposed to pay. The 32-byte width is load-bearing far outside the envelope (policy files, revocations, freshness marks), so a future suite deriving its own id gives an author a new identity: the honest reading, since a different suite is a different statement about who signed. - Both keys are inside both preimages, so the pair cannot be spliced: keeping the sound half's key and signature while substituting a key for the broken half invalidates the survivor.
- A host without an ML-DSA verifier refuses the bundle with its own error rather than falling back to the Ed25519 signature alone, which would be the downgrade the whole construction exists to prevent. An envelope claiming
0x01is also refused; Ed25519-only verification is not supported.
One implementation, three targets. ML-DSA-65 comes from a single freestanding wasm module built from the pinned mldsa-native submodule: the browser fetches it, Node reads it, and the native binary instantiates it under wazero, exactly as all three share libsodium.wasm for Ed25519. This is not an optimization: the accept/reject boundary of a verifier is consensus, and two independent implementations of a lattice scheme can disagree at the edges while both pass their own tests. The module is checked against NIST's published ACVP vectors rather than only against itself, and native/mldsa_test.go sends hybrid-signed bundles, tampered envelopes included, through the production install path. The native binary verifies hybrid manifests but does not sign them.
What is deliberately not solved. Neither suite byte makes a live deployment agile: there is still exactly one accepted suite per layer at any moment, compiled in. That is the intended trade. Runtime algorithm negotiation would mean shipping several implementations, keeping a downgrade-resistant selection protocol correct across versions, and admitting an attacker-influenced choice into the handshake; the byte instead buys the ability to change the constant without the format going ambiguous, which is the part that is expensive to retrofit and cheap to reserve. Migration remains an operator action, deliberately.
Manifests require hybrid signatures and channels use hybrid key establishment (§14.1). Channel AUTH and guest signatures remain Ed25519. None of this design has been reviewed by a cryptographer.
Sealed boxes are a third asymmetric role, but they are the consumer's, not the runtime's. Nothing on seedkernel's own path uses a long-term DH/KEM key or publishes sealed-box calls above the guest seam. A consumer that needs PQ sealing ships and versions that transform in its own bundle; the host does not provision ml-kem-768/* for it.
"Authenticity has no deadline" holds for live-verified signatures, and app-layer signing is not one. The argument in §14.1, that a signature need only be unforgeable at the moment it is verified, is exactly right for the two cases it names: a manifest is verified at install (§12.4), an AUTH transcript at handshake (§12.6), and a recording of either buys an attacker nothing. But the runtime hands out a third class of signature, the guest-obtainable ones scoped under DOMAIN_guest (§12.2, §16.1) and made with the node's one identity key (§12.6.2b). They are separated by scope, never by algorithm: it is the same Ed25519. Those are made for apps, and §5 and §13 both point at the case where an app keeps a replicated append-only log with per-message signatures and a backlink chain. A signature in such a log is verified not once but indefinitely, by every future replica, so its unforgeability deadline is set by the record's lifetime rather than by a verification event. Once Ed25519 falls, an attacker can mint history that verifies. The manifest suite does not cover this axis: the hybrid construction is per-manifest and the guest seam still signs with Ed25519 alone. An app that needs long-horizon non-repudiation wants a PQ signature or an external anchor (timestamping, a witnessed checkpoint), not Ed25519 on its own.
No suite byte on the seam, and no key in the guest. A suite byte makes a format self-describing, and node/sign is a call, not a format: a scope oracle whose algorithm is the host's business, on the same footing as the domain and the scope (§12.2). So the byte belongs in the app's own record, beside its backlinks and version, where the surviving format actually is. The second is not agility but custody. Putting a PQ private key in the guest would surrender the guarantee §14 above rests on: a guest compromise could exfiltrate it, and unloading the bundle would no longer revoke its use.
No fallback, and multi-suite rollout is unspecified. The channel accepts only 0x03 and the manifest verifier only 0x02; neither retries an older suite after failure. The signed suite byte closes downgrade by modification (a flipped byte makes the two ends sign different transcripts), but not downgrade by refusal: a dialer speaking a newer suite than the peer accepts gets silence, indistinguishable from a wrong contact secret, a wrong network key, a declined identity or a peer that never answers. The pressure that creates is to add a fallback because the peer looks down, which reintroduces the downgrade. No current deployment accepts overlapping suites; one that ever does needs an explicit rollout rule: never retry at an older suite after silence, and pin each peer's dial preference alongside the dialedPeerId an outbound dial already carries. Accepting both while dialing at a pin is the sanctioned pattern; a fallback triggered by absence is not.
Constant-time behaviour is an open item. mldsa65.wasm (a host artifact, §14.1) and mlkem768.wasm (a private module of the transport bundle) are each built once from pinned source and run byte-identically on all targets, with no target-native implementation to drift. Constant-time behaviour of the built signing and decapsulation paths remains an independent review item; passing functional vectors does not establish that property.
Exposure as it stands.
| Use | Primitive | Suite byte | Clock | Status |
|---|---|---|---|---|
| Channel session keys (§12.6) | ephemeral X25519 + ML-KEM-768 | yes (§16.1) | harvest-now clock | closed for suite 0x03 |
| Channel identity / AUTH (§12.6) | Ed25519 | yes (§16.1) | relaxed (verified live) | adequate per §14.1 |
| Manifest signature (§12.4) | Ed25519 + ML-DSA-65 | yes (§16.1) | relaxed (verified at install) | closed: suite 0x02, admitted by policy |
| Guest / app signatures (§12.2) | Ed25519 | no, deliberately | depends on record lifetime | the relaxed clock does not transfer; the app's to close, inside its own record |
| Block ids and author ids (§5, §12.4) | BLAKE2b-256 | n/a | none | no change needed |
Long-lived application records need protection appropriate to their lifetime. The scoped node/sign API supplies Ed25519 only; a durable PQ signature or external checkpoint is the application's responsibility.