Skip to content

metafactory_cortex: install the target at a pin the role itself proves (proposed) - #30

Merged
vpzed-dev-lux merged 4 commits into
vpzed-dev:mainfrom
mellanon:feat/metafactory-cortex
Sep 2, 2026
Merged

metafactory_cortex: install the target at a pin the role itself proves (proposed)#30
vpzed-dev-lux merged 4 commits into
vpzed-dev:mainfrom
mellanon:feat/metafactory-cortex

Conversation

@mellanon

@mellanon mellanon commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

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 current main now that both are in. The diff is three files: the new role, and the inventory example.

What the role does

metafactory_cortex installs the software under test at an exact commit, then proves the commit took. It runs as the login user with no become — arc installs entirely under the user's home, same as the metafactory_arc role 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 --pin would 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's match test is Python's re.match, where $ also matches immediately before a trailing newline — so a pin arriving from a command substitution or a file read would carry a \n past a $-anchored gate and then fail the far less legible string comparison at the foot of the role. \Z rejects 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.0 src/cli.ts guards on !opts.yes && !process.stdin.isTTY and 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 its arc-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. A stat on 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/arc exists as a symlink and ls -l reports 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 remove when 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.0 arc install --pin short-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 to arc remove first 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 HEAD in the checkout arc reports and asserts it equals the pin, and separately asserts the tree is unmodified. Only after both pass does it set metafactory_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_cortex before assay_env. assay_env is 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/cache was hashed into CORE, the prune matched install/cache/*.npm only, and an arc-installed target running bun install for 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.md moves exactly the 537 .bun/install/cache lines 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

  • Standard review — commented on the branch; fixes applied (--check survival, dirty-tree rejection, and walking back an overstated digest claim).
  • Adversarial pass over five surfaces. Four were refuted — the attacks did not land. One was confirmed: F4, the bun install cache 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:

Issue What Status
the-metafactory/arc#396 install --pin silently ignored on an already-installed package (exit 0, wrong commit) Fixed and merged arc-side. The refusal guard here stays regardless — it is the check that caught it, and it still holds the line for any arc older than the fix.
the-metafactory/arc#398 depends_on.packages install unpinned — a pinned install's dependency tree is not reproducible Open. Does not move the core digest (all of reposDir sits under the pruned share/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_pin today. spec is a strict object() type (modules/vm-pve/variables.tofu) and ansible/inventory/tofu.py builds 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 -e or host_vars instead:

ansible-playbook ansible/site.yaml --limit <host> -e metafactory_cortex_pin=<40-char sha>

and inventory-example.yaml documents 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_vars map surviving object() and reaching tofu.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 to roles/metafactory_cortex (3 files).
  • ansible-playbook ansible/site.yaml --syntax-check -i localhost, — passes.
  • Refusal harness re-run against localhost. Every guard observed failing on the fault it exists for, per the "a detector is untrusted until observed failing" discipline: pin unset, pin as a branch name, pin as a valid SHA carrying a trailing newline (the \Z case — 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.
  • --check mode, 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

mellanon and others added 3 commits September 2, 2026 15:43
…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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@mellanon

mellanon commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

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. --check rejecting a fresh host — fixed.

You were exactly right about the mechanism: the install task skips, but every read in the proof block carries check_mode: false and ran anyway, so it measured a machine the install was never allowed to touch.

The dry run now models the pending install instead. New metafactory_cortex_install_pending fact 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.

One deliberate omission worth flagging: that branch does not set metafactory_cortex_installed_commit. The fact means "this commit was proved on this host", and in check mode nothing was — a receipt finding no fact is correct, one finding a fact set by a dry run would be a lie with a sha in it.

Observed, fresh-host --check (induced with a 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"

The already-installed --check path is unchanged, as you asked:

ok=16 changed=0 failed=0 skipped=6
  - both assertions ran and passed
  - verified commit still recorded
  - dry-run branch skipped

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. A real run at the matching pin still reports ok=16 changed=0.

2. Unconditional invariant vs. the residual — reworded.

The contradiction was mine: residual 1 has always said the first install can create .local/share and move the digest, while the header claimed the invariant simply holds. And you are right that the example ordering is not a dependency contract.

The header now reads:

THE INVARIANT HOLDS on current main WHEN THE PARENT DIRECTORIES THE CAPTURE WALKS ALREADY EXIST

with a qualifier paragraph stating that on a spec omitting claude — a valid spec — the first install creates those parents and the capture moves by exactly those directory path lines.

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.

inventory-example.yaml carries the same correction and now says explicitly that its ordering is an example, not a dependency contract.

Gates: ansible-lint production clean (28 files tree-wide, 3 role-scoped), site.yaml --syntax-check passes, and all five refusal cases re-run unchanged (pin unset, branch-name pin, trailing-newline pin, dangling arc shim, pin ≠ HEAD).

@vpzed-dev-lux vpzed-dev-lux left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@vpzed-dev-lux
vpzed-dev-lux merged commit 82359ef into vpzed-dev:main Sep 2, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants