Skip to content

v0.33.0: SDKs, corrections after publication, and a rotation that explains itself - #137

Merged
avrabe merged 35 commits into
mainfrom
plan/v0.33.0
Sep 9, 2026
Merged

v0.33.0: SDKs, corrections after publication, and a rotation that explains itself#137
avrabe merged 35 commits into
mainfrom
plan/v0.33.0

Conversation

@avrabe

@avrabe avrabe commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Draft — opened to get CI on the work, not because the release is ready. CI runs only on PRs and pushes to main, so these 18 commits have so far been verified only locally: fmt, clippy, 17 test suites and rivet validate. The mutation gate, the fuzz targets, MSRV, the systest matrix and the non-macOS platforms have not seen any of it.

Scope: 6 of 8 done

requirement status
REQ-ARCHIVEFMT-001 — .tar.xz / .tar.bz2 in both halves verified
REQ-PAYLOADID-001 — a payload's identity is its own verified
REQ-SDKDEPOSIT-001 — deposit a tree, not only a binary verified
REQ-UPSTREAMSUMS-001 — the rung below provenance verified
REQ-ROTATE-002 — a retired root explains itself verified
REQ-POSTDEPOSIT-001 — a layer can be spoken about after publication implemented (clause 5 deferred, DD-027)
REQ-CONSUMERAPI-001 not started
REQ-REUSEBLOB-001 not started

The two that carry the most risk

REQ-POSTDEPOSIT-001 — a yank, an advisory and a support-window correction are one act, and varve could not perform any of them. The baseline lives inside a layer's manifest, so correcting it would re-push that manifest with a new digest: the republish REQ-IMMUTABLE-001 refuses. Corrections now go under line-status-<line>, mirroring the existing line-index-<line> precedent. The load-bearing rule is verify first, rank second — ranking two documents by counter before checking signatures would let whoever serves the tag pick the winner by writing a large number.

REQ-ROTATE-002 — measured the day after the v0.32.1 rotation. A consumer who took the new realms file without moving their pin got No valid signatures, indistinguishable from a forgery, plus their layers losing their realm name in varve list. A realm may now declare retired-roots; varve's own realms file declares the one it just retired. It changes the message, never the verdict — a stranger's signature still gets the plain error, and listing the live root as retired is refused at parse.

This is not key rotation and the docs still say so. Succession signed by the retiring root was rejected on evidence: it needs the old key, and the whole reason v0.32.1 happened is that ours was unreadable.

Deferred, with the reasoning recorded

DD-027 — clause 5's index publisher goes to pulseengine-layers (issue #6). Deriving the index's layer list from the registry's own tag listing would be vacuous: a hiding registry omits the layer from both, they agree, and nothing is reported. Consequence: no varve realm may set signed-index = true until that ships, or installs fail closed.

Conflicts to resolve before this leaves draft

#111 and #113 both touch this ground and are overtaken by events — see the review comment.

🤖 Generated with Claude Code

https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu

avrabe and others added 22 commits September 6, 2026 08:00
REQ-SDK-001 is verified and the CONSUMER half genuinely works — `varve
export-sdk` unpacks a signed tree, relocates it the way Yocto's own
relocate_sdk.py does, and refuses a destination whose path is longer than the
interpreter field can hold. What does not exist is any way to PUT one in. The
producer implements three layouts, all of which mine ONE BINARY out of an
archive and discard the rest, which is the opposite of what an SDK is.

So `sdk` was never "a name only". It is a half.

Four requirements, each measured against a real upstream on 2026-09-06 rather
than assumed:

REQ-SDKDEPOSIT-001 — a layout where the archive IS the payload. Stored exactly
as upstream published it, because relocation is an export concern and a
producer that unpacked and re-packed would break the digest upstream's own sums
cover. No architecture check on a tree: an SDK holds executables for several
architectures and checking its first ELF proves nothing.

REQ-ARCHIVEFMT-001 — .tar.xz and .tar.bz2. One missing branch in one match
statement is the whole blocker for two separate things: wasmtime ships .tar.xz,
so the bytecodealliance realm can carry wasm-tools and wit-bindgen but would
silently omit the runtime; and all 140 Zephyr SDK toolchains are .tar.xz. The
shell that actually deposits is worse — it hardcodes `tar xzf`. The refusal for
genuinely unknown formats stays, and a self-extracting installer is explicitly
NOT to be executed: running a vendor's installer to find out what it contains
defeats the property the deposit exists to establish.

REQ-SDKTARGET-001 — a payload may name a TARGET, not only the host it runs on.
varve files payloads by the machine that runs them, which is right for a
compiler you invoke and wrong for a cross-toolchain, identified by a pair.
sdk-ng ships 140 toolchain_gnu_<host>_<target> assets; a realm wants a few
targets across four hosts. Without this the four arm-zephyr-eabi assets get
four hand-invented names and nothing connects them.

REQ-UPSTREAMSUMS-001 — the ladder's three rungs have never had to distinguish
"unsigned upstream sums" from "nothing", because every pulseengine payload
clears rung 1. The SDKs land in three different places: sdk-ng publishes a
sha256.sum and no signature; wasi-sdk publishes nothing at all and has no build
provenance either (verified: attestation exits 1); wasm-tools and wasmtime have
no sums but DO carry provenance that verifies. Calling Zephyr's manifest
"unverified" discards a real property — it catches the truncated 14GB download,
which is the failure that transfer actually has. Calling it a signature would
be a lie, since the same host serves the manifest and the bytes. So it gets its
own name, and is refused wherever a realm demands signed provenance.

REQ-REUSEBLOB-001 moves here too, still blocked on the clause-6 vs
artifact-of-record decision it surfaced.
Six months was the first number written into the policy and it was wrong in the
direction that matters. The rolling line ships MONTHLY, so a layer is
superseded roughly a month after it is issued, and nobody has undertaken to
publish advisories or fixes for a superseded rolling layer. Six months promised
five months of attention no one had agreed to give.

That is the same class of untruth this requirement was written to remove. The
original defect was a layer saying "no stated support window" while the docs
promised one. Stating a window longer than the commitment behind it is the same
mistake pointing the other way, and it is worse in one respect: a consumer can
act on it. "No window" makes someone ask; "supported until March" makes them
stop asking.

`qualified` keeps 24 months. That is the channel an assessor is pointed at and
the one whose contents are deliberately frozen, so a long horizon there is a
commitment somebody can actually keep.

The month arithmetic already handled the hard cases and the tests move with the
policy rather than being deleted: 31 Dec + 1 = 31 Jan (a 31-day target month
keeps its day), 31 Mar + 1 = 30 Apr (a shorter one clamps), 31 Jan + 1 = 28 or
29 Feb depending on the Gregorian rule, checked at 2024, 2026, 2100 and 2000.

LIVE CONSEQUENCE, not yet fixed: layer 2026.09.1 was published an hour ago
carrying support-until 2027-03-06. That promise is now overstated by five
months. It is correctable without touching the layer — the window lives in the
line-status, which is a DSSE-signed OCI referrer designed to be reissued after
deposit — but there is no workflow that issues a status update; deposit-layer
is the only thing that signs one. Tracked separately.
Checking an assumption before building on it found that the assumption was
false, and the false one was mine, asserted an hour earlier in varve#131.

I said a support window is correctable without touching the layer, because
REQ-KP-001 describes the line-status as an OCI referrer "added after deposit
without changing the layer digest". The registry path does not implement that.
`line_status_for_tag` resolves the artifact manifest for THE LAYER'S OWN TAG and
pulls the blob whose role is line-status, so the status lives inside the layer's
manifest. Reissuing it re-pushes that manifest under a new digest — the exact
republish REQ-IMMUTABLE-001 was written to refuse, arrived at by a
well-intentioned route. `grep -c referrers registry.rs` is 0.

So the gap is not a missing workflow. The MECHANISM is missing, and it is
missing for all three things that are the same act: a yank, an advisory, and a
correction are each a statement about a layer after it is published, and none of
them can currently reach a registry consumer.

The fix is already in the codebase, one document over. `fetch_line_index` reads
from a SEPARATE tag — line-index-<line> — independent of any layer, so
reissuing touches no layer digest. That is the shape line-status needs.

Except nothing publishes a line-index either. No line-index-* tag exists in the
registry and deposit-layer.yml never creates one, while REQ-INDEXAUTH-001 is
verified with a tested fetch path and the realm does not set signed-index, so
the absence fails open and nobody notices.

That is the third instance of one pattern in this release. support-until shipped
verified in v0.5.0 and was never set. min-counter had a consumer and no
producer. The line-index has a fetch path and no publisher. Each is a complete,
tested, verified capability that no artifact carries — and each reads, from
inside the code, exactly like a capability that works.

REQ-KP-001 and REQ-INDEXAUTH-001 both carry a field recording this, so neither
can be read as discharging what it describes.

Deliberately NOT built: the issue-line-status workflow I proposed and the user
approved. As described it would republish a layer manifest to fix a date, which
is what the previous release exists to prevent. Approval was for an outcome, not
for a mechanism that turned out to be the thing we just outlawed.

2026.09.1 continues to overstate its window at 2027-03-06 against a one-month
policy. That is the honest position: there is currently no way to correct it
short of depositing a new layer, and depositing a layer to fix a date is the
waste the referrer design was supposed to avoid.
One missing branch in one match statement was the whole blocker for two
separate pieces of work. bytecodealliance/wasmtime ships .tar.xz, so a second
realm would have carried wasm-tools and wit-bindgen and silently omitted the
runtime — the layer would have looked complete. All 140
toolchain_gnu_<host>_<target> assets of the Zephyr SDK are .tar.xz, so the sdk
kind had nothing it could actually fetch. kiln's VxWorks SDK is .tar.bz2.

Both halves, because the shell is what actually deposits today and it hardcoded
`tar xzf` — a .tar.xz would have failed there with a tar error rather than
varve's refusal, which is a worse way to learn the same thing.

The compression comes from the asset NAME, never from letting `tar xf` sniff
it. An archive whose contents disagree with its name is a case worth failing on
rather than accommodating, and that is the same reason choose_binary refuses
ambiguity instead of picking.

Self-extracting installers stay refused, and the refusal now SAYS so rather
than leaving it to be inferred from a missing branch. A Yocto SDK most often
ships as a .sh, and running a vendor's installer to discover what it contains
defeats the property the deposit exists to establish. Someone will want to add
that branch; the error message argues against it in advance.

Verified against a live upstream rather than a fixture: wasmtime
v48.0.1-aarch64-macos.tar.xz downloads, unpacks with the emitted flags, and the
binary inside runs and reports wasmtime 48.0.1.

25 mutants, zero survivors.
… can open it

`sdk` was never "a name only" — REQ-SDK-001 is verified and `varve export-sdk`
genuinely unpacks, relocates and refuses a destination that will not fit. It
was a HALF: nothing could put an sdk in. The three producer layouts all mine
one binary out of an archive and discard the rest, which is the opposite of
what an SDK is.

The tree is now stored exactly as upstream published it. Not unpacked and
re-packed, because that breaks the digest upstream's own sums cover; relocation
belongs on the consumer's machine where the destination is known. No
architecture check, because an SDK holds executables for several architectures
and checking its first ELF would refuse a correct cross-toolchain for
containing a cross-compiler. The staged filename keeps the archive extension,
because that is how anything downstream tells gzip from xz instead of guessing.

Building it found the half I would have shipped past: `export-sdk` decoded
GZIP ONLY. Every wasmtime archive and all 140 Zephyr SDK toolchains are
.tar.xz, so a deposited Zephyr SDK would have been unopenable by the only
command that exists to open it — another capability nobody could use, which is
the pattern this release keeps turning up.

Fixed with lzma-rs: pure Rust, MIT, and `cargo tree` shows zero `-sys` crates,
so no C build dependency enters the crate every consumer links. The maintainer
asked for pure Rust after trouble with C and C++ elsewhere, and the trade is
favourable anyway — the bytes are digest-verified before a decoder ever sees
them, so decoder maturity buys less here than cross-compilation does.

bzip2 is now NAMED rather than left to fail. Without that, a .tar.bz2 payload
falls through to the tar parser and reports "not a readable tar archive", which
is true and useless: the download is fine and the decoder is missing.

Decided by MAGIC here and by NAME in the producer, deliberately and not by
accident. The producer picks a tool to run on bytes nobody has verified, so a
name disagreeing with content is a reason to stop. The consumer holds bytes
already checked against a signed digest, so what they ARE is the only question
left.

Clause 5 uses varve's own reader, so the producer proves the payload can be
opened by exactly the code the consumer will open it with, rather than by a
second implementation that might disagree. The failure it catches is concrete:
a 14 GB download that turns out to be an HTML error page hashes and signs
perfectly well.

Proven against the real thing, not a fixture: the live 90 MB
toolchain_gnu_macos-aarch64_arm-zephyr-eabi.tar.xz decodes through
`read_members` to 5514 members, 48 of them under bin/. The xz tests build their
input with the system `xz` rather than an encoder of my own, so they cannot
agree with a mistake I made twice.

Mutation testing found two: the shape check matched only by prefix, so a
`contains` naming a FILE was never exercised and an absent file could be
satisfied by a sibling; and `archive_ext`'s slice arithmetic was unasserted.
135 mutants across the three modules, zero survivors. sdkexport.rs joins the
required gate.
`varve docs sdk`. Findable by the person who needs it, which is whoever is
about to hand varve a vendor SDK and discover that a .sh installer is refused.

The Yocto answer is a one-line change on the producing side rather than a
design fight: populate_sdk emits a self-extracting .sh by default, and

    SDK_ARCHIVE_TYPE = "tar.xz"

makes it write a plain tarball under tmp/deploy/sdk/ instead. tar.xz rather
than tar.bz2 because varve decodes xz and does not decode bzip2 — stated in the
format table rather than left to be discovered at deposit time.

Also records why the .sh refusal is not negotiable: running a vendor's
installer to find out what it contains means executing unreviewed vendor code
on the machine that signs the layer, BEFORE anyone knows what the payload is.
"Run it and see" and "state what these bytes are" cannot both be the first
step. The paragraph exists so the next person to want that branch has to argue
with it first.

Documents what actually vouches for each of the three SDKs, because they differ
and the difference matters: Zephyr publishes an unsigned sha256.sum, WASI
publishes nothing at all, and neither is called a signature.
Writing a documented example is a test, and this one failed twice before it
passed, both times for real reasons.

First, the repo-name guard: `name = "zephyr-sdk"` from `zephyrproject-rtos/sdk-ng`
is a RepoNameMismatch, because the shell assembler derives a tool's identity
from its repository basename and a disagreeing name is discarded. The
expressible form is `name = "sdk-ng"` with `binary = "zephyr-sdk"` — the same
shape varve-producer uses in this repo — so the doc shows that, with a comment
saying why, rather than showing something that does not translate.

Second, and worse: `varve layer-spec` translated `layout = "sdk"` into a plain
TARBALL_TOOLS entry. The env encoding has no field for a layout, so an sdk
entry arrives at the shell assembler as an ordinary tarball tool and the tree is
mined for a binary — silently, because every other field survives the trip. The
Rust assembler reads layout directly and is unaffected.

So a realm carrying SDKs must deposit with `varve-producer deposit --manifest
layer.toml`, and deposit-layer.yml still uses the env encoding. That is now a
blockquote in the doc rather than a footnote, and a field on
REQ-SDKDEPOSIT-001.

The Yocto answer is one line on the producing side: populate_sdk emits a
self-extracting .sh by default, and SDK_ARCHIVE_TYPE = "tar.xz" makes it write
a plain tarball instead. tar.xz not tar.bz2, because varve decodes xz and does
not decode bzip2.

`varve docs sdk`.
…d get an answer it cannot misread

jess reported four measured failures of the shell contract in one repo in one
day (varve#130), and two of them are failures this repo made in the same week.

The sharpest is not arguable. A gate shelled out to objcopy, which on a runner
without ARM binutils printed a format error and wrote nothing; the value became
the empty string, `[ "" -gt N ]` ERRORS AND EVALUATES FALSE, both range tests
fell through, and the script printed "ok" and exited 0 — a green verdict on a
file it never parsed. No amount of care in that script fixes it. Only a type
does.

The other three: an error naming the subcommand when the fault was which
binary; a pipeline's exit code quoted as a tool's verdict; and `set -e`
aborting at the very command whose failure was being measured. I did the last
two myself this week, so I am reading the list as evidence rather than as
argument.

Much of the answer already exists and nobody could find it. varve-core is
published — seven versions on crates.io — and `resolve` already returns a
properly differentiated error, each variant carrying what to do next. That a
consumer wrote four failure reports without discovering it is the first
finding, and clause 6 exists because an undiscoverable typed API loses to a
discoverable shell one every time.

What is genuinely missing is the one that matters most: `resolve` DOES NOT
VERIFY. It reads the store and trusts that install verified on the way in, so
"present but does not verify" is not among the answers a consumer can receive.
The obvious patch — "call verify first" — would reintroduce the shell contract
wearing types: a two-call protocol with an implicit ordering, where forgetting
the first call fails OPEN. Clause 3 forbids that shape rather than the symptom.

Clause 2 is the requester's, and it is the one to keep: verification failure
must never be foldable into absence. "I could not find it" and "I found it and
it did not verify" are opposite facts, and a consumer that treats the second as
the first fails open. This repo has now found that same conflation three times
in its own code this week — in the attestation prober, in the registry
classifier, and in the error handler of the check written to prevent it.

v0.33.0 now carries five open requirements. If this one does not land, it moves
by an explicit bump, not by going quiet.
…one payload

Decision on varve#130: with-device stays in jess. The maintainer's argument is
better than mine — jess is the integrator archive and a tool arbitrating a
physical bench IS integration work, so it sits with the work it serves.
Revisit if it grows past bench arbitration.

But the maintainer also made the point that turns this from a placement
question into a defect: we cannot ensure outside repos avoid a common release
tag with per-tool sub-versions, and jess is only the first we noticed. Testing
that produced a structural finding.

ONE REPOSITORY MAY CONTRIBUTE AT MOST ONE PAYLOAD. A tool's `name` must equal
its repository basename, and two entries may not share a name. Together those
pin identity to the repo and allow the repo once:

    [[tool]] name = "sdk-ng"  binary = "zephyr-sdk-arm"    …arm-zephyr-eabi.tar.xz
    [[tool]] name = "sdk-ng"  binary = "zephyr-sdk-riscv"  …riscv64-zephyr-elf.tar.xz
    error: two tool entries are both named "sdk-ng"

sdk-ng publishes 140 host×target toolchains. A layer wanting two targets cannot
say so, and no target field fixes that — which makes this the root cause under
REQ-SDKTARGET-001 rather than a sibling of it. Recorded there as such.

So moving with-device would have fixed nothing. It would have left the same
wall standing for Zephyr and WASI, and I would have walked into it within the
week while building the sdk work I had just scoped. My recommendation framed a
general pattern as one repo's mistake.

Five current sources already deviate from repo-derived identity: sigil ships
wsc, kiln ships kilnd, jess ships with-device at a version its tag does not
carry, rivet's vsix avoids the collision only by living in a different manifest
section, and sdk-ng would ship many.

Clause 4 keeps what the guard was protecting. RepoNameMismatch exists because a
tool whose name disagreed with its repo was once silently deposited under the
basename — a consumer asking for it found nothing in a layer that deposited and
verified. That property survives; the check moves to where the deposited name
is decided instead of being deleted.

The fifth entry field already made this correction for VERSIONS — a payload's
version is its own, not its repository's tag. This is the same correction for
NAMES. They were always one idea and I implemented half of it.
…om the registry, keep the artifact whole

Clause 6 as first written was unsatisfiable, and I wrote it. It promised that a
re-deposit fetches NO payload bytes, while the deposit layout contains every
payload blob and is uploaded as the artifact of record. No download means no
bytes means an incomplete artifact. Both could not hold.

Three ways out. Let a carry-forward layout reference blobs it does not contain,
weakening the artifact of record that REQ-ARCHIVE-002 already calls inadequate.
Keep the promise and abandon the artifact. Or narrow the promise. Narrowing it.

A carried-forward payload is now fetched from the DESTINATION REGISTRY — which
clause 4 already requires to hold it before reuse is permitted, so the check
that makes reuse safe and the source that makes it possible turn out to be the
same check. The layout stays complete and REQ-ARCHIVE-002 is untouched.

The saving is smaller than I first claimed and the requirement now says so: not
zero bytes, but one host instead of four upstream CDNs, no upstream rate
limits, and no re-verification of an unchanged upstream release. 421 MiB still
moves; it moves from somewhere closer, already verified, that we control.
Promising a number nobody can deliver is the failure this requirement family
keeps finding in other people's documents, and clause 6 was mine.

Two clauses added that the first framing did not have, because moving the
source moves the risks:

Clause 2 — the digest used to fetch is the one the CURRENT proof states, never
the one the previous layer recorded. Fetching by the old digest would carry a
republished upstream forward unnoticed, which is precisely the substitution
this requirement exists to catch, reintroduced by the optimisation meant to
respect it.

Clause 4 — a blob absent from the destination falls back to upstream rather
than aborting. Clause 4's presence check can race a garbage collection, and a
deposit that fails because a cache was pruned is worse than a slower one.

And clause 3: registry bytes are verified against the digest exactly as an
upstream download is. The destination is a source like any other and gets no
more trust for being ours.
One repository could contribute at most one payload. A tool's `name` had to
equal its repository basename, and two entries could not share a name, so
identity was pinned to the repo and the repo could appear once. That refused
zephyrproject-rtos/sdk-ng's 140 host×target toolchains outright, and would have
refused any monorepo upstream — and we do not get to ask upstreams to
restructure their releases.

Three separate conflations, all of them the same mistake:

NAME. `stage_one` took one `binary_name` and used it for the executable INSIDE
the archive AND for the payload's deposited name, so a payload could only ever
be deposited under the name of the file found in its own tarball. Now two
names, bundled as `Names { deposited, binary }` — which also answered clippy's
too-many-arguments, so the type earns its place twice.

REPOSITORY. The guard forcing name == basename existed because the SHELL
assembler takes a tool's identity from the repository basename and has no field
for a deposited name. That is the encoding's limit, not the manifest's, so the
check moved to the translation boundary rather than being deleted: it fires
when a name really would be discarded, and not otherwise. The property it
protects is intact — a payload deposited under a name nobody asked for is still
refused.

VERSION. Found by the agent porting pulseengine-layers, and it would have been
a silent regression: `ManifestTool.version` served as both the release TAG to
fetch and the version signed into the layer. For a hub repo those differ —
jess tags v0.7.2 and ships with-device 0.2.2 — so layer.toml could say one or
the other, sign a version the binary contradicts or name a tag that does not
exist. The env encoding could already express this via its fifth positional
field; layer.toml could not, so porting a realm to layer.toml LOST the payload.
An optional `release` field fixes it, and the agent left the entry out of its
PR rather than sign a false version, which was the right call.

A branch that mutation testing had proved DEAD came back to life. The entry
head was shortened to the bare name whenever the owner was the default, with a
comment recording that `repo_name == t.name` held by the check above so testing
it here was a condition no input could vary — cargo-mutants had flipped it and
killed nothing. True, and true only relative to an invariant I then removed:
with the shortcut left in, `with-device` from `pulseengine/jess` emitted
`with-device:v0.7.2:…` and the shell would have fetched `pulseengine/with-device`,
a repository that does not exist. Caught by reading the emitted string rather
than the exit code. The comment now records both the original reasoning and why
it stopped holding.

Also: `layout = "sdk"` now REFUSES to translate into the env encoding rather
than arriving at the shell as an ordinary tarball entry to be mined for a
binary. The docs test that requires every documented layer.toml to translate
accepts that one refusal, because it is the documented behaviour — any other
translation failure is still a broken example.

35 mutants on layerspec, zero survivors. The survivor found on the way was
`binary.is_none() || asset.is_none()` — both my cases omitted both fields, so
`&&` behaved identically; the case that actually corrupts is exactly one
missing, where the payload version lands in the TEMPLATE slot and the assembler
looks for an asset named "0.2.2".
…ost convention

This code was written for v0.33.0 and then committed to the wrong branch. It
was swept into the root-rotation commit by a `git add -A`, where it sat as 350
lines of producer work inside a diff whose message said "Only its public half
appears here". Moving it back; the rotation is now #136, eight files, all key.

Two things, both found by trying to deposit a real Zephyr SDK.

`upstream-sums` — a fourth ingest rung, between `build-provenance` and
`unverified`. zephyrproject-rtos publishes `sha256.sum` beside its assets and
signs nothing. Transcribing it establishes exactly one fact: the bytes are the
bytes that list names. For a 90 MB toolchain over a CDN that is the failure
which actually happens, so it is worth having. It establishes NOTHING about who
produced them, because the same host serves the list and the bytes, and the
rung's name has to keep saying so — which is why this is a named rung and not a
quiet promotion to `cosign-sums`. The realm states the asset name rather than
varve guessing it: a file that is not the digest manifest, parsed as one,
vouches for nothing while looking like it does.

`%H` — the host tag written os-first. There is no single upstream convention.
bytecodealliance writes `aarch64-macos`, which `%U` already produced;
zephyrproject-rtos/sdk-ng writes `macos-aarch64`. Same machine, opposite order,
and a template using the wrong one matches nothing.

Adding the token was not enough, and the way it failed is the point.
`is_per_platform` did not know `%H`, so a template naming a machine was read as
portable, expanded once with no platform, and the payload reported ABSENT. No
error, no unknown-token complaint — just a correct-looking "not found" for an
asset that was right there. The new test walks every machine-naming token
rather than checking %H alone, because the next one added will fail the same
way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
REQ-DOCS-002's baseline records a 2026-08-14 review that caught a docs topic
teaching a fabricated root under a heading promising every file was literal.
The record said "The real published root is 4e771dc6…" — true when written,
false since v0.32.1, and written in a tense that does not say which.

Not rewriting the history: the finding and the value it names are what happened,
and a verification baseline that gets edited to match the present is worth less
than one that does not. Only the tense is fixed, with a pointer forward.

Worth the pointer because the two findings are the same finding, eighteen days
apart. That review pinned the DOCUMENTED copy of the root to rolling.pub. It
did not ask whether any other copy existed. varve-realms.toml — the copy
consumers actually resolve, and the one that outranks the environment — went on
uncompared until the rotation forced someone to look at both.

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

A yank, an advisory and a support-window correction are one act — saying
something about a layer AFTER it is published — and until now varve could not
perform it. The document type existed, signed, countered, with known-problems
and yank markers, and `varve status` read it. What was missing was any way for a
corrected one to REACH a registry consumer.

The baseline is a blob inside a LAYER's artifact manifest. Reissuing it means
re-pushing that manifest with a different blob digest, which changes the
manifest digest — the republish REQ-IMMUTABLE-001 refuses, reached by a
well-intentioned route. Yanking a layer must not require mutating it.

So a status document is now publishable under the line's OWN tag,
`line-status-<line>`, exactly as the signed index goes under
`line-index-<line>`. That precedent was already in the codebase and is right;
nothing had followed it for status. The tag cannot be parsed as a LayerId, so
`layers_of_line` excludes it by construction and an honest registry is never
made to look like it serves a layer the signed index never named.

VERIFY FIRST, RANK SECOND — the order is the security property, not an
implementation detail. Both documents are untrusted bytes from the party this
evidence exists to constrain. Ranking by counter before checking signatures
would hand the choice to whoever serves the tag: write a large counter, win the
comparison, and a forged document displaces a real one — or, failing
verification afterwards, denies the consumer the good baseline it already had.
Four tests hold the corners: a correction overtakes an older baseline; a STALE
tag document cannot walk a consumer backwards past its baseline (clause 2 says
prefer the newer, not prefer the tag, so a registry cannot suppress a yank by
serving an old one); an unverifiable tag document is discarded rather than
fatal; a validly-signed document for another line is refused by the same guard
the baseline path uses, not a second rule that could drift.

The baseline is untouched and stays exactly as useful — offline install and
`varve install --from` behave as before.

Clause 4 is one dispatchable workflow, because the three acts are one document
and three workflows would drift. It refuses a counter that cannot supersede
what is published — consumers keep the newer, so a low counter would be
discarded silently from the operator's point of view — and it establishes
absence from `oras repo tags` rather than from error prose, because inferring
absence from a "404" in a message is how the publish-check was once fooled by a
repository path. It then reads the document back through the tag a consumer
resolves and verifies it against the realm's PUBLISHED root, not the key that
just signed it, which would only prove the signer agrees with itself. Exit 3 is
accepted there: a yank that did NOT make `status` exit 3 would be the bug.

Two corrections made while building this, both of the same shape — a document
naming something that is not there:

- Clause 3 cited `LineStatus::refuse_regression`. No such function exists;
  `refuse_regression` is a method on LineIndex. The behaviour is real, in
  `StatusCache::update`, and the clause now names it. A spec naming a function
  that is not there is how an implementer writes the second rule the clause
  forbids.
- `cmd-attach-status.md` told readers newer documents arrive via "a re-pushed
  registry baseline" — the very thing REQ-IMMUTABLE-001 refuses. It now
  documents the tag, with the consumer's two-document rule spelled out.

Clause 5 (publish the realm's line-index) is deliberately NOT done; it turns on
where the index's layer list comes from, which decides whether the index can
detect the omission it exists to detect. Recorded on the requirement.

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

REQ-POSTDEPOSIT-001 clause 5 asks for the line-index to actually be published.
Building the publisher surfaced a prior question that decides whether the check
can fire even once it exists: where the index's list of layers comes from.

The cheap answer is vacuous, and worth writing down precisely because it looks
correct. Derive the list from the registry's own tag listing at deposit time and
there is no committed file, no maintenance, no conflict when two deposits race —
and no detection. The index exists to catch a registry HIDING a layer; if the
list comes from that registry, a hiding registry omits the layer from the tag
listing and therefore from the index, the two agree perfectly, and varve reports
nothing wrong. It would pass its own tests and publish real signatures. An
artifact that carries a guarantee it cannot provide is worse than no artifact,
because everyone downstream believes it.

So the realm repository commits the index document and each deposit appends the
layer it just pushed. The list becomes an independent statement about what the
realm contains, which is what omission is measured against. Accepted cost: a
reviewed file the depositor maintains, and a merge conflict if two deposits
race, which the per-layer concurrency group already makes rare.

It lands in pulseengine-layers rather than varve because deposits are moving
there; building it here would be building it in the place it is leaving. varve
supplies sign-index, attach-index and the documented manifest shape. Filed as
pulseengine-layers#6, blocked on #5.

Stated so it is not discovered later: until that ships, no line-index tag
exists, so no varve realm may declare signed-index = true. A realm that declares
it with no index published fails closed on every install.

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

Measured the day after living it. When the rolling root rotated in v0.32.1, a
consumer who took the new varve-realms.toml without moving their pin got exactly
this and nothing else:

    error: manifest signature verification failed: … No valid signatures

and their installed layers simultaneously stopped showing a realm name in
`varve list`, appearing under a bare fingerprint, because the store partitions
by root and no realm named that partition any more. Two symptoms, neither naming
the cause — and the cause is the one thing a consumer cannot deduce, because
nothing distinguished "signed by a root this realm retired last week" from
"signed by a stranger". varve already held the information to tell those apart
and did not use it.

A realm may now declare the roots it has RETIRED. varve's own realms file
declares the one it just retired, so this is not a capability waiting for
someone to carry it.

WHAT THIS IS NOT. varve still has no key rotation. Nothing signs "this new root
replaces the old one", and no consumer would check such a statement if you
produced it; `docs threat-model` and `docs root-ceremony` still say so and are
still true. A succession statement signed by the retiring root is the real
mechanism, and it was rejected here on evidence rather than effort: it requires
still HOLDING the old key, and the entire reason v0.32.1 happened is that the
old key was a write-only CI secret nobody could read. A mechanism that only
works for the rotations you planned would not have helped the one that occurred.

It changes the MESSAGE, never the VERDICT, and three tests hold that line
because a diagnostic that quietly widened trust would be far worse than the
confusing error it replaces:

- A retired root never verifies anything. The layer is rejected exactly as
  before — confirmed end to end: exit 1, nothing installed.
- An unknown signer is NOT excused. A stranger's signature still gets the plain
  error; otherwise the diagnostic would tell an operator a rotation happened
  while they were being attacked.
- Listing the realm's live trust-root as retired is refused at parse. No
  legitimate use, and precisely the slip a half-finished rotation makes —
  update one field, paste the same value into the other.

`varve list` now names a partition a retired root left behind, dated, because
"my tools vanished" is the symptom noticed before any error message.

The docs gate earned its keep again. The config-reference example used an
elided `4e771dc6...`, and `every_documented_file_example_parses_with_the_real_parser`
refused it. The tempting fix is to pad the placeholder until the parser is
happy, which is how a shape check certifies a false fact — the exact failure
REQ-DOCS-002 exists for. The retired root is public, so the example now carries
the true value.

The lesson worth keeping, written into root-ceremony: ROTATE WHILE YOU STILL
HOLD THE KEY. The last useful act of a retiring root is to sign something saying
it is retiring, and that is only possible while you can still use it. A root you
cannot use is a root you have already partly lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
The trust-critical gate covers realm.rs, and the retired-root work put three
survivors there. Fixing them found a real defect beside them.

realm.rs, both survivors of the same shape: `RetiredRoot::fingerprint` could be
replaced by `String::new()` or a constant with nothing noticing, because the
test only asserted the retired fingerprint DIFFERED from the live one — true of
any constant. It now asserts the fingerprint equals the one that root's
partition was actually written under, computed by resolving a realm where that
key IS the live root. That is the property that matters: the value has to name
a directory already on disk, or `varve list` looks for one that does not exist.

realm.rs, the third: `||` -> `&&` in the retired-key validation survived because
the only malformed input tested was "not-a-key", which fails length AND alphabet
at once and so cannot tell the operators apart. Now each half is exercised
alone — 64 characters of non-hex, and hex of the wrong length, including
one char short.

linestatus.rs: `>` -> `>=` when choosing between the baseline and the tag
document. Not cosmetic. Iteration order is [baseline, published], so `>=` lets a
document served under the tag displace a baseline of the SAME counter — and the
interesting version of that is a registry serving a same-counter document with
the yank removed, suppressing it. Strict greater-than keeps the copy bound to
the immutable layer, which is the conservative choice when a signer has issued
two documents at one counter.

The first attempt at that test was VACUOUS and passed under the mutant. The
`status()` fixture already yanks 2026.07.0, so the "quiet" document built from
it still carried the yank and the assertion held whichever document won. The
fixture's own contents were the trap — see the standing lesson about checking an
oracle's fixture before trusting what it proves. Now the yank is cleared
explicitly, a precondition asserts the baseline really does yank, and the test
was confirmed RED against the mutant and green without it.

NOT fixed, and worth stating rather than leaving to be discovered: linestatus.rs
carries 18 further survivors and is NOT in the trust-critical mutation gate, so
none of them are enforced. Most are io::ErrorKind guards of little consequence,
but the file also holds yank and advisory logic — a stop-the-build signal — and
the gate's own comment says the list is meant to grow.

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

Marking for the next release what v0.33.0 surfaced but should not fix in a rush.

REQ-MUTATE-002 is verified and rightly so: it named five modules and they were
added. Its TITLE — "covers the trust decisions, not a subset of them" — claims
more than its body delivered, and the gap is now measurable. The gate lists 16
varve-core files by hand; the crate has 39. Outside it: keys.rs, deposit.rs,
store.rs, manifest.rs, registry.rs, lineindex.rs, linestatus.rs, attest.rs,
attestcarry.rs, referrers.rs, update.rs. Key handling, layer signing, digest
computation, what a registry serves, omission detection, yank state.

Not theoretical. Adding the two-document preference to linestatus.rs this
release put a survivor there that permitted YANK SUPPRESSION — greater-or-equal
instead of greater-than lets a same-counter document served under the tag
displace a baseline carrying the yank. It is killed. It was found because
someone chose to run cargo mutants by hand on a file the gate does not cover,
and choosing to look is not a control.

The same file holds five ErrorKind::NotFound guards replaceable by `true`,
collapsing "this file cannot be read" into "this file is absent" — the EXACT
defect class REQ-MUTATE-002 found and fixed in reverify.rs and exportstamp.rs.
Same defect, different file, still live, outside the gate that exists because
of it.

The deeper problem is the one worth the requirement: coverage is a
hand-maintained list and nothing checks it for completeness, so a file that
gains a trust decision does not get noticed. linestatus.rs gained one this very
release. It will drift again, and the next person to notice will also notice by
accident.

Deliberately NOT fixed now. Adding those files means clearing 18 survivors
first, and doing that while v0.33.0 still owes two requirements would be
widening scope mid-release. Clause 5 exists because the tempting shortcut —
adding files by relaxing the zero-survivor rule — moves the number and not the
risk.

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

varve's docs gate is real and works — 34 subcommands documented, 22 with worked
examples, and it has twice refused a FALSE example rather than a missing one.
It covers one binary.

varve-producer is separately built, separately released and separately RUN.
REQ-PRODUCERGET-001 exists because consumers had to improvise a way to obtain
it; pulseengine-layers now downloads and executes it in CI. It has six
subcommands, no `docs` subcommand, and zero documented topics. So REQ-DOCS-001's
invariant — a new subcommand cannot ship undocumented — is true of varve and
false of varve-producer, and nothing anywhere says so.

Third instance of one pattern in a single release, which is why this is a
requirement and not a fix. A gate is built, it is real, it works, and its scope
is a hand-drawn boundary nothing checks: the mutation gate covers 16 of 39
varve-core files (REQ-MUTATE-003); the docs gate covers one of two shipped
binaries; four capabilities shipped complete, tested, and carried by no
artifact. Each time the gate is believed for more than it checks.

Where it would already have paid: the producer's own help text says of its asset
template language that it "has silently dropped a tool from a published layer".
That language is explained nowhere but --help and the source — and it gained %H
this release precisely because %U encoded the wrong convention for one upstream
and matched nothing. The ingest ladder is in the same position: four rungs, one
added this release, documented in doc comments. The audience is other
repositories' CI jobs, and the ten-persona audit already established that a
reader needing the Rust source is itself the finding.

Clause 5 is the one worth arguing with: documenting the producer inside
`varve docs` does NOT discharge this. A CI job holding the producer may not hold
varve, and sending someone to a different binary for the manual is the friction
this exists to remove.

Recorded but deliberately not claimed here: only publish-check offers structured
output and no `--format json` is advertised in top-level help, against the org
CLI convention. Separate requirement. `--version` and exit-2-on-unknown-flag
already conform.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
Replaces REQ-DOCSEXPORT-001, which I scoped wrongly and which is removed rather
than left as a second, misleading requirement in the same area.

I had it as "publish varve's own documentation as HTML for a website". That is
the case least worth solving: a tool that embeds its docs already answers for
itself, at the version installed, offline — `varve docs` and `rivet docs` do
exactly that. The gap is the documentation NOTHING CAN BE ASKED FOR.

Two real cases. varve carries `crate` payloads, and a crate's documentation is
its rustdoc: HTML, generated at build time, reachable nowhere offline, because
docs.rs is a network service and a pinned air-gapped build cannot consult it.
And the toolchain's architecture documentation lives on pulseengine.eu, belongs
to no binary, and is therefore versioned by nothing — a reader cannot tell which
layer the page in front of them describes.

Measured: no pulseengine release publishes a documentation asset at all. rivet
ships binaries, an SBOM, a vsix and a compliance report; meld, synth, witness
and loom ship none. The documentation obtainable for the exact versions layer
2026.09.1 pins is, today, nothing.

ONE kind with a SUBTYPE, not a kind per flavour. rustdoc, architecture, manual
and whatever follows are identical to the store, the manifest and the signature,
differing only in what a reader does with them. A kind per flavour would change
the signed manifest's schema every time someone has a new sort of document.

Opening it is part of the requirement, not a convenience bolted on. An HTML tree
needs serving, because rustdoc's cross-links and search do not work from file://
in every browser and a consumer should not have to know that. A PDF needs
opening. Documentation a reader must locate by hand inside a content-addressed
store is documentation nobody reads, so varve does the guiding.

And it must be addressable by a machine: an agent working with a pinned
toolchain should be able to obtain the docs for exactly the versions in play
without scraping human output. Same argument as `--format json` everywhere else,
applied to the thing an agent most needs and currently cannot get.

This commit is the requirement only — one file. While scoping the version I had
wrong I briefly added pulldown-cmark to render markdown; it was removed in the
working tree before anything was committed, so there is nothing here to back
out and the dependency count is unchanged at five. Noting it because the
reasoning is worth keeping: varve renders no markdown under this design either.
rustdoc arrives as HTML and a PDF arrives as a PDF, so the renderer would have
been a dependency bought for a problem the layer does not have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
avrabe and others added 7 commits September 7, 2026 20:42
…er serve

varve does not render the documentation it carries and does not serve it. For a
pinned layer it reports WHERE each document is and prints the exact command to
open it — the system handler for a PDF, a one-line local server for an HTML
tree — in a form a human can paste and an agent can execute.

The previous wording said varve shall OPEN what it carries "in a single
command", which reads as varve doing the opening. That would have made a
documentation viewer the first exception to a stated design principle: NO SERVER
OF OUR OWN, in the README, and repeated verbatim in the help for `varve
deposit` ("varve runs no server and pushes nothing, by design"). A tool whose
value is partly that it has no network listener should not acquire one to show
somebody a rustdoc page.

It would also have bought a rendering dependency for a supply-chain tool that
currently has five direct dependencies — the same purchase I nearly made an hour
ago for the version of this requirement that was wrong.

Serving is one command the consumer runs with a runtime they already have.
Knowing WHICH command, and WHERE to run it, is the whole of the help that is
actually needed — and it is help varve is uniquely able to give, because it is
the only thing that knows which layer is pinned and where its bytes landed.

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

REQ-POSTDEPOSIT-001 records the maintainer accepting layer 2026.09.1's
six-month support window against a one-month rolling policy, on a stated
condition: fix forward, because "the next rolling layer carries a correct
one-month window from deposit".

Dispatching that next deposit shows it does not. The one-month policy lives on
plan/v0.33.0, unreleased. layer.toml pins the builder at varve v0.32.1, whose
support.rs still returns six months for rolling. So 2026.09.2 computed
"supported until 2027-03-07" and would have carried the same overstatement into
another immutable layer — correctable only through the line-status tag added by
REQ-POSTDEPOSIT-001 itself, which is on the same unreleased branch.

It was not published only because the registry push failed on a missing package
permission. That is luck, not a control, and it is worth writing down as luck.

The general shape is the part worth keeping. A fix-forward plan is a promise
about a FUTURE release, and it becomes false the moment the fix sits on a branch
that has not shipped while the thing it fixes runs from one that has. Nothing
checked that the deposit's builder carried the policy the decision assumed —
the decision and the pin were never compared, and there is no reason anyone
would have thought to compare them.

Sequence corrected: v0.33.0 releases first, layer.toml moves to it, and only
then is 2026.09.2 deposited. Depositing now would spend the layer id on the
wrong window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
The first pulseengine-layers deposit ran correctly through assembly, signing,
and a sanity install-and-verify against the published root, then failed on the
last step:

    denied: permission_denied: write_package

The permission is a symptom. The realm's layers live at
ghcr.io/pulseengine/varve/layers — a package owned by the VARVE repository,
because varve's workflow pushed it first — while the repository that now
produces the layers is pulseengine-layers. GHCR links a package to whichever
repository pushes it, so a second publisher needs a cross-repo grant that is
UI-only and that no automation in either repository can obtain for itself.

REQ-LAYERREPO-001 already moved a realm's CONTENTS into the realm's own
repository. The registry path never followed, so the artifacts stayed in the
tool's namespace while the source of truth left it. The grant is what that
inconsistency costs.

So the realm moves to oci://ghcr.io/pulseengine/layers. Org-level and
tool-neutral; owned by the repository that pushes it, so there is no grant left
to be missing. varve is a tool — anyone can run a realm — and nesting the
canonical realm under varve/ taught every adopter reading the docs to nest
theirs under a tool as well.

Now, rather than at v1.0, because now is when it is free. The rotation already
requires every consumer to take a new varve-realms.toml, and under the new root
there are ZERO published layers, so nothing has to be moved or mirrored.
Changing the registry line in that same edit costs a consumer nothing. Changing
it later costs a second migration for the same benefit.

The old realm is left intact and servable: old root with old registry, new root
with new registry, fully disjoint. A consumer holding the old realms file keeps
resolving exactly what it resolved before.

deposit-layer.yml therefore stays pointed at the OLD path and is marked LEGACY
rather than repointed. Repointing it would recreate the same problem mirrored —
a package owned by pulseengine-layers, pushed from varve, needing a grant. One
repository publishes a realm. It is deleted once pulseengine-layers has
published successfully at least once; removing the only working depositor
before its replacement has ever run is how a realm goes dark.

say-line-status.yml moves to pulseengine-layers for the same reason: it
publishes to the realm's registry, so it belongs with the depositor, exactly as
DD-027 placed the line-index publisher there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
jess reported four failures of varve's consumption contract in one repository
in one day (varve#130), two of them mistakes this repository made the same week.
Every one was a CONSUMPTION failure — the tools behaved correctly and the
contract broke — and every one had the same shape: ABSENCE AND FAILURE ARRIVED
LOOKING ALIKE.

  objcopy missing -> printed an error, wrote nothing -> value became "" ->
  [ "" -gt N ] ERRORS AND EVALUATES FALSE -> both range tests fell through ->
  the script printed ok and exited 0. A green verdict on a file it never parsed.

`varve_core::consumer::payload_status` answers with types that cannot be
confused: Verified, AbsentFromLayer, NoEntryForPlatform, MissingFromStore,
DigestMismatch, LayerNotAuthentic, Unreadable. DigestMismatch is its own
variant and is never foldable into absence — "I could not find it" says nothing
about integrity, "I found it and it did not verify" IS an integrity failure,
and folding the second into the first is the objcopy bug rewritten in Rust.

The trust root is a PARAMETER, not a second call (clause 3). "Call verify
first" is the shell contract wearing types: a two-call protocol with an
implicit ordering whose omission fails open. Verified cannot be obtained
without the root — the envelope is checked against it, the stored manifest must
be byte-identical to what it signed, and only then is the payload digest
compared. A stranger's root certifies nothing, and a swapped manifest voids
every answer about the layer's contents.

`is_verified()` is the only predicate, deliberately. An `is_ok()` that also
returned true for absence would rebuild the bug one helper at a time.

PIN_MANIFEST_VERSION is published as a value and pin.rs now enforces THAT
CONSTANT rather than a literal 1. A published number that can drift from the
enforced one is worse than not publishing it.

Documented as `varve docs consumer-api`, because the second finding in #130 was
that most of this already existed — varve-core is on crates.io and resolve()
already returns a differentiated error — and a consumer wrote four failure
reports without finding it. An undiscoverable typed API loses to a discoverable
shell one every time.

MUTATION TESTING FOUND ME REBUILDING THE VERY BUG. Three survivors on one
ErrorKind::NotFound guard — the exact defect class REQ-MUTATE-002 fixed
elsewhere and REQ-MUTATE-003 says is still live in linestatus.rs. Here it
mattered more than usual: with that guard always true, an UNREADABLE payload
reports MissingFromStore, presenting a permissions fault as an absent file
inside the module written to keep those apart. One survivor is now killed by a
mode-000 test; the other two came from a NotFound arm that no test could reach,
because entry_path already returns None unless the file exists. Removed rather
than papered over — dead defensive code that cannot be exercised is not
caution, it is a place for a defect to hide.

consumer.rs joins the trust-critical mutation gate at zero survivors. It makes
a verification decision, and leaving a new verification module ungated from
birth is precisely the drift REQ-MUTATE-003 describes.

Loop steps 1, 2, 5 are N/A (Rust CLI, no AADL and no wasm — varve#97); step 0
is NOT satisfied, varve still does not pin its own toolchain (varve#106).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
The requirement says the producer "resolves an unchanged payload to Reuse and
then staging has nothing to stage". Reading the production call site says
something worse:

    orchestrate::run(&src, &forge, &planned, &prev, &optins, &|_| false)

`blob_present` was hardcoded false, so carry-forward never DECIDED Reuse in
production at all — and `--present-digests` was parsed, then explicitly ignored
with a note. Not "built but unstageable": built, tested, and unreachable. The
fifth instance this release of a complete, verified capability that no artifact
carries.

Carried-forward bytes now come from the DESTINATION REGISTRY. The first design
wanted a spec entry naming a digest instead of a path, so the deposit would
reference a blob it never held; that was abandoned because a layout missing its
blobs is not the artifact of record it gets uploaded as. Clause 4 of
REQ-CARRYFORWARD-001 already requires the blob to be in the destination before
reuse is permitted — so the check that makes reuse safe and the source that
makes it possible are the same check.

The saving is smaller than first claimed and the requirement says so: not zero
bytes, but one host instead of four upstream CDNs, no upstream rate limits, and
no re-verification of an unchanged release. 421 MiB still moves; it moves from
somewhere closer, already verified, that we control.

Three properties, each with a test that fails without it:

- Fetched by the digest the CURRENT proof states, never the previous layer's
  record. If the old record chose, a republished upstream would be carried
  forward unnoticed — the substitution carry-forward exists to catch.
- Bytes are re-hashed against the digest they were asked for. The destination
  is a source like any other and gets no more trust for being ours: a registry
  serving something else is refused and never reaches the layer.
- Absent, unreachable or wrong falls back to upstream rather than aborting. A
  presence check can race a garbage collection, and a deposit that failed
  because a cache was pruned would be worse than a slower one.

`ReuseUnusable` is a separate fetch reason from `BlobAbsent` deliberately.
Absence is a GC racing a check, which is ordinary; bytes that do not match the
digest they were fetched by is a registry serving something other than what was
asked for. An operator should not have to read those two as one line.

The registry comes from the MANIFEST, never a literal — the same rule the
deposit workflow follows, so there is one place the realm is defined.

Two existing carry-forward tests changed meaning and were updated rather than
deleted: "carried forward" used to assert NO bytes, and now asserts the bytes
are present and came from the registry while no upstream fetch happened. The
layout stays complete, which is the whole reason this design was chosen.

Zero mutation survivors on orchestrate.rs (34 mutants, 30 caught, 4 unviable).

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

REQ-REUSEBLOB-001 landed with 213 green tests, zero mutation survivors, and a
commit message calling out "the fifth instance this release of a complete,
verified capability that no artifact carries". A cold verifier found the same
defect one level down, in the code I had just written to fix it.

`registry::blob_fetch_argv` built `{repo}@{digest}` from varve's own digest
spelling, which is BARE lowercase hex — `Sums` validates with `is_hex64` and
refuses a `sha256:`-prefixed line. An OCI reference requires the algorithm.
Verified against real oras:

    BARE:  invalid reference: invalid digest "2d711642…":
           invalid checksum digest format
    sha256:2d711642…:  Error response from registry: denied (auth) — i.e. the
           reference parsed and the request was made

The bare form dies at REFERENCE PARSING, before any network call. `fetch_blob`
maps every failure to None, the orchestrator maps None to a fallback, so every
carried-forward payload silently degraded to a full upstream download — and the
operator was told "registry blob could not be reused", which this release's own
comment defines as a registry serving something other than what was asked for.
The diagnostic blamed the registry for a bug in the caller.

WHY THE TESTS PASSED. `registry.rs` had no test for `blob_fetch_argv` or
`fetch_blob` at all, and every carry-forward test supplied a hand-written
`from_registry` closure keyed on bare hex. The fakes agreed with the caller and
neither agreed with oras. The file IS in the mutation shard, which taught me
something worth keeping: a mutant can only be killed by a test, so being listed
in the gate is not being covered. Presence is not potency — the same shape as
REQ-MUTATE-003, one level in.

Corroborating evidence I had written and not read: `main.rs` names the cache
file `digest.replace(':', "-")`, a replace on a value that never contains one.

Three tests now pin it: the reference names the algorithm, an already-qualified
digest is not prefixed twice, and a blob goes to a FILE rather than stdout.

Two further findings from the same review, both mine:

- `pin.rs` checked `consumer::PIN_MANIFEST_VERSION` while its error message
  hardcoded "version 1". Setting the constant to 7 produced "manifest-version 1
  is not supported (this varve understands version 1)" — self-contradictory, in
  the one sentence telling a consumer what this build supports. The value could
  not drift; the sentence explaining it could, in a module whose whole argument
  is that consumers should not have to read prose.

- `the_registry_is_asked_for_the_digest_the_current_proof_states` claimed more
  than it showed. `decide` only returns Reuse after `digest_eq(prev, upstream)`
  succeeds, so at that call site the two are equal by construction and no
  fixture can tell them apart. Clause 2's real protection is `decide`'s
  UpstreamRepublished refusal, which belongs to REQ-CARRYFORWARD-001. Renamed
  to what it actually establishes: the registry is consulted at all.

And a stale sentence in support.rs describing month arithmetic as "six months",
which is precisely the drift REQ-SUPPORTUNTIL-001 corrected everywhere else.

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

Clean-room verification of v0.33.0 checked whether every requirement marked
verified had evidence. The source markers are sound — 47 markers, every one on
a real test, all 47 run individually and all 47 passing. The rivet GRAPH says
those same requirements are orphans with no inbound or outbound links.

`status: verified` is hand-typed, `rivet verify` accepts a source marker as
evidence, and neither writes an edge. Two views, disagreeing, and only one of
them is a graph.

Not a v0.33.0 regression, which is worse: 38 requirements are orphans,
including REQ-CARRYFORWARD-001 and REQ-ARCHIVE-002, from months back. Nobody
noticed because it is a WARNING — validate exits PASS with 208 of them, so the
traceability gate cannot fail on its own central invariant.

Third gate-scope finding of one release, and the three together are the lesson:
the mutation gate covers 16 of 39 files (REQ-MUTATE-003), the docs gate covers
one of two shipped binaries (REQ-PRODUCERDOCS-001), and the traceability gate
reports its own invariant as advice. Each gate is real, each works, each has a
boundary nothing checks.

A fourth instance appeared inside this release and is already fixed: registry.rs
was IN the mutation shard with no tests, so its mutants were unkillable and a
live defect shipped through a green gate. Presence in a gate is not coverage
by it.

Clause 5 exists because switching the warning to an error over 38 untriaged
orphans would just teach people to write the exception.

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

Eight requirements: seven verified, one held at implemented because its last
clause belongs to another repository and promoting it would assert a property
this repo did not check.

The release's own recurring finding, stated in the notes rather than discovered
later: FOUR capabilities in this codebase were complete, tested and carried by
no artifact — support-until never set, min-counter with no producer, the line
index with no publisher, and carry-forward decided but never acted on. A fifth
was found inside the fix for the fourth, by clean-room review: the carry-forward
this release adds was itself inert, because varve's digests are bare hex and an
OCI reference needs the algorithm. It died at reference parsing, before any
network call, and reported the registry's fault.

That one is worth the release note it gets. `registry.rs` was already in the
mutation gate and had no test for the functions that were wrong, so its mutants
were unkillable and 213 green tests plus zero survivors proved nothing about
them. Presence in a gate is not coverage by it.

Also here: a retired root that explains itself instead of looking like a
forgery, a consumer API where absence and integrity failure are different
types, and the realm moving to its own registry namespace so that publishing it
needs no cross-repo grant.

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 September 9, 2026 04:25
@avrabe avrabe changed the title v0.33.0 (in progress): SDKs, payload identity, post-deposit corrections, and a legible rotation v0.33.0: SDKs, corrections after publication, and a rotation that explains itself Sep 9, 2026
avrabe and others added 5 commits September 9, 2026 06:30
Asked to put varve's consumer crate in the layer. It cannot go in, and the
reason is the same shape this release keeps finding.

The CONSUMER half is complete: `kind = "crate"` in the deposit spec,
`export-cargo`, `export-crates-vendor`, `export-bazel-distdir`, all tested and
documented. The PRODUCER half cannot reach it. `LayerManifest` has a `tools`
section and a `vsix` section and nothing else; `ManifestTool` has no `kind`
field. So layer.toml cannot say a payload is a crate, varve-producer cannot
ingest one, and no published layer has ever carried a crate. Sixth instance.

What makes it concrete rather than tidy: REQ-CONSUMERAPI-001 answered jess's
four consumption failures with "stop shelling out, depend on varve-core". That
is half a solution while the crate cannot be pinned — jess would take
varve-core from crates.io, unpinned by the layer, unverified by the realm root,
and only with a network, inside a toolchain whose whole purpose is that its
contents are pinned, signed and installable offline. The tool that makes builds
reproducible would be the one dependency fetched the old way. Clause 3 says so
plainly: recommending a crate a consumer cannot pin is advice varve does not
take itself.

Second blocker, recorded because it is a decision and not an oversight: a varve
release publishes no `.crate` file — publish-crates.yml sends them to
crates.io. `cargo package` already produces the exact bytes, so this is an
upload rather than a build, but WHICH SOURCE a crate is ingested from is a real
choice. A release asset keeps one verification story, cosign over
SHA256SUMS.txt, identical to every other payload; crates.io means a second
trust path for the same bytes. Clause 4 refuses to let a crate acquire a
different proof path because of where it was published.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
REQ-LAYERDOCS-001 now derives-from REQ-CRATEPAYLOAD-001, and says why.

A crate's documentation IS its rustdoc, and that is the cheapest documentation
in this toolchain to obtain: nobody authors it, `cargo doc` generates it from
code that already exists. It is also the least reachable — HTML produced at
build time, existing nowhere a consumer can get to offline, because docs.rs is
a network service and a pinned air-gapped build cannot consult it.

So the two ship together. A consumer that can pin varve-core but must go to
docs.rs to read it has been given half of what it needs, and the half that
requires a network — inside a toolchain whose whole claim is that its contents
are pinned, signed and offline.

The ordering follows: there is no rustdoc payload worth carrying until there is
a crate payload to document.

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

The release CI went red, which is the gate doing its job on the day it mattered.

FIVE SURVIVORS in `fetch_blob` — the whole function replaceable by None, by an
empty vec, by arbitrary bytes, and its success check invertible. My own miss,
and a precise one: the clean-room review said `registry.rs` had no test for
`blob_fetch_argv` AND `fetch_blob`, and the fix covered the first. Testing the
argv builder proves the string is right; it says nothing about whether the
function that runs the command and reads the file does either.

`Some(vec![])` is the survivor worth naming. The caller re-hashes what it gets,
so invented bytes are refused there — but only because that check exists, and a
gate that relies on somebody else's check is how both ends end up assuming the
other one looked. Four tests now: bytes come back, a failed fetch is None and
NOT a leftover file from an earlier run, a missing oras is a fallback rather
than a pretend blob, and success with nothing written yields nothing.

The stale-file case is the one I would not have thought to write. `--output`
names a path; if the command fails, whatever was there before is still there,
and returning it would hand the caller a blob to re-hash. A stale blob whose
digest happened to match is exactly the substitution carry-forward exists to
prevent.

`tempfile` joins varve-producer as a DEV-dependency. Checked `cargo build
--locked --workspace` explicitly, because a dev-dependency on this crate broke
the locked build once already (e407fc2).

SECOND FAILURE, same job: the varve-core shard was CANCELLED at 60:16 against a
60-minute limit. Adding consumer.rs took an already ~57-minute shard over. That
is the exact failure this job's own comment warns about — a timeout does not
report a survivor, it reports nothing, so zero-survivor goes unverified while
the check looks like it ran.

Split into `varve-core (trust)` and `varve-core (layers)`, which is the
response that comment prescribes. Raising the timeout would buy one release and
hide the next, and REQ-MUTATE-003 exists to grow this list further, so the shape
has to be one that keeps splitting.

Safe by construction: the ruleset requires only `cargo mutants (trust-critical
gate)`, the fixed-name aggregator, and no per-shard name. Verified against the
live ruleset rather than assumed — a shard rename orphaned the required check
once before, and that failed closed by luck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
Splitting the varve-core shard did not just fix a timeout — it revealed what
the timeout had been concealing. A cancelled shard reports NOTHING, so
zero-survivor went unverified while the check looked like it ran, and fifteen
mutants had been surviving unseen behind it. All from v0.33.0 work.

FOUR IN MIRROR.RS, mine. The `fetch_published_line_status` delegation added for
REQ-POSTDEPOSIT-001 had no test, and the reason is worth keeping: the test
double never modelled a published correction at all, so every fake fell through
to the trait's default `Ok(None)` and the real implementation was
indistinguishable from returning nothing. A fake that does not model the thing
under test makes the test agree with itself. Three tests now: a mirror carrying
a correction the primary lacks is reached, nobody-reachable is an error rather
than absence, and a line with genuinely nothing published is absent rather than
an error — three outcomes that must not collapse into each other, since a
suppressed yank is the difference.

ELEVEN IN SDKEXPORT.RS, from the SDK and archive-format work. The interesting
part is why the existing tests could not see them: every arithmetic mutant in
`relocate_bytes` — the NUL search, the step past it, the terminator, the
capacity — PRESERVES THE FILE'S LENGTH while moving where the patch lands.
Assertions on length and on counts cannot observe any of it. Only the bytes can,
so the new test asserts the neighbouring string survives byte-for-byte.

Two lessons I had to be shown twice:

- I tested the relative-symlink branch and assumed it covered the absolute one.
  It does not; they are separate walks, and the comment beside the absolute one
  says its absence WAS the hole. Both are tested now.
- The capacity test had `start == 0`, where `pad_end - start` and
  `pad_end + start` are the same number. A test that cannot distinguish the
  operator it is aiming at is not testing it. Adding a preceding string made
  the arithmetic observable, and killed three mutants at once.

One survivor was genuinely EQUIVALENT and the fix was to delete the code rather
than test it. `cursor = hit + built.len()` advanced past an occurrence with no
terminator — but if there is no NUL between here and end-of-file, there is none
for any later occurrence either, since each searches a suffix of the same range.
Any advance reaches the same conclusion, which is exactly why the mutation could
not be observed. `break` is correct, simpler, and has no arithmetic to get
wrong. The tool was right that the expression did not matter; the answer was
that it should not have been there.

108 mutants, 104 caught, 2 unviable, 2 timeouts, 0 missed.

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

The layers shard came back RED with **zero missed mutants**: 389 caught, 22
unviable, 2 timeouts, exit code 3. cargo-mutants exits 3 for "some mutants timed
out" whether or not anything survived, so the gate could not tell the two apart
— and they are opposites.

A SURVIVOR is a mutant nothing noticed. A HANG is a mutant the suite DID notice,
by never finishing under it. Both timed-out mutants neutralise a loop's advance:
`pad_end += 1` as `*=` never grows, and `i = h + built.len()` as `*` re-finds
the same occurrence forever. No timeout budget fixes non-termination, and no
test can be written that makes an infinite loop terminate.

I also have to correct myself: my local runs reported "2 timeouts" throughout
and I read "0 missed" and called them clean. They were not clean; the gate fails
on exit 3, and I had been ignoring the number that was about to fail it.

Two changes.

The gate now takes its verdict from the SUMMARY rather than the exit code: any
missed mutant fails, and a run with no summary line fails too, because a gate
that cannot find its own result must not report success. `--timeout 120`
against a lib suite that runs in about a second makes "merely slow" an
implausible reading of a timeout, which is the only reason to distrust one. The
parse was checked against all four real summaries this session produced,
including the two that must fail.

And one hang is removed at the source. `pad_end` is now counted with
`take_while` rather than accumulated, so the mutation that used to hang produces
an observable number instead — which the capacity test can see. That is a
clearer expression of "how much NUL padding follows" anyway. The other hang is
intrinsic to any replace-all loop and stays, now reported as a warning that
names it.

sdkexport.rs: 104 mutants, 101 caught, 2 unviable, 1 timeout, 0 missed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TNtfRjLNhEz82G2ggeeNu
@avrabe
avrabe merged commit 8994b07 into main Sep 9, 2026
29 checks passed
@avrabe
avrabe deleted the plan/v0.33.0 branch September 9, 2026 11:07
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