Commit graph Idunn/src
Author SHA1 Message Date
Meta Vens
87fa57dd8c Revert "Recover fenced rollback from current activation"
This reverts commit a148802cb6.

Rollback after fencing adopted whatever activation was projected under the
shared Expected key, so a continuity abort's leftover activation could be
demoted by a later deploy. The drift is closed at its source by the next
commit: a continuity abort demotes its own exact activation. Rollback demotes
the admitted generation's exact activation again.

The doc paragraph is dropped; the 9f00e7a paragraph above it stays until B4.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 01:30:09 +02:00
Meta Vens
8ae00a1244 Allow Idunn to cancel stateless candidates 2026-09-28 17:59:02 +02:00
Meta Vens
29946bbe66 Wait for Idunn stable route listener 2026-09-28 17:29:25 +02:00
Meta Vens
ae8ac45df4 Treat process title as launch evidence 2026-09-28 17:11:37 +02:00
Meta Vens
5e94eb82f0 Name native identity fields that drift 2026-09-28 17:02:46 +02:00
Meta Vens
e0f514745a Revert "Expose current pre-fence cleanup blocker"
This reverts commit 4200330fcc.
2026-09-28 17:00:38 +02:00
Meta Vens
4200330fcc Expose current pre-fence cleanup blocker 2026-09-28 16:48:39 +02:00
Meta Vens
e976d32243 Name fields when workload identity drifts 2026-09-28 16:41:57 +02:00
Meta Vens
865190611f Accept dynamic primary group in workload audit 2026-09-28 15:19:52 +02:00
Meta Vens
9f00e7ab84 Replay admitted provider receipts at admission time 2026-09-28 14:47:55 +02:00
Meta Vens
7a61c77420 Revert "Continue commit from refreshed topology receipt"
This reverts commit 55e5d559d6.
2026-09-28 14:20:26 +02:00
Meta Vens
55e5d559d6 Continue commit from refreshed topology receipt 2026-09-28 14:18:22 +02:00
Meta Vens
a148802cb6 Recover fenced rollback from current activation 2026-09-28 13:56:05 +02:00
Meta Vens
9475dfa742 Land the fourth Cut 1 fix batch (S4-1 through S4-6)
Soul's fourth pass found the symlink resolver newly exponential
(resolve_frozen_source_symlink recursed per path component and
re-resolved every shared prefix from scratch, so an n-link
self-doubling chain cost 2^n real filesystem resolutions), the
streamed hash unpinned, three of the five S5 guard tests not killing
their own guard, all three S1 fsck guards unpinned against
already-local objects, no test proving a legitimate in-root ".."
freezes, and the symlink writer still buffering a whole blob for a
120000 entry.

S4-1: resolve_frozen_source_symlink is now an explicit iterative walk
(a work-stack of frames standing in for the old call stack) with
per-symlink memoisation and one shared traversal budget charged per
distinct symlink read, not per nesting depth. Soul's n=20 doubling
chain (a0 -> ., ak -> a{k-1}/a{k-1}) now resolves in milliseconds
instead of 43.7 s, and freezes rather than refusing: nothing in the
chain dangles or escapes the root, matching S6.

S4-2: two trees differing only past the first 64 KiB chunk now assert
different digests, killing the zero-length-buffer and first-chunk-only
mutants cargo-mutants found.

S4-3: the duplicate-path, ensure_frozen_directory containment, and
create_dir guards each get a fixture that only that guard refuses --
calling git_tree_entries directly (before any fsck runs) for the
duplicate path, and pre-seeding created_dirs with state a second,
independent bookkeeping pass could produce for the other two.

S4-4: Soul's eleven already-local hostile fixtures (dup-trees, six
.git look-alikes, an unsorted tree, mode 120000 on a tree, mode 040000
on a blob, a zero-padded mode) land as tests against freeze_exact's
own explicit fsck. A second test proves freeze_exact's own fetch fsck
flag is what refuses a hostile revision that was never local, by
asserting which context wraps the error.

resolve()'s own fetch fsck flag is still unpinned: exercising it needs
a real HTTPS origin (OperatorBinding::validate requires one), and
url.insteadOf does not work around that -- `git remote get-url` expands
the rewrite, so it can never satisfy ensure_checkout's own
origin-equality check. Confirmed by hand against a local git; a real
smart-HTTPS harness like Soul's S1 rig is what closing this needs.
Recorded as a gap, not faked with a broken test.

S4-5: a legitimate in-root ".." link (two hops up from a nested
directory, back to a file at the root) now has a test proving it
resolves and freezes, not just that an escaping "..' refuses.

S4-6: materialize_tree_raw refuses a 120000 entry whose blob exceeds
the platform's PATH_MAX before reading it, for both the streamed-blob
and the shares-content-with-a-regular-file cases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:30:12 +02:00
Meta Vens
f540d50835 Fix S1 test: use a safe.directory-scoped git for post-chown commit-tree/update-ref
git_at runs plain `git` with no `-c safe.directory=*`. By the time the
hostile commit is built, the origin repo is already chowned to the
unprivileged fixture identity, so root's plain git refuses it as dubious
ownership. Use the same safe.directory-scoped helper the duplicate-tree
fixture above already uses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:28:22 +02:00
Meta Vens
ad31f51273 Fix S1's fetch-guard test order: establish the checkout on the good commit first
The test was moving `main` to the hostile commit before the first checkout
existed, so the initial (also fsck-guarded) clone refused it, never
exercising the fetch line S1 actually changed. Establish the checkout on the
good root commit first, then advance `main`, matching the real scenario: an
already-fetched deploy target whose admitted ref later moves to a hostile
commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:27:04 +02:00
Meta Vens
748e9f3dfd Fix the fix batch: S6 test against the new symlink split, S1 test around the HTTPS-only binding
frozen_source_rejects_a_symlink_that_escapes_the_transaction_tree asserted
against harden_frozen_source, which no longer resolves symlink chains (S6
moved that to validate_frozen_source_symlinks, against the tree's final
published path). Point it at the right function.

resolve_then_freeze_refuses_hostile_trees_through_the_production_path could
not go through the literal SourcePort::resolve()/freeze() trait methods:
OperatorBinding::validate() requires an https:// origin unconditionally, and
this suite has no HTTPS Git fixture. Replaced with a fetch that reproduces
resolve()'s exact invocation (this fix's -c transfer.fsckObjects=true
included) against the checkout, the same approximation Soul's own probe used,
followed by the real freeze_exact as defense in depth.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:25:32 +02:00
Meta Vens
57ecd43d6b Land the third Cut 1 fix batch: fsck on every fetch, published-location symlink checks, streamed hashing
S1: guard resolve()'s admitted-ref fetch with transfer.fsckObjects=true (it
had none), and have freeze_exact additionally run an explicit `git fsck`
against the selected revision so objects that were already local -- not only
freshly transferred ones -- get checked too. Refusal now happens through the
production path (resolve() then freeze()), not only a hand-built fetch.

S5: behavioural tests for six guards Soul's third pass found unpinned: root
ownership in harden_frozen_source, an exact duplicate leaf path, the
ensure_frozen_directory root-escape check on its own, the writer's pass-1
create_dir semantics, and copy_artifact's containment check. (The sixth,
case-sensitive ancestor alias, goes away under S7.)

S6: symlinks are now judged against the tree's real, final published path,
not `.partial`, the temporary name the tree is built under before the atomic
rename that publishes it. A dangling link whose target stays inside the root,
resolving every component that exists, is accepted; one that escapes is
refused, dangling or not. harden_frozen_source_tree defers symlink chain
resolution; the new validate_frozen_source_symlinks walk runs once, right
after the rename, and validate_frozen_source_symlink (used by observe_frozen
too) now shares the same dangling-tolerant resolver, so freeze-time and
observe-time agree.

S7: delete the case-folding collision/alias refusal in
refuse_conflicting_tree_entries. The host is case-sensitive ext4, the check
was inconsistent (leaf paths only, plain to_lowercase), and .git look-alikes
are now refused by git fsck itself (S1), independent of case. The exact-name
alias case (a leaf sharing a root with a tree recursing into it) stays
refused at write time by ensure_frozen_directory, which never writes through
an existing entry.

S9: hash_frozen_source_tree streams each file through the hasher in 64 KiB
chunks instead of fs::read-ing it whole, so peak RSS no longer scales with
the largest file in the tree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:21:37 +02:00
Meta Vens
82e0edfaf7 Land the second Cut 1 fix batch: F2/F3 write-outside-root regression, F4 hardening, F5 chain-aware symlink check
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>
2026-09-22 18:29:36 +02:00
Meta Vens
43c7a6e46d wip5 fix spy log perms 2026-09-22 17:47:29 +02:00
Meta Vens
f8baa93dac wip3 tests+psd1 2026-09-22 17:46:02 +02:00
Meta Vens
2ee7c225e2 wip2 2026-09-22 17:41:06 +02:00
Meta Vens
95016e817d Land F2: freeze writes blobs raw, per Self's ruling on the Eve survey finding
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>
2026-09-22 16:48:17 +02:00
Meta Vens
7a5d32f02d Land the Cut 1 fix batch: pin every argv branch, one fetch, dead StepPort gone
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>
2026-09-22 16:15:38 +02:00
Meta Vens
95e15efca3 Cut 1: one step primitive and one freeze primitive (behaviour-preserving)
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>
2026-09-22 15:46:57 +02:00
Meta Vens
9dc80f8f5e An unobservable host is silence, not a death; stop-then-start for fixed-resource hosts
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>
2026-09-11 14:42:35 +02:00
Meta Vens
3269ffde74 The host actuator hub is serviced on its own thread
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>
2026-09-11 14:35:39 +02:00
Meta Vens
2d73608c4e The actuator's git never rewrites line endings; the frozen tree is byte-exact
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:05:22 +02:00
Meta Vens
2d4f9840a3 A binding path is absolute by its own shape, not the judging platform
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>
2026-09-11 14:02:54 +02:00
Meta Vens
fb82dce265 A host-actuator recipe declares the presence identity it is handed as environment
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 13:59:17 +02:00
Meta Vens
ffaeae3829 idunn-provision prints the derived identity id of an enrolled store
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>
2026-09-11 13:51:50 +02:00
Meta Vens
144581f9c7 Host actuator: Idunn manages a workload on a Windows host it cannot touch
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>
2026-09-11 13:49:25 +02:00
Meta Vens
c848459471 Bindings are driver-tagged and the observation is a variant per host kind
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>
2026-09-11 13:25:50 +02:00
Meta Vens
692ba1fe4d An aged-out Odin correlation is absence to wait on, not evidence to refuse
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>
2026-09-11 00:35:30 +02:00
Meta Vens
e81c73efad Warming asks the admitted Odin for the candidate's correlation once
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>
2026-09-11 00:31:06 +02:00
Meta Vens
f16fb46c95 Route driver owns the host firewall allow for its stable endpoint
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>
2026-09-11 00:23:28 +02:00
Meta Vens
1899df2244 Withdraw stale incarnations from the projection in supervise
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>
2026-09-10 23:58:05 +02:00
Meta Vens
f659b9d7ca Retire one resident terminal transaction per tick
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>
2026-09-10 23:00:29 +02:00
Meta Vens
7bcd567ef0 Prove a resident command with only historical transactions is retired, not re-frozen; log retirements
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 22:57:39 +02:00
Meta Vens
1a54db1831 Consume commands by any transaction, retire the resident backlog, do not let a busy target block the queue
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>
2026-09-10 22:46:54 +02:00
Meta Vens
6b68d65880 Count refused continuity restarts from history too
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>
2026-09-10 22:32:30 +02:00
Meta Vens
1a0a0e7958 Observe the candidate before waiting on Odin's evidence in AwaitingReady, Routing, and Committing
A candidate that died while its correlation was pending was never observed
again: each phase returned on no-evidence-yet every tick, the error that
triggers the post-fencing abort never happened, and the transaction owned the
target forever behind a process that was gone. Seen on yggdrasil with a
continuity transaction parked in Committing across 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>
2026-09-10 22:28:48 +02:00
Meta Vens
903e1c8e63 Key the projection by incarnation; publish Expected after the brake; consume commands once; add cancel
The projection kept one Expected/activation/lease slot per target. A
deployment's Sealing overwrote that slot with the candidate's Expected, the
incumbent failed activation-expected-projection on its next heartbeat and
exited, the deployment was aborted for having killed its incumbent, and the
command -- left resident after its transaction was retired to history --
read as queued and was frozen again. Odin cycled on that every few minutes.

- Topology driver records are keyed {target}@{expected sha}; the incumbent and
  the sealed candidate are projected side by side. One CAS mutation replaces
  the six bespoke loops. withdraw_incarnation removes only one incarnation;
  demote_to_expected_only replaces restore_admitted_expected_only and is used
  only for an incarnation whose process is gone. Legacy target-keyed records
  are retired on the first publish for that target.
- receive() takes the incarnation; the read-as-absence compensator for a
  correlation about another incarnation is gone.
- Candidate Expected is published as Starting's first step, after the brake
  admitted the transaction, not in Sealing before it.
- A pre-fencing abort withdraws only the candidate; a post-fencing abort also
  demotes the stopped incumbent.
- A consumed command is archived with its last transaction; status still
  answers for it from history.
- An engaged lifecycle brake stops continuity being minted instead of parking
  a transaction on the target.
- idunn cancel <command-id> retires a queued command as an operator refusal.

Verified with cargo test --lib in rust:1.95-bookworm on yggdrasil: 114 passed,
2 ignored.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 22:22:46 +02:00
Meta Vens
222bfcc133 Retire finished transactions to history.cc
The control store held every transaction Idunn had ever run -- 260 on
yggdrasil, all terminal, 2.6 MB re-read and byte-exact checked on every
scheduler tick. A finished transaction gates nothing; it only makes the
live set slower to read and gives history more ways to refuse one.

replace_transaction now moves a transaction out of control.cc the moment
it becomes terminal. History first, then the live copy: a crash between
the two leaves the record in both, which reads as still-resident and is
archived again next time. The other order can lose it, and a completion
nobody can observe is what R11 refused to trade away for a smaller live
set. insert_entry_if_absent makes the repeat a no-op and the delete is a
compare-and-swap on the exact envelope archived, so a transaction that
changed underneath is left alone rather than dropped.

history.cc is read leniently and for display only; control.cc keeps the
byte-exact canonical check because its records still gate decisions. That
asymmetry is the whole reason the two files are separate. status merges
both, so a completed command still reports what it did rather than
appearing never to have run.

No sweep. The earlier attempt archived resident terminal records from
inside the scheduler tick's propagating prefix, so an unwritable history
starved every live transaction on every other target. One primitive, one
caller, at the moment of completion.

Verified against a copy of the live store: status output is byte-identical
between the installed binary and this one, so the 260 existing records are
unaffected. They stay resident -- moving them is a separate explicit
operation, not something to bolt onto a scheduler.

  cargo test --locked -> test result: ok. 113 passed; 0 failed; 2 ignored

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 02:06:25 +02:00
Meta Vens
64cc78d748 Stop letting terminal history and a missing brake refuse startup
validate_durable_authority read the deployment-brake operator anchor
unconditionally and then re-proved every transaction in the store. Two
consequences, both live until now:

An absent brake artifact refused startup against an empty store. That is
a deployment brake gating Idunn itself, which F:\Projects\CLAUDE.md
forbids in as many words -- a target brake "may never gate Idunn itself
or unrelated service lifecycle". The anchor is now read on demand, when
a record actually carries an authorization to verify.

Terminal transactions were re-proved on every boot. They describe what
already happened and authorize nothing further, so the only thing that
re-proving them can do is invent new ways for history to refuse a boot --
a rotated signer on a record from last week making the daemon
unstartable. They are skipped. On yggdrasil that is 259 of 260 records,
and this is what R4 meant by terminal history not being live authority.

Live records keep every check unchanged.

Known gap, deliberately not closed here: a live record whose signed
evidence no longer verifies still refuses startup. The right answer is to
abort that transaction rather than refuse the boot, which needs the
post-fencing abort path and is its own cut.

  cargo test --locked -> test result: ok. 110 passed; 0 failed; 2 ignored

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 01:41:14 +02:00
Meta Vens
5b6a650482 Let one bad transaction fault its tick instead of the daemon
serve's loop propagated every scheduler-body error with `?`. Idunn runs
under Restart=always, so a single unreadable record turned into a
crashloop that took every unrelated target's continuity down with it --
the daemon-survival organ killed by exactly the thing it exists to
survive. This is the second half of the store-history work; the first
half landed as the admission gates.

run_scheduler_tick extracts one pass with the loop's ordering preserved
exactly, including that Ok(true) means "go round again immediately",
which is what the caller's `continue` did. The loop logs a faulting tick
and keeps polling. The fault recurs every poll until someone fixes it,
which is louder than a restart loop and cheaper than an outage.

Adds EngineFixture, the first Engine-level test harness this crate has
had -- enrolled Idunn and Odin identities, an exported anchor, tempdir
roots. Its absence is why "does one bad record kill the daemon" was
arguable rather than measurable.

The test proves the tick is a clean unit of failure: a 0xc1 payload (the
one byte msgpack never emits) makes three consecutive ticks report an
error and leaves the control store byte-identical, so a tick that cannot
read does not rewrite, truncate or silently repair. The loop's own match
arm is not covered -- serve never returns, so no unit test reaches it --
and that part rests on inspection.

  cargo test --locked -> test result: ok. 109 passed; 0 failed; 2 ignored

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 01:37:30 +02:00
Meta Vens
da927ea30e Name the workload, not the candidate, in the restart-policy refusal
is_permanently_stopped is called on the incumbent as well as the
candidate since 8448f83, so a message naming only the candidate would
misdirect the first person who hits it. Unreachable today -- observe_unit
guarantees every recorded workload is Restart=no -- which is exactly why
the wording would be read cold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 01:05:42 +02:00
Meta Vens
8445c6b45a Pin the schema refusal for a Present record without an instance id
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 01:04:05 +02:00
Meta Vens
db5c21a558 Correct two audit test expectations
A Present correlation with no activation is refused by the schema before
authentication, and a foreign Odin signature only names a different
identity when the record carries that identity's id.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 01:03:13 +02:00
Meta Vens
ffa5926d0d Audit tests for the admission gating rebuild (c09993c..8448f83)
Soul pass on the five gate changes. Negative checks only:
- another incarnation's perfect correlation, another activation of the same
  Expected, and a foreign target beside our projection are all refused by
  authentication alone (the deleted identity gate was a subset of it)
- the signing fixture refuses at every link: activation/Expected binding,
  Idunn anchor, provider key, foreign Odin signer, forged signature
- a durable receipt replays at its own admitted_at and not at now
- is_permanently_stopped is true only for failed or not-found units; every
  transitional ActiveState reads as running, and a systemctl failure errors
- an ignored live-store inventory probe

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 01:01:31 +02:00