The version tag could not be recorded where the ruling said, because the
stored value is validated to a bare sha256 shape by a pinned dependency in
another repository. Hands implemented an equivalent implicit version read,
flagged the divergence, and asked whether proceeding was right. It was: the
ruling named a mechanism, the hazard was live, and halting would have left the
fleet exposed.
Five tests prove it by failing without the fallback, which is the point, since
the whole finding was that nothing had ever exercised the stored path.
The frozen-source digest turned out to have the same exposure through a
narrower window, and this cut's own sixth and seventh batches crossed it
twice. Same answer applied, with the prior shapes reproduced from git history
rather than from memory.
Hands also spotted a third instance and declined to chase it: the systemd
actuator compares a raw content-only digest read through procfs against a
stored value now carrying mode, gid and xattr terms. Two different algorithms
compared to each other, which is the same defect a third time. Flagged for its
own look rather than guessed at.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bypass is a compile error now, proven by builds that fail on Soul's exact
spellings rather than by reading the lint config. The repository had no CI
surface at all before this, so there was nothing for "denied in CI" to attach
to until now.
Hands flagged that the single-file artifact digest format changed and reasoned
nothing compares against literals. That holds for fixtures and not for
receipts: two call sites in host.rs compare a freshly computed digest against
a stored value. So the first upgrade past this commit makes every already
installed workload fail "installed executable differs from Expected", and
every running one fail "the installed executable changed under the running
process". Idunn is the deployment and daemon-survival authority, and this
would land silently at restart across the fleet.
The ruling versions the digest and verifies in the version the receipt names,
rather than re-sealing admitted state to make new code agree with itself. It
is not closed until a test seals under the old format and verifies under the
new, because the whole finding is that nothing exercised the stored path.
Two bugs in this batch were found by running the suite rather than trusting
it, including a test that set an attribute on a root whose own metadata is
never hashed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The port can be walked around. Two spellings of the original bug restore it
with all 193 tests green, and the proof it leaks is already in the file: one
function still calls fs::read_link directly, and a probe measured sixteen
counted calls beside three uncounted syscalls. My record claimed the count was
total rather than conventional. It is still conventional; the convention moved
from remembering the counter to remembering the port. That is the second time
this record has overclaimed what a pin does.
The artifact digest's single-file path never got the ruling, and it is the
path the installed executable actually takes. Setuid, mode and a file
capability are all invisible there, immediately before launch. Every test for
it wraps the file in a directory so the other path runs, and one of their doc
comments claims to pin exactly the scenario it does not reach.
The xattr framing is sound and nothing proves it. The suite stays green with
values dropped entirely, so a capability edited from cap_setuid to
cap_sys_admin is covered by no test.
Soul named one cause with three faces: every pin here is a convention a later
edit steps around, and the fixtures use the convenient shape rather than the
one production uses. So the eighth batch makes the bypass a compile error and
moves the tests onto the production shape, instead of adding a ninth round of
point fixes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
193 tests, and the mutation that passed in 0.39 seconds on pass 7 now fails.
Putting canonicalize and symlink_metadata behind a port that charges the
counter inside the same method as the syscall is what turned an honour system
into a measurement; the hand-placed call sites are gone.
The validator mask tests are precise rather than merely red: narrowing the
file mask fails exactly the setgid test and narrowing the directory mask fails
exactly the setuid one, with the other five staying green. That is the
evidence they pin single bits.
The unreachable test is deleted rather than repaired, with a note saying why
the defence it claimed never existed. The xattr tests close Soul's file
capability finding and state in their own doc comments that the probe runs as
root and proves nothing about an unprivileged attacker.
Hands shipped a compile bug in the first commit and its own Yggdrasil run
caught it before the report. That is the order these should happen in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Soul restored the exact bug this line of rulings exists to prevent, without
instrumenting it, and the new call-count pin passed in 0.39 s. The counter
only counts calls someone remembered to annotate, so it is an honour system
rather than a measurement. The code said so in a doc comment four lines below
the assertion; my record claimed the opposite. That record is corrected here.
The fixture I recorded as deleted for being unable to fail is in the tree,
still unable to fail. Hands' reasoning about why the collision class is
unreachable was sound and Soul retested it; what landed on top of that
reasoning was a test that cannot go red.
A file capability is invisible to both the validator and the digest, which is
the grant setuid used to be with no mode bit to see it. no-new-privileges
prevents it on our own deployment path, but the digest is the detection and
Cut 4's verdict claims the tree is the one we froze. The artifact digest also
got a single executable boolean where the ruling said full mode and gid, so a
post-install setuid re-verifies clean.
Six rulings for the seventh batch. The first moves filesystem access behind a
narrow injected port so the count is total rather than conventional, which is
the shape doctrine already asks for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
183 tests green. The timing pin is now a call counter asserting an exact
formula, which is what it should have been before a clamp made a 1.63x
separation look like 33x.
I ruled the bind mount should carry nosuid. Docker exposes no such
per-bind-mount flag through either syntax, and Hands verified that against
the real CLI rather than arguing from memory. The container already runs with
no-new-privileges, which is process-wide and stronger than what I asked for.
The ruling is withdrawn: the prevention was already there and I ordered a
second one without looking.
Two fixtures in this batch would have lied and were caught by mutating rather
than reading. The container's chown clears a freshly-set setuid bit, so a
fixture that sets then chowns tests nothing. And the first observe_frozen test
tampered with the recipe file, which a different check inside observe_frozen
catches, so it stayed green with the digest check deleted.
The artifact digest's symlink terminator is deliberately left unpinned:
symlink(2) cannot put a NUL in a target, so no honest test exists. Hands built
one, confirmed it did not go red, and deleted it instead of keeping it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five of seven rulings hold. The containment argument, where I asked Soul to be
most suspicious after S5-8, survived twenty escape shapes it built itself.
R-I1's pin is not a ratio test. Its clamp fires on every run, so it reads as
"under 200 ms", and clean code has already measured 198.9 ms under the
three-slot load the operator just authorised. The reported 33x separation was
an artifact of that clamp; the real separation is 1.63x, which a slightly
faster host erases. It fails in both directions, and under Cut 4 a false red
is a false verify verdict on unattended work.
R-I3 is not met. The validator masks the mode to 0o777 and checks uid but not
gid, so setuid, setgid, sticky and gid are all permitted to vary and none are
hashed. A frozen binary made setuid-root re-observes clean against its
receipt, and the tree is bind-mounted into runner containers with no nosuid.
The ruling adds the mount flag as well as the digest terms: the digest detects
and the flag prevents, and the flag costs a mount option.
Soul also found that the artifact digest still has the blindness the frozen
source digest just lost, and that observe_frozen, which is the tamper check
itself, has no test at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All seven rulings landed, 175 tests green, and every new test dies under its
own mutation. The two-link cycle fixture hangs rather than asserting, which is
the correct falsification: nothing but the budget bounds a cycle, so a timeout
is what a broken budget produces.
The batch's real finding runs the other way. 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 whole target is ".."
at the root resolves to the parent of the root and returns Ok, because no
later Normal step ever runs to trip the other arm. That is a containment
escape, the exact class this resolver exists to prevent. Soul was wrong, my
ruling repeated it, and a hand-trace caught it.
Soul's HTTPS rig had also evaporated: no session, no work directory, no
scratch trace. Hands rebuilt it from this map's description, which worked only
because the description was detailed. A probe that is going to be committed
has to be handed over while it still exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Soul could not falsify any claim the fourth batch made. What it found instead
is that the S4-1 fix reached the exponent and not the per-component cost, and
that the line responsible is dead code: the value is already canonical, so
deleting four lines removes a cubic term and leaves the suite green.
The other half of the bill is coverage. The traversal budget is the only thing
that terminates a symlink cycle and nothing tests it; cargo-mutants missed two
mutants on that single line. The digest that serves as the tamper check is
blind to symlink targets and to the executable bit, both of which the
validator permits to vary. S4-6 was reported landed and has no test at all.
Seven rulings for the fifth batch. R-I5 departs narrowly from my earlier
"keep S6's behaviour exactly" and says so rather than letting it pass as
drift: the 40-traversal ceiling matches the kernel's own ELOOP limit and is
kept, but it must be declared and the error must name the link.
FU-Heimdall-Gitlink cleared upstream on its own. Heimdall froze end to end,
945 entries, zero mismatches. The ops follow-up closes.
Co-Authored-By: Claude Opus 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 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>
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>
cd662cd claimed no target is Idunn-managed and that every idunn-odin-*
unit was failed. Both wrong. idunn-odin-25f32a8d is active and running
release 928da3ef, and it is the live Odin -- the odin.service unit still
in /etc/systemd/system is active with MainPID 0 and owns no process.
That claim came from reading the first 20 lines of an alphabetically
sorted unit list. 25f32a8d sorts below the cut. Truncated enumeration,
stated as a whole-system fact, in the same commit that corrected someone
else for a whole-system fact taken from the wrong artifact.
Heimdall genuinely is not migrated; that half stands.
Also records why odin's transaction is stuck in Committing: the gate
requires the latest Odin observation to equal the Ready receipt, which
no target with a working observer can satisfy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The header claimed "no target has been migrated" and that the installed
binary was the previous generation. Half of that was stale: yggdrasil
runs the declarative binary and idunn status answers.
The other half was right, and a proposed "correction" to say odin and
heimdall are now Idunn-managed would have been false. Verified on the
host: heimdall.service and odin.service are hand-written units under
/etc/systemd/system, both active, and every idunn-heimdall-* and
idunn-odin-* unit is failed.
The "Correction: Odin is migrated first, not last" section reads like a
completion record and is not one -- it changed the ordering, it did not
migrate a target. Said so in the header, because that misreading has now
happened twice.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five more preprovisioning requirements, each of which cost a candidate that
started, ran, and then died on a file it could not open: bundle traversal,
the setgid runtime root the write-lease hardening assumes, the lease record's
group, a default ACL on the published projection, and the route directory's
ownership left over from unprivileged Idunn.
Also states the two shapes that made every one of those faults arrive in
disguise: a store's .lock is opened whenever the store is, and CultLib reports
an unreadable file as an empty store rather than an error.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five things that had never been exercised failed on the way through Odin, and
each is now a test: the unprojectable RUDP route, step environment not being
ambient, build containers having no machine identity, the published projection
sitting inside Idunn's private root, and a signing identity living in the
state root the workload can write.
Also documents that `idunn validate` proves nothing about deployability
without a binding -- admit is where every cross-document rule lives -- and
repairs apostrophes an earlier pass mangled into ".s".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deploying Heimdall proved the migration order backwards. A candidate's
warming presence -- the evidence Idunn requires before fencing an incumbent
-- is observed through Odin's authenticated runtime topology correlation.
advance_warming has exactly one exception: target == "odin" while no Odin
is admitted may warm on directly observed presence. Every other target
waits for Odin to publish.
So with the previous generation's Odin still running, a Heimdall candidate
starts, serves on its assigned bind, publishes health, and warms forever.
It never fails and never promotes. That is not a defect; it is the
observation chain being rooted somewhere I had not looked. It also explains
why Odin has the only recipe in the estate that was ever kept current.
The document said "migrate the authority's own dependencies last". Odin is
not a dependency to defer, it is the root, and the entry now says so.
The wait also recorded no reason, so a candidate could sit in Warming
indefinitely with nothing to read -- the fourth coarse-signal case today.
It now records what it is waiting for and why the wait cannot resolve
without Odin.
Noted alongside: health published over RUDP to the previous generation's
--rudp-health-bind goes nowhere, since the current serve has no such flag.
That produces a steady stream of timeouts in the candidate's journal and is
not the reason promotion stalls.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first launch with a full environment failed EACCES on
/srv/heimdall/keys/signing-key.pem: the workload runs under DynamicUser and
cannot open a heimdall:heimdall 0600 file. The wrong fix is a supplementary
group -- widening access to resolve a permissions error.
Idunn already has the right one. A [workload.secret_files] entry is loaded
as a systemd credential, and the driver sets an env var named after the
entry whose value is the credential path. Naming the entry after the
variable the service already reads wires it up with no code change, and
setting that name in [workload.environment] too is refused as a collision.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The runtime presence identity is handed to the workload as an inherited
file descriptor, so Idunn requires the source file to be root-owned, mode
0400, and singly linked. Enrolment leaves it 0600, which fails.
The nlink check is the interesting one: a second hard link would be a
second path to the signing key, and a descriptor that cannot be reopened
is the whole point of passing it this way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ProtectSystem=full makes /etc read-only, so Idunn could not create Heimdall's
runtime bundle under /etc/gamecult/heimdall/runtime. A target whose roots are
not in ReadWritePaths is a target Idunn cannot deploy. Every migration adds
exactly three paths -- runtime root, state root, release root -- which keeps
the write set enumerated and is the honest blast radius of running as root.
Also records the per-target preprovisioning Idunn requires rather than
creates, each discovered by a failed deployment: the state root must be
root:<state_group> mode 2770 so the workload's dynamic user inherits group
access through setgid; the runner cache must be 0700 owned by the runner's
container uid, not the source identity, with a root-owned parent (/srv/build
is idunn:idunn and fails); the frozen source stage must be root-owned,
because source is frozen as idunn and then copied into a root-owned
actuation stage; and the binding's docker network must exist.
None of these are Idunn being fussy. Each is a check that a binding cannot
widen access by naming a path someone else can write.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The RUDP connection id 0x1d0d0001 appeared nowhere in this repository. It lived
only in Odin's publisher implementation, which meant the constant defining how
to reach Idunn was owned by a consumer.
The three constants are now stated here: connection id, signed statement schema,
and the unsigned diagnostic schema. The publishing transport moves to CultLib as
a generic module that takes them as a contract, so consumers declare them
against this document rather than each carrying their own copy of the transport.
Recorded a discrepancy while writing it down: the wire value for the unsigned
schema is idunn.daemon_health, with no version suffix, while prose in guide.md
and earlier in this file says idunn.daemon_health.v1. The table gives the wire
value. Reconciling the prose is a separate question about whether the wire
should carry the suffix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The CultLib forks do not disagree. cultcache-ts reads Idunn.s live control
store -- 36 records -- and ordinary CultCache stores share one framing
across runtimes. Only service identity private stores use the bare
single-envelope container, deliberately, because they hold a key rather
than a record set.
The section now says which shape is which and names the failure it nearly
caused: a consumer that hand-decodes the private-store framing and applies
it to expected.cc and the write lease fails on first deployment while its
tests pass, because hand-built fixtures agree with the hand-built reader.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Enrolling Heimdall's signer surfaced that cultcache-ts cannot read a
single-file store written by cultcache-rs at all: pullAll() throws
invalid_type, expected object received array. Verified against the real
idunn-provision enrolment on yggdrasil.
Idunn writes every store a target must read -- presence identity, process
write lease, expected incarnation -- with the Rust client. Rust targets
consume them natively; TypeScript targets cannot, and must decode the
layout by hand. That makes this a per-runtime blocker rather than a
Heimdall quirk, so it belongs in the migration route.
Worth separating what broke from what held: the record schemas are
identical across runtimes, including the identity's protector binding. The
contracts are fine. The store framing drifted.
Which framing is canonical is a CultLib decision and is not taken here; the
C# implementation is the stated reference and is the tiebreaker.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Shared prerequisite for every routed target, done on yggdrasil. Debian
ships ngx_stream_module as a separate dynamic package and it was absent:
nginx -V showed --with-stream=dynamic with no .so. Installed
libnginx-mod-stream at 1.26.3-3+deb13u7, matching the running nginx
exactly so nothing else moved, added a top-level stream block including
/etc/nginx/idunn-stream-routes/*.conf, and created that directory
root:idunn 0775. nginx -t passed before the reload; gamecult.org and
heimdall.gamecult.org/healthz both returned 200 after it. Backup at
/root/nginx.conf.bak-20260906-stream.
Worth recording how the module check nearly went wrong: the first
`nginx -V` ran without sudo, hit "command not found" because the binary is
not on the unprivileged PATH, and reported NO STREAM MODULE. That is the
estate's own trap -- a failed read presented as a negative result -- caught
only because the answer looked too convenient.
The larger correction: the unit I wrote yesterday ran User=idunn, and this
generation cannot work that way. There is no sudo anywhere in Idunn's
source; it calls systemctl, systemd-run, nginx and docker directly, so a
confined user actuates nothing. deployment-authority.md states the intended
model plainly -- freeze source as idunn, copy into a root-owned actuation
stage, the privileged driver never opens Git -- and --source-uid/--source-gid
are validated as non-zero, which only makes sense for a privileged daemon
dropping down for source work.
So the unit now runs as root with --source-uid 986 --source-gid 978, keeps
ProtectSystem=full and re-opens each actuated path individually, and adds
RuntimeDirectory=idunn for the route driver's preflight staging. The
comment block explains the tradeoff rather than leaving the next reader to
infer it: privilege separation is internal, and what makes it tolerable is
that Idunn accepts no imperative input.
That is a genuine increase in blast radius over a confined process, and
migration.md says so instead of selling it. The counter-argument is that
the old shape was confined in name only -- a sandboxed process whose sudo
grant pointed at an unaudited root shell with eleven hardcoded targets.
systemd-analyze verify passes on the rewritten unit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
StreamPixels is changing direction: 2D animation could not express what
the spec required, so it is moving to a 3D-rendered overlay, and whether
that runs in WebGL or needs dedicated rendering infrastructure is open.
The client funding it cannot absorb rendering overhead, which is what makes
the WebGL question load-bearing rather than a preference.
Its deployment shape is downstream of that answer -- a WebGL overlay keeps
roughly the current two-unit shape, rendering infrastructure is a new
target class with hardware requirements this host has never expressed. A
binding written now would pin the shape being replaced.
The survey of its current defects stays as a record but is marked
superseded, with an explicit instruction not to act on it. The tsx-at-
runtime path is exactly the kind of thing a passing agent would tidy, and
tidying it would be work spent on something being replaced.
heimdall leads the remaining three instead. Its prerequisite is deeper
than repixelizer's but far better understood: a vendor/CultLib gitlink,
following Ghostlight's existing vendor/eve pattern, replacing a
cultlib_commit hardcoded in a shell manifest.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
I called these three "the simple ones" and "compose-shaped". Both were
wrong. None of them is compose -- the legacy actuator restarts all three
with `docker compose -f /srv/compose/yggdrasil-apps.yaml`, and that file
does not exist; /srv/compose holds only odin, voidbot and voidbot-retrieval.
The path is guarded, so every restart of these three has been failing at
the guard. They are running only because nothing has asked them to restart,
which is the difference between a supervised service and one that happens
to be up.
Each needs a change in its own repository before a recipe means anything,
and the changes are different shapes, so this is not one piece of work:
streampixels has real build scripts (tsc, next build) and no external
dependencies, but its service unit runs TypeScript through tsx from a
checkout, and it is two units under one target name. A recipe declares one
[service]; the two have different environments, ports, and an ordering
dependency. It was one target only because a shell line restarted both.
repixelizer has the cleanest dependencies of the three -- no CultLib, no
submodules -- but runs a venv interpreter against a checkout, so there is
no artifact to seal and no revision observable from the process.
heimdall is the most release-shaped and needs the deepest change: it takes
CultLib as file:../CultLib, a sibling checkout outside the repository. The
current manifest works around that by cloning CultLib separately with
cultlib_commit hardcoded in shell -- exactly the untyped authority recipes
exist to replace. It needs vendor/CultLib as a gitlink, like Ghostlight's
vendor/eve.
Also corrects the advice to copy CodexConnector's recipe. That recipe is
stale: it declares required_gitlinks for vendor/cultcache-rs and
vendor/cultnet-rs, and the repo has no .gitmodules, no vendor/, and takes
CultLib as a pinned cargo git dep instead. Recipes drift from their repos
silently because nothing re-checks them; run `idunn validate` in each
target's CI so drift fails at the repo rather than on the host.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>