No description
  • Rust 99.1%
  • Shell 0.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Meta Vens ac7824b482 Land R-I25: version the artifact digest, and the frozen-source digest too
Self's finding: R-I20 changed digest_artifact's bare-file shape, but three
stored values are compared against a fresh recompute forever after they are
sealed -- expected.artifact_sha256 (IdunnExpectedIncarnationRecord),
ArtifactReceipt::sha256 (SealedRelease, in host.rs's verify_artifacts), and
HostWorkloadObservation::executable_sha256. The first Idunn upgrade past
R-I20 would have refused every already-installed and already-running
workload sealed before it.

The ruling asked for an explicit version tag recorded with the stored
value. That channel does not exist here: expected.artifact_sha256 and
ArtifactReceipt::sha256 are both validated to a bare `sha256-<64 lower hex>`
shape with no room for one -- IdunnExpectedIncarnationRecord's validator
lives in the pinned cultnet-rs git dependency, a separate repo this
worktree has no authority to widen, and ArtifactReceipt::sha256's own local
validator enforces the identical shape. There has only ever been one prior
shape (raw_sha256 of the file's bytes alone, nothing else), so
verify_artifact_digest recomputes under the current algorithm first and
falls back to that exact legacy shape -- the same effect as reading an
implicit version, without a tag this worktree cannot add. New seals always
call digest_artifact directly, so they always use the current shape;
nothing here ever rewrites a stored value to match.

host.rs's start(), observe() and verify_artifacts() now go through
verify_artifact_digest instead of comparing digest_artifact's output to the
stored string directly. start() carries expected.artifact_sha256 forward
into the returned observation rather than a fresh recompute, so a
legacy-shaped receipt is echoed, not silently upgraded to a value it was
never sealed against.

Checked whether frozen_source_sha256 (the frozen-source digest,
observe_frozen's stored-value comparison against FrozenSourceReceipt) has
the same exposure: yes. Its algorithm changed twice before this batch
touched anything -- R-I9 added mode and gid, R-I18 added xattrs -- and a
FrozenSourceReceipt is persisted in DeploymentTransaction.frozen_source
between the freeze() step and the step that calls observe_frozen, each a
separate advance_sealing call that can cross a restart. The window is
narrower than the artifact side (one in-flight Sealing-phase transaction,
not every installed workload), but this cut's own batches 6 through 8
already crossed it twice. verify_frozen_source_digest applies the same
answer against both prior shapes, which are reproduced verbatim in
frozen_source_sha256_legacy from git history (f873b54^ and f873b54).

Tests: verify_artifact_digest_accepts_a_receipt_sealed_under_the_pre_r_i20_shape
and verify_frozen_source_digest_accepts_both_shapes_that_predate_this_cuts_sixth_batch
construct a stored value the old code would have produced and confirm the
new code still accepts it, confirm the current-algorithm-only comparison
they replace would have refused it, and confirm a real content tamper is
still caught. verify_artifacts_accepts_a_receipt_sealed_under_the_pre_r_i20_shape
exercises the same thing through host.rs's own verify_artifacts. Manually
mutated (fallback removed, then restored) on Yggdrasil; see the verification
report.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 05:48:48 +02:00
.github/workflows Fix R-I26: turn host_actuator.rs's never_loop into a real if, restore --all-targets 2026-09-23 05:48:07 +02:00
deploy Host actuator: Idunn manages a workload on a Windows host it cannot touch 2026-09-11 13:49:25 +02:00
docs Record Soul pass 5: Cut 1 closes on mechanism, and the bill it leaves 2026-09-22 21:42:40 +02:00
src Land R-I25: version the artifact digest, and the frozen-source digest too 2026-09-23 05:48:48 +02:00
tests/fixtures Gate Yggdrasil releases through Bifrost 2026-07-16 18:34:26 +01:00
.gitignore Stand Idunn up as its own repository 2026-09-05 17:53:29 +02:00
Cargo.lock Bump CultLib pin to 61549aa: anchor-export permission fix 2026-09-13 19:25:50 +02:00
Cargo.toml Bump CultLib pin to 61549aa: anchor-export permission fix 2026-09-13 19:25:50 +02:00
clippy.toml Land R-I23 of Cut 1's eighth fix batch: make the resolver's bypass a 2026-09-23 05:14:32 +02:00
LICENSE Add the MIT license file the manifest already declares 2026-09-05 22:02:45 +02:00
README.md Cut 0: kill the stale docs, verify transaction campaign 2026-09-22 15:21:11 +02:00

Idunn

Deployment admission and daemon-survival authority for the GameCult swarm.

Idunn decides two things and nothing else: whether an artifact may be installed, and whether an already-installed body may be restarted. It keeps daemons alive across a fleet, admits releases against operator-signed authority, and reports typed health — while depending on none of the services it manages.

Extracted from Odin on 2026-09-05 with its full 136-commit history. Idunn had lived as a crate inside the repository of a service it manages, which inverted the authority it exists to hold.


Quick start

Requires Rust 1.95+. Idunn is a Linux daemon; see Platform.

git clone https://github.com/GameCult/Idunn.git
cd Idunn
cargo build --release

Two binaries land in target/release/:

Binary Purpose
idunn the daemon — keepalive supervision, deployment actuation, health
idunn-provision operator tooling — brake status, identity enrolment, provisioning

Idunn's CLI is declarative. There are five commands and no imperative escape hatch — you cannot hand it a shell string to run:

idunn serve    [runtime options]                      # the control plane
idunn up       <service|profile:name> [--no-wait]     # request a deployment
idunn status   [--command ID]                         # read what happened
idunn cancel   <command-id>                           # cancel a pending command
idunn validate --recipe PATH [--binding PATH]         # offline admission check

A test, cli_exposes_only_declarative_commands, asserts that --deploy-command, --restart-command and --swarm-profile are rejected. What a target is, how it is built, and what it is allowed to touch live in two documents, never in a command line — see Recipes and bindings.

Request a deployment and watch it:

idunn up ghostlight --requested-by "$USER"
idunn status --command <id>

idunn up accepts a service name or profile:<name>; profiles are declared by the bindings, so idunn up profile:full-gamecult brings up everything bound into that profile.

Authority

Owner. Deployment admission, and the continuity of installed bodies.

Inputs. Its own typed state stores, its own credentials, and operator-authored trust anchors and bindings. It does not read a managed target's binaries, health, or availability to decide anything about itself.

The one deliberate exception, stated plainly rather than hidden: Idunn reads Odin's published topology correlation to gate deployment promotion, so that a promotion waits for semantic discovery. It is read-only, an absent store yields None rather than an error, and it is structurally unreachable from the continuity path — validate_live_providers_for_deploy never invokes the provider check for CommandKind::Continuity. Odin being down blocks promotions. It cannot stop daemon survival.

The invariant.

Idunn must be able to start, recover, observe and report while any managed target is absent, broken, or deliberately braked.

A target's brake gates mutation of that target only. It can neither authorize nor block Idunn's own startup, nor any other target's lifecycle.

Two brakes, not one. Deployment and continuity are separate authorities with separate stores, declared per target in that target's binding. A binding naming one store for both is rejected outright — "deployment and lifecycle brakes are identical".

  • Continuity is restarting an already-admitted installed release. Physiology.
  • Deployment is changing an artifact, revision, configuration, schema, unit, or authority binding.

A deployment brake does not own the former. Suspending restart actuation requires the separately named lifecycle brake.

Forbidden. Idunn does not depend on a managed target, and does not repair one merely to satisfy a deploy. If recovery would require that, the target dependency is the thing to cut.

Recipes and bindings

Two documents describe a target, and the split is the security model.

The recipe lives in the target's own repository at deployment/idunn/recipe.toml (gamecult.idunn.target_declaration.v1). It declares capability: build and test steps, the artifacts they produce, the service's argument shape, its health contract, its state slots, and what it provides. It names no host paths, no images, no privileges.

The operator binding lives on the host under --bindings-dir (gamecult.idunn.operator_binding.v2). It supplies everything privileged: which origin and ref are admitted, the pinned runner image, the workload's roots and hardening, the runtime trust anchor, the route, the two brake stores, rollout strategy and placement. It also declares which profiles the target belongs to.

# recipe — in the target's repo, capability only
[[steps]]
id = "build"
phase = "build"
runner = "rust-build"
argv = ["cargo", "build", "--locked", "--release", "-p", "thing"]

[service]
executable_artifact = "thing"
arguments = [
  { kind = "literal", value = "--store" },
  { kind = "binding", name = "state_root" },
]
# binding — on the host, privilege only
[repository]
origin = "https://github.com/GameCult/Thing.git"
admitted_ref = "refs/heads/main"
selection = "ref-head"
recipe_path = "deployment/idunn/recipe.toml"

[brakes]
deployment_store = "/var/lib/gamecult/idunn-authority/thing-deployment-brake.cc"
lifecycle_store  = "/var/lib/gamecult/idunn-authority/thing-lifecycle-brake.cc"

A recipe cannot grant itself an affordance: the binding must name every affordance the recipe's runners derive, and admit() rejects a binding that omits one. Unknown fields are rejected on both sides. A repository that gets compromised can therefore change what is built, but not what it is allowed to touch on the host.

Contracts

Idunn's typed contracts are published by CultLib in cultnet-rs — deployment and lifecycle brakes, service identity, runtime activation, expected incarnation, and the provider-health family. Idunn consumes them and defines no private copies.

State is CultCache (.cc typed documents) throughout. Health is published over CultNet RUDP.

Odin keeps its own separate projection of Idunn state for aggregation. That is the all-seer's view, not Idunn's contract, and the two sets do not overlap.

Running as a service

deploy/ carries a worked systemd installation:

File
idunn-yggdrasil.service the unit
idunn-yggdrasil operator wrapper
idunn-yggdrasil.sudoers the narrow sudo grant the wrapper needs

The unit runs as a dedicated idunn user under ProtectSystem=full, ProtectHome=yes, PrivateTmp=yes. ReadWritePaths is Idunn's own state plus the build root; the operator anchor, the bindings, and Odin's correlation store are all ReadOnlyPaths. Idunn admits against operator authority — it does not author it.

Note Wants=docker.service, not Requires=. Only the build step is containerized; warm, fence, promote, drain and restart run on systemd, and worktree-tree recipes skip Docker entirely. A hard requirement would let a Docker outage stop Idunn itself and take daemon survival down with it.

Adapt the store paths for your host. Every path in ExecStart is explicit on purpose; Idunn discovers no authority implicitly.

Documentation

docs/migration.md start here if you run a swarm today — moving a target from the previous generation, in order, and cutting over the authority itself
docs/authority-map.md authority map, repository declaration, operator binding, release foundation, promotion and continuity
docs/deployment-authority.md the deployment transaction in depth
docs/signed-daemon-health-authority.md signed health admission and trust bindings
docs/guide.md the previous generation's keepalive daemon — no longer runs anywhere, kept for migration history; its commands no longer work
deploy/legacy/ the shell actuator that generation used, no longer running, and what replaced each part of it

Platform

Idunn targets Linux and does not compile on Windows (drivers.rs uses std::os::unix unconditionally). A local cargo check on a Windows workstation proves nothing. Test and build where it runs, in the same image Odin uses, with the working tree shipped to the host's build root:

git ls-files -z | tar --null -T - -cf - | ssh ygg 'sudo rm -rf /srv/build/idunn && sudo mkdir -p /srv/build/idunn && sudo tar -x -C /srv/build/idunn && sudo chown -R gamecultadmin:gamecultadmin /srv/build/idunn'
ssh ygg 'sudo docker run --rm -v /srv/build/idunn:/w -v /etc/machine-id:/etc/machine-id:ro -v /srv/build/cargo-registry:/usr/local/cargo/registry -v /srv/build/cargo-git:/usr/local/cargo/git -w /w rust:1.95-bookworm sh -c "cargo test --lib && cargo build --release"'

/etc/machine-id must be mounted or every identity-enrolling test fails. --lib because the tests under tests/ assume the host layout. The release binary lands in /srv/build/idunn/target/release/idunn; install it over /usr/local/bin/idunn and restart idunn-yggdrasil.service.

Licence

MIT.