# 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/.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:` 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/.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-`, 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`. | 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** | `//{frozen,runner-}`. | 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--`, label `gamecult.idunn.verify=`. | 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** | `/`, 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:` 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:` 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 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/`, 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`. 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` reusing `deployment.rs:79-87`, `steps: Vec`). 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`). 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`). - `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` 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`, timestamps). - The CLI commands: - `idunn verify --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 --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-- --label gamecult.idunn.verify= …` 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 `. 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 }`, 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 `. 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 ` (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 ` 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 ` 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 ...` 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.