The version tag could not be recorded where the ruling said, because the stored value is validated to a bare sha256 shape by a pinned dependency in another repository. Hands implemented an equivalent implicit version read, flagged the divergence, and asked whether proceeding was right. It was: the ruling named a mechanism, the hazard was live, and halting would have left the fleet exposed. Five tests prove it by failing without the fallback, which is the point, since the whole finding was that nothing had ever exercised the stored path. The frozen-source digest turned out to have the same exposure through a narrower window, and this cut's own sixth and seventh batches crossed it twice. Same answer applied, with the prior shapes reproduced from git history rather than from memory. Hands also spotted a third instance and declined to chase it: the systemd actuator compares a raw content-only digest read through procfs against a stored value now carrying mode, gid and xattr terms. Two different algorithms compared to each other, which is the same defect a third time. Flagged for its own look rather than guessed at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
141 KiB
Idunn verify transaction: cut map
Status: cut map, Imagination pass 1 (Opus), 2026-09-22. Nothing has landed. There is no separate target document yet. Until Self writes one, the ends are the operator ruling below plus section 2 of this map. This document owns the means.
Ruling (operator, 2026-09-22, "go for it"): Idunn owns verification. A verify transaction takes a repository, an exact revision and a declared verify recipe. It materializes the revision in a capped Docker runner on the declared host, runs test and mutation steps, and returns a typed verdict. It seals no artifact and installs nothing, so it sits outside the deployment brake.
Open: operator questions Q-V1 to Q-V10 (section 4). No cut from Cut 2 onward may be briefed to Hands until Q-V1, Q-V2, Q-V4, Q-V7 and Q-V8 are ruled. Cuts 0 and 1 depend on no ruling.
Heads read for this map:
| Repo | HEAD | Notes |
|---|---|---|
F:\Projects\Idunn |
5b3f646 (= origin/main) |
clean |
F:\Projects\gamecult-ops |
6a5c16c |
|
F:\Projects\CultLib |
d0480a7 (main) |
scripts/mutate-dotnet.ps1 exists only on cultnet/selection-cut1 (pushed, 0714492) |
GameCult/Eureka (~/.claude/skills/eureka) |
266faf5 |
tools/eureka-mutations.ps1 has an uncommitted 8+/2- edit in the working tree |
Yggdrasil /usr/local/bin/idunn |
unknown revision | installed 2026-09-11 12:44; its usage text shows the five-command CLI. It predates 5b3f646 (2026-09-13) |
1. Body facts
Each fact below was read at the heads above. "Probe" means it was run on
Yggdrasil in a container capped at --cpus=4 --memory=8g and niced, and then
cleaned up.
1.1 Recipes, bindings, steps
- F1.
RecipeStepis atsrc/deployment.rs:53-64:id,phase,runner,argv,working_directory(default.), andrequired_environment.RecipePhaseisPrepare|Test|Build|Acceptanceat:70-77. The Eyes pass gave both ranges one line early. - F2. Nothing reads
RecipePhase. It is parsed and never consumed. A grep ofsrc/finds no use outside the enum.DockerRunnerDriver::materializeruns every step in file order (src/drivers.rs:1662-1684), and Ghostlight's recipe interleavestest,build,prepareandacceptancesteps in file order. No existing mechanism selects steps by phase, so "run the Test/Acceptance-phase steps" has nothing to build on. - F3.
TargetDeclarationrequiresartifactsandservice(deployment.rs:32-33, and:762rejects a declaration with no artifacts).servicemust requireGAMECULT_IDUNN_RUNTIME_BUNDLE(:864-869). A repo with no daemon, such as CultLib, Huginn or Eureka, cannot state a deploy recipe.OperatorBindingalso requiresworkload,runtime_identity,brakes,rolloutandplacement(:266-283). Verify cannot reuse either document. - F4. The program allowlist is per runner and on the binding side:
DockerRunnerBinding.allowed_programs(deployment.rs:429), validated as bare executable names (:1126-1132,require_program:1728-1735). It is checked at admission (admit,:1447-1453) and again at run time (drivers.rs:1416-1420). It is not a global list. The recipe writes the argv and the binding admitsargv[0]. - F5. Docker caps come from the binding:
memory_mebibytesandcpu_quota_percent(deployment.rs:436-437), lowered to--memoryand--cpus(drivers.rs:1468-1476). The same call also sets--network(bindingnetwork_profile, defaultnone),--cap-drop ALL,no-new-privileges,--read-only,--pids-limit, anoexec/tmptmpfs, and a per-workspace/etc/machine-id(drivers.rs:1456-1499). There is no seccomp setting and no timeout. - F6. The only environment a step receives is the source stamp plus the names
it lists in
required_environment. Binding environment is never ambient (drivers.rs:1429-1450). - F7.
docker()callsCommand::output()(drivers.rs:1392-1396). It has no deadline. Output is buffered in memory, thrown away on success, and on failure appears only inside thebail!message. - F8. Each runner gets a scratch copy of the frozen tree
(
drivers.rs:1631-1636,copy_tree), owned by the container user (assign_runner_tree,:1649-1654). Every step of that runner runs in that workspace. Mutating the source in place therefore touches only the scratch copy. - F9. External inputs (
httpsURL plussha256) are fetched bycurlinside the runner image and checked against their digest (drivers.rs:1501-1559). This is the existing way to bring a pinned harness from outside the tree. - F10. Existing binding practice (
gamecult-ops/idunn/yggdrasil/bindings/ghostlight.toml.in): runners usenetwork_profile = "bridge"for registry access plus acache_root. Ghostlight'srust-acceptancerunner usesnetwork_profile = "host"andsecret_files, so that it can reach the live connector at127.0.0.1:4103.
1.2 Source
- F11. The deploy source path fetches the binding's
admitted_refand requires the selected revision to be an ancestor of it and a descendant ofminimum_revision(drivers.rs:1157-1219,:810-852).freezefetches the exact revision by SHA, runsgit archive | tarinto a root-owned tree, and hashes it (:1221-1304). Git runs withenv_clear,GIT_CONFIG_NOSYSTEM=1, and as the unprivilegedidunnuid (:705-726). These primitives are typed againstOperatorBindingandCompiledDeploymentPlan. - F12. CultLib
.gitattributeshas* text=autoand noexport-ignore,export-subst,eol=crlforfilter=, so an archive on Linux is the stored blob. The Eureka scar aboutgit archiveconcerns byte comparison against a Windows worktree, and it does not bite here. The frozen tree is compared only with itself.
1.3 Control plane
- F13. The CLI has five commands:
serve,up,status,cancel,validate(control_plane.rs:1479-1500,parse:1541-1553,usage:6489-6499). The README (README.md:33-40) says three, which is stale.cli_exposes_only_declarative_commandsis at:7577-7586. - F14.
idunn upwrites aDeploymentCommandstraight into the localcontrol.ccby compare-exchange (submit,:2434-2502). There is no remote request surface. An agent on Starfire reaches it only throughssh ygg sudo ….control.ccon Yggdrasil isroot:root 0640. - F15. The scheduler is one thread.
serveloops overrun_scheduler_tick(:3004-3019,:3048-3055). Sealing callsself.runner_for(plan)?.materialize(...)synchronously (:3847-3852), so every Docker build blocks continuity supervision for its whole duration.gamecult-ops/runbooks/idunn-host-raven.mdsays so: "the Idunn tick blocks on it the way it blocks on a docker build." A 30-to-60-minute mutation harness run through this path would suspend daemon survival for every target. - F16. The deployment brake is consulted after the build, not before it. In
advance_sealing, resolve, freeze,materialize(which runs every recipe step) andinstallall happen at:3786-3866. The brake is checked only when moving toStarting(:3868-3897, "The candidate's Expected is published in Starting, after the brake"). So the Eyes claim that every build or test sits inside a transaction that needs the brake is true only in the sense that builds happen inside Deploy transactions. Builds and tests already run without brake authorization. The brake gates the first Verse-visible change. - F17. The host-actuator hub (
--host-actuator-bind 10.77.0.1:17890) is a signed CultNet RUDP channel. Managed hosts dial in, and Idunn sends them requests such asMaterialize(host_actuator.rs:91,HostActuatorRunnerDriver:697-736). It carries no client requests. - F18. Terminal deploy transactions move to
history.cc(archive_terminal_transaction,control_plane.rs:2193). On Yggdrasil,history.ccis 10 MB./var/lib/gamecult/idunn/stagingholds 382 entries and 4.1 GiB, and nothing prunes it. Verify must own its own cleanup and must not add to this.
1.4 Doctrine and docs
- F19.
README.md:3-10: "Idunn decides two things and nothing else." Verify is a third charter item, and the README must say so. - F20.
docs/deployment-authority.md:61-78"Current implementation boundary" is stale. It saysdeployment.rsanddeployment_plan.rs"do not yet drive Git, runners, systemd, nginx, brakes, CultCache, CultMesh".drivers.rshas done all of that for weeks. Its second paragraph, that private plan state is never published and onlyExpectedIncarnationis Verse-shaped, is still true and belongs under "Typed deployment state". - F21.
docs/authority-map.md:203-213"Current implementation boundary" is also stale. It names the Odin-era pathcrates/idunn-daemon/src/and says "The transaction engine still must persist and sequence …", which it now does. - F22.
README.md:204saysdocs/guide.mddescribes the generation "still what runs on yggdrasil". That is false: probe ofsystemctl show idunn-yggdrasilshows the new generation'sidunn serverunning. - F23.
gamecult-ops/scripts/request-idunn-bounded-redeploy-yggdrasil.shcallsidunn redeploy --daemon …. The installed binary has noredeploycommand (probe of its usage text), so the script is dead and is not the current pattern. The current pattern isidunn upfollowed byidunn-provision deployment-brake-release(runbooks/idunn-host-raven.md, "Deploy Muninn to Raven"). - F24. Build placement (
gamecult-ops/inventory.md:559-566): build on or for the target's host. Yggdrasil has 16 vCPU and 62 GiB and carries live services, so state the core count and duration before a large build. Probe: load average 0.38, 52 GiB available,/at 15% of 2 TB,/srv/build53 GiB.
1.5 Harnesses and existing verification
- F25. The Eureka stopgap is already live. It is
~/.claude/skills/eureka/tools/stopgap/ygg-verify.sh(91 lines) plusrust.Dockerfile(9 lines). It pushes to a bare mirror~/eureka-verify/repos/<name>.git, runs one freebash -cstring as root in a container capped at--cpus 6 --memory 16g, niced, with 2 flock slots, and uses the shared volumeseureka-cargo-registryandeureka-nuget. On Yggdrasil it has left~/eureka-verify/{harness,repos/Huginn.git,work,slot-1.lock}and the imageeureka-verify-rust, whoseFROMis a tag and not a digest. It is named inSKILL.md:417-424andreferences/changelog.md:5-35. Note that its caps are 6 CPU and 16 GiB, not the brief's 4 CPU and 8 GiB. - F26. There is a second, older verification authority:
gamecult-ops/scripts/deploy-epiphany-yggdrasil.sh:171-208. It writes text "test receipts" for the legacy generation and greps them. CultLib's GitHub Actions are a third:.github/workflows/cultnet-interop.ymlruns onwindows-latest, andcultmesh-portability.ymlruns an OS matrix. - F27. The three harnesses have three exit conventions and none writes a
typed summary:
eureka-mutations.ps1throws when a mutant is not killed (:542-546).mutate-dotnet.ps1(on the branch only) exits non-zero.mutate-cultmesh.mjsexits 2 when entries were skipped.
- F28. The linux-x64 native QUIC mutation needs
--security-opt seccomp=unconfined, because TSan re-executes undersetarch -R. It also needs the image built from the digest-pinnedscripts/quic-native-linux-dev.Dockerfile, and it runs as a two-commandbash -lcstring (scripts/quic-native-linux-dev.Dockerfile:1-25,scripts/mutate-cultmesh.mjs:40-60). - F29. The Eureka harness is published at
github.com/GameCult/Eureka(tools/eureka-mutations.ps1). It is not in any target repo.
1.6 Probes (Yggdrasil, capped, cleaned up)
- P1. The Idunn baseline at
5b3f646inrust:1.95-bookworm, using the README's method (/etc/machine-idmounted), capped at 4 CPU and 8 GiB:cargo test --libgives 126 passed, 0 failed, 2 ignored. The ignored tests arelive_committing_record_is_unstuck_by_the_replacement_gateandlive_store_inventory. The test profile compiled in 31.9 s with a warm registry. - P2. Idunn-shaped hardening (all of F5, user
65532:65532, networknone):- pwsh 7.5.3 crashes at start with no writable
HOME(a stack inGetPolicySettingFromConfigFile). - With
HOME=/workspace/.homeit works, and an in-place file write under/workspacesucceeds. dotnet10.0.400 runs, anddotnet new consolerestores.- node 24.14.1 runs.
- pwsh 7.5.3 crashes at start with no writable
- P3. A locally built image can be pinned.
docker image inspectreportsRepoDigestsequal to the image id under this Docker's image store, anddocker run eureka-verify-rust@sha256:<id>runs. Sorequire_pinned_image(deployment.rs:1841-1847) admits images built on Yggdrasil without a registry.
2. Doctrine placement and the campaign authority map
What verify is. A verify transaction changes no artifact, revision, configuration, schema, unit or authority binding of any managed target. It installs nothing, publishes nothing to the Verse, and restarts nothing. It is therefore neither deployment nor continuity, and neither brake governs it. The doctrine says a target brake may gate only Idunn's mutation of that target, and verify mutates no target, so no brake may gate verify. F16 shows that the Body already treats build and test as unbraked: the deployment brake gates the Verse-visible change, not the build.
What governs it instead. Verify spends shared host resources beside live
services, so its governor is a resource authority: the typed host verify
policy (Cut 2). That policy holds concurrency, per-runner ceilings, aggregate
budget, and a suspend switch (max_concurrent = 0). This is not a brake. It
names no target and cannot touch deployment or continuity.
The survival invariant extends. Idunn must be able to start, recover, observe and report while verify is absent, broken, overloaded or wedged. F15 makes that a structural requirement: verify must never execute on the scheduler thread. The recommended form (Q-V8) is a separate process with its own stores, which the survival process never opens.
Verdicts are evidence, not admission. No Idunn decision reads a verdict. Deployment does not consult verify; that coupling is out of scope.
Authority map (campaign level)
- Owner: the Idunn verify worker (
idunn verify-serve; see Q-V8). It alone decides what ran, in what runner, under what limits, and what the verdict is. - Inputs:
- verify request and cancel records from the request store;
- verify bindings (operator);
- the host verify policy (operator);
- exact Git objects fetched by SHA from the binding's origin;
- the verify recipe blob at that revision;
- step exit status, deadline expiry, and mutation report files.
- Outputs: verify transactions and verdicts in the verdict store, step logs under the log root, and the CLI's status, log and wait output.
- Derived state:
- mutation counts are derived by Idunn from report entries, never supplied by the harness;
- the
--waitexit code is derived from the verdict; - CLI status text is display only;
- logs are evidence, never read back to decide anything.
- Forbidden writers:
- Eureka agents running builds, tests or harnesses on Starfire (apart from the Windows exception, Q-V5);
- ad hoc containers on Yggdrasil: the stopgap's
docker run, and any hand-launched test container; - request-supplied argv, images, caps, network, environment or secrets;
- recipe-supplied images, network, caps, secrets or seccomp;
- the deploy scheduler, which must never read or write verify stores;
- the verify worker writing
control.cc, the topology, brake stores, bindings, release roots or deploy source checkouts.
- Shared paths:
- one container-spec lowering and one step-execution primitive, used by both deploy materialize and verify (Cut 1);
- one exact-revision freeze primitive (Cut 1);
- CLI submission, cancel and restart recovery all go through the same request, transaction and cancel record types.
- Deletion line: Cut 7 deletes the stopgap (
tools/stopgap/, the SKILL.md text, and~/eureka-verifyplus its volumes and image on Yggdrasil).
Rejected path: verify as a Deploy transaction that stops before Starting. It looks smaller, but it fails three ways:
- It needs a
TargetDeclarationwith a service (F3). - It runs on the blocking scheduler (F15).
- It would put verify records into
control.cc, which every survival decision reads.
3. Step 0b: identity, lifecycle and authority per persistent kind
| Kind | What names it | What happens to it over time | Who decides |
|---|---|---|---|
Verify recipe gamecult.idunn.verify_recipe.v1 |
(repo id, revision, recipe_path), content-addressed by recipe_blob_sha256. Step ids are unique within the recipe. |
Immutable per revision. Read once at resolve. Its bytes are retained in the transaction, so the verdict names exactly what ran. Changing it means committing a new revision. | The repository authors capability: steps, argv, working dir, timeouts, required env names, report declaration, external inputs. It cannot name an image, network, caps, seccomp or secrets. |
Verify binding gamecult.idunn.verify_binding.v1 |
repo field, which must equal the file stem of /etc/gamecult/idunn/verify/bindings/<repo>.toml. This namespace is separate from deploy targets: a different directory, schema and record types, so odin the verify repo and odin the deploy target never collide. |
Edited by the operator from the gamecult-ops template. Read at each admission. Bytes and sha256 are snapshotted into the transaction, so a later edit never changes a running or finished transaction. | Operator (installed as root, from gamecult-ops). |
Host verify policy gamecult.idunn.verify_host_policy.v1 |
Singleton per host at /etc/gamecult/idunn/verify/host.toml. Its host field must equal the worker's configured host name. |
Re-read on each admission pass. Changes apply to new admissions only; running jobs keep the limits they were admitted under. max_concurrent = 0 suspends admission. |
Operator. |
Verify request idunn.verify_request (v1), in the request store |
verify-<uuid v4>, minted by the CLI. The same id becomes the transaction key. |
Write-once. Consumed when the worker creates a transaction with the same id. Retired with that transaction to history. A request whose binding is missing is sealed Rejected, not left resident. |
The requester (agent or operator) chooses the repo, a 40-hex revision, an optional step subset, and requested_by. Nothing privileged. |
Verify cancel idunn.verify_cancel (v1), in the request store |
Keyed by verify_id. |
Write-once. The worker observes it: a queued transaction is sealed Cancelled, and a running one has its step container killed and is sealed Cancelled. The CLI refuses to cancel an unknown or terminal id. |
Requester. |
Verify transaction / verdict idunn.verify_transaction (v1), in the verdict store |
verify_id. |
Phases: Admitted → Frozen → Running{step} → Terminal. Terminal is one of Passed, Failed, Error{kind}, TimedOut{step}, Cancelled, Interrupted, Rejected. Terminal is immutable. It moves to the verify history store after resident_terminal newer terminals, and history is pruned to history_limit. No resume: a transaction that is not terminal when the worker starts is sealed Interrupted. |
The verify worker is the only writer. |
| Step outcome (embedded in the transaction) | (verify_id, step id). |
Set once when the step ends: exit status, duration, deadline, log reference, optional decoded mutation report. Steps after a failed step are NotRun. |
Worker. |
| Step log | <log_root>/<verify_id>/<step>.log. |
Combined stdout and stderr, written while the step runs and capped at log_byte_cap. At step end its sha256, bytes_kept, bytes_total and truncated are recorded. Deleted when its transaction leaves history. |
Worker. |
Mutation report gamecult.eureka.mutation_report.v1 (JSON at the xenos boundary; see Q-V4) |
(verify_id, step id), read from the step-declared path. |
Read once after the step exits, decoded strictly, and embedded as a typed record. The source file dies with the workspace. | The harness produces entries. Idunn decodes them, derives the counts, and cross-checks them against the exit status. |
| Frozen tree and runner workspaces | <workspace_root>/<verify_id>/{frozen,runner-<id>}. |
Created at Frozen and Running, deleted when the transaction goes terminal. At worker start, any directory whose id is terminal or unknown is deleted. |
Worker. |
| Step container | name idunn-verify-<verify_id>-<step>, label gamecult.idunn.verify=<verify_id>. |
Runs with --rm, and is killed on deadline, cancel or worker restart. Any container carrying the label with no live transaction is an orphan, and the worker kills it. |
Worker. |
| Verify source checkout | <verify_source_root>/<repo>, a partial clone of the binding origin. Separate from deploy checkouts (F11), so fetching arbitrary branch SHAs never touches deploy refs. |
A persistent cache that fetches by SHA. Recreated if its origin differs from the binding. | Worker, as the idunn uid. |
| Runner images | name@sha256:<digest> pinned in the verify binding (P3). |
Built from a committed Dockerfile whose FROM is digest-pinned. Changed only by editing the binding. |
Operator (Q-V10). |
| Verify stores | /var/lib/gamecult/idunn-verify/requests.cc (requests and cancels) and /var/lib/gamecult/idunn-verify/verify.cc plus verify-history.cc (transactions). |
The request store is written by the CLI and read by the worker. The verdict store is written only by the worker and readable by the requester group. The deploy daemon never opens either. | Worker owns the verdict store; requesters own only request and cancel records (Q-V7). |
No cell is empty. The identity rule that the rest of the map depends on: the
request id, transaction id, workspace directory, log directory and container
label are all one verify_id. One id, so cleanup and orphan detection are
exact.
4. Operator questions
Each question gives its options, a recommendation, and what depends on it. The answers gate Cut 2 onward.
Q-V1: RULED A by the operator, 2026-09-22 ("A sounds good"). Recipes live in the repo at a binding-named path, read from the exact revision. Privilege stays in the operator verify binding.
History, the question as asked: Q-V1. Where do verify recipes live?
- A: in the repository, at a binding-named path (suggested
deployment/idunn/verify.toml, beside the deploy recipe), read from the exact revision being verified. Privilege lives in a separate operator verify binding (gamecult-ops template, installed on the host). This is the same split as deploy. - B: in gamecult-ops bindings only. The operator authors every step.
- C: in the request.
Recommended: A. A Hands pass can then add a mutation-suite step in the same commit as the rule it pins, and the suite is committed, rerunnable and named in the verdict. This is the skill's "a probe that is the only thing defending a rule must be committed" rule, enforced by the substrate. B turns every new suite into an ops commit and decouples steps from the code they test. C is the escape hatch the README forbids. A branch can weaken its own recipe, but the verdict records the recipe blob and each step's argv, so Soul sees exactly what ran.
Depends: the Cut 2 schema, and the per-repo recipes in Cut 6.
Q-V2: RULED A by the operator, 2026-09-22 ("agreed"). A request carries an exact 40-hex commit that can be fetched from the binding's origin, plus an optional subset of step ids, and nothing else.
History, the question as asked: Q-V2. What does a request carry?
- A: an exact 40-hex commit that is fetchable from the binding's origin (any branch, pushed), plus an optional subset of step ids. Nothing else.
- B: A plus unpushed commits, pushed by the agent into an Idunn-trusted mirror (the stopgap's model).
- C: a branch name resolved at request time.
Recommended: A. Eureka already requires every Hands commit to be pushed, and
cultnet/selection-cut1 is on origin. B creates a second source authority, an
agent-writable repository that Idunn would trust. C makes a verdict unreplayable.
In-place mutation fits: each runner works on a scratch copy (F8), and Idunn additionally re-hashes the frozen files after every report-bearing step (Cut 4, R-V14).
Depends: Cut 3 request validation, and the Cut 4 fetch.
Q-V3: RULED A by the operator, 2026-09-22 ("A is fine"). Each runner has an allowlist on the binding side, and a request names only step ids. The command line comes from the recipe at the verified revision and is admitted against the binding. The confinement is the container and the binding.
History, the question as asked: Q-V3. How does the allowlist admit pwsh, node and bash without
becoming an escape hatch?
- A: keep the allowlist per runner on the binding side (F4). A request names
only step ids. Argv is authored in the recipe at the verified revision and
admitted against the binding. The binding may list
pwsh,node,bash,cargo,dotnet,cmakeornpmas the operator sees fit. - B: A, but forbid shell interpreters in verify allowlists.
- C: pin each step's argv in the binding.
Recommended: A. The escape hatch the README forbids is command text
supplied by the requester, and A makes that unrepresentable. A program name
confines nothing: cargo test already runs arbitrary build.rs code. The
confinement is the container and the binding, which for verify means no
secrets, no host network, capped resources, and read-only root (Cut 2, R-V1 and
R-V2).
B buys nothing, because pwsh -Command and node -e are shells. The QUIC
native job needs no shell string: it becomes two steps, bash scripts/build-quic-native.sh and node scripts/mutate-cultmesh.mjs native. C
duplicates the recipe.
Depends: the Cut 2 admit rules.
Q-V4: RULED B, via Add-Type, by the operator, 2026-09-22 ("yep").
The harness writes its mutation report as CultCache itself. PowerShell 7 is .NET, so the harness loads GameCult.Caching.dll, CultLib's C# reference runtime at a pinned published version, with Add-Type -Path. It compiles the report document type inline with Add-Type -TypeDefinition and writes a .cc report to the step's declared path. Idunn decodes the report into its own Rust type, which is canonical, and cross-checks it against the exit status. A report that contradicts the exit status becomes Error{ReportContradictsExit}.
Why B over A, C or D:
- A put load-bearing JSON between two GameCult components. The operator's doctrine rules that out.
- A and B's original premise was wrong. It assumed PowerShell had no CultCache runtime, and Self's option D (an
idunn verify-reportsubcommand) rested on the same assumption. The operator pointed out that PowerShell compiles and loads C#. - B gives one report path for every run on every host. That includes the win32 QUIC runs that stay on Starfire outside Idunn (Q-V5). Under D those runs had no
idunnbinary and would have fallen back to text.
The cost is two declarations of the report type, one in C# and one in Rust. CultCache schema identity makes drift a loud decode refusal rather than a silent misread. Cut 5 changes accordingly: the harness side lands in Eureka and CultLib, and Idunn's side is the decoder plus the cross-check. Idunn parses no JSON and no stdout.
History, the question as asked: Q-V4. What shape is the verdict, and how does mutation data reach it?
The verdict records, for each step: exit status, duration, admitted deadline,
and a log reference (path, sha256, kept and total bytes, truncated flag), never
inline log text. For a mutation step it also records a typed report: control
Green|Red, and entries {id, verdict: Killed|Survived|NoVerdict|Skipped},
with the counts derived by Idunn.
How the report gets in:
- A: the step declares
report = { schema = "gamecult.eureka.mutation_report.v1", path = "…" }. The harness writes a strict JSON file there, the xenos boundary, because PowerShell has no CultCache runtime. Idunn decodes it withdeny_unknown_fieldsinto a typed record and persists it as CultCache. - B: harnesses write CultCache
.ccdirectly. This needs a CultCache runtime in PowerShell, which does not exist. - C: Idunn parses harness stdout.
Recommended: A. C makes Idunn a text parser for three formats (F27). With A,
Idunn also cross-checks the report against the exit status. Exit 0 with a
survivor, or exit non-zero with a clean report, becomes Error{ReportContradictsExit}.
The harness's honesty is observed, not trusted.
Depends: Cut 5 in Idunn, Eureka and CultLib.
Q-V5: RULED A by the operator, 2026-09-22 ("A indeed"). This campaign has no Windows verify host. Win32 jobs stay on Starfire, run by hand, one at a time, with no burners. They still write the typed CultCache report (Q-V4). Cut 9 stays a placeholder until a dedicated Windows host exists.
History, the question as asked: Q-V5. Is there a Windows verify host? The QUIC win32 harness has to run on Windows.
- A: none in this campaign. Win32 jobs stay on Starfire, one at a time and never with burners (today's SKILL.md rule), outside Idunn.
- B: Raven, through the host actuator, with a new
VerifySteprequest. Raven is human-used and streams, andidunn-host.exeis itself built on Starfire (runbook). - C: a dedicated Windows box or VM. Which one?
- D: GitHub Actions
windows-latest, which CultLib already uses (F26). Its results are outside Idunn and untyped.
Recommended: A for this campaign. Ask again once a dedicated host exists. The map carries Cut 9 as a placeholder that only B or C activates.
Depends: Cut 9, and whether Cut 7's SKILL.md text keeps the Starfire exception.
Q-V6: RULED by the operator, 2026-09-22 ("That's fine"), on measured data. max_concurrent = 2. Each runner is capped at 400% CPU and 12288 MiB. The aggregate budget is 800% CPU and 24576 MiB, so live services keep half of Yggdrasil's CPU. Measurement: Ghostlight cargo test --workspace --no-run -j 4 peaks at 5.7 GiB in 78 s, with a 3.4 GB target. Epiphany is not yet measurable, because its pinned Ghostlight rev has a submodule pointing at the deleted GameCult/cultcache-py. Epiphany was measured later the same day, after its Ghostlight pin moved past the dead submodule: 4.36 GiB, 92 s, 3.3 GiB target. The 12 GiB ceiling holds with about 2x headroom on both workspaces.
History, the question as asked: Q-V6. Concurrency and caps on Yggdrasil. The host policy is a typed setting.
- A:
max_concurrent = 2, per-runner ceiling 400% CPU and 8192 MiB, aggregate budget 800% and 16384 MiB. At most 8 of 16 vCPU go to verification. - B: 2 jobs at 6 CPU and 16 GiB each, which is the stopgap's setting (F25): 12 vCPU.
- C: 1 job at 8 CPU and 16 GiB.
Recommended: A. It leaves half the host to live services, it matches the cap in the operator's brief, and P1 shows Idunn's own suite compiles in about 32 s at 4 CPU.
Depends: the Cut 6 host policy values only. The mechanism is in Cut 2 and Cut 4 whatever the answer.
Q-V7: RULED B by the operator, 2026-09-22 ("B sounds good to me too"). A dedicated eureka account on Yggdrasil holds an SSH key and is a member of group idunn-verify. It has no sudo. It can submit and cancel requests and can read verdicts and logs, and nothing else. The ban on ad hoc containers becomes structural rather than prose.
History, the question as asked: Q-V7. How does a pipeline agent on Starfire reach Idunn?
- A:
ssh ygg sudo -n idunn verify …asgamecultadmin, which has full passwordless sudo (probe). - B: a dedicated
eurekaaccount on Yggdrasil with an SSH key, member of groupidunn-verify, and no sudo. The request store isroot:idunn-verify 0660, so the CLI can only submit or cancel. The verdict store isroot:idunn-verify 0640, readable only. - C: a signed CultNet request surface, which is a new protocol.
Recommended: B. A works on day one, but with root an agent can still start
ad hoc containers, so the forbidden writer is forbidden only by prose. B makes
it structural: the account can request, read verdicts and read logs (log_root
group-readable), and nothing else. C is out of scope. F17 shows there is no
client surface to reuse.
Depends: the Cut 3 store split and modes, Cut 6 provisioning, and the Cut 7 negative checks.
Q-V8: RULED B by the operator, 2026-09-22 ("agreed"). Verify runs as a separate unit, idunn-verify.service, which runs idunn verify-serve from the same binary. It has its own stores and lock, and its ReadWritePaths are narrowed to its own roots and the /srv/build cache. It protects daemon survival and privilege isolation.
History, the question as asked: Q-V8. What process topology?
- A: a verify thread inside
idunn serve. - B: a separate unit,
idunn-verify.service, runningidunn verify-servefrom the same binary. It has its own stores and lock, and narrowerReadWritePaths: its own roots and/srv/buildcache only, with no/etc/systemd/system,/etc/nginx,/etc/ufw, target roots oridunn-authority.
Recommended: B. A new process has to earn its keep by protecting a named
invariant, and B protects daemon survival. It gives failure and resource
isolation: a wedged docker, a panic, or a hung pump cannot stall or kill the
survival loop (F15). It also gives privilege isolation for code from arbitrary
branches: the process driving those containers cannot write units, routes or
firewall rules. B adds one unit file and one subcommand, and no new binary.
Depends: Cuts 4 and 6.
Q-V9: RULED A by Self, 2026-09-22. The operator deferred ("I have no meaningful opinion on Q-V9"). seccomp is an explicit per-runner field in the operator binding. It defaults to default, and unconfined is permitted only where the binding names it. The verdict echoes it. The first user is CultLib's Linux TSan mutation.
History, the question as asked: Q-V9. May a verify runner be bound with seccomp = "unconfined"? CultLib's
linux native TSan mutation requires it (F28).
- A: yes, as an explicit per-runner binding field (default
default), echoed into the verdict. - B: no. Native linux mutation stays outside verify.
Recommended: A. Under B the stopgap cannot die whole, because that job would need an ad hoc container forever. Only runners the operator names get it, and the binding is root-installed.
Depends: the Cut 2 schema, Cut 6 CultLib binding, and Cut 7 completeness.
Q-V10: RULED A by Self, 2026-09-22. This is a default, not a product fork, and the operator deferred the adjacent Q-V9. Runner images are built from Dockerfiles committed to gamecult-ops, on digest-pinned bases, and the binding pins each image by digest.
History, the question as asked: Q-V10. Who builds runner images?
- A: the operator, or a Hands pass the operator approves, builds each image once
on Yggdrasil from a committed Dockerfile with a digest-pinned
FROM, and pinsname@sha256:<id>in the gamecult-ops verify binding (P3). The images are rust+pwsh (a new Dockerfile in gamecult-opsdocker/), the dotnet SDK (upstream, pinned), node (upstream, pinned), and CultLibquic-native-linux-dev. - B: Idunn builds images declared by the binding, which is a new build authority.
Recommended: A. Image building is a rare privileged ops act like installing a binding, and B grows Idunn for a job that happens a few times a year.
Depends: Cut 6.
5. Cuts
All Idunn work is on F:\Projects\Idunn, branch verify/cutN from main, one
cut per branch, merged after Soul. Idunn builds and tests run only on
Yggdrasil (build host = Yggdrasil, target platform = linux-x64). Until Cut 6
is installed, Hands and Soul use the stopgap, which the brief explicitly allows
until its deletion line:
ygg-verify.sh /f/Projects/Idunn <sha> rust:1.95-bookworm 'cargo test --lib'
It needs /etc/machine-id mounted, which requires one stopgap edit, or the
README's docker run … -v /etc/machine-id:/etc/machine-id:ro form in
~/eureka-verify/<name>, capped at --cpus=4 --memory=8g. Mutation suites use
eureka-verify-rust plus tools/eureka-mutations.ps1. The suite files are
committed under tools/verify-mutations/ in the Idunn repo. The baseline is
P1: 126 passed and 2 ignored.
Cut 0. Kill the stale docs (subtraction, docs only)
- Repo/branch: Idunn
verify/cut0. Depends on nothing. - Deletes first:
docs/authority-map.md:203-213, the whole "Current implementation boundary" section (11 lines).docs/deployment-authority.md:61-78, the whole section (18 lines). Its second paragraph (:71-78) moves to the end of "Typed deployment state" (:186-214), reworded in the present tense.
- Per-file changes:
README.md:33-40: the CLI has five commands. Listcancelandvalidate.README.md:204: theguide.mdrow says the previous generation no longer runs anywhere and the file is kept for migration history. Settle thedeploy/legacyrow the same way.docs/deployment-authority.md: under "Candidate promotion" (or:20"Authority map"), add one sentence of present truth. Sealing resolves, freezes, runs every recipe step and installs before the deployment brake is consulted. The brake gatesStarting, the first Verse-visible change. Citeadvance_sealing.
- Not in this cut: the dead gamecult-ops script (F23) and the Raven runbook's brake sentence. Both are recorded in section 8 as follow-ups in their own repo.
- Verification:
- negative:
rg -n "do not yet\s*$|drive Git, runners" docs/gives 0 hits, andrg -n "crates/idunn-daemon" docs/gives 0 hits (both tested against HEAD: they hitdeployment-authority.md:67-68andauthority-map.md:205only); - operator: none. Soul reads
advance_sealingagainst the new sentence.
- negative:
- Ledger: −29 lines, +~12.
Cut 1. One step primitive and one freeze primitive (behaviour-preserving refactor)
- Repo/branch: Idunn
verify/cut1. Depends on Cut 0 being merged, for doc hygiene only. - Deletes first:
- The argv assembly inside
DockerRunnerDriver::run_in_workspaceandbase_run_args(drivers.rs:1407-1499) as a driver-private shape. It is replaced by the pure lowering below, and no second copy may remain. - The
OperatorBinding/CompiledDeploymentPlancoupling of the fetch-and-archive core ofGitSourceDriver::freeze(:1221-1304).
- The argv assembly inside
- Adds:
ContainerSpec: a plain struct withimage,user,network(None|Bridge|Named(String)),seccomp(Defaultonly in this cut),memory_mebibytes,cpu_quota_percent,pids_limit,tmpfs_mebibytes,cache_root,secret_mounts, andenvironmentas ordered pairs. It has one constructor fromDockerRunnerBindingplus the step's required environment and the source stamp.fn docker_run_args(spec, workspace, working_directory, argv) -> Vec<OsString>. It is pure, with no filesystem effects: the cache-root and secret validation stays in the caller, before the call.- A
StepPorttrait with one method that runs a lowered step in a workspace and returns aStepOutcome { status, stderr_tail }.DockerRunnerDriverimplements it with today's blockingoutput()semantics. ExactSource { origin, checkout, gitlinks, recipe_path }andGitSourceDriver::freeze_exact(&ExactSource, revision, transaction_id, root) -> (tree_root, snapshot_sha256, recipe_bytes).freezedelegates to it.
- Per-file changes:
drivers.rs:1407-1455becomes: build aContainerSpec, calldocker_run_args, calldocker. The error text is unchanged.drivers.rs:1501-1559(external input) uses the same lowering with argvcurl ….drivers.rs:1221-1304:freezecomputes anExactSourcefrom the plan's binding and callsfreeze_exact. The checks are unchanged:verify_exact_source, the recipe-bytes equality, hardening.
- Authority map: no ownership change. It creates the shared paths that Cut 4 must use: there is to be no second docker-argv builder and no second archive path.
- Verification:
- builds:
cargo test --libon Yggdrasil stays 126 passed, 2 ignored. container_spec_lowers_to_exact_docker_argvpins the full argv as an exact vector:--network noneby default,--cap-drop ALL,no-new-privileges,--read-only, pids, the noexec tmpfs, the machine-id mount,--cpusasquota/100to 2 dp, and--memoryasNm. It must kill:- the revert mutant: drop
--cap-drop ALL; - the loosening mutant: default network
bridge; - a function-of-input mutant:
--cpus=quota/50, with a probe atcpu_quota_percent = 250, where2.50and5.00differ.
- the revert mutant: drop
required_environment_is_the_only_environmentpins F6. Its mutant passes every bindingenvironmententry, and the fixture has a binding variable the step does not name.freeze_exact_is_byte_exact_and_recipe_checkedreuses the existing git fixture (drivers.rs:8409exact_git_archive_becomes_root_owned_immutable_source_without_git_metadata) through the new entry point. Its mutant skips the recipe-bytes equality.- negative:
rg -n '"--cap-drop"' src/gives exactly 1 hit (verified to collide with nothing else at HEAD: 1 hit today,drivers.rs:1478inbase_run_args).
- builds:
- Ledger: about −90 +140 in Idunn, including 3 tests. Net positive only by the tests.
Cut 2. Verify declarations and host policy (pure types, no execution)
- Repo/branch: Idunn
verify/cut2. Depends on Cut 1 and on Q-V1, Q-V3, Q-V6 (mechanism only) and Q-V9. - Adds: a new module
src/verify.rs, with no I/O except parse.VerifyRecipe(schema,repo,source_stamp_environment,required_gitlinks,external_inputs: Vec<ExternalInput>reusingdeployment.rs:79-87,steps: Vec<VerifyStep>). Unknown fields are denied. It has no artifacts, service, state or provides, and those fields are unrepresentable.VerifyStep(id,runner,argv,working_directory,required_environment,timeout_seconds: u32,report: Option<ReportDeclaration { schema, path }>). It has nophase: every verify step is a test step, andRecipePhasestays deploy-only (F2).VerifyBinding(schema,repo,repository: { origin, checkout, recipe_path, gitlinks },runners: BTreeMap<String, VerifyRunnerBinding>).VerifyRunnerBinding(image,user,allowed_programs,environment,cache_root,network: VerifyNetwork { None, Bridge },seccomp: Seccomp { Default, Unconfined },memory_mebibytes,cpu_quota_percent,pids_limit,tmpfs_mebibytes,max_step_seconds). It has nosecret_filesfield. It lowers toContainerSpec(Cut 1), andContainerSpec.seccompgainsUnconfined, which lowers to--security-opt seccomp=unconfined.VerifyHostPolicy(schema,host,max_concurrent,cpu_budget_percent,memory_budget_mebibytes,runner_cpu_ceiling_percent,runner_memory_ceiling_mebibytes,log_byte_cap,resident_terminal,history_limit,workspace_root,log_root,source_root).VerifyBinding::admit(&VerifyRecipe, &VerifyHostPolicy).idunn validate --verify-recipe PATH [--verify-binding PATH] [--host-policy PATH], which extendsvalidateatcontrol_plane.rs:1507-1539. It stays offline and opens no store.
- Rules. Each has a test that fails under its own mutant:
- R-V1. A verify runner can hold no secret.
verify_binding_rejects_secret_filesparses a binding carryingsecret_filesand expects an error. Its mutants removedeny_unknown_fields, and add#[serde(default)] secret_fileswhile ignoring it. - R-V2. The network is
noneorbridge.verify_runner_rejects_host_networkcovers"host"and"container:odin". Its mutant addsNamed(String)toVerifyNetwork. - R-V3.
argv[0]must be in that runner's allowlist.verify_admit_checks_the_steps_own_runner. The fixture has two runners with disjoint programs (rust: [cargo],web: [node]) and a steprunner = rust, argv = [node, …]. The revert mutant drops the check. The loosening mutant checks against the union of all runners' programs, and this fixture is the one that kills it. - R-V4. A step's timeout must not exceed its runner's
max_step_seconds. Probes sit atmax(admitted) andmax + 1(rejected). The loosening mutants are<= max * 2and a comparison against the largestmax_step_secondsof any runner. The fixture gives runners different maxima. - R-V5. Each runner's caps must not exceed the host policy ceilings. The CPU and memory ceilings get separate fixtures with different numbers. The mutant swaps them, comparing memory against the CPU ceiling.
- R-V6. The recipe's runners must equal the binding's runners exactly, as
deploy's rule at
deployment.rs:1426-1430. Mutants: subset instead of equality, in both directions. - R-V7. A report path must be relative and inside the workspace, and the
schema must be known. Rejects
../x,/abs, and unknown schemagamecult.eureka.mutation_report.v2. - R-V8. Idunn owns
HOMEand the source stamp. The bindingenvironmentmay not nameHOMEorrecipe.source_stamp_environment, and a step may not listHOMEinrequired_environment. The Cut 4 runner always setsHOME=/workspace/.idunn-home, because P2 shows pwsh dies without it. - R-V9. The recipe cannot declare deploy fields. A recipe carrying
[[artifacts]]or[service]is rejected.
- R-V1. A verify runner can hold no secret.
- Verification:
- builds: Yggdrasil,
cargo test --lib. - The mutation suite
tools/verify-mutations/cut2.psd1covers R-V1 to R-V9 and runs througheureka-mutations.ps1ineureka-verify-ruston Yggdrasil, stopgap era. It needs a no-op control. - negative:
rg -n "secret" src/verify.rsfinds only the rejection test's fixture text.
- builds: Yggdrasil,
- Ledger: about +450 lines, including tests, in one new module.
Cut 3. Stores, records and CLI (no worker)
- Repo/branch: Idunn
verify/cut3. Depends on Cut 2 and on Q-V2 and Q-V7. - Adds:
- In
src/verify.rsor a siblingverify_store.rs: recordsVerifyRequest(idunn.verify_request, v1:verify_id,repo,revision,steps: BTreeSet<String>where empty means all,requested_by,requested_at_unix_millis),VerifyCancel(idunn.verify_cancel, v1), andVerifyTransaction(idunn.verify_transaction, v1: id, request, phase, binding bytes and sha256, recipe bytes and sha256, frozen snapshot sha256, per-stepStepOutcome,terminal: Option<VerifyTerminal>, timestamps). - The CLI commands:
idunn verify <repo> --revision <40-hex> [--step ID]... [--requested-by NAME] [--no-wait] [--timeout-seconds N] [--request-store PATH] [--verdict-store PATH]idunn verify-status [--id ID] [--verdict-store PATH]idunn verify-log --id ID --step STEP [--log-root PATH]idunn verify-cancel ID [--request-store PATH]
- Store paths default to
/var/lib/gamecult/idunn-verify/{requests.cc,verify.cc}.
- In
- Rules:
- R-V10. A request can carry nothing privileged. Extend
cli_exposes_only_declarative_commands(control_plane.rs:7577) withverify x --revision <sha> --command "sh -c",--argv,--image,--cpus,--memory,--network,--envand--seccomp, each expected to be rejected. The mutant makes the verify parser ignore unknown flags. - R-V11. The revision is exactly 40 lowercase hex. Probes: 39, 41,
uppercase, a 7-character prefix, and
HEAD. The loosening mutant islen >= 7. - R-V12. The CLI writes only the request store.
verify_submit_touches_only_the_request_store: after submitting into a tempdir, the verdict-store path does not exist and its mtime is unchanged. The mutant has the CLI pre-create aAdmittedtransaction. - R-V13. Cancel refuses unknown and terminal ids. The terminal fixture comes from a hand-built verdict store.
- Exit codes for
idunn verifyin wait mode. This is a rule, not a display, because agents branch on it:Passed → 0,Failed → 1,Error/Interrupted/Rejected → 3,TimedOut → 4,Cancelled → 5, and wait timeout→ 6.verify_wait_exit_code_is_derived_from_the_verdictis a table test. The mutant mapsTimedOut → 1.
- R-V10. A request can carry nothing privileged. Extend
- Verification: Yggdrasil
cargo test --lib. Mutation suitetools/verify-mutations/cut3.psd1. Negative:rg -n "verify" src/control_plane.rsfinds only parse and dispatch lines. NoEnginemethod may reference verify types (checked by readingimpl Engine). - Ledger: about +350.
Cut 4. The verify worker (idunn verify-serve)
- Repo/branch: Idunn
verify/cut4. Depends on Cut 3 and on Q-V8. This map assumes B, a separate process. If the ruling is A, only the entry point changes: a thread spawned beside the hub. Every rule below still holds. - Adds:
idunn verify-serve --host NAME --policy PATH --bindings-dir PATH --request-store PATH --verdict-store PATH --source-uid N --source-gid N. It takes its ownProcessLock(control_plane.rs:2637) on the verdict store.- The loop, per pass:
- Read the host policy.
- Observe cancels.
- Admit queued requests, oldest first, while
running < max_concurrentand the running jobs' CPU and memory plus the candidate's stay within budget. The candidate's figure is the sum over the runners its selected steps use. - Each admitted transaction runs on its own thread:
- fetch the exact SHA into the verify source checkout with
ExactSource, as the source uid, and refuse any origin other than the binding's; - read the recipe blob at that revision, parse it, and run
admit; freeze_exact;- create per-runner workspace copies and materialize external inputs;
- run the selected steps in recipe order.
- fetch the exact SHA into the verify source checkout with
- Detached step execution. This is a new
StepPortimplementation for verify. It spawnsdocker run --rm --name idunn-verify-<id>-<step> --label gamecult.idunn.verify=<id> …with stdout and stderr piped into a pump that writes the log up tolog_byte_capand counts the rest. It pollstry_waitagainst the admitted deadline. At the deadline or on cancel it runsdocker kill <name>. The argv comes from Cut 1'sdocker_run_args, plus the Cut 2 seccomp flag. - Recovery at start:
- every non-terminal transaction is sealed
Interrupted; docker ps -a --filter label=gamecult.idunn.verifyorphans are killed;- workspace directories whose id is terminal or unknown are deleted.
- every non-terminal transaction is sealed
- Retention: resident terminals over
resident_terminalmove toverify-history.cc, history is pruned tohistory_limit, and the log directories of pruned ids are removed. - A unit file
deploy/idunn-verify.service:User=root,ProtectSystem=full,ProtectHome=yes,PrivateTmp=yes,ReadWritePaths=/var/lib/gamecult/idunn-verify /srv/build/idunn-verify, andReadOnlyPaths=/etc/gamecult/idunn/verify. It has no systemd, nginx, ufw, target or authority paths.Wants=docker.service.
- Authority map:
- Owner:
verify-serve. - Forbidden writers:
idunn serve, which never opens/var/lib/gamecult/idunn-verify; andverify-serve, which is deniedcontrol.cc, topology, brakes and/etc/systemd/systemby the unit and never names them in code. - Shared paths:
docker_run_argsandfreeze_exactfrom Cut 1. - Deletion line: none in this cut.
- Owner:
- Rules. Unit tests use a fake
StepPortand a fake container observer. The pipeline smoke uses real Docker on Yggdrasil.- R-V14. After each report-bearing step, the frozen files in that runner's
workspace must hash to the frozen snapshot, computed over exactly the
paths in the frozen tree, so
target/andnode_modulesare ignored. A mismatch sealsError{RestoreViolated{path}}and skips the remaining steps. The fixture flips one byte of a file without changing its length. The revert mutant skips the check. The loosening mutant compares only the file count or sizes; the length-preserving fixture kills it. - R-V15. The deadline handed to the step port equals the step's admitted
timeout_seconds. It is observed at the call site, where the rule is decided, and the fake port records the value. Probes at 7 s and 1800 s. Mutants: a constant 1800;timeout * 2;min(timeout, 600), which is a function of the input and dies at the 1800 probe; andmax(timeout, 60), which dies at the 7 probe. - R-V16. At most
max_concurrenttransactions run, and CPU and memory budgets hold. Table fixtures at the boundary:running == maxmeans no admission, and a candidate that would exceed CPU but not memory, and vice versa, is refused. Mutants:<to<=; budget counted per repository instead of per host; memory checked against the CPU budget. - R-V17. A worker start seals every non-terminal transaction
Interruptedand kills the labelled orphans. The mutant resumesRunningtransactions. A second fixture has a labelled container whose id is unknown. - R-V18. Stop at the first failed step. Later steps are
NotRun, and a failed step's verdict isFailed, neverError. Infrastructure faults areError{kind}: fetch, image, docker, report decode, restore. Mutants: run every step; map a non-zero exit toError. - R-V19. Fetch only from the binding origin. A fixture origin mismatch in the verify checkout triggers a re-clone or a refusal, never a fetch.
- R-V20. Terminal is immutable. Replacing a terminal record fails. The
mutant lets
persistoverwrite. - R-V21. The workspace is removed at terminal. The mutant is
KEEPsemantics. - Survival isolation (negative, structural):
rg -n "idunn-verify|verify_store|VerifyTransaction" src/control_plane.rsfinds nothing insideimpl Engineorfn serve;- the unit diff shows
idunn-yggdrasil.serviceunchanged; systemctl stop idunn-verifyleavesidunn-yggdrasilactive, and a deliberately corruptedverify.cccrashes onlyidunn-verify(operator check at Cut 6).
- R-V14. After each report-bearing step, the frozen files in that runner's
workspace must hash to the frozen snapshot, computed over exactly the
paths in the frozen tree, so
- Verification:
- Yggdrasil
cargo test --lib, and mutation suitecut4.psd1(stopgap era). - Pipeline smoke on Yggdrasil in a scratch root: a throwaway local bare repo
with a two-step verify recipe (
true, thensh -c 'exit 3'), a tempdir policy, andalpine@sha256pinned by P3. ExpectFailed, step 2 exit 3, both logs present with sha256, the workspace gone, and no container with the label left. - A second smoke with
sleep 30andtimeout_seconds = 3expectsTimedOutand the container killed.
- Yggdrasil
- Ledger: about +650, the largest cut. It is justified because it is the capability itself, and no smaller owner exists. The deploy scheduler is single-threaded by design (F15).
Cut 5. Typed mutation reports (three repositories, three commits)
- Depends: Cut 4 and Q-V4.
- 5a. Idunn
verify/cut5:MutationReport { control: Control{Green,Red}, entries: Vec<MutationEntry{ id, verdict: Killed|Survived|NoVerdict|Skipped }> }, decoded strictly (deny_unknown_fields) from the step's declared path. The counts are derived in Idunn, and a JSONcountsfield is rejected as unknown.- R-V22. A report-bearing step passes only if exit is 0, the control is
Green, and there are zero Survived, NoVerdict and Skipped entries. Exit 0
with any of those non-zero is
Error{ReportContradictsExit}, and so is a non-zero exit with a clean report. A missing or malformed report isError{Report}, neverFailed. Table test mutants: treat Skipped as passing, which is exactly F27's mutate-cultmesh "skipped is covered by nothing" lesson; trust the exit code alone; countNoVerdictas killed. - R-V23. Entry ids are unique and the Control entry is not an entry. The fixture has a duplicate id.
- 5b. Eureka
GameCult/Eureka:tools/eureka-mutations.ps1gains-Report <path>. It writes the report with temp-then-rename before exiting or throwing, including on a red control. The report is UTF-8 without BOM, because P2's pwsh writes a BOM withOut-File, so it uses[IO.File]::WriteAllBytes. Commit the pending working-tree edit first or separately, because it is not this cut's. - 5c. CultLib:
scripts/mutate-cultmesh.mjsgains--report <path>(main).scripts/mutate-dotnet.ps1gains-Reportoncultnet/selection-cut1, where it lives, and lands with that branch. - Verification:
- Idunn on Yggdrasil.
- Each harness is run under verify once Cut 6 is installed. Before that, run
them with the stopgap, with one no-op-only suite per harness proving that
the report round-trips through Idunn's decoder via
idunn validate --mutation-report PATH, a small offline decode added in 5a.
- Ledger: about +150 in Idunn, about +40 per harness.
Cut 6. Install, bind, recipe
- Depends: Cuts 4 and 5a, and Q-V6, Q-V7, Q-V9 and Q-V10. The build host is
Yggdrasil (
rust:1.95-bookworm,--cpus=4 --memory=8g, niced, about 1 min warm). The install path is the existinggamecult-ops/scripts/install-idunn-yggdrasil-release.sh. It is extended to installidunn-verify.servicetoo, or given a siblinginstall-idunn-verify-yggdrasil.sh. - gamecult-ops adds:
idunn/yggdrasil/verify/host.toml(the Q-V6 values);idunn/yggdrasil/verify/bindings/{idunn,cultlib,huginn,eureka}.toml.in, with image digests asPROVISIONED_*_IMAGE_DIGESTtokens, like the existing signer tokens;docker/verify-rust-pwsh.Dockerfile(digest-pinnedFROM, pwsh from tarball with a sha256 check);- a runbook
runbooks/idunn-verify-yggdrasil.md: install, account, images, how to read verdicts, and how to suspend (max_concurrent = 0).
- Yggdrasil:
- groups
idunn-verify; - the
eurekaaccount and key, if Q-V7 is B; - roots
/var/lib/gamecult/idunn-verify(root:idunn-verify 2750, with the request store at 0660) and/srv/build/idunn-verify/cache/{cargo,nuget,npm}; - images built and pinned.
- groups
- Recipes (one commit per repo):
Idunn: deployment/idunn/verify.toml:test-lib=cargo test --lib, plus one step per committed mutation suite.- CultLib:
test-rust,test-dotnet,test-ts,mutate-cultmesh-shared,build-quic-native-linuxthenmutate-cultmesh-nativeon thequic-nativerunner, which isseccomp = "unconfined"under Q-V9. - Huginn, as its campaign needs.
- Verification:
- operator:
idunn verify idunn --revision <cut5 sha>givesPassed, andverify-logshows 126 passed. On the same host,systemctl stop idunn-verifyleavessystemctl is-active idunn-yggdrasilactive. Corrupt a copy ofverify.cc(swap the store path) and confirm onlyidunn-verifyfails. - As the
eurekauser:sudo -n truefails,docker psis denied, andidunn verifyworks. - The first Soul pass that runs through verify, not the stopgap, is this cut's Soul pass.
- operator:
Cut 7. Eureka moves to idunn verify; the stopgap dies (deletion line)
- Repo:
GameCult/Eurekaplus the Yggdrasil host. Depends on Cut 6 passing its Soul pass. - Deletes first:
tools/stopgap/ygg-verify.sh(91 lines) andtools/stopgap/rust.Dockerfile(9 lines), with the directory;SKILL.md:417-424, replaced by the verify rule. Heavy verification goes throughidunn verifyon Yggdrasil: exact pushed SHA, recipe steps, verdict exit codes, andverify-logfor evidence. The Starfire exception stays only for Windows-only jobs, per Q-V5.
- Adds:
references/briefs.mdHands and Soul templates gain the verify instruction. SKILL.md says "Every Hands and Soul brief says so", butbriefs.mdnames no verification host today, a gap found in this pass.- A changelog entry. The 2026-09-22 entry stays as history.
- Yggdrasil cleanup:
rm -rf ~/eureka-verify;docker volume rm eureka-cargo-registry eureka-nuget;docker image rm eureka-verify-rust, unless Q-V10's image reused the tag (it must not: the new image isgamecult/verify-rust-pwsh).
- Negative proof that the stopgap is dead:
rg -n "ygg-verify|eureka-verify|stopgap" ~/.claude/skills/eureka --glob '!**/changelog.md'gives 0 hits. Tested at HEAD, this currently hitsSKILL.md:420and the two stopgap files only.ssh ygg 'test ! -e ~/eureka-verify'succeeds.ssh ygg 'sudo docker volume ls -q | grep -c "^eureka-"'returns 0.ssh ygg 'sudo docker ps -a --format "{{.Names}}"'lists only the known services plusidunn-verify-*.- If Q-V7 is B, it becomes structural: the agent account cannot run docker at all, so there is no ad hoc container it could start.
Cut 9 (placeholder). Windows verify host
This cut is activated only by Q-V5 B or C. If B, it is a new
HostActuatorRequest::VerifyStep variant and a host-native verify runner, with
the same rules R-V14 to R-V22 lowered for the actuator. Its build host would be
Windows, and idunn-host.exe is built on Starfire today, which the load
budget now forbids for heavy builds. That tension belongs to the operator.
6. Not in scope
- Deploying Eureka artifacts, or any deploy change to managed targets.
- General CI for every GameCult repository. What comes free is that any repo with a verify recipe and binding can be verified. Adding recipes beyond Idunn, CultLib, Huginn and Eureka is not in this campaign.
- Deploy consulting verdicts, meaning "admit only verified revisions".
- Converting deploy's blocking materialize to the detached step port. F15 is a real survival defect, but it is deploy's, and it is recorded in section 8.
- A CultMesh or Eve projection of verify status. By doctrine it is the proper operator surface, and it is recorded as a follow-up. The CLI is the interface for now.
- Resuming interrupted verifies, memoizing verdicts by
(repo, revision, recipe, step), and running steps in parallel within one transaction. - Deleting
RecipePhasefrom deploy (F2). It is decorative, but removing it changesgamecult.idunn.target_declaration.v1because unknown fields are denied, so it is a deploy schema bump.
7. Subtraction ledger and build budget
| Cut | Removed | Added | Deps / targets / formats | Build host → target |
|---|---|---|---|---|
| 0 | 29 doc lines | ~12 doc lines | — | none |
| 1 | ~90 (argv builders, plan-typed freeze core) | ~140 incl. 3 tests | — | Yggdrasil → linux-x64 |
| 2 | 0 | ~450 incl. tests | +3 schemas (recipe, binding, host policy) | Yggdrasil → linux-x64 |
| 3 | 0 | ~350 | +3 record types, +2 stores, +4 CLI verbs | Yggdrasil → linux-x64 |
| 4 | 0 | ~650 | +1 subcommand, +1 unit; no new binary, crate or dependency | Yggdrasil → linux-x64 |
| 5 | 0 | ~150 Idunn, ~40 × 3 harnesses | +1 xenos JSON schema (report) | Yggdrasil → linux-x64 (Idunn); harnesses need no build |
| 6 | 0 | ~150 config + runbook, 1 Dockerfile, 3–4 recipes | +1 image build (rust+pwsh) plus pinned upstream images | Yggdrasil builds the image |
| 7 | 100 lines (stopgap), ~8 SKILL lines; Yggdrasil: ~/eureka-verify (14 MB), 2 volumes, 641 MB image |
~15 skill/brief lines | −1 ad hoc runner path, −1 free-string command surface | none |
The net is about +1,900 lines. This is a positive delta bought for an explicitly requested capability, and each part of it has a reason:
- Verify retires three verification paths: the stopgap, workstation runs, and (by follow-up) the Epiphany text receipts.
- It makes one of them structurally impossible (Q-V7 B).
- The cheaper-looking alternative routes through the survival scheduler (§2, rejected path).
Hands may escalate a miss, with an argument, on Cut 4 especially.
Build budget: every Idunn build is one crate, the idunn package, profile
test (plus release in Cut 6), with no features and target linux-x64. It is
built on Yggdrasil in rust:1.95-bookworm at 4 CPU and 8 GiB, niced, with the
cargo registry cached. Measured warm test compile: 31.9 s (P1). Its footprint
is one target/ per workspace (no target/ is shared between checkouts),
deleted with the workspace. Nothing is compiled on Starfire in any cut.
8. Follow-ups outside this campaign
- Deploy materialize blocks the survival tick (F15;
control_plane.rs:3847, runbookidunn-host-raven.md). Move deploy steps onto Cut 4's detached step port. This can wait because it is today's behaviour, and verify no longer adds to it. - The Raven runbook says the deployment brake prevents building during a
stream (
runbooks/idunn-host-raven.md, "Known edges"). F16 shows the build precedes the brake. The fix is in gamecult-ops. It can wait because it is doc truth, not behaviour. scripts/request-idunn-bounded-redeploy-yggdrasil.shis dead (F23). Delete it in gamecult-ops. It can wait because nothing can execute it successfully.- Epiphany's legacy test receipts (
deploy-epiphany-yggdrasil.sh:171-208) become a verify verdict when Epiphany moves to the new generation. /var/lib/gamecult/idunn/staginghas no retention (F18: 382 entries, 4.1 GiB). Deploy owns this.- Verify status as a CultMesh/Eve projection (doctrine).
9. What this pass could not probe
- The exact revision of the installed
/usr/local/bin/idunn. It has no version stamp. It predates5b3f646. - Whether GitHub serves
git fetch origin <sha>for a SHA reachable only from a non-default branch, under the verify source driver's uid and filtered clone. I expect yes, since GitHub allows fetching reachable SHAs, but it is unprobed. Cut 4's smoke must include one fetch fromcultnet/selection-cut1. - CultLib's native TSan mutation inside Idunn's hardening, meaning
--read-only,--cap-drop ALL, user 65532 and a noexec/tmp, combined withseccomp=unconfined.setarch -Rmay need more than seccomp relaxation. This is the first Cut 6 smoke and may reopen Q-V9. - dotnet test and npm under the hardened shape with a bridge network and a
NuGet cache on
/cache. Only the restore of an empty console app was probed (P2).
Progress (Self)
- 2026-09-22: Cut 0 landed at
2eb25b6(docs only).- Both stale "Current implementation boundary" sections are deleted.
- The README lists five commands.
- The docs now state that the build runs before the brake.
- 2026-09-22: Cut 1 landed at
95e15ef(Sonnet, verified on Yggdrasil through the stopgap).- Adds
ContainerSpecanddocker_run_args, the only Docker argv lowering in the codebase (--cap-drophas one site). - Adds
ExactSourceandfreeze_exact. - Tests: 126 to 130.
tools/verify-mutations/cut1.psd1kills 5/5, with M0 green. - Discrepancies Hands reported:
- The external-input curl step now goes through the shared lowering. It gains
--workdirand one inert environment variable. - The gitlink cache directory is named by a hash of the checkout path, because
ExactSourcehas no target. - The size is
drivers.rs−178/+426, against a ledger of about −90/+140. The type surface and a fourth, negative test account for the difference.
- The external-input curl step now goes through the shared lowering. It gains
- Soul's pass is dispatched.
- Adds
- Rulings so far: Q-V1 A, Q-V2 A, Q-V3 A, Q-V4 B via
Add-Type, Q-V5 A, Q-V7 B, Q-V8 B. - 2026-09-22: Soul on Cuts 0–1 (Opus, on Yggdrasil).
- Held.
- Behaviour is preserved. A fake-docker probe over 8 real call paths found the security flags byte-identical and in order.
- Only the declared differences appeared, plus one extra: secret environment variables are reordered (F4). That is harmless in Docker and is accepted, now declared.
- The numbers reproduce: tests 126 to 130, and cut1 5/5. Both needed a machine-id; see F10, fixed in the stopgap at Eureka
2fdc056.
- F1 (high): the argv test pins only the default runner path. Thirteen of 14 mutants survive, including:
--cap-drop ALLdropped only onbridge;--cpusrounded to 0.5;- environment passthrough by prefix;
- ambient
GAMECULT_*/IDUNN_*environment; - writable secret mounts;
- explicit
nonelowered tobridge; --read-onlydropped on a named network;- an extra mount of the cache's parent;
- the secret and cache-root validation calls removed.
- F2 (medium):
freeze_exactisgit archive, so it is not byte-exact. Non-recipe files come through witheol=crlfapplied,export-ignoredropped,export-substandidentexpanded, and LFS left as pointers. The recipe check does catch transforms on the recipe itself. The test asserts no byte-exactness at all. - F3: deploy's
freezefetches twice. - F5: the gitlink loop is untested; skipping it survives.
- F7:
StepPortandStepOutcomeare dead code, andrun_stepduplicatesdocker(). - F8: the
--cap-dropnegative check also matches a test. - F9:
docs/guide.md:11is still stale.- These gamecult-ops files still route through the dead
idunn redeployscript:runbooks/odin-yggdrasil.md:88,ghostlight-dungeon-yggdrasil.md:134,243,heimdall-discord-launch.md:125,144,epiphany-yggdrasil-deploy.md:156, and thebootstrap-ghostlight-yggdrasil.shandbootstrap-codex-connector-yggdrasil.shscripts.
- Pre-existing: a binding environment variable with the source stamp's name overrides the stamp.
- Held.
- Self's rulings for the Cut 1 fix batch, 2026-09-22:
- F1. The lowering is pinned on every branch: network
none,bridgeand named; cache present and absent; secrets present; required environment present; a range of CPU quotas (100, 150, 250, 333, 800). Every Soul mutant becomes an entry and must die. The validation calls are pinned by tests that go throughContainerSpec::for_step. - F2. The frozen tree is the commit's blobs, exactly. Blobs are written raw from the object store (
ls-tree -r -zpluscat-file --batch), with no attribute transforms. Modes and symlinks are preserved as the tree records them, and gitlinks are handled as today. LFS content is not fetched: a pointer is the blob, and the verdict records that the tree contains LFS pointers. Verifying an LFS repository's real content is a follow-up. Deploy shares this freeze, so its behaviour would change. Before changing it, Hands surveys the.gitattributesof every Idunn deploy target (the target repos named in the gamecult-ops bindings) forexport-ignore,export-subst,ident,eol/text,filterand LFS. If any deploy target depends on an archive transform, stop and report: that is a fork for the operator. The test asserts byte equality againstcat-filefor every file of a fixture that has CRLF,export-ignore,export-subst,ident, a symlink, the executable bit and a gitlink. - F3:
freezefetches once. - F5: add a gitlink test and entry.
- F7: delete the dead
StepPort/StepOutcome. There is one Docker spawn path;run_stepgoes throughdocker(). - F8: make the negative check
rgoversrc/excluding#[cfg(test)], or state its hit count. - F9: fix
guide.md:11, and repoint every listed gamecult-ops runbook and bootstrap script at the current procedure (idunn upplus a brake release, as inrunbooks/idunn-host-raven.md), or delete the steps. Delete the dead script. - Stamp override: refuse a binding environment variable whose name collides with the source stamp.
- F1. The lowering is pinned on every branch: network
- 2026-09-22: the Cut 1 fix batch landed (Sonnet, verified on Yggdrasil).
- Commits: Idunn
7a5d32f(F1, F3, F5, F7, F9, stamp override) and95016e8(F2); gamecult-ops62b41b1(runbooks and bootstraps moved off the deadidunn redeploy, script deleted). - Tests: 130 to 142.
cut1.psd117/17 killed. The revert togit archivedies onfreeze_exact_is_byte_exact_across_every_attribute_transform. - The F2 survey found one attribute: Eve's
packages/*/dist/** text eol=lf, reached through Ghostlight's gitlink. Self ruled it not a fork.eol=lftransforms nothing on stored-LF blobs. At Eve rev672c0c1e, all 26distfiles hash the same throughgit archiveand throughhash-object --no-filters, and none contains a CR. Every other deploy target (Ghostlight, Odin, CodexConnector, Muninn, Heimdall and its CultLib gitlink) is clean. So the byte-exact freeze produces exactly what deploy got before. - Deleted:
git_archive_into,materialize_gitlink_archive, and the deadtar_program. - Not yet reached:
cut1-freeze-exact-recipe-check. Both sides are now the same raw blob, so it is kept as defense in depth. - Soul's S8 is not yet reached.
harden_frozen_sourceenforces the same invariants first. - Follow-up:
freeze_exactreturns an LFS-pointer flag thatFrozenSourceReceiptdoes not carry yet. It must reach the verify verdict (Cut 2/4). - Recorded, not this cut's:
test-voidbot-swarm-yggdrasil.shfails onadd-daemon-health-trust-binding. That failure is pre-existing. - Soul's pass dispatched.
- Commits: Idunn
- 2026-09-22: Soul on the Cut 1 fix batch (Opus, on Yggdrasil). Cut 1 does not close.
- Held.
- 142 tests; cut1 17/17 killed.
- Byte-exact on a 3,026-entry fixture: 0 mismatches, against 7 on the old commit. The fixture covers CRLF, export-*, ident, LFS, the three symlink kinds, the executable bit, a gitlink carrying attributes, hostile names, and 6 MB and empty files.
- Hostile trees are refused.
- Deploy equivalence: identical digests on all five live targets.
- F3: one fetch.
- The stamp refusal holds.
- No live
redeployremains.
- F1 (high): the raw freeze is 40 to 80 times slower on real targets. Ghostlight takes 412 s against 5.3 s, Heimdall 457 s against 8.5 s, Odin 90 s against 2.2 s.
cat-file --batchon the blobless checkout (drivers.rs:788,1053,1245) fetches missing blobs lazily in small packs.read_blobsalso holds the whole tree in memory. - F2 (high, new regression): a tree with duplicate or aliased names makes root write outside the frozen root. A symlink
apointing outward plus a subtreea/pwngives a root-ownedpwnoutside the root, left behind after the freeze fails.fs::write,symlinkandcreate_dir_allfollow symlinks and run before hardening (:1173-1203,:1350). The old tar path refused this tree. The fetch does not settransfer.fsckObjects. - F3 (medium): by the same mechanism, the recipe check can fail, and a single alias silently freezes the wrong content. The "not yet reached" claim is false.
- F4 (medium): no test pins the hardening. Removing both
harden_frozen_sourceandvalidate_frozen_sourcefromfreeze_exactsurvives all 142 tests. The two calls are redundant. - F5 (medium, pre-existing): a symlink chain (
D -> .,L -> D/D/../../../etc/passwd) passes the lexical check (:5516) and resolves to/etc/passwd. - F6 (medium): unpinned combinations. A named network plus a cache drops
--read-only(N1). A secret mount becomes writable when a plain environment variable is present (N2). The stamp is dropped when a secret and a plain variable are both present (N3). The LFS flag is never set (N5). - F7 (medium):
bootstrap-ghostlight-yggdrasil.shandbootstrap-codex-connector-yggdrasil.shstill provision the previous generation (/srv/odin/deploy-manifests,daemon-health-trust.cc,/srv/ghostlight/current, the old identity store). Only their last line changed, and neither has a brake release. - F8 (low):
heimdall-discord-launch.mdsaysidunn up heimdall, but Heimdall has no installed binding. - F9 (low):
read_blobsnever drains stderr, and its error paths leave zombie processes.
- Held.
- Self's rulings for the second Cut 1 fix batch, 2026-09-22:
- F1: fetch the exact commit's whole tree in one bulk fetch (depth 1, no blob filter), then read blobs locally. Stream to disk rather than holding the tree in memory. Target: within twice the old times on the five live targets, measured on Yggdrasil at the host's git version if the container can match it. Otherwise say which version you measured with.
- F2 and F3: two layers, both required.
- Fetch with
transfer.fsckObjects=true(or run fsck on the fetched tree) and refuse duplicate, aliased and case-folding-colliding entries before anything is written. - The writer never follows a link: each parent component must be a real directory created by the freeze itself, and symlinks are written last.
- Fixtures: Soul's duplicate-name and alias trees. Nothing is written outside the root, and the error is refused by name.
- Fetch with
- F4: keep one hardening pass, the one that runs last, and pin it. Tests assert that absolute, escaping and chained symlinks are refused and that modes are root-owned 0444/0555. Its removal must die.
- F5: the symlink check resolves the full chain inside the root; a lexical check is not enough. The chain fixture must refuse.
- F6: pin N1, N2, N3 and N5 (the flag is set and tested). Carrying the flag into
FrozenSourceReceiptstays in Cut 2/4. - F7: delete the previous generation's provisioning steps from both bootstrap scripts. Keep only what is true under the v2 bindings, and add the brake-release step. If a script has nothing true left, delete it and point to the binding-install runbook.
- F8: mark the Heimdall runbook step as gated on installing its binding.
- F9: drain stderr and wait on the child on every path.
- 2026-09-22: the second Cut 1 fix batch landed (Sonnet, on Yggdrasil), Idunn
82e0edfand gamecult-ops9fc3af0.-
F1: one bulk
git fetch --stdinof every blob, plus a streaming writer.Target Freeze time Previous Ratio Ghostlight 5.17 s 5.3 s 0.98x Heimdall 10.08 s 8.5 s 1.19x Odin 2.20 s 2.2 s 1.0x Muninn 1.93 s 1.9 s 1.0x CodexConnector 1.76 s 1.8 s 0.98x Measured with git 2.39 in the container, not the host's 2.47.
-
Digests are identical on all five live targets.
-
F2 and F3:
refuse_conflicting_tree_entriesruns before any write, andensure_frozen_directorynever follows a link.transfer.fsckObjectsis kept as defense in depth. Its entry is not yet reached, because the refusal always fires first. -
F4: one hardening pass, pinned.
-
F5: chain-aware symlink resolution.
-
F6: N1, N2, N3 and N5 pinned.
-
F9: stderr is drained and the child is waited on.
-
F7: the four v1 artifacts are removed from both bootstraps, with a v2 anchor export and a brake release.
-
F8: the Heimdall step is gated.
-
Tests go from 142 to 152. cut1 is 28/28 killed.
-
Operator step, outside this campaign: the Ghostlight and CodexConnector bindings still carry a placeholder
expected_signer_identity_id. The scripts print the derived key. -
Soul's third pass dispatched.
-
- 2026-09-22: Soul's third pass on Cut 1 (Opus, on Yggdrasil).
- Held:
- 152 tests pass. cut1 killed 26/26 before its deletion; the commit message said 28.
- Speed is 1.0 to 1.3x across the five targets, and the digests are identical.
- A Forgejo origin works, and so does protocol v2 without
allowAnySHA1InWant. - Freezing 30k files plus a 200 MiB blob takes two fetches.
- Duplicate, alias and gitlink attacks are refused, and nothing is written outside the root.
- The chain resolver works for cycles, depth, and
copy_artifact.
- Cut 1 does not close.
- S1, medium-high:
resolve()'s fetch (drivers.rs:1735) has no fsck, so fsck never sees the main repo's trees. The claim "not yet reached" is false. Two sibling trees both nameddfreeze merged. fsck-rejected names freeze too:.GIT,git~1,.git.and.gitwith a zero-width non-joiner or a trailing space,/inside a name, and zero-padded modes. - S2, medium: both bootstraps print
provider-health-public-key, but the binding needs the identity id (provider-health-identity-id). - S3, medium: neither bootstrap can work. The
ghostlight,codex-connectorandodintemplates setcheckout = /srv/build/idunn-sources/*, but the installed unit uses--source-root /var/lib/gamecult/idunn/sources, and the host'sodin.tomlalready uses that. The two scripts contradict each other aboutcodex-connector.service. v1 provisioning remains in both. The wiring test pins the old unit text. - S4, low: Heimdall's binding is installed on Yggdrasil, so the runbook note is wrong about the host.
- S5, low: these guards are unpinned:
- root ownership (X04);
- an exact duplicate path (X06);
- case-sensitive ancestor alias (X08);
ensure_frozen_directorycontainment (X09);- pass 1
create_dir_all(X10); copy_artifactcontainment (X11).
- S6, low: a symlink is validated against
.partialbut published after the rename. Dangling in-root links are now refused, where the old lexical check accepted them. - S7, low: the case-folding refusal is partial and inconsistent (files only,
to_lowercase). The host is case-sensitive ext4. - S9, low, pre-existing:
hash_frozen_source_treereads each file whole. A 200 MiB file costs 232 MB of RSS.
- Held:
- Self's rulings for the third Cut 1 fix batch, 2026-09-22:
- S1: fsck guards every fetch that brings the main repository's objects. That is
resolve()as well asfreeze_exact. Independently,freeze_exactalso runs fsck on the selected tree, so that objects already present in the local store are checked too. Refuse every.gitlook-alike by name, case-insensitively and under fsck's own rules, even though the host is case-sensitive, because a frozen tree can be copied elsewhere. Fixtures: Soul'sdup-treesfixture and each look-alike. Each must refuse through the production path (resolve, thenfreeze), not a hand-built fetch. - S2: fixed in the S3 deletion.
- S3: delete both bootstrap scripts. Nothing true is left in them, which is the F7 ruling applied. Point every caller at the binding-install procedure, which includes the
provider-health-identity-idstep. Correct theghostlight,codex-connectorandodintemplates to the source root the installed unit uses (/var/lib/gamecult/idunn/sources/*), matching the host's installedodin.toml. Delete the wiring test's assertions on old unit text, or the whole test if nothing true remains. This is gamecult-ops hygiene the campaign exposed, and it lands in the same batch. - S4: correct the Heimdall runbook note. The binding is installed on the host and absent from the repo.
- S5: a behavioural test for each guard, through
freeze_exactwherever the guard is reachable from there. No mutation suite. - S6: validate symlinks against their final published location. Allow a dangling link whose target stays inside the root (resolving every component that exists), and refuse one that escapes. Test both.
- S7: delete the case-folding refusal. The host is case-sensitive, and the partial check is inconsistent. The
.gitlook-alikes are covered by S1. - S9: stream the hash.
- S1: fsck guards every fetch that brings the main repository's objects. That is
- 2026-09-22: the third Cut 1 fix batch landed. Idunn
57ecd43..f540d50, gamecult-ops2f3c1b3and9d0e37e.- S1: fsck guards
resolve()'s fetch, andfreeze_exactfscks explicitly. The dup-trees fixture and all six.gitlook-alikes are refused through the production path. - S3: both bootstrap scripts are deleted (319 lines). Callers point at the binding-install procedure. The three
checkouttemplates now match the installed source root. The stale wiring assertions are gone. - S4: the Heimdall note is corrected.
- S5: six behavioural tests.
- S6: symlinks are judged at their published location. A dangling in-root link is accepted, and the
.partialcase refuses. - S7: the case-fold refusal is deleted.
- S9: hashing streams. Peak RSS on a 200 MiB blob falls from 232 MB to 25.4 MB.
- Tests: 152 to 160. Digests unchanged on all five targets, with 0 mismatches over 1,975 entries. Times run 0.96x to 1.17x.
- Discrepancies to judge:
- No test stands up a real HTTPS origin, so the S1 test reproduces
resolve()'s fetch invocation and then calls the realfreeze_exact. test-ghostlight-yggdrasil-wiring.shstill pins the static units and the bespoke deploy scripts. Whether that whole generation is superseded by the binding and transient-unit path is a follow-up, larger than this batch.
- No test stands up a real HTTPS origin, so the S1 test reproduces
- Soul's fourth pass dispatched.
- S1: fsck guards
- 2026-09-22: Soul's fourth pass on Cut 1 (Opus, on Yggdrasil). Cut 1 does not close.
- Held, and proven harder than the shipped tests show. Soul stood up a real smart-HTTPS origin in the container, with a self-signed CA and
git-http-backend, and froze a clean repo end to end over it. Every ingress carries fsck (:833,:1137,:1525,:1536,:1610,:1629,:1736). Eleven hostile fixtures, brought in by an unguarded fetch first so the objects were already local, were all refused byfreeze_exact's own fsck: dup-trees, six.gitlook-alikes, and three attacks nobody had tried (an unsorted tree, a zero-padded mode, and mode 120000 on a tree). S6 holds across eight link shapes. S7 cannot collide on ext4. S9's RSS holds at 27 MB for 30k files plus a 200 MiB blob. The gamecult-ops templates match the host, and no caller invokes a deleted bootstrap. 160 tests; digests identical on four targets over 1,109 entries. - S4-1, medium-high, introduced by this batch: the symlink resolver is exponential.
resolve_frozen_source_symlink(drivers.rs:5878-5945) recurses per path component, soMAX_HOPS = 40bounds depth rather than work. Measured: n=16 2.7 s, n=18 10.7 s, n=20 43.7 s, and n=30 about 6 hours. Through production,freeze_exactwith n=18 returnedOkafter 15.9 s andobserve_frozenspent another 15.8 s. Both run on the single scheduler thread with no timeout, so 40 small symlinks pushed to any bound repository wedge continuity for every target. It is a regression: at82e0edfthe check waspath.canonicalize(), which did the n=30 fixture in 2 ms and let the kernel's ELOOP refuse it. - S4-2, medium: the streamed hash has no behavioural pin. Hashing zero bytes of every file, or only the first 64 KiB, leaves 160 tests green. The digest is the receipt anchor, so a content-blind digest lets two trees share one receipt.
- S4-3, medium: three of the five S5 tests do not kill their guard. The duplicate-path check,
ensure_frozen_directory's root containment andcreate_diragainstcreate_dir_allall survive deletion, because another refusal fires first. The batch note said six tests; five landed. - S4-4, low: all three fsck guards survive deletion, because the shipped test hand-builds the argv and every fixture is refused at the fetch, so the arm that exercises the mechanism never runs.
- S4-5, low: nothing proves a legitimate in-root
..link freezes. - S4-6, low, pre-existing: the writer buffers a whole blob for a symlink. A 64 MiB blob with mode 120000 takes RSS to 206 MB before failing closed.
- Could not run, and not this batch's: Heimdall's digest fails at every revision, including
82e0edf. Itsvendor/CultLibgitlink namesdfe07704, which GitHub no longer serves. Soidunn up heimdallwould fail on the host today. Recorded as an ops follow-up.
- Held, and proven harder than the shipped tests show. Soul stood up a real smart-HTTPS origin in the container, with a self-signed CA and
- Self's rulings for the fourth Cut 1 fix batch, 2026-09-22:
- S4-1: bound the work, not the depth. Resolve iteratively rather than recursively: keep one resolved prefix, and count every link traversal against one budget, so a chain costs O(n). Memoise resolved prefixes. Keep S6's behaviour exactly: a dangling in-root link freezes, an escape refuses. Add a test with Soul's
a0 -> .,ak -> a{k-1}/a{k-1}fixture at n=20 that must complete in under a second and refuse or accept as S6 says. Say which. - S4-2: pin the hash with two trees that differ only in file content past 64 KiB, and assert their digests differ. The zero-length-buffer and first-chunk-only mutants must die.
- S4-3: each S5 test isolates its own guard. Build a fixture that only that guard refuses, so deleting the guard makes the test fail rather than another refusal covering it. Do that for the duplicate path, root containment and
create_dir. - S4-4: commit Soul's already-local fixtures as tests. Establish the checkout on a clean commit, bring the hostile objects in with an unguarded fetch, then prove
freeze_exact's own fsck refuses. Deleting each of the three guards must fail a test. - S4-5: test that a legitimate in-root
..link freezes. - S4-6: read a symlink target without buffering a whole blob, and refuse a target longer than the platform's limit.
- Heimdall: record as an ops follow-up, FU-Heimdall-Gitlink. The binding is installed, and its gitlink names a commit GitHub does not serve. That is not this campaign's to fix, but the deploy is broken today and the operator should know.
- S4-1: bound the work, not the depth. Resolve iteratively rather than recursively: keep one resolved prefix, and count every link traversal against one budget, so a chain costs O(n). Memoise resolved prefixes. Keep S6's behaviour exactly: a dangling in-root link freezes, an escape refuses. Add a test with Soul's
- 2026-09-22: the fourth Cut 1 fix batch landed at
9475dfa(Sonnet, on Yggdrasil). One commit,src/drivers.rsonly, +940/-35.- S4-1: the resolver is iterative and memoised, and counts link traversals against one budget. Soul's n=20 chain goes from 43,700 ms to effectively instant, and it freezes, which matches S6: every hop resolves inside the root and nothing escapes.
- S4-2: the hash is pinned by two trees differing only past the first chunk. Both the zero-length-buffer and first-chunk-only mutants die.
- S4-3: each of the three guards has a test that isolates it, and all three mutants die.
- S4-4: Soul's 11 already-local hostile fixtures are committed as tests. Deleting the explicit fsck, or the freeze's own fetch flag, each fails a test.
- S4-5 and S4-6: landed.
- Tests 160 to 168. Digests identical on all five reachable targets over 1,109 entries. Heimdall is still unreachable for the unrelated gitlink reason.
- Gap, honestly reported:
resolve()'s own fsck flag has no test. Reaching the realresolve()needs a validated binding, which needs an HTTPS origin. Rewriting the origin withinsteadOffailsensure_checkout's own origin-equality check first, so the shortcut cannot work. Soul has a working smart-HTTPS rig from its first pass; that is where this closes. - A note on the fsck fixtures: some hostile objects are refused by the git server's own
pack-objectsbefore any Idunn code runs, so they cannot serve as already-local fixtures. The test asserts that at least one fixture reaches the explicit fsck, so it cannot pass vacuously. - Soul's fifth pass dispatched, including a judgement on the batch's size.
Soul pass 5 on Cut 1's fourth fix batch, 2026-09-22
Range f540d50..9475dfa, all runs on Yggdrasil (eureka-verify-rust:471272061cc0,
4 CPUs / 12 GiB). Baseline at 9475dfa: 168 passed, 0 failed, 2 ignored.
Verdict: Cut 1 closes on mechanism. Soul could not falsify any claim the
batch made. The resolver rewrite is correct on every shape it could build,
memoisation is sound and pinned, the fsck path holds against a real
smart-HTTPS origin, and the digests are unchanged across five real targets
with 1,975 entries hash-compared against ls-tree and zero mismatches. What
survives is a bill, not a blocker.
- S5-1, medium. S4-1 is half fixed, and the line that costs the time is
dead.
drivers.rs:6039-6042callscanonicalize()per path component. The budget charges distinct symlinks read and never charges components walked, so one link with k real components costs roughly O(k^3): 7.35 ms at k=50, 255 ms at k=200, 14.92 s at k=800, extrapolating to about four minutes at thePATH_MAXceiling. Through the production walk, depth 400 x 40 links took 78.6 s.frame.resolvedis already canonical there, so the call cannot change the answer: replacing the four lines withstack[top].resolved = candidate;leaves 168/168 green. This restores S4-1's own failure scenario at a lower exponent - a push adding 40 symlinks with long targets wedges the scheduler thread, twice. - S5-2, medium. The traversal budget is the resolver's only termination
guarantee and nothing tests it.
MAX_LINK_TRAVERSALStou32::MAXleaves 168/168 green.cargo mutants --in-difffound 16 mutants, 11 caught, 3 unviable, 2 missed, both on:5963, each making the budget never decrease. Cycles are refused only by the budget - memoisation cannot help, because in-progress frames are never inserted. One character turns a two-link cycle in a bound repository into an unbounded loop on the scheduler thread. - S5-3, low-medium. The digest is blind to symlink targets. Deleting the
target from the hash at
:6205leaves 168/168 green. Sinceobserve_frozencomparesfrozen_source_sha256against the receipt, repointing every symlink in a frozen tree to another in-root path passes both containment and the digest. S4-2's test has one regular file and no symlink. - S5-4, low. The digest is blind to the executable bit. Deleting the
0o111term leaves 168/168 green, andvalidate_frozen_sourceaccepts both0444and0555, so a frozen file can be made executable undetected. - S5-5, low. S4-6 landed with no test, and the batch note called it
landed. Both
ensure!s replaced withtrueleave 168/168 green. The mechanism does hold - a 64 MiB blob at mode120000is refused with VmHWM unchanged - but the threshold is off by one: 4095 bytes is admitted by the guard, written, then refused downstream. - S5-6, low-medium. A legitimate target through more than 39 symlinked
components is now refused, where
f540d50accepted it. The old recursion charged each sibling depth 1, so the ceiling never fired for that shape. Fail-closed, undeclared, and the error names no link. - S5-7, low.
resolve()is still unpinned and has no test at all. Soul settled the mechanism on a real rig - self-signed CA,git-http-backendbehind TLS, a validated binding, a clean--filter=blob:noneclone, thenmainmoved onto a hostile commit. As shipped: clean admitted, duplicate trees refused,.GITrefused. With the flag deleted: both admitted. The gap is a test gap, not a mechanism gap. - S5-8, informational. Four containment checks where one suffices, each
surviving deletion alone because the loop tail covers all three arms; only
:6021-6024earns its place.FrozenSymlinkFrame::symlink_pathis anOptiononly ever constructed asSome, and its doc comment says the opposite. - S5-9. 940 lines, about 400 nameable as deletion. The 189-line
resolve_style_fetch_then_freeze_exact_refuses_hostile_treesasserts nothing today: its only assertion sits in anOk(_)arm that pass 4 showed is never taken, and a newer test runs the same seven fixtures plus four more through a harder unguarded fetch. About 190 further lines are boilerplate six module-level helpers would collapse.
Promises that held, with numbers. S4-1's headline (n=20 chain 43,700 ms to instant, and it freezes); S4-2's zero-buffer and first-chunk-only mutants both killed; all three of S4-3's guards killed by isolating tests; S4-4's explicit fsck and its own fetch flag killed. Memoisation is sound: making the memo never hit kills the n=20 test, and no stale answer was found by probe or by reading. Seven of the eight earlier link shapes reproduce identically. The budget is per call and that is fine: 31,200 symlinks validate in 8.65 s, linear at about 11 ms per link.
FU-Heimdall-Gitlink has cleared upstream. Heimdall froze end to end:
e0618f7f, 10.07 s, 945 entries, 0 mismatches; vendor/CultLib @ b6b1d9c6
serves again. No Idunn change caused the break or the fix. The ops follow-up
closes.
Self's rulings for the fifth fix batch, 2026-09-22
- R-I1 (S5-1). Delete
drivers.rs:6039-6042. Soul proved the call cannot change the answer and that deleting it leaves the suite green. This is not an optimisation, it is removing dead code that costs cubic time. Then pin the cost: a test that freezes a link with a long component chain and fails if it is not linear-ish, so the term cannot come back unnoticed. - R-I2 (S5-2). The budget gets the two fixtures Soul specified: a two-link cycle that must refuse, and 41 distinct links that must refuse. Both must die under their own mutation - a budget that never decreases must turn them red. cargo-mutants missed both mutants on that line, which is exactly why a hand check belongs here.
- R-I3 (S5-3, S5-4). The digest covers everything a tamper could change: symlink targets and the executable bit, each with a test that fails when its term is removed. A digest that is the tamper check must not be blind to a field the validator permits to vary.
- R-I4 (S5-5). S4-6 gets its test, and the off-by-one is fixed so the guard refuses exactly what the kernel refuses. The batch note is corrected: "landed" must not mean "landed unpinned".
- R-I5 (S5-6). Keep the 40-traversal ceiling - it matches the kernel's own
ELOOPlimit, and a fail-closed refusal at that boundary is the right behaviour. But declare it: the limit is documented, and the error names the link and the count it reached. My earlier ruling said "keep S6's behaviour exactly"; this is a deliberate, narrow departure from it, recorded as such rather than left as drift. - R-I6 (S5-7). Commit the rig. It buys the only coverage
resolve()has and runs in 1.5 s. It skips rather than fails whenopensslorgit-http-backendis missing, since neither is guaranteed on a workstation. - R-I7 (S5-8, S5-9). Take the deletion. The three redundant containment
checks, the dead
Optionand its wrong comment, the 189-line test that asserts nothing, and the boilerplate the six helpers collapse. Delete the dead test rather than repairing it - a newer test already covers its fixtures through a harder path.
The fifth fix batch landed, 2026-09-22
Sonnet, three commits on idunn/cut1-fix5 (f52cefc, 0b2ac95, 87daa06).
175 passed, 0 failed, 2 ignored on Yggdrasil (168 baseline, +8 new, −1
deleted). src/drivers.rs only: 988 insertions, 486 deletions.
- R-I1. The dead per-component
canonicalize()is gone. Pinned by a test asserting roughly linear cost in component count; restoring the call takes four times the components from 11 ms to 359 ms, about 33×, and the test goes red. - R-I2. Both budget fixtures added. Under the mutation that makes the budget never decrease, the 41-link fixture goes red — and the two-link cycle hangs rather than failing an assertion. That is the correct falsification for this rule: nothing but the budget bounds a cycle, so a timeout is exactly what a broken budget produces.
- R-I3. Digest tests for the symlink target and the executable bit; each goes red when its term is deleted.
- R-I4. Off-by-one fixed to
<. Verified against the container's real kernel: a 4095-byte target writes, 4096 failsENAMETOOLONG. The guard now refuses exactly what the kernel refuses. - R-I5. The 40-traversal ceiling is documented as matching Linux's
MAXSYMLINKS, and the error names the link and the count it reached. - R-I6. The HTTPS rig is committed, runs in about a second, and skips
cleanly where
opensslorgit-http-backendis missing. Deletingtransfer.fsckObjects=truefromresolve()'s fetch turns it red on the first hostile fixture —resolve()now has coverage where it had none. - R-I7. The dead
Optionand its backwards comment are gone, the 189-line test that asserted nothing is deleted, and the hostile-tree boilerplate is collapsed into module-level helpers.
Two findings that came back the other way, both worth more than the batch.
S5-8 was wrong, and my ruling repeated it. Soul called three containment
checks redundant and I ruled "delete them". Hands deleted two, hand-traced the
third, and found that with all three gone a symlink whose entire target is
".." at the root — root/escape -> ".." — resolves to the parent of the
root and returns Ok, because no later Normal step ever runs to trip the
other arm's check. That is a containment escape, the exact class this
resolver exists to prevent. Hands kept the check, added
validate_frozen_source_symlink_refuses_a_bare_parent_reference_at_the_root
to pin it, and recorded the deviation in code rather than quietly keeping it.
Deleting the surviving check makes that test fail exactly as predicted. Both
Soul and Self were wrong here; a Hands hand-trace caught it.
Soul's rig was not recoverable. The brief said to reuse
soul-idunn05-notes.md, probe5_https2.rs and s5-https-run.sh. None
survived: no session, no Yggdrasil work directory, no scratch trace. Hands
rebuilt the rig from this map's description of it, which worked only because
the description was detailed. A probe that is going to be committed must be
handed over while it still exists, not left in a scratchpad to be
reconstructed from prose.
Scope tradeoffs Hands named: three commits rather than seven, because R-I1, R-I5 and R-I7's resolver edits sit inside one ~120-line function and splitting them by hand was the riskier option; and the boilerplate collapse touched only the three tests tied to the deleted one, leaving about fourteen other pre-existing sites alone to bound risk. Both judgements accepted.
Soul pass 6 on Cut 1's fifth fix batch, 2026-09-22
Opus. Counts reproduced independently four times on Yggdrasil: 175 passed, 0
failed, 2 ignored (the 2 are unrelated control_plane.rs live-store tests).
13 hand-built mutations, 6 of them not plain reverts.
Verdict: five of seven rulings hold under attack. R-I1's pin and R-I3's coverage promise are not delivered. The containment argument — where the pass was told to be most suspicious — survived everything Soul could invent.
- F1, CONFIRMED, medium. R-I1's pin is a wall-clock assertion wearing a
ratio's clothes.
drivers.rs:11636-11642readslarge < small.max(0.01) * 20.0. In all eight runssmallwas 2.7–9.4 ms, always under the 10 ms floor, so the clamp fires and the test is simplylarge < 200 ms. Clean code measured 63.2 … 198.9 ms, the last with three concurrent suites in one container — which is exactly the three-slot Yggdrasil now in use. A loaded slot turns a clean revision red, and under Cut 4 that is a falseverifyverdict on unattended work. The other direction is worse: with thecanonicalizerestored Soul measured 324.9–377.3 ms, a separation of 1.63×, not the 33× Hands reported. Hands' 11 ms / 359 ms pair straddled the clamp boundary, which is what made the ratio look decisive. A host 1.7× faster and the mutant survives. - F2, CONFIRMED, medium, and it is a privilege finding. R-I3 is not met:
setuid, setgid, sticky and gid are all fields the validator permits to
vary and the digest does not cover. The validator masks
& 0o777, so0o4555satisfies== 0o555, and it checksuid() == 0but never gid. The digest hashes onlymode & 0o111 != 0for files and nothing for directories. Measured on a real root-owned 0555 tree: setuid file04555accepted, digest unchanged; setgid file, setgid dir, sticky dir, gid 12345 — all the same.observe_frozenis exactly these two calls, so a frozen source whose binary has been made setuid-root re-observes clean against its receipt, and the tree is bind-mounted into runner containers with nonosuidwhile the workload runs as65532:65532.harden_frozen_source_treenormalises modes at write time, so this is post-write tamper — which is precisely what the digest exists to catch. - F3, CONFIRMED, low-medium. The cycle fixture's hang is real and nothing
bounds it. Under the budget mutation,
timeout -s KILL 90returned 137.cargo testhas no per-test timeout and the stopgap has no wall-clock cap; each traversal pushes a frame, so it grows toward the 12 GiB cap. Hands' argument that the hang is the correct falsification stands — the missing piece is a wall-clock cap on the verify runner, which belongs to Cut 4, not a change to this test. Separately the fixture asserts onlyis_err(), not which error. - F4, PLAUSIBLE, informational. "Refuses exactly what the kernel refuses"
is slightly overstated.
libc::PATH_MAXis per-target and< PATH_MAXmatches the VFS rule on all of them, so the constant is not merely correct in this container — the platform question answers itself. But the ceiling that actually applies is the filesystem's: an ext4 slow symlink is bounded by block size, so on a 1 KiB-block filesystem the kernel refuses targets this guard admits. Doc accuracy, not a defect. - F5, informational, and it runs the opposite way to S5-8. Two
containment checks remain where one suffices, and deleting the Normal
arm's leaves the suite and a 20-shape escape sweep green — because
frame.resolvedmutates only inside the match and the tail check runs after every iteration. Deleting the tail check alone turns the bare-..test red. If anyone revisits this,:6105is the one that must survive. Two further mutants are genuinely equivalent: component-wisestarts_withversus string-prefix (every escape transits the root's parent first), and droppingensure!(target.is_relative())(frozen_symlink_stepsbails onComponent::RootDirregardless). - F6, informational, outside the batch.
digest_tree— the artifact digest — still has the exact blindness R-I3 just fixed for frozen source: no executable-bit term, no\0after the symlink target. No ruling covered it, so it must not be assumed fixed by association. Also: no test callsobserve_frozenat all; its tamper check is exercised only through its two constituents.
Promises that held, with numbers. R-I1's behaviour claim confirmed by
reading plus call-site check: both production entries discard the resolved
path, so removal cannot change an answer any consumer sees. R-I2's 41-link
fixture dies under two different mutations, including moving the ceiling to
400 — the constant is pinned, not just the direction. R-I3's symlink-target
and exec-bit tests each die under their own term's deletion. R-I4's test dies
under the reverted comparison. R-I5's message names the link and yields 41
where it fires. R-I6's rig ran, it did not skip: 1.09 s, and deleting
transfer.fsckObjects=true from resolve()'s own fetch turns it red, so the
refusal is genuinely fsck's. Since validate requires an https:// origin,
an HTTPS rig is structurally the only way resolve() can be covered — the
"only coverage" claim is correct. R-I7's deleted 189-line test had exactly
the first seven of the eleven fixtures that replaced it; nothing real was
lost, and the collapse is faithful at every site.
Containment, built by Soul rather than inherited. Twenty shapes — bare
.., ../.., ./.., ../., ././././.., real-dir-then-two-up,
nonexistent-then-two-up, three-up from depth 2, absolute target, absolute
in-root target, link-to-escaping-link, link through an escaping dir-link,
out-and-back-by-name, sibling-root prefix confusion, memoised-then-escape,
dangling-then-escape, plus four that must be accepted. All correct. No
escape.
Did the addition buy its keep? Production 7900 → 7947 lines, and the logic is net-negative; the +47 is R-I5 and R-I7 justification comments. Tests 4472 → 4927 net, about 830 gross against −189 dead test and −190 boilerplate. Yes.
What Soul could not run: no non-Linux check of R-I4 (the path is
#[cfg(unix)] and the other arms bail, so F4 is reasoning rather than
measurement); the container runs as root, so F2's probe got its
"root-owned" precondition free and did not prove a non-root attacker can
reach it; and no end-to-end observe_frozen against a real receipt, because
none exists and building a CompiledDeploymentPlan was out of budget.
Self's rulings for the sixth fix batch, 2026-09-22
- R-I8 (F1). Replace the timing pin with a deterministic one. Count the
canonicalize/symlink_metadatacalls through a test-only counter and assert the count, not the clock. A wall-clock assertion on a host that runs three jobs at once is a coin toss in both directions, and Cut 4 turns a false red into a falseverifyverdict on unattended work. Hands' reported 33× was an artifact of the clamp, not a measurement — that goes in the record, because the number is what made the pin look adequate. - R-I9 (F2). A frozen source may not carry setuid, setgid or sticky at
all. The validator refuses them outright rather than masking them away;
nothing in a frozen source has any business with them. The digest covers
the full mode and the owning gid, not
& 0o111, and covers directories as well as files. Each term gets a test that fails when it is removed. Additionally, the bind mount getsnosuid— defence in depth, since the digest is a detection and the mount flag is a prevention, and the cost is a mount option. - R-I10 (F3). The wall-clock cap is Cut 4's, recorded as a requirement on the verify runner: no step runs unbounded. It is not a reason to weaken this fixture. The fixture does gain an assertion on which error it got.
- R-I11 (F4). Soften the claim to what is true: the guard bounds the
buffer at
PATH_MAXand the filesystem may refuse less. Doc only. - R-I12 (F5). Record that
:6105must survive, in a comment at the check itself. The two equivalent mutants are recorded as equivalent. No deletion. - R-I13 (F6).
digest_treegets the same treatment as the frozen-source digest, with its own tests, andobserve_frozengets an end-to-end test against a real receipt. A tamper check with no test of its own is the thing this campaign keeps finding.
The sixth fix batch landed, 2026-09-22
Sonnet, two commits on idunn/cut1-fix5 (f873b54, 9e572bd).
183 passed, 0 failed, 2 ignored (175 + 8). src/drivers.rs only: 701
insertions, 41 deletions, no new dependencies.
- R-I8. The wall-clock pin is gone. A
#[cfg(test)]thread-local counter records everycanonicalize()/symlink_metadata()call inside the resolver, and the test asserts an exact formula (components + 2) rather than a clock. Deleting one probe call site turns it red: 52 expected against 1 measured. This is what the pin should have been from the start — deterministic under any load, where the old one was a coin toss on a three-slot host. - R-I9, parts 1 and 2. The validator's mask widens
0o777→0o7777, so setuid, setgid and sticky are refused outright instead of masked away. The digest hashes the full mode and the gid, for directories as well as files. Five new tests, each killing its own mutation. A real bug surfaced while writing them: the container'schownsilently clears a freshly-set setuid bit even when the owner does not change, so the fixture had to chmod last. A fixture that sets the bit and then chowns is testing nothing, and would have looked like a pass. - R-I10. The cycle fixture now asserts the error names the 40-traversal
ceiling rather than only
is_err(). Rewording the message turns it red. - R-I11. Doc softened to what is true: the guard bounds at
PATH_MAXand a filesystem may refuse less. - R-I12. Comments record that the loop-tail check is the one that must survive, that the Normal arm's is redundant, and that the two mutants Soul identified are equivalent. No deletion, as ordered.
- R-I13.
digest_treegains an executable-bit term, pinned and mutation-killed. The symlink-target terminator is deliberately not pinned: a real encoding collision needs a NUL inside a symlink target, whichsymlink(2)cannot produce. Hands built a two-name boundary-shift fixture by hand, confirmed it does not go red under the term's removal, and removed it rather than keep a test that lies about what it proves. That is the correct call.observe_frozennow has a real end-to-end test throughcompile_deployment_planandfreeze_exactagainst a real recipe and binding: it accepts an intact receipt and refuses a post-freeze tamper.
R-I9's third part was wrong, and Hands proved it against the real tool.
I ruled the bind mount should carry nosuid. Docker exposes no such
per-bind-mount flag through either syntax — --mount type=bind,…,nosuid and
-v host:dst:nosuid are both rejected outright. The container already runs
with --security-opt no-new-privileges, which is the standard mitigation for
this class and is process-wide rather than per-mount, so it is stronger than
what I asked for. The ruling is withdrawn; the prevention was already
there and I had not checked before ordering it.
A second fixture that would have lied. The first observe_frozen test
tampered with the recipe file, which a different check inside
observe_frozen also catches — so it passed while the digest check was
deleted. Hands found this by running the mutation, not by reading, and fixed
the fixture to tamper where only the digest can see it. A test that goes
green for the wrong reason is the failure this campaign keeps producing, and
it is caught only by mutating against the final spelling of the code.
Soul pass 7 on Cut 1's sixth fix batch, 2026-09-23
Opus. 183 passed / 0 failed / 2 ignored, reproduced twice on Yggdrasil. 12 hand mutations, 9 not plain reverts, plus a privilege-surface probe on a real root-owned 0555 tree.
Verdict: four of six rulings hold. What this pass found is three pieces of verification that do not verify — a pin blind to its own regression, a test that cannot fail, and a digest that got half its ruling.
- F1, CONFIRMED, medium. R-I8 traded a flaky pin for a deterministic blind
one. The counter at
:11748fires only whererecord_frozen_symlink_probe_call()was hand-placed. Soul restored the original S5-1 bug — the per-componentcanonicalize()this whole line of rulings exists to keep out — without instrumenting it. The test passed, in 0.39 s. The count stays at 52/202 because the reintroduced syscall is not counted. Hands' "deleting one probe call site turns it red" mutates the instrumentation, not the code under test. The batch record claimed the regression "cannot come back unnoticed"; the test's own doc comment, four lines below, says it "cannot catch its own regression by call count alone". The code was honest and the record was not. The formula itself is right —(symlinks opened) + (Normal steps walked), asserted only against its own fixture, and a memoised revisit reduces it — so it is not a false red the way the clock was. It is simply blind. - F2, CONFIRMED, medium. The test the record says was deleted for lying is
in the tree, still lying.
digest_artifact_differs_across_a_symlink_target_boundary_shift(:12292) landed in9e572bd. Deleting the terminator it names leaves the entire suite green. It cannot fail: the name is already NUL-terminated before the target, so the two spellings differ whatever happens after it. Hands' underlying reasoning was sound and Soul tested it —symlink("a\0b")and a NUL in a filename both fail atInvalidInputbefore the kernel, and the same closure covers path components, so the collision class really is unreachable. The gap is honest; the fixture built on top of it is not, and it sits in the slot where this map says a fixture was removed for exactly this. - F3, CONFIRMED, low-medium, privilege. A file capability is invisible to
both the validator and the digest. On a real root-owned 0555 tree,
security.capabilityset tocap_setuid=ep: validator ACCEPT, digest unchanged. That is the grant setuid used to be, with no bit formode & 0o7777to see. Any other xattr behaves the same; mtime and hard-link count are also invisible and harmless, since content is hashed. What holds the line is not the digest — both the runner and the systemd workload requireno-new-privileges, which neuters file capabilities exactly as it neuters setuid, the same mitigation cited when thenosuidruling was withdrawn. So this is a detection gap, not a live escalation on the deployment path, and it matters wherever anything else on the host executes that tree. Under Cut 4 it means averifyverdict of "this is the tree we froze" that is false for xattrs. - F4, CONFIRMED, medium. R-I13's digest got half its ruling. The ruling
said
digest_treegets the same treatment as the frozen-source digest, which now hashesmode & 0o7777and gid for files and directories.digest_treegot a single executable boolean. Measured blind: setuid, setgid, uid, gid, and the read bits (0o500hashes as0o555); directories get no mode term at all. This is the artifact digest, re-derived against the sealed receipt immediately before launch, so a post-install tamper adding setuid to an installed binary re-verifies clean. - F5, CONFIRMED, low. R-I9's "each term gets a test" holds for the digest,
not the validator. Narrowing the file mask to
0o5777(blind to setgid on a file) or the directory mask to0o3777(blind to setuid on a directory) each leave 183 passing. Only setuid-on-a-file is pinned. A future edit narrowing either mask readmits setgid or sticky with a green suite, and setgid on a directory is the one with real semantics. - F6, informational.
nix_group_or_skip(:12248) never skips — it returnsOk(1)unconditionally, and its doc's reasoning about/etc/groupis moot becausechown(2)never consults it. Three tests now hard-require root. Not a new class, but the name says something the function does not do.
Promises that held. R-I13's observe_frozen fixture fix is real:
neutering the digest comparison turns the new end-to-end test red, so the
second fixture does tamper where only the digest can see it. R-I9 verified
on a real filesystem rather than by reading the mask: setuid, setgid and
sticky on a file are each refused by name and each move the digest; a foreign
gid is accepted by the validator, which still checks only uid, and now moves
the digest. R-I10 holds. R-I11 and R-I12 match what Soul could re-derive, and
the :6105-class check is correctly identified.
What Soul could not run. No non-Linux arm. No non-root attacker model —
the container is root, which gave F3's setxattr and every root-owned
precondition free; nothing here shows an unprivileged process can reach any of
these tampers. No cargo-mutants sweep, because two other campaigns held slots,
so the budget went to twelve targeted hand mutations instead.
Rig parked at F:\Projects\eureka-rigs\idunn-cut1-soul7\ — outside the
repo and the scratchpad: privilege-surface-probe.rs, mutations.py (the
twelve, self-restoring), README.md.
Soul's judgement: Cut 1 is not done, but what is left is small and named. The mechanism closed two passes ago and nothing built against it has moved. "If a seventh batch turns up the same shape again — a green test that proves nothing — that is a signal about how this file is being tested, not about the code."
Self's rulings for the seventh fix batch, 2026-09-23
- R-I14 (F1). Make the count total rather than conventional. Put
canonicalizeandsymlink_metadatabehind a narrow injected port, so the resolver cannot reach the filesystem except through the counted path. Then restoring the S5-1 bug raises the count and the pin fires. Doctrine already asks for this shape — inputs as narrow ports, mockable probes — and a counter that only counts the calls someone remembered to annotate is an honour system. Also correct this map: it claimed the regression could not return unnoticed, which was false when written. - R-I15 (F2). Delete the boundary-shift test. Not repair it — it cannot fail, and the class it names is unreachable. The production comment saying the term is not independently pinned is the true one; the test's doc comment is the false one and goes with it. The map's claim that this fixture was already deleted is corrected above.
- R-I16 (F4).
digest_treegets the ruling it was actually given: fullmode & 0o7777and gid, for directories as well as files, each term pinned by a test that fails when the term is removed. A tamper check re-derived immediately before launch must not be blind to the bit that grants privilege. - R-I17 (F5). Each validator mask term gets its own test — setgid and sticky on a file, setuid, setgid and sticky on a directory — so narrowing the mask cannot pass.
- R-I18 (F3). The digest covers extended attributes, names and values, in
a defined order.
no-new-privilegesis a prevention on our own deployment path; the digest is the detection, and Cut 4's verdict asserts "this is the tree we froze", which is false today for any xattr. Record plainly that the probe ran as root and does not establish that an unprivileged attacker can set one. - R-I19 (F6).
nix_group_or_skipeither skips or is renamed for what it does. A helper whose name promises a skip it never performs will mislead the next reader.
The seventh fix batch landed, 2026-09-23
Sonnet, three commits on idunn/cut1-fix5 (1edc49a, 67d0f93, a46b997).
193 passed, 0 failed, 2 ignored (183 + 11 new − 1 deleted). One file,
src/drivers.rs, +602 / −48. Two Yggdrasil runs, one slot at a time.
The pin now catches its own regression. R-I14 put canonicalize and
symlink_metadata behind a FrozenSymlinkFs port whose single production
implementation charges the counter inside the same method that makes the
syscall, so the two hand-placed call sites are gone and the count is total
rather than conventional. Restoring the S5-1 bug through the port — exactly
Soul's mutation, which passed in 0.39 s on pass 7 — now reads:
test ..._resolves_a_long_component_chain_with_a_linear_call_count ... FAILED
- R-I15. The unreachable boundary-shift test is deleted, with a comment in
its place saying why: the link name is NUL-terminated before the target,
so any two differing
(name, target)pairs diverge there regardless, and no real symlink target or filename can carry a NUL. The production comment that pointed at the deleted test now points at that note. No mutation to run — the ruling was to stop claiming a defence that did not exist. - R-I16.
digest_tree_is_executableis replaced bydigest_tree_mode(mode & 0o7777) anddigest_tree_gid, on both the file and directory arms, with four new tests. Deleting the file term fails three tests; deleting the directory term fails two. - R-I17. Five new refusal tests, no production change — the masks were
already
0o7777from R-I9 and simply untested per bit. Narrowing the file mask to0o5777fails exactly..._refuses_a_setgid_file; narrowing the directory mask to0o3777fails exactly..._refuses_a_setuid_directory, with the other five refusal tests staying green in each run. That precision is the evidence the tests pin single bits rather than overlapping. - R-I18.
hash_frozen_source_xattrshashes names and values sorted by name, count-prefixed and self-delimiting, onlibc::llistxattr/lgetxattr— an existing dependency, no new crate. Two tests: a generaluser.namespace pin, and a direct reproduction of Soul'ssecurity.capability=cap_setuid,epfinding, which also asserts the validator still legitimately accepts the tree, since this ruling was detection through the digest rather than refusal. Both doc comments state plainly that the process runs as root and that this does not establish an unprivileged attacker can reach the same path. - R-I19.
nix_group_or_skipis renamedsecond_gid_for_chown_fixture. It never skipped and never will; only the name lied. A rename has no behaviour to falsify, and Hands said so rather than inventing a mutation for it.
Hands shipped a compile bug and caught it itself. The first commit missed
a third pre-existing call site still using the old two-argument signature. The
first Yggdrasil run caught it, exit 1, fixed in a46b997. Worth recording
because it is the system working: the run happened before the report, not
after.
Soul pass 8 on Cut 1's seventh fix batch, 2026-09-23
Opus. 193 passed / 0 failed / 2 ignored, reproduced twice on Yggdrasil. 25 hand mutations, 11 not plain reverts, plus six probes on a real root-owned tree. One slot at a time, about 40 minutes held in total.
Judgement: Cut 1's mechanism is done. Its verification is not, and the reason is now structural rather than accidental.
- F1, CONFIRMED, medium. The port can be walked around, and the pass-7
mutation still passes in two spellings. At
:6174, replacing the assignment withcandidate.canonicalize()?orfs::canonicalize(&candidate)?restores the S5-1 cubic bug and leaves all 193 green. Only the spelling that goes through the port turns it red. Nothing makes the resolver use the port: noclippy.toml, nodisallowed-methods,Pathandfsboth in scope. Proof by existence:open_frozen_symlink_frameat:6061already callsfs::read_linkdirectly, and a probe measured a resolution charging 16 port calls while making three uncountedread_linksyscalls. Hands' own commit message was honest — "written the way the surrounding code is now written". This map's record was not: it said "the count is total rather than conventional". It is still conventional. The convention merely moved from "remember to call the counter" to "remember to use the port". - F2, CONFIRMED, medium, privilege.
digest_artifact's single-file path never got R-I16 — and it is the path the installed executable actually takes.:7723-7727returns a content-only sha256 for a regular file: no mode, gid, uid or xattr.host.rs:682("installed executable differs from Expected", immediately before launch) andhost.rs:803("the installed executable changed under the running process") both hand it a single executable file. Measured: setuid blind,0555versus0500blind,security.capability=cap_setuid=epblind. All threedigest_artifacttests wrap the file in a directory sodigest_treeruns; the fast path is untested — and one of their doc comments claims it pins "exactly Soul pass 7's post-install-tamper-adding-setuid-to-an-installed-binary scenario". The real scenario is a bare file. - F3, CONFIRMED, medium. The artifact digest has no xattr term at all.
digest_treegot R-I16's mode and gid and none of R-I18, whilehash_frozen_source_treegot both. On an artifact tree:security.capabilityblind,user.*blind, uid blind. The asymmetry runs the wrong way — the frozen source is a 0555 root-owned tree nothing executes; the artifact is the binary that gets exec'd and re-verified before every launch. - F4, CONFIRMED, medium. R-I18's framing and its non-file coverage are
pinned by nothing. The encoding is genuinely self-delimiting and Soul
could not collide it across four adjacent-boundary attacks. But the suite
does not know that. All 193 stay green while dropping the value length
prefix, dropping the count prefix, dropping the name NUL, folding names to
lowercase (a real collision class,
user.Aagainstuser.a), and — the material one — hashing names only and dropping values entirely. Nothing proves an xattr value is hashed, sosecurity.capabilityedited fromcap_setuidtocap_sys_adminis covered by no test. Removing the xattr term from the directory and symlink arms also passes; the code covers both, confirmed on a real tree, but only the file arm is tested.llistxattris the right call and does work on symlinks. - F5, CONFIRMED, low. The count test's doc still describes the pre-R-I14
machine and still tells the reader the pin is blind (
:11908-11938, untouched across the batch). Pass 7 found code honest and record overclaiming; this is the inverse of the same defect. The record says "total", the test's own doc says "blind", and neither matches what Soul measured.
Promises that held, with numbers. R-I14's pin does fire through the port (three separate mutations, each red on exactly the count test). R-I17's precision holds further than Hands claimed — all six mask bits, not two: each of six narrowings fails exactly its own refusal test with the other five green. R-I16's term claims hold exactly: mode→0 fails three, gid→0 fails two, the directory arm alone fails exactly the two directory tests, the file arm alone exactly the three file tests. R-I18 does catch the finding it was written for. R-I15 is a real deletion and R-I19 a rename with nothing to falsify, correctly stated.
Remaining surface. FIFOs, sockets and device nodes are refused by the
validator and both digests — closed. ACLs are xattrs, so covered in the frozen
source and uncovered in the artifact. Hard links accepted and harmless, mtime
blind and harmless, artifact uid blind and never ruled on. trusted.* is
invisible without CAP_SYS_ADMIN.
Could not run: no non-Linux arm; no non-root attacker model, since the
container is root and every lsetxattr, chown and mknod precondition came
free; no cargo-mutants sweep.
Rig at F:\Projects\eureka-rigs\idunn-cut1-soul8\ with 25 self-restoring
mutations and both raw logs. It documents its own defect: under
--nocapture --test-threads=1, cargo's test <name> ... line has no newline,
so a grep "^P[0-9]" silently eats each test's first println!. That cost a
slot, and it is the second time this campaign has lost output to that exact
artifact.
The cause Soul named
"Every pin in this file is a convention a future edit steps around, and the tests are all built on the fixture shape that is convenient rather than the shape production uses."
The counter was an honour system; the port is an honour system one level up.
The digest tests wrap a file in a directory; production hands digest_artifact
a bare file. R-I18 tests presence against absence; the thing that carries
privilege is the value. One cause, three faces — and an eighth batch of
point fixes would produce a ninth.
Self's rulings for the eighth fix batch, 2026-09-23
- R-I20 (F2). Land this one regardless of anything else. It is a real
detection hole on the launch path.
digest_artifact's file branch delegates to the same primitivedigest_treeuses, rather than growing its own copy of the terms. And its tests use the production shape — a bare file — not a file wrapped in a directory. The wrapped fixture is why this survived: it exercised a path production does not take. - R-I21 (F3). The artifact digest covers xattrs, like the frozen-source digest. The binary that gets executed deserves at least what the tree nobody executes already has.
- R-I22 (F4). Pin the framing, not the presence. Tests that fail when the value is dropped, when the count prefix goes, when the name terminator goes, and when names are case-folded; plus the directory and symlink arms. The value is the part that carries privilege, and it is currently pinned by nothing.
- R-I23 (F1). Make the bypass a compile error. A
clippy.tomlwithdisallowed-methodsforPath::canonicalize,fs::canonicalize,fs::symlink_metadataandfs::read_linkwithin this module, denied in CI. This is what R-I14 was actually asking for, and it is the doctrine's own answer: move the authority instead of maintaining a convention. Convertopen_frozen_symlink_frame's directfs::read_linkto the port in the same pass, since it is the proof the convention leaks. - R-I24 (F5). Rewrite the count test's doc to describe the port. A comment that tells the reader a live pin is blind is worse than no comment.
- Standing, and this one outlives the batch: a test's fixture must be the shape production uses. Where it cannot be, the test says so in one line. Three of this pass's five findings are that rule being broken.
The eighth fix batch landed, 2026-09-23
Sonnet, idunn/cut1-fix5 at 75153b0. 201 passed / 0 failed / 2 ignored
(193 + 8). Ten ygg-verify.sh invocations, one slot each, sequential, about
eleven minutes of slot time in total.
R-I23: the bypass is now a compile error, and it was verified by failing
builds rather than by reading. Everything the resolver can reach moved into
a frozen_symlink_port submodule carrying #![deny(clippy::disallowed_methods)],
with read_link added to the port and open_frozen_symlink_frame's direct
fs::read_link — the exact leak Soul named — converted. clippy.toml lists
Path::canonicalize, fs::canonicalize, fs::symlink_metadata and
fs::read_link. Since disallowed-methods has no per-module scope,
drivers.rs blanket-allows at file scope and the submodule's inner deny
overrides it back — documented lexical scoping, not a hopeful guess.
On Yggdrasil, against real source: Soul's candidate.canonicalize()? and
fs::canonicalize(&candidate)? each fail the build with
use of a disallowed method, as does reintroducing the fs::read_link leak.
Clean baseline builds before and after each, file restored and reconfirmed.
The repository had no CI surface at all before this batch — "denied in CI"
had nothing to attach to — so .github/workflows/lint.yml is new.
R-I20 and R-I21. digest_artifact's bare-file branch now delegates to
digest_file_entry, the same primitive digest_tree's file arm uses, instead
of a content-only raw_sha256. An xattr term covers all three digest_tree
arms through shared framing with the frozen-source side, so the two cannot
drift. The three tests that wrapped a file in a directory — which is exactly
why the bare-file blindness survived three batches — now call
digest_artifact on the file directly.
Reverting the file branch to its old body fails all three of the gid, setuid and xattr tests, so they pin something.
Two real bugs found by running the suite rather than trusting it.
digest_tree(root, root, …) never hashes the root's own metadata, so a
directory-arm test setting the xattr on the artifact root tested nothing;
moved to a child. And lsetxattr with a user.* name on a symlink returns
EPERM even as root — a kernel restriction to regular files and
directories, not a privilege problem — so that arm uses security.capability.
R-I22. Five tests call hash_xattr_list directly with lists chosen so
each framing piece is what causes a real collision when removed, rather than
merely changing the digest: value length prefix, count prefix, name
terminator, name case, and value-versus-names-only. Each was verified against
a real line-targeted mutation of production code, each failing exactly its own
test. (A python heredoc attempt hit the \0-escaping trap the rig's README
warns about; Hands switched to sed rather than fighting it.)
R-I24. The doc is rewritten and the formula corrected to components + 4,
not + 2 — read_link and the now-ported root canonicalize each add one. The
false "cannot catch its own regression" line is gone: it was true before
R-I23, and the compile-time deny now catches Soul's mutation independently of
what the count asserts.
Self's finding: the digest format change is a migration hazard
Hands flagged that digest_artifact's single-file format changed, and
reasoned that nothing compares against literals. That is true of fixtures
and false of receipts. host.rs:682 compares a freshly computed digest
against expected.artifact_sha256, and host.rs:803 against
prior.executable_sha256. Both are stored values.
So on the first Idunn upgrade past this commit:
- every already-installed workload whose
Expectedwas sealed under the old format fails"installed executable differs from Expected"and refuses to launch; - every workload already running fails
"the installed executable changed under the running process".
Idunn is the deployment and daemon-survival authority. A change that makes its own upgrade refuse every previously admitted artifact is precisely the class of failure its continuity rules exist to prevent, and it would land silently, at restart, on the whole fleet.
- R-I25. The artifact digest carries an explicit format version, recorded with the stored value. Verification recomputes in the version the receipt names and compares against that, so an existing receipt still verifies; new seals use the current version. A bare unversioned value is read as version 1.
- No re-seal-everything migration. Re-sealing on upgrade would mean Idunn rewriting admitted state to make its own new code agree with itself, which is the opposite of what a receipt is for.
- A test must cover the upgrade path directly: seal under the old format, verify under the new code, and pass. Until that test exists, this is not closed — the whole finding is that nothing exercised the stored-value path.
Also owed: cargo clippy --all-targets already fails on a pre-existing
clippy::never_loop in host_actuator.rs's test code, present on the parent
commit too. Hands scoped the workflow to avoid it and said so rather than
absorbing it silently. Fix never_loop and restore --all-targets — test
code is exactly where a bypass would hide, and it is currently the one place
the new lint does not reach.
The ninth fix batch landed, 2026-09-23
Sonnet, idunn/cut1-fix5. 206 passed / 0 failed / 2 ignored (201 + 5).
Five sequential Yggdrasil runs.
R-I25 could not be implemented as written, and Hands said so. An explicit
version tag cannot be recorded beside the stored value: both
expected.artifact_sha256 and ArtifactReceipt::sha256 are validated to a
bare sha256-<64 lowercase hex> with no room for metadata, and that validator
lives in the pinned cultnet-rs dependency — a separate repository this
worktree has no authority to widen.
What landed instead is an implicit version read: verify_artifact_digest
recomputes under the current algorithm, then falls back to the exact shape
digest_artifact produced before R-I20. There has only ever been one prior
shape, so the fallback is total. New seals always call digest_artifact
directly and therefore always use the current shape; nothing rewrites a
stored value, and start() carries expected.artifact_sha256 forward into
its observation rather than recomputing, so a legacy receipt is echoed and not
silently upgraded.
The proof is a test that fails without the fix, which is what this finding
was about. Five new tests — the upgrade path, a tamper still caught under the
legacy shape, a no-reseal check, the frozen-source equivalent, and one at the
real host.rs call site. Stripping the fallback and rerunning: all five
fail. Restored, 206/0/2.
The frozen-source digest was exposed too, and this cut's own batches crossed
the window twice. hash_frozen_source_tree changed in batch 6 (mode and
gid) and again in batch 7 (xattrs), while observe_frozen compares a fresh
recompute against receipt.snapshot_sha256 — a FrozenSourceReceipt
persisted in DeploymentTransaction.frozen_source between two separate
advance_sealing calls that can cross a restart. The window is narrower
than the artifact side — one in-flight Sealing-phase transaction rather than
every installed workload — but it is real. verify_frozen_source_digest
applies the same answer against both prior shapes, reproduced verbatim from
git history rather than reconstructed from memory.
R-I26. The never_loop was a test-only while let whose only body path
returned, so it could never reach a second iteration; the surrounding for
loop was already doing the retrying. Changed to if let. The workflow now
runs cargo clippy --all-targets --all-features -D clippy::disallowed_methods,
verified exit 0 on Yggdrasil after adding the clippy component, which the
base image lacks. Only unrelated, undenied warnings remain.
Self's rulings
- R-I27. Hands made the right call proceeding. The instruction named a mechanism; the hazard was live and the alternative was to halt and leave the fleet exposed. An equivalent fix, implemented and flagged with its divergence, beats a literal one that cannot be built. Standing: where a ruling names a mechanism and the mechanism is unavailable, satisfy the ruling's purpose and report the substitution — do not halt, and do not quietly redefine the ruling as satisfied.
- R-I28. The fallback chain is a liability that grows with every format
change, and it is only tolerable because there is exactly one prior shape
per digest. Follow-up outside this cut: widen the stored value's shape in
cultnet-rsto carry an explicit version, then collapse the fallbacks. Recorded, not scheduled — it is a cross-repo change on a pinned dependency and does not belong inside Cut 1. - R-I29. Investigate the third instance Hands spotted and did not chase.
SystemdWorkloadActuator's native-process path comparessha256_reader— raw, content-only, read through procfs — againstexpected.artifact_sha256, which for a file-based artifact is sealed viadigest_artifactand now carries mode, gid and xattr terms. Two structurally different algorithms compared to each other is the same class of defect as the two above. Hands flagged it rather than guessing, correctly. Settle whether it is a live mismatch or a misreading, and report before fixing.