Idunn/docs/verify-transaction-cut.md
Meta Vens 99d3e86d03 Record the ninth batch: the frozen-source digest was exposed too
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>
2026-09-23 05:54:43 +02:00

141 KiB
Raw Blame History

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. RecipeStep is at src/deployment.rs:53-64: id, phase, runner, argv, working_directory (default .), and required_environment. RecipePhase is Prepare|Test|Build|Acceptance at :70-77. The Eyes pass gave both ranges one line early.
  • F2. Nothing reads RecipePhase. It is parsed and never consumed. A grep of src/ finds no use outside the enum. DockerRunnerDriver::materialize runs every step in file order (src/drivers.rs:1662-1684), and Ghostlight's recipe interleaves test, build, prepare and acceptance steps in file order. No existing mechanism selects steps by phase, so "run the Test/Acceptance-phase steps" has nothing to build on.
  • F3. TargetDeclaration requires artifacts and service (deployment.rs:32-33, and :762 rejects a declaration with no artifacts). service must require GAMECULT_IDUNN_RUNTIME_BUNDLE (:864-869). A repo with no daemon, such as CultLib, Huginn or Eureka, cannot state a deploy recipe. OperatorBinding also requires workload, runtime_identity, brakes, rollout and placement (: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 admits argv[0].
  • F5. Docker caps come from the binding: memory_mebibytes and cpu_quota_percent (deployment.rs:436-437), lowered to --memory and --cpus (drivers.rs:1468-1476). The same call also sets --network (binding network_profile, default none), --cap-drop ALL, no-new-privileges, --read-only, --pids-limit, a noexec /tmp tmpfs, 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() calls Command::output() (drivers.rs:1392-1396). It has no deadline. Output is buffered in memory, thrown away on success, and on failure appears only inside the bail! 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 (https URL plus sha256) are fetched by curl inside 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 use network_profile = "bridge" for registry access plus a cache_root. Ghostlight's rust-acceptance runner uses network_profile = "host" and secret_files, so that it can reach the live connector at 127.0.0.1:4103.

1.2 Source

  • F11. The deploy source path fetches the binding's admitted_ref and requires the selected revision to be an ancestor of it and a descendant of minimum_revision (drivers.rs:1157-1219, :810-852). freeze fetches the exact revision by SHA, runs git archive | tar into a root-owned tree, and hashes it (:1221-1304). Git runs with env_clear, GIT_CONFIG_NOSYSTEM=1, and as the unprivileged idunn uid (:705-726). These primitives are typed against OperatorBinding and CompiledDeploymentPlan.
  • F12. CultLib .gitattributes has * text=auto and no export-ignore, export-subst, eol=crlf or filter=, so an archive on Linux is the stored blob. The Eureka scar about git archive concerns 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_commands is at :7577-7586.
  • F14. idunn up writes a DeploymentCommand straight into the local control.cc by compare-exchange (submit, :2434-2502). There is no remote request surface. An agent on Starfire reaches it only through ssh ygg sudo …. control.cc on Yggdrasil is root:root 0640.
  • F15. The scheduler is one thread. serve loops over run_scheduler_tick (:3004-3019, :3048-3055). Sealing calls self.runner_for(plan)?.materialize(...) synchronously (:3847-3852), so every Docker build blocks continuity supervision for its whole duration. gamecult-ops/runbooks/idunn-host-raven.md says 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) and install all happen at :3786-3866. The brake is checked only when moving to Starting (: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 as Materialize (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.cc is 10 MB. /var/lib/gamecult/idunn/staging holds 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 says deployment.rs and deployment_plan.rs "do not yet drive Git, runners, systemd, nginx, brakes, CultCache, CultMesh". drivers.rs has done all of that for weeks. Its second paragraph, that private plan state is never published and only ExpectedIncarnation is 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 path crates/idunn-daemon/src/ and says "The transaction engine still must persist and sequence …", which it now does.
  • F22. README.md:204 says docs/guide.md describes the generation "still what runs on yggdrasil". That is false: probe of systemctl show idunn-yggdrasil shows the new generation's idunn serve running.
  • F23. gamecult-ops/scripts/request-idunn-bounded-redeploy-yggdrasil.sh calls idunn redeploy --daemon …. The installed binary has no redeploy command (probe of its usage text), so the script is dead and is not the current pattern. The current pattern is idunn up followed by idunn-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/build 53 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) plus rust.Dockerfile (9 lines). It pushes to a bare mirror ~/eureka-verify/repos/<name>.git, runs one free bash -c string as root in a container capped at --cpus 6 --memory 16g, niced, with 2 flock slots, and uses the shared volumes eureka-cargo-registry and eureka-nuget. On Yggdrasil it has left ~/eureka-verify/{harness,repos/Huginn.git,work,slot-1.lock} and the image eureka-verify-rust, whose FROM is a tag and not a digest. It is named in SKILL.md:417-424 and references/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.yml runs on windows-latest, and cultmesh-portability.yml runs an OS matrix.
  • F27. The three harnesses have three exit conventions and none writes a typed summary:
    • eureka-mutations.ps1 throws when a mutant is not killed (:542-546).
    • mutate-dotnet.ps1 (on the branch only) exits non-zero.
    • mutate-cultmesh.mjs exits 2 when entries were skipped.
  • F28. The linux-x64 native QUIC mutation needs --security-opt seccomp=unconfined, because TSan re-executes under setarch -R. It also needs the image built from the digest-pinned scripts/quic-native-linux-dev.Dockerfile, and it runs as a two-command bash -lc string (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 5b3f646 in rust:1.95-bookworm, using the README's method (/etc/machine-id mounted), capped at 4 CPU and 8 GiB: cargo test --lib gives 126 passed, 0 failed, 2 ignored. The ignored tests are live_committing_record_is_unstuck_by_the_replacement_gate and live_store_inventory. The test profile compiled in 31.9 s with a warm registry.
  • P2. Idunn-shaped hardening (all of F5, user 65532:65532, network none):
    • pwsh 7.5.3 crashes at start with no writable HOME (a stack in GetPolicySettingFromConfigFile).
    • With HOME=/workspace/.home it works, and an in-place file write under /workspace succeeds.
    • dotnet 10.0.400 runs, and dotnet new console restores.
    • node 24.14.1 runs.
  • P3. A locally built image can be pinned. docker image inspect reports RepoDigests equal to the image id under this Docker's image store, and docker run eureka-verify-rust@sha256:<id> runs. So require_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 --wait exit 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-verify plus 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:

  1. It needs a TargetDeclaration with a service (F3).
  2. It runs on the blocking scheduler (F15).
  3. 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, cmake or npm as 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-report subcommand) 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 idunn binary 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 with deny_unknown_fields into a typed record and persists it as CultCache.
  • B: harnesses write CultCache .cc directly. 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 VerifyStep request. Raven is human-used and streams, and idunn-host.exe is 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 … as gamecultadmin, which has full passwordless sudo (probe).
  • B: a dedicated eureka account on Yggdrasil with an SSH key, member of group idunn-verify, and no sudo. The request store is root:idunn-verify 0660, so the CLI can only submit or cancel. The verdict store is root: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, running idunn verify-serve from the same binary. It has its own stores and lock, and narrower ReadWritePaths: its own roots and /srv/build cache only, with no /etc/systemd/system, /etc/nginx, /etc/ufw, target roots or idunn-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 pins name@sha256:<id> in the gamecult-ops verify binding (P3). The images are rust+pwsh (a new Dockerfile in gamecult-ops docker/), the dotnet SDK (upstream, pinned), node (upstream, pinned), and CultLib quic-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. List cancel and validate.
    • README.md:204: the guide.md row says the previous generation no longer runs anywhere and the file is kept for migration history. Settle the deploy/legacy row 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 gates Starting, the first Verse-visible change. Cite advance_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, and rg -n "crates/idunn-daemon" docs/ gives 0 hits (both tested against HEAD: they hit deployment-authority.md:67-68 and authority-map.md:205 only);
    • operator: none. Soul reads advance_sealing against the new sentence.
  • 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_workspace and base_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/CompiledDeploymentPlan coupling of the fetch-and-archive core of GitSourceDriver::freeze (:1221-1304).
  • Adds:
    • ContainerSpec: a plain struct with image, user, network (None|Bridge|Named(String)), seccomp (Default only in this cut), memory_mebibytes, cpu_quota_percent, pids_limit, tmpfs_mebibytes, cache_root, secret_mounts, and environment as ordered pairs. It has one constructor from DockerRunnerBinding plus 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 StepPort trait with one method that runs a lowered step in a workspace and returns a StepOutcome { status, stderr_tail }. DockerRunnerDriver implements it with today's blocking output() semantics.
    • ExactSource { origin, checkout, gitlinks, recipe_path } and GitSourceDriver::freeze_exact(&ExactSource, revision, transaction_id, root) -> (tree_root, snapshot_sha256, recipe_bytes). freeze delegates to it.
  • Per-file changes:
    • drivers.rs:1407-1455 becomes: build a ContainerSpec, call docker_run_args, call docker. The error text is unchanged.
    • drivers.rs:1501-1559 (external input) uses the same lowering with argv curl ….
    • drivers.rs:1221-1304: freeze computes an ExactSource from the plan's binding and calls freeze_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 --lib on Yggdrasil stays 126 passed, 2 ignored.
    • container_spec_lowers_to_exact_docker_argv pins the full argv as an exact vector: --network none by default, --cap-drop ALL, no-new-privileges, --read-only, pids, the noexec tmpfs, the machine-id mount, --cpus as quota/100 to 2 dp, and --memory as Nm. 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 at cpu_quota_percent = 250, where 2.50 and 5.00 differ.
    • required_environment_is_the_only_environment pins F6. Its mutant passes every binding environment entry, and the fixture has a binding variable the step does not name.
    • freeze_exact_is_byte_exact_and_recipe_checked reuses the existing git fixture (drivers.rs:8409 exact_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:1478 in base_run_args).
  • 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> reusing deployment.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 no phase: every verify step is a test step, and RecipePhase stays 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 no secret_files field. It lowers to ContainerSpec (Cut 1), and ContainerSpec.seccomp gains Unconfined, 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 extends validate at control_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_files parses a binding carrying secret_files and expects an error. Its mutants remove deny_unknown_fields, and add #[serde(default)] secret_files while ignoring it.
    • R-V2. The network is none or bridge. verify_runner_rejects_host_network covers "host" and "container:odin". Its mutant adds Named(String) to VerifyNetwork.
    • 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 step runner = 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 at max (admitted) and max + 1 (rejected). The loosening mutants are <= max * 2 and a comparison against the largest max_step_seconds of 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 schema gamecult.eureka.mutation_report.v2.
    • R-V8. Idunn owns HOME and the source stamp. The binding environment may not name HOME or recipe.source_stamp_environment, and a step may not list HOME in required_environment. The Cut 4 runner always sets HOME=/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.
  • Verification:
    • builds: Yggdrasil, cargo test --lib.
    • The mutation suite tools/verify-mutations/cut2.psd1 covers R-V1 to R-V9 and runs through eureka-mutations.ps1 in eureka-verify-rust on Yggdrasil, stopgap era. It needs a no-op control.
    • negative: rg -n "secret" src/verify.rs finds only the rejection test's fixture text.
  • 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.rs or a sibling verify_store.rs: records VerifyRequest (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), and VerifyTransaction (idunn.verify_transaction, v1: id, request, phase, binding bytes and sha256, recipe bytes and sha256, frozen snapshot sha256, per-step StepOutcome, 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}.
  • Rules:
    • R-V10. A request can carry nothing privileged. Extend cli_exposes_only_declarative_commands (control_plane.rs:7577) with verify x --revision <sha> --command "sh -c", --argv, --image, --cpus, --memory, --network, --env and --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 is len >= 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 a Admitted transaction.
    • R-V13. Cancel refuses unknown and terminal ids. The terminal fixture comes from a hand-built verdict store.
    • Exit codes for idunn verify in 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_verdict is a table test. The mutant maps TimedOut → 1.
  • Verification: Yggdrasil cargo test --lib. Mutation suite tools/verify-mutations/cut3.psd1. Negative: rg -n "verify" src/control_plane.rs finds only parse and dispatch lines. No Engine method may reference verify types (checked by reading impl 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 own ProcessLock (control_plane.rs:2637) on the verdict store.
    • The loop, per pass:
      1. Read the host policy.
      2. Observe cancels.
      3. Admit queued requests, oldest first, while running < max_concurrent and 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.
      4. 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.
    • Detached step execution. This is a new StepPort implementation for verify. It spawns docker 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 to log_byte_cap and counts the rest. It polls try_wait against the admitted deadline. At the deadline or on cancel it runs docker kill <name>. The argv comes from Cut 1's docker_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.verify orphans are killed;
      • workspace directories whose id is terminal or unknown are deleted.
    • Retention: resident terminals over resident_terminal move to verify-history.cc, history is pruned to history_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, and ReadOnlyPaths=/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; and verify-serve, which is denied control.cc, topology, brakes and /etc/systemd/system by the unit and never names them in code.
    • Shared paths: docker_run_args and freeze_exact from Cut 1.
    • Deletion line: none in this cut.
  • Rules. Unit tests use a fake StepPort and 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/ and node_modules are ignored. A mismatch seals Error{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; and max(timeout, 60), which dies at the 7 probe.
    • R-V16. At most max_concurrent transactions run, and CPU and memory budgets hold. Table fixtures at the boundary: running == max means 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 Interrupted and kills the labelled orphans. The mutant resumes Running transactions. 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 is Failed, never Error. Infrastructure faults are Error{kind}: fetch, image, docker, report decode, restore. Mutants: run every step; map a non-zero exit to Error.
    • 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 persist overwrite.
    • R-V21. The workspace is removed at terminal. The mutant is KEEP semantics.
    • Survival isolation (negative, structural):
      • rg -n "idunn-verify|verify_store|VerifyTransaction" src/control_plane.rs finds nothing inside impl Engine or fn serve;
      • the unit diff shows idunn-yggdrasil.service unchanged;
      • systemctl stop idunn-verify leaves idunn-yggdrasil active, and a deliberately corrupted verify.cc crashes only idunn-verify (operator check at Cut 6).
  • Verification:
    • Yggdrasil cargo test --lib, and mutation suite cut4.psd1 (stopgap era).
    • Pipeline smoke on Yggdrasil in a scratch root: a throwaway local bare repo with a two-step verify recipe (true, then sh -c 'exit 3'), a tempdir policy, and alpine@sha256 pinned by P3. Expect Failed, step 2 exit 3, both logs present with sha256, the workspace gone, and no container with the label left.
    • A second smoke with sleep 30 and timeout_seconds = 3 expects TimedOut and the container killed.
  • 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 JSON counts field 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 is Error{Report}, never Failed. 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; count NoVerdict as 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.ps1 gains -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 with Out-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.mjs gains --report <path> (main). scripts/mutate-dotnet.ps1 gains -Report on cultnet/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 existing gamecult-ops/scripts/install-idunn-yggdrasil-release.sh. It is extended to install idunn-verify.service too, or given a sibling install-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 as PROVISIONED_*_IMAGE_DIGEST tokens, like the existing signer tokens;
    • docker/verify-rust-pwsh.Dockerfile (digest-pinned FROM, 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 eureka account 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.
  • 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-linux then mutate-cultmesh-native on the quic-native runner, which is seccomp = "unconfined" under Q-V9.
    • Huginn, as its campaign needs.
  • Verification:
    • operator: idunn verify idunn --revision <cut5 sha> gives Passed, and verify-log shows 126 passed. On the same host, systemctl stop idunn-verify leaves systemctl is-active idunn-yggdrasil active. Corrupt a copy of verify.cc (swap the store path) and confirm only idunn-verify fails.
    • As the eureka user: sudo -n true fails, docker ps is denied, and idunn verify works.
    • The first Soul pass that runs through verify, not the stopgap, is this cut's Soul pass.

Cut 7. Eureka moves to idunn verify; the stopgap dies (deletion line)

  • Repo: GameCult/Eureka plus the Yggdrasil host. Depends on Cut 6 passing its Soul pass.
  • Deletes first:
    • tools/stopgap/ygg-verify.sh (91 lines) and tools/stopgap/rust.Dockerfile (9 lines), with the directory;
    • SKILL.md:417-424, replaced by the verify rule. Heavy verification goes through idunn verify on Yggdrasil: exact pushed SHA, recipe steps, verdict exit codes, and verify-log for evidence. The Starfire exception stays only for Windows-only jobs, per Q-V5.
  • Adds:
    • references/briefs.md Hands and Soul templates gain the verify instruction. SKILL.md says "Every Hands and Soul brief says so", but briefs.md names 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 is gamecult/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 hits SKILL.md:420 and 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 plus idunn-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 RecipePhase from deploy (F2). It is decorative, but removing it changes gamecult.idunn.target_declaration.v1 because 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, runbook idunn-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.sh is 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/staging has 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 predates 5b3f646.
  • 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 from cultnet/selection-cut1.
  • CultLib's native TSan mutation inside Idunn's hardening, meaning --read-only, --cap-drop ALL, user 65532 and a noexec /tmp, combined with seccomp=unconfined. setarch -R may 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 ContainerSpec and docker_run_args, the only Docker argv lowering in the codebase (--cap-drop has one site).
    • Adds ExactSource and freeze_exact.
    • Tests: 126 to 130. tools/verify-mutations/cut1.psd1 kills 5/5, with M0 green.
    • Discrepancies Hands reported:
      • The external-input curl step now goes through the shared lowering. It gains --workdir and one inert environment variable.
      • The gitlink cache directory is named by a hash of the checkout path, because ExactSource has 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.
    • Soul's pass is dispatched.
  • 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 ALL dropped only on bridge;
      • --cpus rounded to 0.5;
      • environment passthrough by prefix;
      • ambient GAMECULT_*/IDUNN_* environment;
      • writable secret mounts;
      • explicit none lowered to bridge;
      • --read-only dropped on a named network;
      • an extra mount of the cache's parent;
      • the secret and cache-root validation calls removed.
    • F2 (medium): freeze_exact is git archive, so it is not byte-exact. Non-recipe files come through with eol=crlf applied, export-ignore dropped, export-subst and ident expanded, 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 freeze fetches twice.
    • F5: the gitlink loop is untested; skipping it survives.
    • F7: StepPort and StepOutcome are dead code, and run_step duplicates docker().
    • F8: the --cap-drop negative check also matches a test.
    • F9:
      • docs/guide.md:11 is still stale.
      • These gamecult-ops files still route through the dead idunn redeploy script: runbooks/odin-yggdrasil.md:88, ghostlight-dungeon-yggdrasil.md:134,243, heimdall-discord-launch.md:125,144, epiphany-yggdrasil-deploy.md:156, and the bootstrap-ghostlight-yggdrasil.sh and bootstrap-codex-connector-yggdrasil.sh scripts.
    • Pre-existing: a binding environment variable with the source stamp's name overrides the stamp.
  • Self's rulings for the Cut 1 fix batch, 2026-09-22:
    • F1. The lowering is pinned on every branch: network none, bridge and 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 through ContainerSpec::for_step.
    • F2. The frozen tree is the commit's blobs, exactly. Blobs are written raw from the object store (ls-tree -r -z plus cat-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 .gitattributes of every Idunn deploy target (the target repos named in the gamecult-ops bindings) for export-ignore, export-subst, ident, eol/text, filter and 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 against cat-file for every file of a fixture that has CRLF, export-ignore, export-subst, ident, a symlink, the executable bit and a gitlink.
    • F3: freeze fetches once.
    • F5: add a gitlink test and entry.
    • F7: delete the dead StepPort/StepOutcome. There is one Docker spawn path; run_step goes through docker().
    • F8: make the negative check rg over src/ 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 up plus a brake release, as in runbooks/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.
  • 2026-09-22: the Cut 1 fix batch landed (Sonnet, verified on Yggdrasil).
    • Commits: Idunn 7a5d32f (F1, F3, F5, F7, F9, stamp override) and 95016e8 (F2); gamecult-ops 62b41b1 (runbooks and bootstraps moved off the dead idunn redeploy, script deleted).
    • Tests: 130 to 142. cut1.psd1 17/17 killed. The revert to git archive dies on freeze_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=lf transforms nothing on stored-LF blobs. At Eve rev 672c0c1e, all 26 dist files hash the same through git archive and through hash-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 dead tar_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_source enforces the same invariants first.
    • Follow-up: freeze_exact returns an LFS-pointer flag that FrozenSourceReceipt does not carry yet. It must reach the verify verdict (Cut 2/4).
    • Recorded, not this cut's: test-voidbot-swarm-yggdrasil.sh fails on add-daemon-health-trust-binding. That failure is pre-existing.
    • Soul's pass dispatched.
  • 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 redeploy remains.
    • 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 --batch on the blobless checkout (drivers.rs:788,1053,1245) fetches missing blobs lazily in small packs. read_blobs also 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 a pointing outward plus a subtree a/pwn gives a root-owned pwn outside the root, left behind after the freeze fails. fs::write, symlink and create_dir_all follow symlinks and run before hardening (:1173-1203, :1350). The old tar path refused this tree. The fetch does not set transfer.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_source and validate_frozen_source from freeze_exact survives 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.sh and bootstrap-codex-connector-yggdrasil.sh still 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.md says idunn up heimdall, but Heimdall has no installed binding.
    • F9 (low): read_blobs never drains stderr, and its error paths leave zombie processes.
  • 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.
    • 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 FrozenSourceReceipt stays 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 82e0edf and gamecult-ops 9fc3af0.
    • F1: one bulk git fetch --stdin of 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_entries runs before any write, and ensure_frozen_directory never follows a link. transfer.fsckObjects is 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 named d freeze merged. fsck-rejected names freeze too: .GIT, git~1, .git. and .git with 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-connector and odin templates set checkout = /srv/build/idunn-sources/*, but the installed unit uses --source-root /var/lib/gamecult/idunn/sources, and the host's odin.toml already uses that. The two scripts contradict each other about codex-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_directory containment (X09);
      • pass 1 create_dir_all (X10);
      • copy_artifact containment (X11).
    • S6, low: a symlink is validated against .partial but 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_tree reads each file whole. A 200 MiB file costs 232 MB of RSS.
  • 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 as freeze_exact. Independently, freeze_exact also runs fsck on the selected tree, so that objects already present in the local store are checked too. Refuse every .git look-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's dup-trees fixture and each look-alike. Each must refuse through the production path (resolve, then freeze), 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-id step. Correct the ghostlight, codex-connector and odin templates to the source root the installed unit uses (/var/lib/gamecult/idunn/sources/*), matching the host's installed odin.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_exact wherever 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 .git look-alikes are covered by S1.
    • S9: stream the hash.
  • 2026-09-22: the third Cut 1 fix batch landed. Idunn 57ecd43..f540d50, gamecult-ops 2f3c1b3 and 9d0e37e.
    • S1: fsck guards resolve()'s fetch, and freeze_exact fscks explicitly. The dup-trees fixture and all six .git look-alikes are refused through the production path.
    • S3: both bootstrap scripts are deleted (319 lines). Callers point at the binding-install procedure. The three checkout templates 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 .partial case 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 real freeze_exact.
      • test-ghostlight-yggdrasil-wiring.sh still 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.
    • Soul's fourth pass dispatched.
  • 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 by freeze_exact's own fsck: dup-trees, six .git look-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, so MAX_HOPS = 40 bounds 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_exact with n=18 returned Ok after 15.9 s and observe_frozen spent 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: at 82e0edf the check was path.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 and create_dir against create_dir_all all 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. Its vendor/CultLib gitlink names dfe07704, which GitHub no longer serves. So idunn up heimdall would fail on the host today. Recorded as an ops follow-up.
  • 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.
  • 2026-09-22: the fourth Cut 1 fix batch landed at 9475dfa (Sonnet, on Yggdrasil). One commit, src/drivers.rs only, +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 real resolve() needs a validated binding, which needs an HTTPS origin. Rewriting the origin with insteadOf fails ensure_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-objects before 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-6042 calls canonicalize() 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 the PATH_MAX ceiling. Through the production walk, depth 400 x 40 links took 78.6 s. frame.resolved is already canonical there, so the call cannot change the answer: replacing the four lines with stack[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_TRAVERSALS to u32::MAX leaves 168/168 green. cargo mutants --in-diff found 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 :6205 leaves 168/168 green. Since observe_frozen compares frozen_source_sha256 against 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 0o111 term leaves 168/168 green, and validate_frozen_source accepts both 0444 and 0555, 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 with true leave 168/168 green. The mechanism does hold - a 64 MiB blob at mode 120000 is 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 f540d50 accepted 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-backend behind TLS, a validated binding, a clean --filter=blob:none clone, then main moved onto a hostile commit. As shipped: clean admitted, duplicate trees refused, .GIT refused. 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-6024 earns its place. FrozenSymlinkFrame::symlink_path is an Option only ever constructed as Some, 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_trees asserts nothing today: its only assertion sits in an Ok(_) 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 ELOOP limit, 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 when openssl or git-http-backend is missing, since neither is guaranteed on a workstation.
  • R-I7 (S5-8, S5-9). Take the deletion. The three redundant containment checks, the dead Option and 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 fails ENAMETOOLONG. 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 openssl or git-http-backend is missing. Deleting transfer.fsckObjects=true from resolve()'s fetch turns it red on the first hostile fixture — resolve() now has coverage where it had none.
  • R-I7. The dead Option and 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-11642 reads large < small.max(0.01) * 20.0. In all eight runs small was 2.7–9.4 ms, always under the 10 ms floor, so the clamp fires and the test is simply large < 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 false verify verdict on unattended work. The other direction is worse: with the canonicalize restored 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, so 0o4555 satisfies == 0o555, and it checks uid() == 0 but never gid. The digest hashes only mode & 0o111 != 0 for files and nothing for directories. Measured on a real root-owned 0555 tree: setuid file 04555 accepted, digest unchanged; setgid file, setgid dir, sticky dir, gid 12345 — all the same. observe_frozen is 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 no nosuid while the workload runs as 65532:65532. harden_frozen_source_tree normalises 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 90 returned 137. cargo test has 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 only is_err(), not which error.
  • F4, PLAUSIBLE, informational. "Refuses exactly what the kernel refuses" is slightly overstated. libc::PATH_MAX is per-target and < PATH_MAX matches 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.resolved mutates 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, :6105 is the one that must survive. Two further mutants are genuinely equivalent: component-wise starts_with versus string-prefix (every escape transits the root's parent first), and dropping ensure!(target.is_relative()) (frozen_symlink_steps bails on Component::RootDir regardless).
  • 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 \0 after the symlink target. No ruling covered it, so it must not be assumed fixed by association. Also: no test calls observe_frozen at 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_metadata calls 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 false verify verdict 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 gets nosuid — 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_MAX and the filesystem may refuse less. Doc only.
  • R-I12 (F5). Record that :6105 must survive, in a comment at the check itself. The two equivalent mutants are recorded as equivalent. No deletion.
  • R-I13 (F6). digest_tree gets the same treatment as the frozen-source digest, with its own tests, and observe_frozen gets 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 every canonicalize()/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's chown silently 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_MAX and 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_tree gains 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, which symlink(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_frozen now has a real end-to-end test through compile_deployment_plan and freeze_exact against 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 :11748 fires only where record_frozen_symlink_probe_call() was hand-placed. Soul restored the original S5-1 bug — the per-component canonicalize() 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 in 9e572bd. 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 at InvalidInput before 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.capability set to cap_setuid=ep: validator ACCEPT, digest unchanged. That is the grant setuid used to be, with no bit for mode & 0o7777 to 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 require no-new-privileges, which neuters file capabilities exactly as it neuters setuid, the same mitigation cited when the nosuid ruling 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 a verify verdict 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_tree gets the same treatment as the frozen-source digest, which now hashes mode & 0o7777 and gid for files and directories. digest_tree got a single executable boolean. Measured blind: setuid, setgid, uid, gid, and the read bits (0o500 hashes as 0o555); 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 to 0o3777 (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 returns Ok(1) unconditionally, and its doc's reasoning about /etc/group is moot because chown(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 canonicalize and symlink_metadata behind 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_tree gets the ruling it was actually given: full mode & 0o7777 and 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-privileges is 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_skip either 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_executable is replaced by digest_tree_mode (mode & 0o7777) and digest_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 0o7777 from R-I9 and simply untested per bit. Narrowing the file mask to 0o5777 fails exactly ..._refuses_a_setgid_file; narrowing the directory mask to 0o3777 fails 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_xattrs hashes names and values sorted by name, count-prefixed and self-delimiting, on libc::llistxattr/lgetxattr — an existing dependency, no new crate. Two tests: a general user. namespace pin, and a direct reproduction of Soul's security.capability=cap_setuid,ep finding, 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_skip is renamed second_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 with candidate.canonicalize()? or fs::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: no clippy.toml, no disallowed-methods, Path and fs both in scope. Proof by existence: open_frozen_symlink_frame at :6061 already calls fs::read_link directly, and a probe measured a resolution charging 16 port calls while making three uncounted read_link syscalls. 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-7727 returns 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) and host.rs:803 ("the installed executable changed under the running process") both hand it a single executable file. Measured: setuid blind, 0555 versus 0500 blind, security.capability=cap_setuid=ep blind. All three digest_artifact tests wrap the file in a directory so digest_tree runs; 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_tree got R-I16's mode and gid and none of R-I18, while hash_frozen_source_tree got both. On an artifact tree: security.capability blind, 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.A against user.a), and — the material one — hashing names only and dropping values entirely. Nothing proves an xattr value is hashed, so security.capability edited from cap_setuid to cap_sys_admin is 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. llistxattr is 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 primitive digest_tree uses, 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.toml with disallowed-methods for Path::canonicalize, fs::canonicalize, fs::symlink_metadata and fs::read_link within 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. Convert open_frozen_symlink_frame's direct fs::read_link to 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 Expected was 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-rs to 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 compares sha256_reader — raw, content-only, read through procfs — against expected.artifact_sha256, which for a file-based artifact is sealed via digest_artifact and 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.