Make the public entry point describe the binary that actually ships

The README and unit file I wrote at extraction documented the previous
generation's CLI. Running the deployed ExecStart against a build of this
repository returns `unknown Idunn command "--swarm-profile"`: commit 330e4cd
rebuilt Idunn around typed authority ports and a test now asserts that
--deploy-command, --restart-command and --swarm-profile are rejected. Anyone
following the README would have installed a unit that crashloops on exec
every 5 seconds under Restart=always.

Corrected against the binary, not against the old docs:

- deploy/idunn-yggdrasil.service now invokes `idunn serve` with the real
  flags. systemd-analyze verify passes on yggdrasil. /var/lib/gamecult/odin
  moved from ReadWritePaths to ReadOnlyPaths -- Idunn only reads Odin's
  topology correlation (pull_all_read_only_snapshot, absent store yields
  None), and it gates deployment promotion only: continuity never consults
  it, per stale_odin_provider_receipt_cannot_gate_continuity_restart.
- README documents the three declarative commands, and adds a section on the
  recipe/binding split, which is the security model: capability lives in the
  target's repo, privilege lives in the host binding, and a binding that
  omits an affordance the recipe derives fails admit().
- Corrected two inherited claims. The distinct-brake check is per-binding
  validation ("deployment and lifecycle brakes are identical"), not a
  startup check. And the "inputs: nothing else" claim was false -- the Odin
  correlation read is a real, deliberate exception and now says so.

The legacy actuator moves to deploy/legacy/ rather than being deleted: it is
what is installed and running, so it is the before-side of the migration.
Its README maps each shell mechanism to the contract that replaced it, and
preserves the eleven-target inventory read out of deployment_target_policy().
Seven of the eleven are pinned to a codex/ branch rather than main.

docs/guide.md is marked as the previous generation rather than silently
carrying dead commands, and docs/migration.md is new: per-target order,
what to fix before carrying a ref forward, and why the authority cuts over
last. Also fixes a broken cross-link in authority-map.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-05 23:15:46 +02:00
commit 814e08e0c4
8 changed files with 345 additions and 39 deletions

View file

@ -1,8 +1,10 @@
[Unit]
Description=Idunn Yggdrasil-local lifecycle coordinator
Description=Idunn deployment admission and daemon-survival authority
After=network-online.target docker.service
Wants=network-online.target
# Deployment runners use Docker, but continuity actuation does not. A hard
# Deployment runners use Docker, but continuity actuation does not. Warm,
# fence, promote, drain and restart all run on systemd; only the build step
# is containerized, and worktree-tree recipes skip even that. A hard
# Requires= would let a Docker outage stop Idunn itself, killing daemon
# survival for every target. Want it, order after it, and let individual
# container-backed deploys fail on their own if it is absent.
@ -12,9 +14,19 @@ Wants=docker.service
Type=simple
User=idunn
Group=idunn
# Operational cancellation ceiling only. Target daemons own readiness through
# typed health; this duration must never be interpreted as deployment success.
ExecStart=/usr/local/bin/idunn --swarm-profile yggdrasil-local --repo-root /var/lib/gamecult/idunn --store /var/lib/gamecult/idunn/yggdrasil.keepalive.cc --release-authority-store /srv/bifrost/state/repository-release-authority.cc --deployment-brake-store /var/lib/gamecult/idunn-authority/deployment-brake.cc --deployment-brake-operator-anchor /etc/gamecult/idunn/deployment-brake-operator-anchor.cc --daemon-health-trust-store /etc/gamecult/idunn/daemon-health-trust.cc --rudp-health-bind 10.77.0.1:17870 --service-identity-store /var/lib/gamecult/idunn/idunn-service-identity.cc --public-health-store /var/lib/gamecult/idunn/authenticated-provider-health.cc --public-health-query-bind 127.0.0.1:17871 --command-timeout-seconds 21600 --execute
# Idunn discovers no authority implicitly. Every store is named here, and
# every one of them is Idunn's own -- no path below belongs to a managed
# target. That is the invariant, expressed as a command line.
ExecStart=/usr/local/bin/idunn serve \
--state-store /var/lib/gamecult/idunn/control.cc \
--bindings-dir /etc/gamecult/idunn/bindings \
--source-root /var/lib/gamecult/idunn/sources \
--staging-root /var/lib/gamecult/idunn/staging \
--topology-store /var/lib/gamecult/idunn/topology.cc \
--idunn-identity-store /var/lib/gamecult/idunn/idunn-service-identity.cc \
--odin-correlation-store /var/lib/gamecult/odin/idunn-runtime-topology.cc \
--odin-trust-anchor /etc/gamecult/idunn/odin-topology-anchor.cc \
--deployment-brake-operator-anchor /etc/gamecult/idunn/deployment-brake-operator-anchor.cc
Restart=always
RestartSec=5
TimeoutStopSec=20
@ -23,8 +35,18 @@ UMask=027
PrivateTmp=yes
ProtectHome=yes
ProtectSystem=full
ReadWritePaths=/etc/gamecult/idunn /etc/gamecult/codex-connector /etc/gamecult/ghostlight-dungeon /etc/systemd/system/codex-connector.service /etc/systemd/system/epiphany-swarm.service /etc/systemd/system/ghostlight-dungeon.service /var/lib/gamecult/idunn /srv/build /srv/voidbot
ReadOnlyPaths=/var/lib/gamecult/idunn-authority
# Idunn's own state, and the build root it materializes releases in.
# Enumerated rather than broadened: this list is the blast radius.
ReadWritePaths=/var/lib/gamecult/idunn /var/lib/gamecult/idunn-authority /srv/build
# Everything Idunn only reads. The operator anchor and target bindings are
# operator-authored: Idunn admits against them, it does not write them.
# /var/lib/gamecult/odin is a managed target's directory and is read-only on
# purpose -- Idunn consumes Odin's published topology correlation to gate
# *deployment* promotion, and nothing else. Continuity never consults it
# (see stale_odin_provider_receipt_cannot_gate_continuity_restart), and an
# absent store returns None rather than failing, so Odin being down blocks
# promotions and leaves daemon survival untouched.
ReadOnlyPaths=/etc/gamecult/idunn /var/lib/gamecult/odin
[Install]
WantedBy=multi-user.target

62
deploy/legacy/README.md Normal file
View file

@ -0,0 +1,62 @@
# Legacy actuator — the generation currently installed on yggdrasil
**These files are superseded. Do not use them for a new installation.** They are
kept because they are what is *running*, and a migration needs both sides.
`idunn-yggdrasil` is a root shell actuator reached through the narrow sudo grant
in `idunn-yggdrasil.sudoers`. It hardcodes eleven targets, their repositories
and their admitted refs, then delegates to executable manifests under
`/srv/odin/deploy-manifests/<target>`. It calls `idunn validate-release-authority`
and `idunn validate-minimum-source-revision` — subcommands the current binary
does not have.
## What replaced it
Everything this script does by convention, the current Idunn does by contract:
| Legacy mechanism | Current owner |
|---|---|
| `deployment_target_policy()` — a `case` over eleven target names | one operator binding per target, in `--bindings-dir` |
| `upstream_ref=refs/heads/...` in shell | `[repository] admitted_ref` |
| `minimum_source_revision_required` | `[repository] minimum_revision` |
| `requires_bifrost_authority` + `--release-authority-store` | `[repository] selection = "signed-release"`, per target |
| `/srv/odin/deploy-manifests/<target>` shell scripts | recipe `[[steps]]` in the target's own repository |
| `run_manifest_while_holding_authority_locks` | the deployment transaction and process write-lease |
| `flock` on a brake store | `[brakes] deployment_store` and `lifecycle_store` |
| root shell with `IDUNN_ACTUATOR=1` | `systemd-transient` workload driver under `DynamicUser` |
The structural difference is not tidiness. The legacy path admits a deployment
by *validating arguments to a root shell script*, and everything after that
validation is unverified shell running as root. The current path seals exact
source and artifacts, admits one incarnation against typed operator authority,
and hands execution to systemd — with no place to pass a command string,
enforced by `cli_exposes_only_declarative_commands`.
## The target inventory, which is still useful
This is the real migration scope, read out of `deployment_target_policy()`:
| Target | Repository | Admitted ref | Notes |
|---|---|---|---|
| `odin` | GameCult/Odin | `codex/ygg-idunn-independent-bootstrap` | ref is a branch, not main |
| `codex-connector` | GameCult/CodexConnector | `codex/ghostlight-release-binding` | minimum revision enforced |
| `ghostlight` | GameCult/Ghostlight | `codex/ghostlight-dungeon-mvp` | minimum revision enforced |
| `voidbot` | GameCult/VoidBot | `main` | |
| `heimdall` | GameCult/Heimdall | `main` | restart is `docker compose restart` |
| `epiphany` | GameCult/Epiphany | `codex/epiphany-shakedown-live` | |
| `epiphany-capstone-17` | GameCult/Epiphany | `codex/epiphany-shakedown-live` | second target, same repo |
| `bifrost-persona-feedback` | GameCult/Bifrost | `main` | **only** target requiring Bifrost release authority |
| `repixelizer` | GameCult/repixelizer | `main` | |
| `streampixels` | GameCult/StreamPixels | `main` | two units, one target |
| `gjallar` | GameCult/Gjallar | `codex/yggdrasil-aggregate-daemon` | |
Seven of eleven are pinned to a non-`main` branch. That is its own problem — the
swarm goal is everyone on `main` and current with each other — and migrating a
target is the moment to fix its ref, not to copy it forward.
## When these files die
When every target above has a binding and a recipe, and `idunn serve` owns
actuation on yggdrasil. At that point delete the sudoers grant first, then the
script, then `/srv/odin/deploy-manifests/`. The grant is the privileged half; it
should not outlive its consumer.