metafactory_cortex: install the target at a pin the role itself proves (proposed) - #30
Conversation
…ommit Installs cortex at an exact commit with arc, and asserts the commit took by reading the checkout's HEAD back with git. crucible#26. The pin variable has no default on purpose: the declared commit is the claim under test (crucible DD-14/AC-3), so an absent pin stops the run rather than installing something nobody chose. Full 40-char SHA only - a tag or branch can move, and this repo does not identify what it installed by anything mutable. Three things read out of arc v0.45.0's source rather than assumed, each of which shapes a task: - `arc install` exits 1 without --yes when stdin is not a TTY, so --yes is mandatory under ansible, not a convenience. - cortex declares non-optional secrets, so --skip-secrets is required to keep the install from reaching for a prompt this role cannot answer. Configuring cortex stays out of scope. - `arc install --pin` is SILENTLY IGNORED for an already-installed package: the duplicate guard in src/commands/install.ts returns success before any clone, and the pin checkout sits inside `if (!existsSync(installPath))`. Re-running with a changed pin therefore exits 0 and changes nothing. arc's README claims otherwise. The role refuses on that case and names `arc remove`, and the self-assertion catches it regardless - an install that reports success is not evidence the declared commit is on the disk. The dependency probe runs arc rather than stat'ing it: `bun link` leaves a symlink, and a stat reports a dangling link as present - which is exactly how it first failed, surfacing a raw ENOENT three tasks later instead of the role's refusal. Digest invariance holds: everything arc writes lands under paths vm-fingerprint.sh prunes (.local/share/metafactory, .local/bin) or never visits (~/.config/metafactory, ~/.claude). The two edges that remain - the unpruned .local and .local/share parents, and cortex's five unpinned depends_on repos - are recorded at the foot of tasks/main.yaml rather than left for the next reader to rediscover. inventory-example.yaml lists the role before assay_env and documents the pin. The pin is shown as a commented example, not a live key, because a VM spec cannot carry it today: spec is a strict object type and tofu.py builds hostvars from a fixed list, so an extra key is dropped by OpenTofu's object conversion with no error and no warning (verified against OpenTofu 1.11.7). Writing it as a live key would document a lie. Verified: ansible-lint clean on the production profile (whole tree and the role alone); site.yaml syntax-check passes; the unpinned, malformed-pin, missing-arc, pin-mismatch and already-at-the-declared-commit paths were each driven against localhost, the last reporting changed=0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018PtJarXm8d8bSHDbhmskVm
…ating the digest claim Review fixes on fc310f6. crucible#26. W1 - the role died under --check with a raw from_json traceback: the read-only tasks were skipped, so the JSON parse met an empty string. check_mode: false on all seven of them (both dependency probes, both `arc list --json`, both `git rev-parse`, and the new status read) with the rationale stated once above the first. The install stays check-skipped, so --check now either confirms the machine already holds the declared commit or refuses for the same reason a real run would. Verified by injecting the fault back into a copy of the role and observing the original traceback. S1 - the final gate now also requires `git status --porcelain` empty. A checkout sitting at exactly the declared pin with edits on top would otherwise be reported downstream as "running <sha>" while running something else - the same class of lie as an unpinned install, one step later. The message names the path and prints the porcelain output. Documented the one way a legitimate install can trip it: arc retries `bun install` without --frozen-lockfile when the lockfile drifted, and that retry can rewrite bun.lock. Failing there is correct - the dependency set installed is not the one the pinned commit declares. F2 - the pin gate used `$`, and Python's re.match also matches `$` before a trailing newline, so a pin read from a command substitution would pass here carrying a "\n" and then fail the far less legible string comparison at the foot of the role. Anchored with \Z instead. F4 - the digest-invariance claim in the header was OVERSTATED and is now written honestly. ~/.bun/install/cache IS hashed into CORE: vm-fingerprint.sh excludes only `install/cache/*.npm`, the compressed blobs, and deliberately keeps the extracted package trees beside them. arc runs `bun install` in the target's repo, so installing cortex writes hundreds of extracted directories straight into the hashed set (this workstation: 154 .npm blobs excluded, 614 other entries hashed). A capture taken before and after this role therefore differs in CORE today, and crucible#26's digest-identity acceptance criterion cannot pass until vm-fingerprint.sh prunes install/cache wholesale - in flight upstream as fix/prune-bun-install-cache. Nothing in this role can make it pass, and it no longer implies otherwise. inventory-example.yaml carried the same overstatement and is corrected too. N1 - the residual note claimed listing after nats_server OR claude was enough to keep the unpruned .local parents from moving the digest. Wrong: nats_server creates .local/bin only, leaving .local/share to be created by this role. Only claude creates both. Verified: ansible-lint clean on the production profile (tree and role); site.yaml syntax-check passes; the harness covers pin unset, tag pin, trailing-newline pin, missing arc, pin mismatch, already-at-the-pin (changed=0), dirty tree, and --check on both the passing and refusing paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018PtJarXm8d8bSHDbhmskVm
The role's header was written while the bun install-cache leak was still open. It said the invariant DOES NOT HOLD, that the digest-identity acceptance criterion CANNOT PASS, and pointed at a fix "in flight upstream as fix/prune-bun-install-cache". That fix is merged: smithy#27 prunes $d/install/cache wholesale, in both layer2 find passes. So the prose was describing a repo that no longer exists. Rewritten to state what is true now: - The invariant HOLDS. ~/.bun/install/cache moves from "the one path that breaks it" into the out-of-reach list beside bin and share/metafactory. - The history stays, in one paragraph, because the reason the current prune is wholesale rather than *.npm-only is the reason to trust it and not re-litigate it later. - The claim cites upstream's own receipt rather than asserting itself: evidence/op-20260901-post-pr-27-fingerprint-diff.md moves exactly the 537 .bun/install/cache lines and the two digests that follow, and nothing else - the fix removed the leak and only the leak. - RESIDUAL drops from three items to two, and both are now stated as things the digest does not feel. The unpinned-dependency edge keeps its entry and gains its issue number (the-metafactory/arc#398). It no longer moves the core digest - reposDir is under the pruned share/metafactory - but it still means the source tree is not fully determined by the pin, which matters to anyone reasoning about a result from the tree instead of from the pin. That distinction is the whole point of keeping the note. inventory-example.yaml carried a short version of the same stale claim; it now matches, and points at the header for the argument. Comments only - no task, default, or variable changed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018PtJarXm8d8bSHDbhmskVm
| | list | first | default('', true) | ||
| }} | ||
|
|
||
| - name: Refuse to report a commit for a checkout that is not there |
There was a problem hiding this comment.
In check mode on a fresh host, the install task above is skipped by Ansible, but this second arc list is forced to execute with check_mode: false. Cortex is therefore still absent and the failure below fires, even though a normal run would successfully install it. Please make the post-install proof conditional when ansible_check_mode is true and an install was required (or otherwise model the pending install), so --check does not reject a valid fresh host.
| # already exist, the first arc install creates them and the CORE digest | ||
| # moves by exactly those path lines. | ||
| # | ||
| # Only the claude role closes both: it writes |
There was a problem hiding this comment.
This residual contradicts the unconditional invariant stated above: on a valid spec that omits claude, the first Cortex install can create .local/share and move the core digest. The example ordering happens to include claude, but the commented example is not a dependency contract. There is already a planned follow-up to model role dependencies more explicitly, so this PR does not need that refactor; please just make the current prose conditional (for example, say the invariant holds when the required parent directories already exist) and avoid claiming that neither residual can move the digest.
…the invariant
Both points from lux's review of smithy#30, accepted as filed.
1. Check mode rejected a host a real run would provision (review comment on
tasks/main.yaml:327).
Ansible skips the install task under --check, correctly - it writes. But
every read in the proof block carries `check_mode: false` and therefore
ran anyway, describing a machine the install was never allowed to touch:
arc reported no package and "Refuse to report a commit for a checkout
that is not there" fired. A dry run failed a perfectly good fresh host,
which is the opposite of what --check is for.
A dry run now MODELS the pending install instead of measuring its
absence. New fact metafactory_cortex_install_pending is true only when
check mode and a missing checkout coincide; the proof block is gated on
its negation, and a new task reports what a real run would do, marked
changed because it would be.
Deliberately NOT set in that branch: metafactory_cortex_installed_commit.
The fact means "this commit was proved on this host", and in check mode
nothing was. A downstream receipt finding no fact is correct; one finding
a fact set by a dry run would be a lie with a sha in it.
The real-run assertions are untouched. install_pending is false whenever
ansible_check_mode is false, so no gate can skip anything in a real run.
Observed, fresh-host --check (package name arc does not have, so
path_before is empty exactly as on a fresh host):
before: failed=1 changed=0 - fatal at tasks/main.yaml:327
after: failed=0 changed=1 - "check mode: ... post-install proof
DEFERRED TO A REAL RUN ... no verified commit recorded"
Already-installed --check, which had to keep working unchanged:
ok=16 changed=0 failed=0 skipped=6 - both assertions ran and passed,
verified commit still recorded, dry-run branch skipped.
2. The invariant was stated unconditionally and the residual contradicted
it (review comment on tasks/main.yaml:438).
lux is right, and the contradiction was mine: residual 1 has always said
the first install can create .local and .local/share and move the digest
by those path lines, while the header claimed the invariant simply holds.
The example ordering in inventory-example.yaml lists claude first, but a
commented example is not a dependency contract and this role cannot
enforce one.
The prose is now conditional - the invariant holds when the parent
directories the capture walks already exist - and the claim that neither
residual can move the core digest is gone. The two remaining residuals
are stated as different kinds: parent-directory creation does move the
digest on a host lacking them; unpinned dependency repos
(the-metafactory/arc#398) do not.
Residual 1 now names the planned role-dependency follow-up as where the
real modeling belongs. Not attempted here: a role-local workaround such
as pre-creating the parents would put a second, quieter answer in the
tree and make the real one harder to land.
Gates: ansible-lint production clean (28 files tree-wide, 3 role-scoped);
site.yaml syntax-check passes; all five refusal cases re-run and unchanged
(pin unset, branch-name pin, trailing-newline pin, dangling arc shim,
pin != HEAD); real run at the matching pin still ok=16 changed=0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018PtJarXm8d8bSHDbhmskVm
|
Both accepted and fixed in 0205220 — thanks, these were good catches, and the second one was a contradiction I had written into the file myself. 1. You were exactly right about the mechanism: the install task skips, but every read in the proof block carries The dry run now models the pending install instead. New One deliberate omission worth flagging: that branch does not set Observed, fresh-host The already-installed Real-run assertions are untouched: 2. Unconditional invariant vs. the residual — reworded. The contradiction was mine: residual 1 has always said the first install can create The header now reads:
with a qualifier paragraph stating that on a spec omitting The claim that neither residual can move the core digest is gone. The two are now stated as different kinds: parent-directory creation does move the digest on a host lacking them; unpinned dependency repos (the-metafactory/arc#398) do not. Residual 1 names your planned role-dependency follow-up as where the real modeling belongs, and I did not attempt it here. I also deliberately avoided a role-local workaround (pre-creating the parents): that would put a second, quieter answer in the tree and make the real one harder to land.
Gates: |
vpzed-dev-lux
left a comment
There was a problem hiding this comment.
Re-review of 0205220: both prior findings are resolved. The fresh-host --check path now models the skipped install, reports changed, bypasses only the proof that cannot honestly run, and deliberately leaves metafactory_cortex_installed_commit unset; the installed-host and all real-run proof paths remain intact. The digest invariant and inventory example are now correctly conditional on the walked parent directories already existing, and residual 1 no longer contradicts that claim. I also re-ran ansible-lint against the updated tree successfully. No remaining findings.
Proposed, not asserted. This is the third of the three roles offered from the crucible side, and like the other two it is a proposal — take it, reshape it, or decline it. The
metafactory_*prefix is a naming convention I picked to keep the ecosystem-specific roles legible next to the generic ones (bun,docker,claude); it is yours to rename. Nothing downstream depends on the name landing as written.Stacked on the two that already merged — smithy#26 (
assay_env) and smithy#27 (fingerprint prune) — and rebased onto currentmainnow that both are in. The diff is three files: the new role, and the inventory example.What the role does
metafactory_cortexinstalls the software under test at an exact commit, then proves the commit took. It runs as the login user with nobecome— arc installs entirely under the user's home, same as themetafactory_arcrole it depends on.A required 40-hex pin, with no default. The commit installed is the claim every downstream receipt reports, so there is no fallback value — a pin nobody chose would be worse than no install. Two guards, in order: refuse when unset, then refuse anything that is not a full 40-character lowercase SHA. arc's
--pinwould happily take a tag or a branch; this repo does not identify what it installed by anything mutable, because a tag can be moved and a branch moves by design, and either makes the receipt unreproducible later.The SHA regex anchors on
\Z, not$. Ansible'smatchtest is Python'sre.match, where$also matches immediately before a trailing newline — so a pin arriving from a command substitution or a file read would carry a\npast a$-anchored gate and then fail the far less legible string comparison at the foot of the role.\Zrejects it where the error message can explain itself.arc install <repo> --pin <sha> --yes --skip-secrets, and both flags are load-bearing rather than stylistic:--yes— at v0.45.0src/cli.tsguards on!opts.yes && !process.stdin.isTTYand exits 1 with "arc install requires an interactive terminal for capability confirmation". Ansible is never a TTY, so without it the task cannot run at all.--skip-secrets— cortex declares non-optional secrets (GH_TOKEN,GITHUB_TOKEN) in itsarc-manifest.yaml. Without the flag, install tries to provision them, which is a prompt this role can neither answer nor see.Dependency probes run by execution, not by
stat. Astaton the arc shim passes a dangling symlink, and the role then fails somewhere less obvious with a worse message. This one is not theoretical: on the workstation used to validate this PR,~/.bun/bin/arcexists as a symlink andls -lreports it happily, but it points at a path that is gone and executing it fails. The probe caught it and named the fix.Refuse-with-
arc removewhen the pin does not match an existing checkout. This is the important one, and it exists because of an arc bug this work surfaced: at v0.45.0arc install --pinshort-circuits on an already-installed package and returns success without checking out the new ref. Re-running would report green and change nothing — the worst possible failure for a role whose entire job is proving which commit is on the box. arc cannot correct it from here, so the refusal has to happen on this side, and the message says toarc removefirst or (on a test VM) destroy and re-provision, which is the fleet's normal reset.Self-assert after the fact. The role does not trust its own install. Afterwards it reads
git rev-parse HEADin the checkout arc reports and asserts it equals the pin, and separately asserts the tree is unmodified. Only after both pass does it setmetafactory_cortex_installed_commit— the AC-3 fact set. The ordering is deliberate: a fact set before the checks would be readable on a machine that failed them.Ordering: list
metafactory_cortexbeforeassay_env.assay_envis the layer-2 capstone recording the environment's identity; the target is not part of that identity, so installing first and fingerprinting after is the order the runbook reads in.The digest invariant, and how it got honest
The role is written around one invariant: installing the target must not move the CORE digest. An environment whose identity moved every time the thing under test moved could not answer the question the digest exists for.
When this branch was first written, that invariant did not hold, and the header said so in those words.
~/.bun/install/cachewas hashed into CORE, the prune matchedinstall/cache/*.npmonly, and an arc-installed target runningbun installfor its own dependencies wrote hundreds of extracted package directories straight into the hashed set. That is smithy#27, now merged — the cache is pruned wholesale, in both layer2 find passes.So the last commit here rewrites the prose to state the current truth: the invariant holds on
main. It cites your own receipt rather than asserting itself —evidence/op-20260901-post-pr-27-fingerprint-diff.mdmoves exactly the 537.bun/install/cachelines and the two digests that follow them, and no other path in the capture shifted. The history stays in one paragraph, because the reason the prune is wholesale rather than extension-matched is the reason to trust it and not re-litigate it later.Review trail
--checksurvival, dirty-tree rejection, and walking back an overstated digest claim).bun installcache digest leak described above. It was fixed above the seam where it belonged, as smithy#27, rather than papered over inside this role — and that PR is merged, which is why the invariant prose in this branch now reads as holding rather than pending.arc bugs this work surfaced
Both were found by writing this role and are tracked arc-side:
install --pinsilently ignored on an already-installed package (exit 0, wrong commit)depends_on.packagesinstall unpinned — a pinned install's dependency tree is not reproduciblereposDirsits under the prunedshare/metafactory) and does not affect the commit this role asserts. It does mean the source tree is not fully determined by the pin, which matters to anyone reasoning about a result from the tree instead of from the pin. Recorded in the role's RESIDUAL note rather than hidden.Known limitation — your call, Vincent
A VM spec cannot carry
metafactory_cortex_pintoday.specis a strictobject()type (modules/vm-pve/variables.tofu) andansible/inventory/tofu.pybuilds hostvars from a fixed list (ansible_host,ansible_user,vm_id,vm_ansible_roles,vm_packages,vm_archive_snapshot,vm_timezone). A key in neither is dropped by OpenTofu's object conversion with no error and no warning — verified against OpenTofu 1.11.7.So the pin rides
-eorhost_varsinstead:and
inventory-example.yamldocuments it as a commented block, deliberately not a live key — writing it as a working spec entry would document a lie. The placeholder SHA there is well-formed (so it reaches the role's check rather than tripping it) and obviously fake (so it cannot be mistaken for a considered choice).A spec passthrough — an
ansible_varsmap survivingobject()and reachingtofu.py— would close this properly, but that is a change to your seam, not mine to make. Offered, not assumed. If you want it, I am happy to write it; if you would rather the pin stay a run-time input, the role works as-is and the example already explains why.Gates
ansible-lint— clean at production profile, tree-wide (28 files) and scoped toroles/metafactory_cortex(3 files).ansible-playbook ansible/site.yaml --syntax-check -i localhost,— passes.\Zcase — a$anchor would have let it through), a dangling arc shim, and pin ≠ installed HEAD. All refused at the right task with the intended message; the install task was never reached.--checkmode, run at the true installed HEAD: 15 ok, 0 changed, 0 failed — read-only probes execute, the install task skips, both asserts pass.🤖 Generated with Claude Code
Co-Authored-By: Claude Fable 5 noreply@anthropic.com
https://claude.ai/code/session_018PtJarXm8d8bSHDbhmskVm