Soul's second pass on the Cut 1 fix batch found a new regression (a
duplicate/aliased tree name lets the raw freeze write outside its root) and
three pre-existing gaps: two redundant hardening passes with no pin, a
lexical (not chain-resolving) symlink escape check, and unpinned Docker argv
combinations. F1's perf finding (40-80x slower than the old git archive path)
is addressed here too.
F2/F3 (write-outside-root, two required layers):
- git_tree_entries now calls refuse_conflicting_tree_entries, which refuses
a tree where two leaf paths collide case-insensitively, or where one leaf
path is a case-folded ancestor of another (the exact shape
`git hash-object --literally` lets through: a symlink and a subtree
sharing one name). The exact-revision fetch and both clone paths
(ensure_checkout, materialize_gitlink_raw) enable transfer.fsckObjects.
- The writer itself never trusts that check alone. ensure_frozen_directory
is the only way materialize_tree_raw obtains a directory to write into: a
path must already be a directory this freeze created (tracked in
created_dirs, shared across the superproject and every Gitlink), or must
not exist at all; anything else is refused rather than traversed.
Symlinks are written in a dedicated last pass, after every directory and
regular file the tree needs already exists.
F4: freeze_exact now calls harden_frozen_source once, last, instead of
harden then a separate validate_frozen_source pass over the same tree.
validate_frozen_source stays as the read-only re-check observe_frozen uses
on an already-hardened tree it did not just write.
F5: validate_frozen_source_symlink resolves the whole symlink chain with
fs::canonicalize instead of lexically counting ".." components, which
undercounts a chain that loops back through another symlink (D -> .,
L -> D/D/.../../../../etc/passwd). copy_artifact (the one place besides
freeze that reads through a possibly-attacker-controlled tree) gets the same
whole-chain resolution: it now takes the artifact's containment root and
refuses a source that resolves outside it, closing the gap where an
intermediate symlink in a runner's own build output could be followed.
F1: materialize_tree_raw bulk-fetches every blob a tree needs in one
`git fetch --stdin` round trip (Git 2.36+, no argv-length limit) instead of
letting a blob:none partial clone lazily fetch each one as `cat-file --batch`
reads it. Blobs stream straight to their destination file instead of being
held in a BTreeMap for the whole tree. read_blobs is replaced by
stream_blobs, which drains stderr on a dedicated thread and waits on the
child on every return path, including an error from the per-object
callback (F9).
F6: pins four previously-unpinned Docker argv/environment combinations
(N1: --read-only survives a Named network plus a cache mount; N2: a secret
mount stays read-only alongside a plain environment entry; N3: the source
stamp is never dropped alongside a secret and a plain environment entry;
N5: an LFS pointer file sets contains_lfs_pointers).
Tests: 142 to 152. cut1.psd1 grows by 12 entries (5 for F1-F5, 4 for F6's
N1/N2/N3/N5, and one recording the F2 fsck flag as not yet reached -- see
below); all 28 non-comment entries in the suite now kill.
Discrepancy: cut1-fix-f2-no-fsck-on-fetch could not be pinned. Every tree
this crate's own tools can build that fsck's duplicateEntries check rejects
also has a literal duplicate path or a case-folded ancestor collision that
refuse_conflicting_tree_entries rejects first, unconditionally, before the
fetch's fsck result can matter. The flag stays as defense in depth (fsck's
other structural checks are broader than this crate's own parser), recorded
as not yet reached in cut1.psd1 rather than removed or falsely claimed
unreachable.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Self reviewed the F2 survey finding (Eve's gitlink under Ghostlight carries
`packages/*/dist/** text eol=lf`) and ruled it is not a real dependency on an
archive transform: Git stores text blobs as LF, `eol=lf` checks them out as
LF, so no conversion happens. Self verified this directly at Ghostlight's
pinned Eve gitlink revision `672c0c1e9fc828b63752345385727b06c50491d0` — all
26 files under `packages/*/dist/` hash identically between `git archive` and
`git hash-object --no-filters`, and none contains a CR. Stopping for the
survey was correct; the ruling is to proceed.
Survey table (repo, attributes found, verdict), recorded here as ruled:
| Repo (deploy target) | Binding source | .gitattributes findings | Verdict |
|---|---|---|---|
| Ghostlight | idunn/yggdrasil/bindings/ghostlight.toml.in | none | clean |
| Ghostlight's gitlink vendor/eve -> Eve | same binding | packages/*/dist/** text eol=lf | transform present, but a no-op on stored LF content (Self's evidence above) |
| Odin | idunn/yggdrasil/bindings/odin.toml.in | none | clean |
| CodexConnector | idunn/yggdrasil/bindings/codex-connector.toml.in | none | clean |
| Muninn (raven-muninn) | idunn/yggdrasil/bindings/raven-muninn.toml.in | none | clean |
| Heimdall | deployment/idunn-bindings/heimdall.toml.template (a second, previously unlisted binding directory, installed live 2026-09-06) | none | clean |
| Heimdall's gitlink -> CultLib | same binding | * text=auto only (F12) | clean |
`freeze_exact` now writes every tree entry raw from the object store:
`git ls-tree -r -z` for the manifest, `git cat-file --batch` for content, with
no `git archive` anywhere in the path and so no attribute-driven transform of
any kind. Modes and symlinks are preserved exactly as the tree records them.
Gitlinks are frozen the same raw way, through the same primitive, at their
own path. LFS content is never fetched; `freeze_exact` now also returns
whether any materialized file is an LFS pointer (recorded in the frozen
result's own return value — not yet plumbed into `FrozenSourceReceipt`, which
has no field for it; that is follow-up scope, not this fix).
Deleted: `git_archive_into`, `materialize_gitlink_archive`, and the
now-unused `tar_program` field — the archive-and-extract path they built has
no remaining caller. `GitSourceDriver::new` no longer takes a tar program.
Tests:
- `freeze_exact_is_byte_exact_across_every_attribute_transform`: a fixture
with `eol=crlf`, `export-ignore`, `export-subst`, `ident`, a symlink, the
executable bit, and a Gitlink. Every frozen file's bytes are asserted equal
to `git cat-file blob` at the selected revision.
- `freeze_exact_matches_archive_when_untransformed_and_diverges_when_transformed`:
the deploy-equivalence test Self's ruling asked for. An `eol=lf` file over
already-LF content matches today's independently-reconstructed `git archive`
tree exactly; a second file with `eol=crlf` over the same LF content
diverges from it, so the equivalence check has teeth rather than trivially
passing for every fixture.
- `exact_git_blobs_become_root_owned_immutable_source_without_git_metadata`
(renamed from the git-archive-era test) now exercises `materialize_tree_raw`.
- `tools/verify-mutations/cut1.psd1`: `cut1-fix-freeze-skips-gitlinks`
retargeted at the new call, and a new `cut1-fix-f2-revert-to-git-archive`
entry that reverts `materialize_tree_raw` to a `git archive | tar` pipeline;
it must die against the byte-exact test.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Soul's pass on Cuts 0-1 found the argv-lowering test pinned only the
default runner path (13 of 14 mutants survived), freeze fetching the
same revision twice, an untested Gitlink loop, dead StepPort/StepOutcome
code, and a stale guide.md line. This lands Self's rulings for all of
it except F2 (the byte-exact freeze): the F2 survey found that Eve, a
Gitlink of the Ghostlight deploy target, depends on an archive
transform (`.gitattributes` eol=lf under packages/*/dist/**), so per
ruling 1 the byte-exact rewrite is withheld and reported instead.
- F1: `docker_run_args`/`ContainerSpec::for_step` are now pinned across
every network branch (none/bridge/named), the full CPU quota range
(100/150/250/333/800), cache root present/absent, and secret mounts,
with dedicated fixtures for the cache-root and secret-file validation
calls so a mutant that skips either is caught.
- F3: `freeze` fetches the exact revision once, through `freeze_exact`,
instead of fetching before it and again inside it.
- F5: `freeze_exact_materializes_gitlinks` pins that the Gitlink loop
actually runs.
- F7: deletes the dead `StepPort`/`StepOutcome` types; `run_step` never
had a caller, and `docker()` is the one Docker spawn path.
- Stamp override: `ContainerSpec::for_step` now refuses a step
environment name that collides with the Idunn source stamp, so the
invariant holds at the one place every runner's environment is built,
not only at deploy's separate `admit` check.
- docs/guide.md:11 no longer claims the retired generation still runs
on Yggdrasil.
F8 (the `--cap-drop` negative check) is a reporting fix, not a code
change: `rg -n '"--cap-drop"' src/` now returns 4 hits (1 real site
plus 3 test fixtures, one of which this batch adds), never the "1 hit"
the map's verification text describes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Deletes the driver-private Docker argv assembly in run_in_workspace and
base_run_args and the OperatorBinding/CompiledDeploymentPlan coupling inside
GitSourceDriver::freeze's fetch-and-archive core. Both become one shared
primitive apiece, the shape Cut 4's verify worker will reuse instead of
growing a second copy:
- ContainerSpec + docker_run_args(spec, workspace, working_directory, argv):
a plain, pure lowering to the exact `docker run` argv. ContainerSpec::for_step
is the one constructor (a DockerRunnerBinding, a step's required
environment, and the source stamp); it does the cache-root and secret
validation against the filesystem before docker_run_args ever runs.
run_in_workspace and materialize_external_input both build a ContainerSpec
and call docker_run_args now; base_run_args is gone (rg '"--cap-drop"'
src/ has exactly one hit).
- ExactSource {origin, checkout, gitlinks, recipe_path} plus
GitSourceDriver::freeze_exact(&ExactSource, revision, transaction_id, root),
which fetches, archives and hardens an exact revision with no
OperatorBinding or compiled plan. freeze() now derives an ExactSource from
the binding, keeps its own verify_exact_source pre-check against the
durable resolution, and delegates the archive to freeze_exact.
- A StepPort trait (DockerRunnerDriver::run_step) that runs a lowered step
and returns exit status plus a bounded stderr tail instead of bailing.
Not yet reached from materialize (Cut 4 wires it into the verify worker);
deploy keeps DockerRunnerDriver::docker's existing bail-on-failure error
text unchanged.
materialize_external_input's curl step now goes through the same lowering,
which adds a harmless --workdir /workspace and one inert IDUNN_EXTERNAL_INPUT_ID
env var to its argv; curl's behavior (an absolute --output path) is
unaffected. materialize_gitlink_archive's cache-directory name changed from
the operator binding's target label to a hash of the checkout path, since
ExactSource carries no target field; this is bookkeeping only; a UUID
already made every gitlink checkout unique.
Adds three unit tests: container_spec_lowers_to_exact_docker_argv pins the
full docker run argv; required_environment_is_the_only_environment pins F6
(a binding environment entry the step does not require never leaks in);
freeze_exact_is_byte_exact_and_recipe_checked and its negative twin
freeze_exact_rejects_a_recipe_the_archive_transforms (an export-subst
recipe, which git archive substitutes and git cat-file does not) exercise
freeze_exact directly, with no OperatorBinding in sight.
tools/verify-mutations/cut1.psd1 covers all five rules with a revert or
loosening mutant apiece, run through the Eureka harness in the stopgap
container. Verified on Yggdrasil at this commit: cargo test --lib is 130
passed, 0 failed, 2 ignored (the P1 baseline of 126/2 plus these four new
tests), and every mutation entry was killed by its own test.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Deletes the two stale "Current implementation boundary" sections in
docs/deployment-authority.md and docs/authority-map.md; both described a
pre-drivers.rs era and were wrong (drivers.rs has driven Git, runners,
systemd, nginx, brakes, CultCache and CultMesh for weeks). The still-true
second paragraph of the deployment-authority.md section (private plan state
is never published as Odin topology) moves to the end of "Typed deployment
state", reworded present tense.
Fixes README.md's stale claims: the CLI has five commands (cancel and
validate were missing), and docs/guide.md plus deploy/legacy/ describe a
generation that no longer runs anywhere, not "still what runs on yggdrasil".
Documents present truth in "Candidate promotion": advance_sealing resolves,
freezes, runs every recipe step and installs before the deployment brake is
consulted at all. The brake only gates the move into Starting, the first
Verse-visible change.
Docs only, no build required.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Operator ruling 2026-09-22 after Starfire froze under agent build load. A
verify transaction takes an exact revision and declared steps, runs them in
capped runners in its own worker, returns a typed verdict, seals nothing and
sits outside the deployment brake. Imagination's map; ten operator questions
open, five of them blocking Cut 2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Idunn's cultmesh-rs/cultcache-rs/cultnet-rs pin moves from c13b6ba to
CultLib origin/main 61549aa, picking up e9b91c1 ("Preserve public
trust-anchor parent permissions"). That fix matters here: Idunn calls
export_service_identity_trust_anchor from provisioning.rs:129 and
control_plane.rs:6633 to publish a public anchor into a directory another
service reads from. Before the fix, export routed through the same
prepare_parent helper as private enrollment and chmod'd that directory
0700 on every export, which would lock the legitimate reader out instead
of protecting anything. CultLib now splits ensure_parent (export, no
narrowing) from prepare_private_parent (enrollment only).
Encoding audit (c13b6ba..61549aa in cultcache-rs/cultnet-rs/cultmesh-rs/
contracts, 21 commits): none touch the eight record types Idunn persists
or verifies (GameCultServiceTrustAnchorRecord, IdunnDaemonHealthTrust
BindingRecord, IdunnDeploymentBrakeRecord, IdunnExpectedIncarnationRecord,
IdunnLifecycleBrakeRecord, IdunnProcessWriteLeaseRecord, IdunnRuntime
ActivationRecord, OdinRuntimeTopologyCorrelationRecord).
- 9e6027e adds only a test (byte_slot_migration.rs); no production flip
lands in this range.
- 2ba404c/96138ff/a3e6877/etc. touch MediaStreamContracts and
media_stream_wire record types (Muninn/Ratatoskr's media path) that
Idunn never derives, persists or reads.
- c2a9a6e changes cultcache-rs's Windows-only atomic-replace retry
(cfg(windows) MoveFileExW), not record encoding, and Idunn targets
Linux.
- 47b9fc4/d3bc6e6/66f05e1 are cultnet-rs rudp/transport changes with no
record-shape effect.
No re-encode guard in Idunn (drivers.rs, control_plane.rs, provisioning.rs
canonical-bytes checks) is at risk; this is a pin bump, not a store
migration.
Schema-id audit: fcc49cf ("Consolidate CultNet schema contracts") moves
JSON Schema files and changes their $id to
https://github.com/GameCult/CultLib/contracts/cultnet/... inside
packages/cultnet-ts and the C# reference implementation. Idunn's Rust
source and tests contain no reference to cultnet-ts/contracts,
schema_catalog, content_hash, CultNetSchemaRegistry or
builtin_schema_registry. Not a consumer.
Live-branch audit: origin/claude/windows-host-actuator (the branch the
live yggdrasil binary was built from, last commit 9dc80f8) is fully
contained in origin/main (git merge-base --is-ancestor confirms it) and
still carries the old c13b6ba pin. This bump is based on origin/main only;
the live branch is not touched here and would need its own follow-up.
Cargo.lock: cargo update -p cultmesh-rs -p cultcache-rs -p cultnet-rs
moved exactly four git-sourced packages (cultmesh-rs, cultcache-rs,
cultcache-rs-derive, cultnet-rs) to the new rev; the lockfile diff is only
their `source = git+...?rev=...` lines, no checksums, versions, or
unrelated packages changed.
No Idunn source changes were needed: CultLib made no API-breaking rename
or reshape Idunn depends on. cargo check --locked (Windows, compile gate
only) and cargo check --locked --tests both pass clean.
Verification ran on yggdrasil (Linux, the deploy target), not the
Windows workstation. Built a throwaway unprivileged toolchain (no sudo,
no docker) under ~/idunn-pin-61549aa since /srv/build is root:idunn
0755 and gamecultadmin has no docker-group access; rustc/cargo 1.95.0
matches the recipe's rust:1.95-bookworm image exactly. `cargo test --lib`:
117 passed, 9 failed, 2 ignored. All 9 failures require root privilege or
ufw (frozen-source root ownership, sealed-release root ownership, runner
cache/secret chown, service-credential 0400 root inode, write-lease
actuation, host-actuator-hub ufw allow) -- capabilities this unprivileged
non-containerized session does not have, not CultLib regressions. Proved
by re-running the identical suite at the old c13b6ba pin in the same
unprivileged environment: identical 117 passed / 9 failed, same 9 test
names, same errors. All 16 provisioning:: tests pass at the new pin,
including identity_enrollment_and_export_are_immutable_and_paths_cannot_alias
and provider_and_odin_anchor_exports_are_profile_exact_immutable_and_non_aliasing,
which cover the trust-anchor export path this bump fixes. `cargo build
--release --locked` also succeeds. Both throwaway directories
(~/idunn-pin-61549aa, ~/idunn-baseline-c13b6ba) were removed from
yggdrasil afterward.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Continuity counted 'host actuator raven is not attached' as a workload
death and burned all three restart attempts inside the reattach window
after an Idunn restart, demoting a healthy Muninn. The hub now returns a
typed HostUnobservable for not-attached, detached, reconnected and
unanswered, and supervision skips the tick instead of restarting.
A route-less host workload that owns fixed host resources (one desktop,
one capture, fixed advertised ports) cannot overlap two incarnations. A
binding may now declare rollout strategy stop-then-start: the admitted
incumbent is stopped at Starting, before the candidate starts, and
supervision leaves the target alone from Starting on. A systemd-transient
workload still rolls out candidate-then-promote only.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A scheduler tick that blocks on a build (odin's docker build took a
minute) left every attached host unserviced; the actuator timed out,
redialled, and three continuity restarts of raven-muninn failed with
'host actuator raven is not attached' inside one minute. The hub now
lives behind a mutex with a service thread; a request holds the lock and
services the socket itself for its whole exchange.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The host actuator re-parses a Linux binding on Windows to reach its own
section; the Idunn host's /var paths must not fail there. This also makes
the Linux-shaped test fixtures pass on a Windows checkout.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
provider-health-identity-id and host-actuator-identity-id are read-only.
The id is what a binding names as expected_signer_identity_id and what
a host is keyed by; deriving it here closes the gap where an operator
had to decode an anchor by hand to fill a binding.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A managed host runs idunn-host in the user session. It dials Idunn's hub
over CultNet RUDP on the mesh, proves itself with a host actuator service
identity, and executes the runner and workload ports for its host: fetch
the exact frozen revision, build with the host's cargo under the binding's
allowed programs, install the sealed release, start the workload as a
child, report pid, creation time and executable digest, stop on request.
It keeps no transaction state and decides nothing.
On the Idunn side the engine chooses the runner and workload driver per
plan from the binding's driver tag; the yggdrasil daemon is still the
single deployment and continuity authority, and a binding of the old kind
takes the same path it always did. Every request is signed by Idunn's
identity and every report by the host's; both ends refuse the other's
message unless signature, session nonce and sequence hold. The hub admits
its own firewall allow the way the route driver does.
idunn-provision learns to enroll and export the host actuator identity.
The unit gains --host-actuator-bind on the mesh address.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
RunnerBinding and WorkloadBinding become enums tagged by driver, so a
host-native runner and a host-actuator workload can be declared without
counterfeiting docker images or systemd units. The single-variant
RunnerDriver and WorkloadDriver enums are gone; the driver is the tag.
WorkloadObservation is now Systemd or Host, untagged on the wire so every
transaction the previous Idunn persisted decodes unchanged (proved under
both MessagePack encodings CultCache uses). IsolationEvidence follows:
Linux proves uid, pid and mount namespaces; a host proves two live
processes distinct by pid and creation time. Mixed kinds are refused.
The build machine-id seal is unix-only so the library compiles on the
Windows host that will run the actuator.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Odin re-stamps a correlation only when its facts change, and a live presence
changes them every heartbeat, so a correlation older than the trusted window
means nothing about that incarnation has arrived lately. Admission and
supervise now read that one refusal as nothing-new. It had failed every
Heimdall deployment and the first observed Odin deployment at Warming.
Verified with cargo test --lib in rust:1.95-bookworm on yggdrasil: 118 passed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Admitting the latest topology persists the sequence cursor; asking twice
with the same record failed the second time as "transaction changed before
topology admission". Only reachable now that an admitted Odin can observe a
candidate Odin under incarnation keys; it failed the first fold-back
deployment on yggdrasil at Warming.
Verified with cargo test --lib in rust:1.95-bookworm on yggdrasil: 118 passed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A route that is admitted must be reachable without an operator opening a
port by hand. NginxRouteDriver admits one ufw allow for the binding's stable
address, port and transport, tagged with the route id, before it reloads a
fragment, and withdraws that one rule when the last membership is removed.
A rule already gone reads as the state asked for. Odin on yggdrasil is
admitted on 10.77.0.1:17971 with ufw still admitting only the previous
generation's 17871; this closes that class.
Verified with cargo test --lib in rust:1.95-bookworm on yggdrasil: 118 passed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A replaced incumbent, an aborted candidate whose reconciliation was skipped,
and a record left by the projection migration all leave an Expected-only
incarnation in the projection that nothing owns. Supervise now withdraws one
per tick for each admitted target: any projected incarnation that is neither
the admitted generation nor a live transaction's. One owner for all three
sources, instead of a per-commit field.
Verified with cargo test --lib in rust:1.95-bookworm on yggdrasil: 117 passed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Transactions that finished before finished transactions travelled to history
are still resident with their commands, gating nothing but growing the store
every decision reads. They leave the same way a transaction finishing today
does, one per tick.
Verified with cargo test --lib in rust:1.95-bookworm on yggdrasil: 116 passed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Commands whose transactions were retired to history before commands travelled
with them were still resident and read as queued; freeze took the oldest
first, so one stale odin command was re-frozen hundreds of times today and
the newest command never got a turn. freeze_one_queued_command now reads
history, retires such commands on sight, and skips a busy target instead of
returning. status reports a live transaction as running rather than an older
attempt's failure, and prints completion and cleanup evidence for --command.
README carries the Linux build recipe; Windows does not compile.
Verified with cargo test --lib in rust:1.95-bookworm on yggdrasil: 114 passed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Failed restarts retire to history.cc as soon as they finish, so the give-up
counter that read only the live set found zero every time and restarted a
release that could not start forever. Seen on yggdrasil during the
projection-key migration.
Verified with cargo test --lib in rust:1.95-bookworm on yggdrasil: 114 passed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>