Skip to content

v0.29.0: multi-realm distribution, and an ingestion premise corrected twice - #101

Open
avrabe wants to merge 16 commits into
mainfrom
plan/v0.29.0
Open

v0.29.0: multi-realm distribution, and an ingestion premise corrected twice#101
avrabe wants to merge 16 commits into
mainfrom
plan/v0.29.0

Conversation

@avrabe

@avrabe avrabe commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Draft — 3 of 4 requirements verified. Opened so CI runs against the real branch.

What lands

requirement
REQ-INGEST-001 GitHub build provenance as a first-class ingestion mechanism beside cosign sums; which mechanism vouched is recorded in the signed payload; a tool with neither is refused
REQ-ROLLING-001 a scanner that notices upstream movement and opens a PR — it never signs
REQ-REALM2-001 two real realms composed end to end, and varve#91 settled
REQ-LAYERREPO-001 pulseengine-layers exists and is bootstrapped; the layer.toml → deposit-spec adapter does not

Two premises I got wrong, both corrected by evidence

bytecodealliance provenance. I reported they publish nothing verifiable. Wrong — I listed release assets and filtered for cosign/sums filenames, and GitHub build attestations aren't release assets. Measured with negative controls: wasm-tools verifies, wit-bindgen has none, cargo-component ships no assets. That correction produced a better design than either option I'd offered — provenance is stronger than a sums file, binding artifact to workflow, repo and commit, which is what [tool.source] always wanted to record and couldn't.

Keyless signing. Recorded as DD-025 on premises drawn from module names and a grep -c todo!. A spike read the source: wsc 0.10.0's airgapped verifier is a stub that fails open, tuf.rs performs no TUF, and the clockless fallback is a diagnostic — the verify path fails closed. Most decisively, the trust bundle is itself signed with a long-lived key the consumer must pin, so keyless relocates the secret rather than removing it. DD-026 supersedes it; varve keeps ed25519. Five issues filed upstream (sigil#256–#260).

varve#91 — the fix its own error message couldn't express

The collision advice — "restrict the pin's tools" — wasn't unimplemented, it was structurally incapable: tools filters by name, and the collision is two layers exposing the same name. tools now takes a realm qualifier (tools = ["bytecodealliance/wasm-tools"]), the unchosen layer stays installed and reachable qualified, and exactly one shim per name means the pin decides dispatch — never install order. Realm precedence was rejected for that reason.

What the gates found that review didn't

tools/systest/compose-realms.sh builds both realms with the production assembler, signs each under its own root, composes them through a signed include, and its negative control rebuilds varve with the pin's choice ignored. It found three defects:

  • the assembler could only build a pulseengine-shaped layer — "a second realm is constructible" was a claim nothing could execute
  • it couldn't produce a composing layer at all
  • a tarball tool whose asset template matched nothing was silently omitted — this dropped the fork and the run went green

It also killed three pre-existing compose.rs mutants and promoted that file into the zero-survivor gate.

Gates

fmt · clippy · test · docs-coverage · rivet validate · claim-check · verification-evidence — all exit 0. Plus deposit-layer.sh, scan-upstream.sh and compose-realms.sh, each with its own negative control confirmed red.

REQ-LAYERADAPT-001 — a realm's manifest, translated exactly or not at all

varve layer-spec reads a realm's layer.toml and prints the environment the assembler already consumes, so a realm's contents live in that realm's repository. Bumping rivet stops being a commit to the tool that signs the layer. The adapter lives beside the assembler it feeds, not in each realm's repo, because the assembler is system-tested here and a per-realm copy inherits none of that.

It refuses far more than it accepts, for one reason: the assembler's inputs are space-separated entries of colon-separated fields, and a shell does not complain when a value contains either — it splits or truncates, and the layer signs and verifies while carrying the wrong bytes.

Two of those refusals exist because cargo mutants found them. The tests and implementation were written together, so they agreed with each other; mutation testing is what proved they could catch a wrong implementation. Two survivors, both real:

  • deleting the "tarball" match arm killed nothing — every fixture omitted layout or used raw-per-platform, so the spelling the docs invite was untested;
  • flipping && to || killed nothing, which exposed the serious one. The assembler derives a tarball tool's identity from the repository basename, then names the payload, extract dir and asset template after it. name = "wsc" with repo = "pulseengine/sigil" would have deposited a payload called sigil — invisible to anyone asking for wsc, in a layer that deposited, signed and verified cleanly.

That second one is the exact defect class this module was written to prevent, sitting inside the module written to prevent it. A foreign owner stays legal (bytecodealliance/wasm-tools is the point of the second realm) and a test pins the distinction. The && survivor's right fix was deleting the condition: once the mismatch is refused, it was a condition no input could vary.

layerspec.rs joins the trust-critical gate at zero survivors (21 mutants, 18 caught, 3 unviable), on the terms compose.rs set. That job's timeout goes 45 → 60 min; a gate that times out is indistinguishable from one that fails.

Two existing gates caught this change and both were right: the CI-tagged-command contract demanded --json, and the documented-example test parsed the new layer.toml with the deposit-spec parser because it classified on [[tool]]. It now classifies on [varve] and translates the block rather than merely parsing it.

What is deliberately NOT claimed

REQ-LAYERREPO-001 is not marked verified and moves to v0.30.0. Its clauses 4 and 5 are not code: they need pulseengine-layers to publish a real layer with a key its custodian must provision, and varve to drop that secret only after that publish succeeds. Neither can be discharged by the party writing the code. The layers repo's deposit workflow still refuses to run rather than pretending, and pulseengine-layers pins varve v0.28.0 — so it cannot call layer-spec until this release ships.

avrabe and others added 16 commits August 21, 2026 13:55
… a human

A manually-cut layer cannot keep up. Days after 2026.08.3 was published, rivet
had moved v0.33.1 -> v0.34.0, spar v0.36.0 -> v0.40.0 (four minors), synth
v0.55.0 -> v0.58.0, witness v0.39.0 -> v0.43.0, ordeal v0.18.0 -> v0.19.0. A
consumer pinned to `rolling` was not getting a rolling toolchain; they were
getting whatever was current the last time somebody remembered.

The obvious automation is the wrong one (DD-024). `channel = "rolling"` is not
a trust boundary in varve — a rolling layer is signed by the SAME realm root as
a qualified one, and the realm is the boundary. Publishing on a timer means
that root signing unattended dozens of times a week, against everything `varve
docs root-ceremony` says about it, in a system with no revocation and no
rotation, where a layer published in error cannot be withdrawn.

So this automates the toil and leaves the judgement:

  tools/scan-upstream.sh        notice what moved; check who vouches for each
                                new artifact; rewrite the pins; write the diff
  tools/upstream-mechanism.sh   the ingestion-mechanism table. The mechanism is
                                a VALUE the proposal reports, not the
                                assumption that it is cosign: cosign-signed
                                sums and GitHub build provenance are two
                                entries, and UPSTREAM_MECHANISMS is
                                configuration
  tools/next-layer-id.sh        the layer id and per-line counter, read out of
                                the published registry rather than typed
  .github/workflows/scan-upstream.yml
                                daily scan -> assembly gate on the PROPOSED
                                pins -> one pull request; and, separately, a
                                merge dispatches the signed deposit

Nothing here deposits, signs or publishes. Clause by clause: the scan opens a
PR carrying the version diff (1); the existing REQ-SYSTEST-002 assembly gate
runs against the proposed env block before anyone is asked to look at it (2) —
in the scan run itself, because a PR opened by GITHUB_TOKEN does not trigger
`pull_request` workflows and a gate that silently never ran would be worse than
none; the merge, and only the merge, dispatches the deposit (3); the PR names
each tool, how far it moved, how many releases went by, and which mechanism
vouched for the new artifact (4); an upstream nothing vouches for holds ITS pin
and lets the rest of the layer move (5); and a scan where nothing moved writes
nothing and opens nothing (6).

Daily, not hourly. Over the 30 days to 2026-08-21 these nine repos published 56
non-draft releases on 15 distinct days — half the days had nothing, and the
busy days came in bursts of seven to nine. Hourly would scan 24x to find the
same answer and, on a burst day, rewrite the same proposal nine times, each
rewrite invalidating the assembly gate that had just run on it.

tools/systest/scan-upstream.sh is the gate, with fixture-backed `gh` and
`cosign`: a repo named in both env lists, an upstream with no attestation, one
whose bundle does not cover its sums, a prerelease and a draft that must not be
proposed, a pin ahead of upstream, both mechanisms in one proposal, the
silent cases, and a negative control that reintroduces a per-list rewrite and
requires the gate to go red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
The layer assembler accepted exactly one proof that an upstream artifact was
genuine — a cosign-signed SHA256SUMS.txt — and aborted the whole run otherwise.
Every PulseEngine repo publishes one, so nobody noticed the constraint until a
second realm was attempted. bytecodealliance/wasm-tools v1.257.1 publishes no
sums and no cosign bundle (measured 2026-08-21), and it was therefore
uningestible despite carrying something STRONGER: a GitHub build attestation
binding each artifact to publish.yml@refs/heads/main and a source commit.

Clause 1. `verify_release` now asks what a release publishes and picks a
mechanism: a cosign-signed sums file, or a build attestation whose in-toto
statement supplies the digest of every asset (which is why provenance REPLACES
the sums file rather than accompanying it). The ladder does not downgrade — a
sums file whose signature is REJECTED aborts, because "no cosign proof" and "a
bad cosign proof" are different facts.

Clause 2. Three annotations inside the DSSE-signed payload, beside the kind and
the source digests: eu.pulseengine.source.proof (the mechanism),
.proof-signer (the identity that vouched), .proof-asserts (what it asserted).
A consumer can tell a cosign-signed tool from an attested one without leaving
the layer. Absent reads as `unrecorded`, never as verified: every layer already
published carries no annotation, and defaulting would upgrade them all to a
claim nobody made.

Clauses 3+4. A release with neither mechanism is refused, and the refusal names
the fork through this org's signed pipeline — for wit-bindgen that IS the
answer. Shipping one anyway needs UNVERIFIED_INGEST="repo=reason"; the reason
is mandatory and is signed into the layer. `varve deposit` independently
refuses `unverified` with no reason, or naming a signer that vouched for
nothing.

TARBALL_TOOLS entries gain an optional owner and asset template, without which
no foreign realm can be named at all. Existing entries parse unchanged.

The producer gate now carries a cosign-signed realm, an attested one and one
with neither, checks the mechanism in the signed payload rather than in the
spec, and gains a second negative control: with the refusal deleted the
unproven tool must sail through, which is what proves the refusal is what
stopped it.

Not satisfied: clause 5. The data is in the layer and reachable via
ManifestEntry::ingest_proof(); wiring it into `varve inspect` is CLI code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
REQ-INGEST-001 and REQ-ROLLING-001 landed from two agents with disjoint
file ownership. Clean merge, no conflicts.

The mechanism id diverged, exactly as the hand-off anticipated: ingest
minted `build-provenance` for the signed annotation, the scanner reported
`gh-provenance` in its proposal. Reconciled toward the PAYLOAD's
vocabulary, not the scanner's — the annotation is a compatibility promise
inside a signature that consumers will policy on, and the PR body is
prose that can change freely. What the proposal says vouched for a tool
is now the same string the layer records.

Wired clause 5 (`varve inspect`), which the ingest agent specified but
could not apply — CLI is not its ownership. Per payload: the mechanism
and the identity credited with vouching, kept as separate fields because
the signer is the thing a consumer might FILTER on ("refuse anything not
signed under pulseengine/") while the assertion is prose. In the summary:
`unverified` and `unrecorded` counts, so a CI gate can assert "nothing
here went unverified" without walking the list.

`unrecorded` is deliberately not `unverified`. Every already-published
layer carries no annotation, and defaulting either way would restate a
hundred payloads as a claim nobody made.

Then the part that matters: the producer gate CHECKED clause 5 with a
NOTE that printed "NOT satisfied" while the gate passed. A clause a
passing gate reports as unmet is a clause nothing enforces — the exact
shape this release exists to remove. It is an assertion now, and it
demands the real mechanisms this layer mixes rather than the mere
presence of a key, plus the summary counts. Verified it bites by deleting
the wiring:

    FAIL: clause 5 — the mechanism is in the signed payload but
    `varve inspect --json` does not surface it (no "ingest_proof" key)

Two findings from the agents worth keeping visible. Provenance REPLACES
the sums file rather than accompanying it — the in-toto statement carries
the sha256 of every asset in the release, so verifying one artifact
authenticates the whole subject list. And `verify_release` now decides
from what a release PUBLISHES, not from a download that failed, so "no
cosign proof" stays distinct from "a REJECTED cosign proof": a bad
signature aborts rather than falling through to the weaker path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
Both advanced via `rivet verify`. INGEST on 7 source markers; ROLLING
needed a VER- artifact because its evidence is shell-based and carries no
cargo-test markers — `rivet verify` refused it outright, correctly, and
that refusal is the reason the record exists rather than a status flipped
by hand.

VER-ROLLING-001 records the constraint, not just the result: automating
the rolling channel end to end would have the root key signing unattended
in a system with no revocation, one release after publishing a ceremony
document that says otherwise. It also records the clause met in substance
but not in letter — a GITHUB_TOKEN PR does not trigger `pull_request`
workflows, so the assembly gate runs BEFORE the PR and its verdict goes
in the body. Stated rather than hidden.

Both gates run here: the scanner systest passes with its negative control
red (a per-list rather than per-repo rewrite is caught), and all four
non-cargo-test evidence steps execute green — checked directly, because
`rivet check verification-evidence` reads cargo-test filters and cannot
see a `run:` grep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
The collision error's own suggested fix is not unimplemented — it is
structurally incapable. `tools` filters by NAME; the collision is two
layers exposing the SAME name. No value of it has ever been able to
disambiguate, which is why the persona who tested it twice found it did
nothing. The other suggestion, remove the duplicate from one layer, is
unavailable to a consumer composing a realm they do not control. So the
command refuses correctly and then offers one impossible fix and one
unavailable one.

This stops being an edge case in v0.29.0. Two realms shipping
`wasm-tools` is the ORDINARY case the moment a fork exists beside its
upstream — exactly the topology this release builds, where the fork
exists precisely because upstream does not attest that tool.

4a gives `tools` an optional realm qualifier, so the filter can express
the choice its own error demands. 4b keeps the unselected layer installed
and verified and addressable qualified, because comparing a fork against
its upstream is a real workflow and losing the binary is worse than
refusing. 4c keeps exactly one shim per name, so a bare name is decided
by the pin and never by install order — realm PRECEDENCE was considered
and rejected for that reason: it would let adding a tool to a
high-priority realm silently change which binary a build runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
Two decisions changed what these clauses should demand, and a clause that
outlives its decision is how a graph starts lying — the same correction
DD-023 forced on REQ-ADVISORY-002 two releases ago.

REALM2 clause 1 required a PUBLISHED bytecodealliance realm. It now
requires the realm to be constructible and composable, proven by a system
gate over real upstream artifacts and real roots; publishing is
operational and tracked separately. Generating a realm root is a ceremony
`docs root-ceremony` says should be air-gapped, two-person and unhurried
— blocking a code release on it would either delay the release or rush
the ceremony, and rushing it is the worse outcome in a system with no
rotation.

LAYERREPO clause 4 said the pulseengine realm migrates first. It now says
exactly ONE repo is stood up first and must publish successfully before a
second exists, so a migration failure lands on the realm we can fix
rather than on a new realm's first impression. The rejected alternative
is recorded: one repo holding both realms puts two roots' secrets in one
repo's settings, and unpicking that later means rotating keys varve
cannot rotate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
`union_tools` refused a tool exposed by two layers and then offered two
fixes. One — "Restrict the pin's `tools`" — is STRUCTURALLY incapable of
working: `tools` filters by NAME and the collision is two layers exposing
the SAME name, so no value of it has ever been able to disambiguate. A
persona tested it twice and reported it doing nothing; they were right.
The other — "remove the duplicate from one layer" — is unavailable to a
consumer composing a realm they do not control. So varve refused
correctly and then offered one impossible fix and one unavailable one.

That stops being an edge case in v0.29.0. `pulseengine/wasm-tools` is a
fork of `bytecodealliance/wasm-tools`, and the fork exists precisely
where upstream does not attest, so two realms shipping `wasm-tools` is
the ORDINARY case the moment a fork is cut.

4a — `tools` accepts an optional realm qualifier:

    tools = ["bytecodealliance/wasm-tools", "rivet"]

A bare name is unchanged where nothing collides; the pin stays a
compatibility surface. A `tools` entry is now either a plain name or one
realm qualifier ahead of a plain name, and everything else — a path, a
deeper path, an empty half — is still refused, with a message that shows
both accepted forms rather than sending a reader back to the one that
cannot express their fix.

4c — the choice is made in exactly one place. `compose::select_tools`
reads the pin and nothing else: not install order, not partition order,
not a precedence list. Realm PRECEDENCE was considered and rejected for
that reason, since it would let adding a tool to a high-priority realm
silently change which binary a build runs. `Resolved.tools` still holds
one entry per NAME, so the shim directory stays one file per name.

4b — the unselected layer stays installed and verified and stays
addressable: `varve run pulseengine/wasm-tools`, `varve which
bytecodealliance/wasm-tools`. Comparing a fork against its upstream is a
real workflow and losing the other binary is worse than refusing. A
qualified `which` also prints the layer that OWNS the binary, which the
existing `layer …` line cannot (it names the layer the pin resolves to).

4d — where the pin has not chosen, the refusal names both providers WITH
their realms and shows the qualified line to paste. Where a qualifier
cannot help — two layers of one realm, or a layer with no realm at all —
it says so instead of printing a form that would not work.

Clause 3, the gate: tools/systest/compose-realms.sh builds BOTH realms
with the production assembler (the same tools/build-deposit-spec.sh
deposit-layer.yml runs), signs each under its own generated root,
composes them through a signed `[[include]]`, installs one at a time and
verifies each against its own realm's root — then settles the name they
share, asking the RUNNING BINARY which realm it came from rather than
reading a path. It ends by rebuilding varve with the pin's choice
deleted from `select_tools` and requiring the run to go red.

Three things the gate found that no unit test could:

* the assembler could only build a PulseEngine-shaped layer — WSC_VERSION
  and VSIX_PACKAGES were required non-empty, and both name pulseengine
  repos, so "a second realm is constructible" was a claim nothing could
  execute. Empty is now a legal answer for both.
* it could not produce a COMPOSING layer at all; composition could only
  be hand-authored. `COMPOSES` emits the `[[include]]`, so the gate tests
  the artifact rather than its own TOML.
* a tarball tool whose asset template matches nothing on ANY platform was
  silently omitted with four notices — the vsix loop has always refused
  that shape. It now refuses too, with a scenario in deposit-layer.sh.

And one regression this found in the change itself: offering every name
the SIGNED manifest declares exposed `loom` on a host whose platform that
release skips, so an unrestricted pin stopped resolving. Offers are now
manifest ∪ `bin/`, minus any name no layer has on disk — which keeps a
half-installed root from silently yielding its name to an included layer
(dispatch decided by install state is exactly what 4c forbids) without
over-reporting a tool this host never received.

Gates, by exit code: cargo fmt --all --check, cargo clippy --workspace
--all-targets -D warnings, cargo test --workspace,
tools/systest/deposit-layer.sh and tools/systest/compose-realms.sh all 0.
cargo mutants over pin.rs and resolve.rs: 107 mutants, 0 survivors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
`select_tools` now decides which binary a bare tool name dispatches to
across two realms (REQ-REALM2-001 clause 4c). That is a trust decision,
and it sat in a file only the advisory nightly run covered.

It had three survivors while it was advisory: two off-by-ones in the
depth bound (`>` mutated to `>=` and to `==`, both invisible because no
test walked a chain exactly MAX_DEPTH long) and one refusal branch. All
three are killed by tests added with the qualifier work — a file admitted
to a zero-survivor gate with known survivors is not a gate.

Measured locally: 37 mutants over compose.rs, 32 caught, 5 unviable, 0
missed; 107 over pin.rs and resolve.rs, 79 caught, 28 unviable, 0 missed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
…o real realms

varve#91's suggested fix was structurally incapable — `tools` filters by
NAME and the collision is two layers exposing the SAME name. `tools` now
accepts an optional realm qualifier, so the filter can express the choice
its own error demands. A bare name still parses to no realm and resolves
exactly as before.

`compose::select_tools` is the ONLY thing that decides what a bare name
dispatches to: it reads the pin's map and nothing else — not install
order, not partition order, not precedence. That is 4c, and precedence
was rejected for exactly this reason. The unchosen layer stays installed
and verified and is reachable qualified; `which` gained a `provided by
realm …` line, because the existing `layer …` line names the layer the
PIN resolved, which for a composed tool is the wrong answer to "which
fork is this".

The gate is the point. `tools/systest/compose-realms.sh` builds BOTH
realms with the production assembler, signs each under its own generated
root, composes them through a signed `[[include]]`, and verifies each
against its own root. Its negative control rebuilds varve with the pin's
choice ignored — a real `cargo build`, not a stub — and requires the
collision to become inescapable again. I ran it: PASS, control red.

Three defects it found that no unit test could:

  * the assembler could only build a PULSEENGINE-shaped layer —
    WSC_VERSION and VSIX_PACKAGES were required non-empty and both name
    pulseengine repos. "A second realm is constructible" was a claim
    nothing could execute.
  * the assembler could not produce a COMPOSING layer at all, so the
    gate would have had to hand-write the TOML it was meant to test.
  * a tarball tool whose asset template matched nothing on any platform
    was silently omitted with a notice. It bit the agent directly — a
    %V mismatch dropped the fork and the run went green.

It also killed three pre-existing `compose.rs` mutants (two off-by-ones
in the depth bound, one refusal branch) and promoted that file into the
zero-survivor gate, since `select_tools` is now a trust decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
Maintainer decision, recorded as a DIRECTION with its open questions
named — the design is not finished and pretending otherwise would be the
overclaim this project keeps removing.

Today a realm IS a long-lived ed25519 secret. threat-model and
root-ceremony both state the consequences as permanent: no rotation, no
revocation, no expiry, no transparency log. That is not an implementation
gap, it is what pinning a raw public key means. Keyless removes the ASSET
rather than mitigating the risk — nothing to generate, split, paper-back,
store, rotate or lose.

What made it credible is that sigil's wsc, which varve already depends
on, has the pieces: signature/keyless/ (Fulcio, Rekor, OIDC, cert
pinning, Merkle) and airgapped/ doing OFFLINE verification against a
provisioned Trust Bundle, with a real tuf.rs — 471 lines, zero stubs. The
air-gap objection was the one that nearly killed this and it does not
survive contact with the source.

Two findings decide feasibility. airgapped::verify_signature takes a
32-BYTE HASH, not a WASM module — a layer manifest digest is exactly
that, so offline verification is already general enough. Signing is not:
sign_module is WASM-shaped, so wsc needs a sign-an-arbitrary-digest
entry point, which is a small addition to sigil rather than a subsystem.

Also worth recording: the bundle verifier handles expiry with a grace
period AND an explicit unreliable-time fallback for devices with no
clock, which is exactly varve's air-gapped consumer. And a short Fulcio
cert does not rot old layers — the Rekor entry is the durable evidence
that the signature existed while the cert was valid.

Five open questions are written into the decision rather than left
implicit, the sharpest being whether provisioning a Trust Bundle is
actually easier for a consumer than pinning 64 hex characters. If it is
not, we have moved the problem rather than solved it, and I would rather
that be answered by a spike than assumed by me.

The hardware alternative was investigated and rejected on evidence, not
preference: the available token is a YubiKey NEO (firmware 3.0.0) whose
PIV and OpenPGP applets cap at RSA 2048 and cannot sign ed25519 at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
…emove it

A spike read the source instead of the module names and refuted three
premises I had written into DD-025 hours earlier.

The deciding fact: wsc's air-gapped trust bundle is ITSELF signed with a
long-lived offline key that the consumer must pin to use the bundle at
all. Keyless does not delete the asset — it moves it from the realm root
to whoever signs the bundle, and charges a 4.5 KB expiring artifact and
roughly 120x per-layer overhead for the move. The maintainer's motivation
was unease with holding this class of secret. The mechanism does not
address it.

For an air-gapped consumer the properties get WORSE. Today's pinned root
has no expiry and no update obligation; a trust bundle hard-fails at day
396, and a clockless device fails immediately.

My three false premises, all from names rather than code:

  * wsc 0.10.0 — the version varve PINS — does not do offline keyless
    verification. verify_crypto says 'in a full implementation we would'
    and does neither, and parses SPKI DER as ed25519 while Fulcio issues
    ECDSA P-256, so it cannot succeed on a real cert. The fix is closed
    upstream but unpublished.
  * tuf.rs performs no TUF. It is an HTTP GET against a hardcoded URL
    plus a JSON converter. I cited '471 lines, zero todo!' as evidence of
    completeness. It is evidence of neither.
  * the clockless fallback is in a DIAGNOSTIC function; the verification
    path fails closed. varve's air-gapped consumer is exactly the case I
    claimed it covered.

Also corrected: Rekor inclusion proofs are verified on neither path and
do not travel — varve's own v0.28.0 bundle carries a SET but no inclusion
proof. The SET is timestamping, not inclusion. Any doc implying otherwise
would be a fresh false claim.

Keyless is deferred, not dead. It buys a real custody reduction — a
ten-minute cert window instead of a decade-long key — and becomes
decidable when wsc 0.11 ships, sigil#256 lands, and the payload-type
binding is designed out rather than lost. Signing a bare digest would
drop a property varve has and TESTS.

Next move is deliberately not a realm-model change: adopt the trust
bundle for varve's own release artifacts first, where a hard expiry
failure is recoverable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
DD-026's next move, scoped deliberately small. The question that decides
whether keyless is ever worth taking is empirical — can a consumer keep a
trust bundle fresh? — and it gets answered on the cheapest surface rather
than the most expensive.

`varve self-verify` already verifies varve's own release sums out of
band. Adding the trust bundle as an ADDITIONAL verifier there exercises
provisioning, expiry and refresh against a real consumer on a path where
a hard failure is recoverable: the user downloads varve again. Putting
the same failure on the realm model first would mean recovery is
re-pinning a realm.

Clause 4 is the one I care about. varve must NOT claim inclusion proof.
What verifies offline is the Rekor SET — a countersignature proving the
log saw an entry at a time. That is timestamping, and it does not detect
an equivocating log. threat-model currently says varve has no
transparency log and no inclusion proofs; adopting a bundle must not turn
that into a softer sentence, or we replace an honest limit with a false
comfort.

Clause 5 explicitly forbids touching varve-realms.toml or
Realm::fingerprint(). Whether a realm can ever be keyless is decided by
what this teaches, not by this.

BLOCKED on sigil#257: wsc 0.11.0 is unpublished and varve pins 0.10.0,
whose airgapped verifier fails OPEN. Building against it would produce a
gate that verifies nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
varve layer-spec reads a realm's layer.toml and prints the environment the
assembler already consumes, so a realm's contents can live in that realm's own
repository. Bumping rivet stops being a commit to the tool that signs the
layer.

The adapter lives here, beside the assembler it feeds, and not in each realm's
repo: the assembler is system-tested in varve (REQ-SYSTEST-002) and a per-realm
copy inherits none of that testing.

It refuses far more than it accepts, for one reason. The assembler's inputs are
space-separated entries of colon-separated fields, an encoding that cannot
represent a value containing either, and the shell does not complain -- it
splits or truncates, and the resulting layer signs and verifies while carrying
the wrong bytes. So a mistyped key is refused rather than ignored, a second
raw-per-platform tool is refused rather than dropped, and an extension under a
foreign owner is refused rather than silently refetched from pulseengine.

Two of those refusals exist because cargo-mutants found them, not because I
reasoned my way to them. The tests and the implementation were written
together, so they agreed with each other; mutation testing is what proved they
could catch a WRONG implementation. It found two survivors, both real:

  - deleting the "tarball" match arm killed nothing, because every fixture
    either omitted layout or used raw-per-platform -- the spelling the docs
    invite was untested;

  - flipping && to || killed nothing, which exposed the serious one. The
    assembler derives a tarball tool's identity from the REPOSITORY basename,
    then names the payload, the extract dir and the default asset template
    after it. A tool declared name = "wsc" with repo = "pulseengine/sigil"
    would have deposited a payload called sigil, and a consumer asking for wsc
    would find nothing in a layer that deposited, signed and verified cleanly.

That second one is the exact defect class this module was written to prevent,
sitting inside the module written to prevent it. Now refused, with an error
naming the basename it would have used. A foreign OWNER stays legal --
bytecodealliance/wasm-tools is the point of the second realm -- and a test
pins that distinction.

The && survivor's right fix was deleting the condition, not testing it: once
the mismatch is refused, repo_name == t.name holds by construction, so the
second conjunct was a condition no input could vary. The mutant proved it dead
rather than my guessing.

layerspec.rs joins the trust-critical mutation gate at zero survivors (21
mutants, 18 caught, 3 unviable), on the same terms compose.rs did earlier this
release. That job's timeout goes 45 -> 60 minutes: the v0.29.0 run used ~40 of
45 with twelve files, and a gate that times out is indistinguishable from one
that fails -- the wrong lesson to draw from a red timeout is to drop a file.

Two existing gates caught this change and both were right. The CI-tagged
command contract (REQ-CIGATE-001 clause 2) demanded --json, which it now has.
And the documented-example test parsed my layer.toml with the deposit-spec
parser, because it classifies on [[tool]] -- a layer manifest has one too. It
now classifies on [varve], and it TRANSLATES the block rather than merely
parsing it, since a documented manifest that parses but cannot be translated
is a broken example.

REQ-LAYERREPO-001 is deliberately NOT marked verified and moves to v0.30.0.
Its clauses 4 and 5 are not code: they need pulseengine-layers to publish a
real layer with a key its custodian must provision, and varve to drop that
secret only after the publish succeeds. Neither can be discharged by the party
writing the code, and claiming them on the strength of the adapter would
report a migration that has not happened. The layers repo's deposit workflow
still refuses to run rather than pretending.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
@avrabe
avrabe marked this pull request as ready for review August 21, 2026 19:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant