From 1f32b7c8e6566a2d8e299027691898ef3b6d9cd1 Mon Sep 17 00:00:00 2001 From: Andreas Date: Wed, 2 Sep 2026 07:05:37 +1200 Subject: [PATCH 1/4] add the metafactory_cortex role: install the target, then prove the commit 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 Claude-Session: https://claude.ai/code/session_018PtJarXm8d8bSHDbhmskVm --- .../metafactory_cortex/defaults/main.yaml | 43 +++ .../roles/metafactory_cortex/tasks/main.yaml | 334 ++++++++++++++++++ inventory-example.yaml | 39 +- 3 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 ansible/roles/metafactory_cortex/defaults/main.yaml create mode 100644 ansible/roles/metafactory_cortex/tasks/main.yaml diff --git a/ansible/roles/metafactory_cortex/defaults/main.yaml b/ansible/roles/metafactory_cortex/defaults/main.yaml new file mode 100644 index 0000000..57ee89d --- /dev/null +++ b/ansible/roles/metafactory_cortex/defaults/main.yaml @@ -0,0 +1,43 @@ +# Knobs for installing cortex - the software under test. Everything here +# decides WHICH code lands and how it is found again; none of it is the +# environment's identity, and none of it is cortex's configuration (a stack, +# secrets, NATS wiring are a later layer and deliberately out of this role). + +# Installed by URL, not by registry name. +# +# `arc install cortex` resolves the bare name through arc's default remote +# source (arc v0.45.0 src/lib/sources.ts, createDefaultSources -> +# DEFAULT_API_SOURCE): a network lookup against a name -> repo mapping that +# lives outside this repo, outside the VM spec, and outside anyone's control +# here. A URL plus a full commit SHA names the code completely and cannot be +# repointed under us, which is the only way this factory is allowed to +# identify the thing it is measuring. +metafactory_cortex_repo: https://github.com/the-metafactory/cortex + +# Where the metafactory_arc role leaves the arc CLI. A variable rather than a +# literal for two reasons: where arc's entry point lands is that role's +# business and not this one's, and a role whose dependency probe cannot be +# pointed somewhere else is a role whose refusal path cannot be exercised +# without breaking the machine you are testing on. +metafactory_cortex_arc_bin: "{{ ansible_facts['user_dir'] }}/.bun/bin/arc" + +# The name arc records for the package in its DB, which is the target's own +# arc-manifest.yaml `name:`. Used ONLY to find the checkout again in +# `arc list --json` output so the role can read its HEAD. It never selects +# code - metafactory_cortex_repo does that. +metafactory_cortex_package: cortex + +# metafactory_cortex_pin is deliberately NOT defined in this file. +# +# A default pin would be a lie about intent. It would let a VM spec that +# names this role but forgets the pin install *something* anyway, and every +# receipt downstream would then carry a commit nobody chose - which is the +# precise failure this factory exists to prevent (crucible DD-14: the pin is +# the claim under test, and AC-3 measures the installed commit against the +# declared one). An absent pin has to stop the run, not fall back to a +# neighbour's answer. +# +# tasks/main.yaml asserts it is set AND is a full 40-character commit SHA, +# before any network call. Full SHA only, not a tag or branch: a tag can be +# moved and a branch moves by design, and this repo does not identify an +# environment - or what was installed into one - by anything mutable. diff --git a/ansible/roles/metafactory_cortex/tasks/main.yaml b/ansible/roles/metafactory_cortex/tasks/main.yaml new file mode 100644 index 0000000..31e185a --- /dev/null +++ b/ansible/roles/metafactory_cortex/tasks/main.yaml @@ -0,0 +1,334 @@ +# Installs cortex - the software under test - at an exact commit, then proves +# the commit took. Login user, no become: arc installs entirely under the +# user's home, exactly like the metafactory_arc role it depends on. Depends on +# metafactory_arc having run first - list metafactory_arc before +# metafactory_cortex in ansible_roles. +# +# ORDERING relative to assay_env: cortex goes BEFORE it. assay_env is the +# layer-2 capstone and records the environment's identity; the target is not +# part of that identity (see the invariant below), so installing it first and +# fingerprinting after is the ordering that matches the runbook, and the +# recorded digest is the same either way. +# +# THE INVARIANT this role is written around: 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 one question the digest exists +# for - "were these two runs performed under the same conditions?" - and +# "same environment, two target versions" is the comparison the whole +# exercise is built on. It holds because of where arc puts things and what +# scripts/vm-fingerprint.sh refuses to look at: +# +# ~/.local/share/metafactory/arc/repos/ the clone (arc v0.45.0 +# src/lib/paths.ts: reposDir = /metafactory/arc/repos on the +# default layout). vm-fingerprint.sh prunes `$d/share/metafactory` +# wholesale from the layer2 files trees. +# ~/.local/bin/ the CLI shim. Pruned +# wholesale as `$d/bin` (smithy#6 / crucible#14 option (a) - the shims +# carry no marker a prune could match, so the whole directory goes). +# ~/.config/metafactory/... arc's DB and config. Not in +# the capture at all: it walks only ~/.local and ~/.bun. +# ~/.claude/skills|agents|... arc's host drops. Same - not +# walked. +# +# So every path this role causes to appear is either pruned or never visited, +# and the target is instead recorded by reference (name plus resolved commit) +# in the run receipt, which is what metafactory_cortex_installed_commit below +# is for. See the RESIDUAL note at the end of this file for the one edge that +# is not this role's to close. +# +# THREE THINGS ABOUT arc v0.45.0 THAT SHAPE EVERY TASK BELOW, all read out of +# the pinned source rather than assumed: +# +# 1. `arc install` REFUSES to run non-interactively without --yes. 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 --yes is mandatory here, not a convenience. +# +# 2. cortex declares NON-optional secrets (GH_TOKEN, GITHUB_TOKEN in its +# arc-manifest.yaml). Without --skip-secrets, install tries to provision +# them, which is a prompt this role can neither answer nor see. It is also +# out of scope by construction: this role installs the target and stops. +# Configuring it - stack, secrets, bus - is a later layer. --skip-secrets +# leaves them unstored, and the cortex daemon says so clearly at first use. +# +# 3. --pin IS SILENTLY IGNORED WHEN THE PACKAGE IS ALREADY INSTALLED. This is +# the important one. src/commands/install.ts returns +# `{ success: true, alreadyInstalled: true }` from its duplicate guard as +# soon as an ACTIVE row with the same repo_url exists - before any clone - +# and the pin checkout (`checkoutPinnedRef`) sits further down, inside +# `if (!existsSync(installPath))`. So re-running with a DIFFERENT pin +# exits 0, changes nothing, and leaves the old commit in place. arc's +# README says "re-run `arc install --pin ` to return to a specific +# ref"; at v0.45.0 that is not true for a package already installed. +# +# That is a green run over stale input - the exact thing this factory +# exists to catch - so the role refuses instead: a checkout already at a +# different commit stops the play and names `arc remove`. It does not +# remove and reinstall on its own, because `arc remove` is destructive to +# whatever else that package is carrying and this role is not the right +# place to decide that unattended. +# +# WHY THE ASSERTION AT THE END IS NOT OPTIONAL: given (3), an install that +# reports success is not evidence that the declared commit is on the disk. A +# role that installs but cannot prove WHAT it installed is a healthy trace +# over stale input. The role therefore reads the checkout's HEAD back with +# git and fails, showing both commits, if it is not the declared one. + +- name: Refuse to install an unpinned target + ansible.builtin.assert: + that: + - metafactory_cortex_pin is defined + - metafactory_cortex_pin | default('', true) | string | length > 0 + fail_msg: >- + metafactory_cortex_pin is not set. This role installs the software + under test, and the commit it installs is the claim every downstream + receipt reports (crucible DD-14/AC-3), so there is no default to fall + back to - a pin nobody chose would be worse than no install. Declare + the full commit SHA for the VM, e.g. + `-e metafactory_cortex_pin=<40-char sha>`, or in host_vars/group_vars. + See inventory-example.yaml for why the VM spec cannot carry it yet. + quiet: true + +# `| string` first: a pin written unquoted in YAML can arrive as a number +# rather than text, and a regex test against a number is a different question +# than the one being asked here. +- name: Refuse a pin that is not a full commit SHA + ansible.builtin.assert: + that: + - metafactory_cortex_pin | string is match('^[0-9a-f]{40}$') + fail_msg: >- + metafactory_cortex_pin is "{{ metafactory_cortex_pin }}", which is not + a full 40-character lowercase commit SHA. arc's --pin would accept a + tag or a branch, but this repo does not identify what it installed by + anything mutable: a tag can be moved and a branch moves by design, and + either would make the commit in the run receipt unreproducible later. + Resolve the ref to its commit first (`git ls-remote + {{ metafactory_cortex_repo }} `) and declare that. + quiet: true + +# Probed by RUNNING arc, not by stat'ing the path. +# +# metafactory_arc installs arc with `bun link`, which leaves a SYMLINK in +# ~/.bun/bin - and a stat of a symlink reports a DANGLING link as present. +# That is not hypothetical: the first version of this task used stat, and on +# a box whose bun global tree had been rebuilt underneath the link it sailed +# through, handing the operator `[Errno 2] No such file or directory` from a +# task three steps later instead of the refusal below. Executing arc also +# proves the rest of the chain the install depends on in one go: the link +# resolves, bun can run it, and the CLI answers. +- name: Probe for arc (cross-role dependency) # noqa: command-instead-of-module + ansible.builtin.command: "{{ metafactory_cortex_arc_bin }} --version" + environment: + PATH: "{{ ansible_facts['user_dir'] }}/.bun/bin:{{ ansible_facts['env']['PATH'] }}" + register: metafactory_cortex_arc_probe + changed_when: false + failed_when: false + +- name: Refuse to continue without a working arc + ansible.builtin.fail: + msg: >- + The metafactory_cortex role installs the target with arc, and + `{{ metafactory_cortex_arc_bin }} --version` did not answer + (rc={{ metafactory_cortex_arc_probe.rc | default('n/a') }}: + {{ metafactory_cortex_arc_probe.stderr | default(metafactory_cortex_arc_probe.msg | default(''), true) | trim }}). + List 'metafactory_arc' before 'metafactory_cortex' in the VM spec's + ansible_roles. If arc is installed somewhere else on this machine, + point metafactory_cortex_arc_bin at it. + when: metafactory_cortex_arc_probe.rc | default(1) != 0 + +- name: Probe for git (layer-1 dependency) # noqa: command-instead-of-module + ansible.builtin.command: git --version + register: metafactory_cortex_git_probe + changed_when: false + failed_when: false + +- name: Refuse to continue without git + ansible.builtin.fail: + msg: >- + The metafactory_cortex role needs git on the guest - arc clones the + target with it, and the pin assertion reads the checkout back with it; + add 'git' to the VM spec's packages list (layer 1) and reprovision, or + install it by hand. + when: metafactory_cortex_git_probe.rc | default(1) != 0 + +# --- decide whether an install is needed ------------------------------- +# +# `arc list --json` is a read of arc's local package DB - no network, unlike +# `arc info`, which also fetches release notes. It reports installPath per +# package, which is where the clone actually is; that is asked rather than +# derived, because the path arc chooses depends on its layout resolution +# (src/lib/paths.ts: the XDG split vs the legacy single tree, selected by +# whether ARC_CONFIG_ROOT is in play) and a path this role hardcoded could +# quietly stop matching. + +- name: Ask arc which packages it already has + ansible.builtin.command: + cmd: "{{ metafactory_cortex_arc_bin }} list --json" + environment: + PATH: "{{ ansible_facts['user_dir'] }}/.bun/bin:{{ ansible_facts['env']['PATH'] }}" + register: metafactory_cortex_list_before + changed_when: false + +- name: Note where arc says the target is checked out, if anywhere + ansible.builtin.set_fact: + metafactory_cortex_path_before: >- + {{ + (metafactory_cortex_list_before.stdout | from_json).packages + | default([]) + | selectattr('name', 'equalto', metafactory_cortex_package) + | selectattr('installPath', 'defined') + | map(attribute='installPath') + | list | first | default('', true) + }} + +# failed_when: false - a recorded package whose checkout is not a git repo is +# a state this role has to report precisely, not die on with git's own error. +- name: Read the HEAD of the checkout arc already has # noqa: command-instead-of-module + ansible.builtin.command: + cmd: git rev-parse HEAD + chdir: "{{ metafactory_cortex_path_before }}" + register: metafactory_cortex_head_before + when: metafactory_cortex_path_before | length > 0 + changed_when: false + failed_when: false + +# See point (3) in the header. Everything about arc's behaviour here is +# "exit 0, change nothing", so the refusal has to happen on this side. +- name: Refuse to re-pin a checkout arc will not move + ansible.builtin.fail: + msg: >- + {{ metafactory_cortex_package }} is already installed at + {{ metafactory_cortex_path_before }}, and its HEAD is + {{ metafactory_cortex_head_before.stdout | default('unreadable', true) | trim }} + where the VM declares {{ metafactory_cortex_pin }}. + arc will not correct that from here: at v0.45.0 + `arc install --pin` short-circuits on an already-installed package and + returns success WITHOUT checking out the new ref, so re-running here + would report green and change nothing. Remove it first and re-run this + role - `arc remove {{ metafactory_cortex_package }}` - or, on a test + VM, destroy and re-provision, which is this fleet's normal reset. + when: + - metafactory_cortex_path_before | length > 0 + - metafactory_cortex_head_before.rc | default(1) != 0 + or (metafactory_cortex_head_before.stdout | default('', true) | trim) != metafactory_cortex_pin | string + +# Only reached when there is no checkout at all, which is also the only case +# in which arc honours --pin. Re-running with the SAME pin skips this task +# entirely, which is the role's idempotence claim: a second run reports zero +# changes because there is nothing left to do, not because a command was +# told to keep quiet. +- name: Install the declared commit of {{ metafactory_cortex_package }} + ansible.builtin.command: + argv: + - "{{ metafactory_cortex_arc_bin }}" + - install + - "{{ metafactory_cortex_repo }}" + - --pin + - "{{ metafactory_cortex_pin | string }}" + - --yes + - --skip-secrets + environment: + PATH: "{{ ansible_facts['user_dir'] }}/.bun/bin:{{ ansible_facts['env']['PATH'] }}" + register: metafactory_cortex_install + when: metafactory_cortex_path_before | length == 0 + changed_when: true + +# --- prove what actually landed ---------------------------------------- +# +# Re-derived from arc rather than reusing anything measured above, so the +# assertion stands on its own: it describes the disk as it is now, on both +# the freshly-installed and the already-correct path. + +- name: Ask arc again where the target is checked out + ansible.builtin.command: + cmd: "{{ metafactory_cortex_arc_bin }} list --json" + environment: + PATH: "{{ ansible_facts['user_dir'] }}/.bun/bin:{{ ansible_facts['env']['PATH'] }}" + register: metafactory_cortex_list_after + changed_when: false + +- name: Note the installed checkout's path + ansible.builtin.set_fact: + metafactory_cortex_path: >- + {{ + (metafactory_cortex_list_after.stdout | from_json).packages + | default([]) + | selectattr('name', 'equalto', metafactory_cortex_package) + | selectattr('installPath', 'defined') + | map(attribute='installPath') + | list | first | default('', true) + }} + +- name: Refuse to report a commit for a checkout that is not there + ansible.builtin.fail: + msg: >- + arc reports no installed package named + '{{ metafactory_cortex_package }}' after the install step. Either the + install did not run, or the target's arc-manifest.yaml records a + different `name:` than this role expects - set + metafactory_cortex_package to match it. Nothing has been asserted + about this machine, so it holds no verified target commit. + arc install said: {{ metafactory_cortex_install.stdout | default('(the install step did not run)', true) }} + when: metafactory_cortex_path | length == 0 + +- name: Read the installed checkout's HEAD # noqa: command-instead-of-module + ansible.builtin.command: + cmd: git rev-parse HEAD + chdir: "{{ metafactory_cortex_path }}" + register: metafactory_cortex_head + changed_when: false + +# The assertion the whole role exists for. Both values are in the message on +# purpose: "the pin did not take" is not actionable, and the commit that DID +# land is the first thing anyone reading the failure needs. +- name: Assert the installed commit is the declared pin + ansible.builtin.assert: + that: + - (metafactory_cortex_head.stdout | trim) == metafactory_cortex_pin | string + fail_msg: >- + The installed target is NOT at the declared commit. + declared (metafactory_cortex_pin): {{ metafactory_cortex_pin }} + installed (git rev-parse HEAD): {{ metafactory_cortex_head.stdout | trim }} + checkout: {{ metafactory_cortex_path }} + Nothing downstream may report this machine as running the declared + commit. Inspect the checkout, or `arc remove + {{ metafactory_cortex_package }}` and re-run this role. + success_msg: >- + {{ metafactory_cortex_package }} is at {{ metafactory_cortex_pin }}, + verified by git in {{ metafactory_cortex_path }}. + quiet: true + +# What the smoke loop reads. Set only after the assertion above, so the fact +# exists on a host if and only if the commit was proved on that host - a fact +# set before the check would be readable on a machine that failed it. +- name: Record the verified commit for the smoke loop + ansible.builtin.set_fact: + metafactory_cortex_installed_commit: "{{ metafactory_cortex_head.stdout | trim }}" + +# --- RESIDUAL, stated rather than hidden ------------------------------- +# +# Two things this role cannot close, both recorded so the next reader does +# not have to re-derive them: +# +# 1. The digest invariant above holds for the PATHS arc writes, and those are +# all pruned or unvisited. It does NOT cover the PARENT directories: +# vm-fingerprint.sh prunes `.local/share/metafactory` and `.local/bin`, +# but `.local` and `.local/share` are themselves printed by the find that +# builds the layer2 files list. On a VM where neither already exists, the +# first arc install creates them and the CORE digest moves by exactly +# those two path lines. In the documented stack it cannot happen - the +# nats_server role creates ~/.local/bin and the claude role creates +# ~/.local/share/claude before anything gets here - so listing this role +# after either of them is enough. A spec that runs metafactory_arc and +# metafactory_cortex with neither of those roles is the one arrangement +# where a before/after capture would differ, and the difference would be +# two directory names, not the target's contents. +# +# 2. The pin fixes cortex's own commit and nothing else. cortex's +# arc-manifest.yaml declares depends_on.packages - five adapter/renderer +# repos - and arc installs those with `install({ repoUrl: dep.repo })`, +# passing no pinnedRef (src/commands/install.ts). They therefore land on +# whatever their default branch is at the time. That does not touch the +# core digest (same pruned tree) and does not affect the commit this role +# asserts, but the tree under reposDir is not fully determined by the pin, +# and a claim that it is would be wrong. diff --git a/inventory-example.yaml b/inventory-example.yaml index 74e9bb5..0acea44 100644 --- a/inventory-example.yaml +++ b/inventory-example.yaml @@ -34,11 +34,48 @@ ipv4: dhcp # in list order - metafactory_arc needs bun before it. Cross-layer deps: the # bun role needs 'unzip' and metafactory_arc needs 'git' in packages. # +# metafactory_cortex installs the software under test at an exact commit and +# then proves the commit took. It needs metafactory_arc before it, and it +# goes before assay_env: the target is not part of the environment's +# identity, so the capture excludes it either way, but installing the target +# and then fingerprinting is the order the runbook reads in. +# # assay_env goes LAST. It installs nothing: it fingerprints whatever the roles # before it put on the machine and writes that identity to # /etc/assay/environment.json, so anything listed after it would be software # the recorded identity does not describe. # packages: [unzip, git] -# ansible_roles: [nats_server, bun, claude, docker, metafactory_arc, assay_env] +# ansible_roles: [nats_server, bun, claude, docker, metafactory_arc, metafactory_cortex, assay_env] + +# The commit metafactory_cortex installs. The placeholder below is +# deliberately not a real commit - it is well-formed (40 lowercase hex) so it +# reaches the role's SHA check rather than tripping it, and obviously fake so +# it cannot be mistaken for a considered choice. Replace it with a real +# commit from the target repo: +# +# git ls-remote https://github.com/the-metafactory/cortex main +# +# Full SHA only. The role rejects a tag or a branch: both can move, and a +# target this fleet cannot name immutably is one whose test results cannot be +# reproduced later. +# +# metafactory_cortex_pin: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef +# +# NOTE - this key does NOT reach ansible from a VM spec today, and the line +# above is an example of the VALUE, not a working spec entry. 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 that is in neither is dropped by OpenTofu's object conversion with no +# error and no warning - verified against OpenTofu 1.11.7 - so writing it +# here as a live key would document a lie. Until a spec passthrough exists, +# supply it at run time: +# +# ansible-playbook ansible/site.yaml --limit \ +# -e metafactory_cortex_pin=<40-char sha> +# +# or from ansible/inventory/host_vars/.yaml. The role fails loudly when +# it is unset, so a forgotten pin stops the run instead of installing +# something nobody chose. tags: ["ubuntu"] From b5a29806cf24377732f6442e37b3f8e904242e97 Mon Sep 17 00:00:00 2001 From: Andreas Date: Wed, 2 Sep 2026 07:25:03 +1200 Subject: [PATCH 2/4] metafactory_cortex: survive --check, reject a dirty tree, stop overstating 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 " 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 Claude-Session: https://claude.ai/code/session_018PtJarXm8d8bSHDbhmskVm --- .../roles/metafactory_cortex/tasks/main.yaml | 163 +++++++++++++++--- inventory-example.yaml | 15 +- 2 files changed, 148 insertions(+), 30 deletions(-) diff --git a/ansible/roles/metafactory_cortex/tasks/main.yaml b/ansible/roles/metafactory_cortex/tasks/main.yaml index 31e185a..3b2bbd8 100644 --- a/ansible/roles/metafactory_cortex/tasks/main.yaml +++ b/ansible/roles/metafactory_cortex/tasks/main.yaml @@ -8,15 +8,19 @@ # layer-2 capstone and records the environment's identity; the target is not # part of that identity (see the invariant below), so installing it first and # fingerprinting after is the ordering that matches the runbook, and the -# recorded digest is the same either way. +# recorded digest is the same either way - subject to the caveat below, +# which applies whichever order they run in. # # THE INVARIANT this role is written around: 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 one question the digest exists # for - "were these two runs performed under the same conditions?" - and # "same environment, two target versions" is the comparison the whole -# exercise is built on. It holds because of where arc puts things and what -# scripts/vm-fingerprint.sh refuses to look at: +# exercise is built on. Most of what arc writes is already out of the +# capture's reach, but ONE PATH BREAKS IT and the break is live - read both +# halves below before trusting a digest taken around this role. +# +# Out of reach already: # # ~/.local/share/metafactory/arc/repos/ the clone (arc v0.45.0 # src/lib/paths.ts: reposDir = /metafactory/arc/repos on the @@ -30,11 +34,38 @@ # ~/.claude/skills|agents|... arc's host drops. Same - not # walked. # -# So every path this role causes to appear is either pruned or never visited, -# and the target is instead recorded by reference (name plus resolved commit) -# in the run receipt, which is what metafactory_cortex_installed_commit below -# is for. See the RESIDUAL note at the end of this file for the one edge that -# is not this role's to close. +# THE INVARIANT DOES NOT HOLD TODAY. Stated here rather than discovered by +# whoever first diffs two captures: +# +# ~/.bun/install/cache IS hashed into CORE, and this install populates +# it. vm-fingerprint.sh excludes only `$d/install/cache/*.npm` - the +# compressed download blobs - and deliberately keeps the EXTRACTED +# package trees beside them ("are what global/node_modules symlinks +# into, and stay in"). arc runs `bun install` in the target's repo +# after landing symlinks (arc README, "If a package's repo root has a +# package.json with dependencies"), and cortex has a large dependency +# tree, so installing the target writes hundreds of extracted package +# directories straight into the hashed set. On this workstation the +# cache holds 154 `.npm` blobs (excluded) beside 614 other entries +# (hashed). +# +# So a capture taken before and after this role runs WILL differ in CORE +# today, and the digest would move with the target's dependency tree - which +# is precisely the coupling the split exists to prevent. The fix belongs in +# vm-fingerprint.sh, above the seam, not here: prune `install/cache` +# wholesale, the same call already made for `bin` and `share/metafactory`. +# It is in flight upstream as fix/prune-bun-install-cache. Until that lands, +# this role's digest-identity acceptance criterion (crucible#26: "a capture +# taken before and after this role runs has an identical core digest") CANNOT +# PASS, and nothing here can make it pass. +# +# What IS already true, and is what the fix completes rather than creates: +# every path arc writes for the target itself is pruned or never visited, so +# once the cache leak is closed the invariant holds without further work. The +# target is recorded by reference (name plus resolved commit) in the run +# receipt instead - which is what metafactory_cortex_installed_commit below +# is for. See the RESIDUAL note at the end of this file for the remaining +# edges. # # THREE THINGS ABOUT arc v0.45.0 THAT SHAPE EVERY TASK BELOW, all read out of # the pinned source rather than assumed: @@ -92,10 +123,17 @@ # `| string` first: a pin written unquoted in YAML can arrive as a number # rather than text, and a regex test against a number is a different question # than the one being asked here. +# +# `\Z`, not `$`. Ansible's `match` test is Python's re.match, where `$` also +# matches immediately BEFORE a trailing newline - so a pin fed in from a +# command substitution or a file read would pass this gate carrying a "\n", +# and then fail the far less legible string comparison against +# `git rev-parse` output at the foot of the role. `\Z` anchors at the true +# end of the string and rejects it here, where the message can say why. - name: Refuse a pin that is not a full commit SHA ansible.builtin.assert: that: - - metafactory_cortex_pin | string is match('^[0-9a-f]{40}$') + - metafactory_cortex_pin | string is match('^[0-9a-f]{40}\Z') fail_msg: >- metafactory_cortex_pin is "{{ metafactory_cortex_pin }}", which is not a full 40-character lowercase commit SHA. arc's --pin would accept a @@ -106,6 +144,17 @@ {{ metafactory_cortex_repo }} `) and declare that. quiet: true +# check_mode: false on every read-only task in this role - the two dependency +# probes, both `arc list --json` reads, and the three git reads of the +# checkout. None of them writes anything: they run --version, read arc's +# local package DB, and read a git checkout's HEAD and status. Skipped under +# --check they would leave the parse below with no stdout, and the run would +# die on a from_json of the empty string instead of showing the honest +# picture for the one task that IS at stake - the install, which stays +# check-skipped and reports what it would do. A --check run therefore either +# confirms the machine already holds the declared commit, or refuses for the +# same reason a real run would. +# # Probed by RUNNING arc, not by stat'ing the path. # # metafactory_arc installs arc with `bun link`, which leaves a SYMLINK in @@ -123,6 +172,7 @@ register: metafactory_cortex_arc_probe changed_when: false failed_when: false + check_mode: false - name: Refuse to continue without a working arc ansible.builtin.fail: @@ -141,6 +191,7 @@ register: metafactory_cortex_git_probe changed_when: false failed_when: false + check_mode: false - name: Refuse to continue without git ansible.builtin.fail: @@ -168,6 +219,7 @@ PATH: "{{ ansible_facts['user_dir'] }}/.bun/bin:{{ ansible_facts['env']['PATH'] }}" register: metafactory_cortex_list_before changed_when: false + check_mode: false - name: Note where arc says the target is checked out, if anywhere ansible.builtin.set_fact: @@ -191,6 +243,7 @@ when: metafactory_cortex_path_before | length > 0 changed_when: false failed_when: false + check_mode: false # See point (3) in the header. Everything about arc's behaviour here is # "exit 0, change nothing", so the refusal has to happen on this side. @@ -246,6 +299,7 @@ PATH: "{{ ansible_facts['user_dir'] }}/.bun/bin:{{ ansible_facts['env']['PATH'] }}" register: metafactory_cortex_list_after changed_when: false + check_mode: false - name: Note the installed checkout's path ansible.builtin.set_fact: @@ -277,6 +331,15 @@ chdir: "{{ metafactory_cortex_path }}" register: metafactory_cortex_head changed_when: false + check_mode: false + +- name: Read whether the installed checkout has been modified # noqa: command-instead-of-module + ansible.builtin.command: + cmd: git status --porcelain + chdir: "{{ metafactory_cortex_path }}" + register: metafactory_cortex_status + changed_when: false + check_mode: false # The assertion the whole role exists for. Both values are in the message on # purpose: "the pin did not take" is not actionable, and the commit that DID @@ -298,31 +361,77 @@ verified by git in {{ metafactory_cortex_path }}. quiet: true -# What the smoke loop reads. Set only after the assertion above, so the fact -# exists on a host if and only if the commit was proved on that host - a fact -# set before the check would be readable on a machine that failed it. +# A commit is only an honest name for the code if the code is still the +# commit. `git rev-parse HEAD` says nothing about the working tree, so a +# checkout sitting at exactly the declared pin with edits on top would sail +# through the assertion above and be reported downstream as "running " +# while running something else. That is the same class of lie as an unpinned +# install, arriving one step later, so it gets the same treatment. +# +# ONE WAY A LEGITIMATE INSTALL CAN TRIP THIS, so it is not mistaken for a +# bug: arc runs `bun install` in the checkout, normally with +# --frozen-lockfile (added automatically when the repo ships a committed +# bun.lock, which cortex does), and frozen does not rewrite the lockfile. +# But arc retries ONCE WITHOUT --frozen-lockfile when the frozen attempt +# fails because the lockfile drifted from package.json (arc README), and +# that retry can rewrite bun.lock - leaving `M bun.lock` here. Failing is +# the right answer in that case, not a false positive: it means the +# dependency set actually installed is not the one the pinned commit +# declares, which is a determinism hole in the thing being measured. The +# porcelain output in the message names the file, so the cause is visible +# rather than inferred. +- name: Assert the installed checkout is unmodified + ansible.builtin.assert: + that: + - (metafactory_cortex_status.stdout | trim) | length == 0 + fail_msg: >- + The installed target is at the declared commit but its WORKING TREE IS + DIRTY, so the code on this machine is not the code + {{ metafactory_cortex_pin }} names. + checkout: {{ metafactory_cortex_path }} + git status --porcelain: + {{ metafactory_cortex_status.stdout | trim | replace('\n', '\n ') }} + Nothing downstream may report this machine as running the declared + commit. Restore the checkout to the pin - `git -C + {{ metafactory_cortex_path }} checkout -- .` for modified tracked + files, `git -C {{ metafactory_cortex_path }} clean -fd` for untracked + ones - or `arc remove {{ metafactory_cortex_package }}` and re-run this + role for a clean install. On a test VM, destroy and re-provision. + success_msg: >- + {{ metafactory_cortex_path }} is clean. + quiet: true + +# What the smoke loop reads. Set only after BOTH assertions above, so the +# fact exists on a host if and only if the commit was proved on that host and +# the tree proved unmodified - a fact set before the checks would be readable +# on a machine that failed them. - name: Record the verified commit for the smoke loop ansible.builtin.set_fact: metafactory_cortex_installed_commit: "{{ metafactory_cortex_head.stdout | trim }}" # --- RESIDUAL, stated rather than hidden ------------------------------- # -# Two things this role cannot close, both recorded so the next reader does -# not have to re-derive them: +# Three things this role cannot close, all recorded so the next reader does +# not have to re-derive them. The first is the ~/.bun/install/cache leak in +# the header - the big one, and the only one that blocks an acceptance +# criterion. The other two: +# +# 1. Beyond the paths arc writes, the prune does NOT cover their PARENT +# directories: vm-fingerprint.sh prunes `.local/share/metafactory` and +# `.local/bin`, but `.local` and `.local/share` are themselves printed by +# the find that builds the layer2 files list. On a VM where they do not +# already exist, the first arc install creates them and the CORE digest +# moves by exactly those path lines. # -# 1. The digest invariant above holds for the PATHS arc writes, and those are -# all pruned or unvisited. It does NOT cover the PARENT directories: -# vm-fingerprint.sh prunes `.local/share/metafactory` and `.local/bin`, -# but `.local` and `.local/share` are themselves printed by the find that -# builds the layer2 files list. On a VM where neither already exists, the -# first arc install creates them and the CORE digest moves by exactly -# those two path lines. In the documented stack it cannot happen - the -# nats_server role creates ~/.local/bin and the claude role creates -# ~/.local/share/claude before anything gets here - so listing this role -# after either of them is enough. A spec that runs metafactory_arc and -# metafactory_cortex with neither of those roles is the one arrangement -# where a before/after capture would differ, and the difference would be -# two directory names, not the target's contents. +# Only the claude role closes both: it writes +# ~/.local/share/claude/versions/, creating `.local` AND +# `.local/share`. nats_server is NOT sufficient on its own - it creates +# `.local/bin`, which brings `.local` into being but leaves `.local/share` +# to be created by whatever first needs it, which on a spec without claude +# is this role. So the safe statement is: list `claude` before this role, +# or accept that the first capture on a claude-less spec moves by one +# directory name. Either way the difference is a path line, not the +# target's contents. # # 2. The pin fixes cortex's own commit and nothing else. cortex's # arc-manifest.yaml declares depends_on.packages - five adapter/renderer diff --git a/inventory-example.yaml b/inventory-example.yaml index 0acea44..8ac8ea5 100644 --- a/inventory-example.yaml +++ b/inventory-example.yaml @@ -36,9 +36,18 @@ ipv4: dhcp # # metafactory_cortex installs the software under test at an exact commit and # then proves the commit took. It needs metafactory_arc before it, and it -# goes before assay_env: the target is not part of the environment's -# identity, so the capture excludes it either way, but installing the target -# and then fingerprinting is the order the runbook reads in. +# goes before assay_env, which is the order the runbook reads in: install the +# target, then fingerprint. +# +# The target is not MEANT to be part of the environment's identity, and +# everything arc writes for it is pruned from the capture - except one path. +# ~/.bun/install/cache is still hashed into CORE and `bun install` populates +# it, so today a capture taken after this role differs from one taken before. +# See the header of ansible/roles/metafactory_cortex/tasks/main.yaml; the fix +# belongs in scripts/vm-fingerprint.sh and is in flight upstream. Listing +# `claude` before this role also matters: it is the only role that creates +# both ~/.local and ~/.local/share, whose absence would otherwise move the +# digest by a directory name on the first install. # # assay_env goes LAST. It installs nothing: it fingerprints whatever the roles # before it put on the machine and writes that identity to From e4595a4ce28e6c077cfbb29de216d1d625dc25bb Mon Sep 17 00:00:00 2001 From: Andreas Date: Wed, 2 Sep 2026 15:50:54 +1200 Subject: [PATCH 3/4] metafactory_cortex: the digest invariant holds, so say so 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 Claude-Session: https://claude.ai/code/session_018PtJarXm8d8bSHDbhmskVm --- .../roles/metafactory_cortex/tasks/main.yaml | 111 +++++++++++------- inventory-example.yaml | 23 ++-- 2 files changed, 81 insertions(+), 53 deletions(-) diff --git a/ansible/roles/metafactory_cortex/tasks/main.yaml b/ansible/roles/metafactory_cortex/tasks/main.yaml index 3b2bbd8..7ce686e 100644 --- a/ansible/roles/metafactory_cortex/tasks/main.yaml +++ b/ansible/roles/metafactory_cortex/tasks/main.yaml @@ -7,20 +7,20 @@ # ORDERING relative to assay_env: cortex goes BEFORE it. assay_env is the # layer-2 capstone and records the environment's identity; the target is not # part of that identity (see the invariant below), so installing it first and -# fingerprinting after is the ordering that matches the runbook, and the -# recorded digest is the same either way - subject to the caveat below, -# which applies whichever order they run in. +# fingerprinting after is the ordering that matches the runbook. The recorded +# digest is the same either way, and since smithy#27 that is true without +# qualification rather than in principle. # # THE INVARIANT this role is written around: 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 one question the digest exists # for - "were these two runs performed under the same conditions?" - and # "same environment, two target versions" is the comparison the whole -# exercise is built on. Most of what arc writes is already out of the -# capture's reach, but ONE PATH BREAKS IT and the break is live - read both -# halves below before trusting a digest taken around this role. +# exercise is built on. THE INVARIANT HOLDS on current main. Every path arc +# writes for the target is pruned from the capture or never walked at all, +# and the list below is the whole of it. # -# Out of reach already: +# Out of reach: # # ~/.local/share/metafactory/arc/repos/ the clone (arc v0.45.0 # src/lib/paths.ts: reposDir = /metafactory/arc/repos on the @@ -34,38 +34,49 @@ # ~/.claude/skills|agents|... arc's host drops. Same - not # walked. # -# THE INVARIANT DOES NOT HOLD TODAY. Stated here rather than discovered by -# whoever first diffs two captures: +# ~/.bun/install/cache the download blobs and the +# extracted package trees bun writes when arc runs `bun install` in the +# target's repo (arc README, "If a package's repo root has a +# package.json with dependencies"). Pruned WHOLESALE - smithy#27, +# merged. Confirm it on this tree rather than trusting this comment: +# scripts/vm-fingerprint.sh carries `-path "$d/install/cache" -prune` +# in BOTH layer2 find passes, the name walk and the content hash. A +# prune present in only one of the two would still move the digest. # -# ~/.bun/install/cache IS hashed into CORE, and this install populates -# it. vm-fingerprint.sh excludes only `$d/install/cache/*.npm` - the -# compressed download blobs - and deliberately keeps the EXTRACTED -# package trees beside them ("are what global/node_modules symlinks -# into, and stay in"). arc runs `bun install` in the target's repo -# after landing symlinks (arc README, "If a package's repo root has a -# package.json with dependencies"), and cortex has a large dependency -# tree, so installing the target writes hundreds of extracted package -# directories straight into the hashed set. On this workstation the -# cache holds 154 `.npm` blobs (excluded) beside 614 other entries -# (hashed). +# The last entry is called out because it was NOT always true, and the +# history is the reason to trust the current state rather than re-litigate +# it. The prune used to match `$d/install/cache/*.npm` - the compressed +# download blobs only - and deliberately kept the extracted package trees +# beside them. That reading held while bun was the only thing growing the +# directory. It stopped holding once an arc-installed target ran `bun +# install` for its own dependencies: the TARGET then populated the hashed +# set, and the environment digest moved with the target's dependency tree, +# which is precisely the coupling the two-plane split exists to prevent. +# smithy#27 closed it by pruning the cache wholesale, the same call already +# made for `bin` and `share/metafactory`. # -# So a capture taken before and after this role runs WILL differ in CORE -# today, and the digest would move with the target's dependency tree - which -# is precisely the coupling the split exists to prevent. The fix belongs in -# vm-fingerprint.sh, above the seam, not here: prune `install/cache` -# wholesale, the same call already made for `bin` and `share/metafactory`. -# It is in flight upstream as fix/prune-bun-install-cache. Until that lands, -# this role's digest-identity acceptance criterion (crucible#26: "a capture -# taken before and after this role runs has an identical core digest") CANNOT -# PASS, and nothing here can make it pass. +# So a capture taken before this role runs and one taken after have an +# identical CORE digest, and the digest-identity acceptance criterion this +# role was written against (crucible#26: "a capture taken before and after +# this role runs has an identical core digest") is satisfied by the +# fingerprint script as it stands. Nothing in this role has to make that +# pass; nothing in this role can break it either, which is the point - the +# guarantee lives above the seam. # -# What IS already true, and is what the fix completes rather than creates: -# every path arc writes for the target itself is pruned or never visited, so -# once the cache leak is closed the invariant holds without further work. The -# target is recorded by reference (name plus resolved commit) in the run -# receipt instead - which is what metafactory_cortex_installed_commit below -# is for. See the RESIDUAL note at the end of this file for the remaining -# edges. +# Upstream's own receipt for the fix is the check, not this paragraph: +# evidence/op-20260901-post-pr-27-fingerprint-diff.md diffs a capture taken +# before smithy#27 against one taken after. The only lines that move are the +# 537 `.bun/install/cache` entries leaving the walk, and the core and +# combined digests that necessarily follow them. No other path in the +# capture shifted - so the fix removed the leak and nothing else. +# +# The target is therefore recorded by REFERENCE - name plus resolved commit - +# in the run receipt rather than by inclusion in the digest, which is what +# metafactory_cortex_installed_commit below is for. Two edges remain that the +# digest no longer feels: cortex's dependency repos install unpinned +# (the-metafactory/arc#398), and the first install can create parent +# directories the prune does not cover. Both are in the RESIDUAL note at the +# end of this file. # # THREE THINGS ABOUT arc v0.45.0 THAT SHAPE EVERY TASK BELOW, all read out of # the pinned source rather than assumed: @@ -411,10 +422,11 @@ # --- RESIDUAL, stated rather than hidden ------------------------------- # -# Three things this role cannot close, all recorded so the next reader does -# not have to re-derive them. The first is the ~/.bun/install/cache leak in -# the header - the big one, and the only one that blocks an acceptance -# criterion. The other two: +# Two things this role cannot close, recorded so the next reader does not have +# to re-derive them. A third used to head this list - the ~/.bun/install/cache +# leak, the only one that blocked an acceptance criterion - and it is gone: +# smithy#27 prunes the cache wholesale and is merged. Neither of the two below +# moves the core digest. # # 1. Beyond the paths arc writes, the prune does NOT cover their PARENT # directories: vm-fingerprint.sh prunes `.local/share/metafactory` and @@ -437,7 +449,18 @@ # arc-manifest.yaml declares depends_on.packages - five adapter/renderer # repos - and arc installs those with `install({ repoUrl: dep.repo })`, # passing no pinnedRef (src/commands/install.ts). They therefore land on -# whatever their default branch is at the time. That does not touch the -# core digest (same pruned tree) and does not affect the commit this role -# asserts, but the tree under reposDir is not fully determined by the pin, -# and a claim that it is would be wrong. +# whatever their default branch is at the time. This is an arc-side gap, +# not a smithy one, and it is tracked as the-metafactory/arc#398. +# +# What it does NOT do, since smithy#27: move the core digest. The whole of +# reposDir sits under the pruned `share/metafactory`, so an unpinned +# dependency landing on a newer commit changes nothing the capture hashes, +# and it does not affect the commit this role asserts either - that is +# cortex's own HEAD, which the pin fixes and the self-assert proves. +# +# What it still costs is full-tree determinism: two runs at the SAME pin +# can have different dependency source under reposDir. The environment is +# reproducible and the target's own commit is reproducible; the target's +# transitive source is not, and a claim that the pin fixes everything +# installed would be wrong. It matters for anyone reasoning about a result +# from the tree rather than from the pin. diff --git a/inventory-example.yaml b/inventory-example.yaml index 8ac8ea5..7df270e 100644 --- a/inventory-example.yaml +++ b/inventory-example.yaml @@ -39,15 +39,20 @@ ipv4: dhcp # goes before assay_env, which is the order the runbook reads in: install the # target, then fingerprint. # -# The target is not MEANT to be part of the environment's identity, and -# everything arc writes for it is pruned from the capture - except one path. -# ~/.bun/install/cache is still hashed into CORE and `bun install` populates -# it, so today a capture taken after this role differs from one taken before. -# See the header of ansible/roles/metafactory_cortex/tasks/main.yaml; the fix -# belongs in scripts/vm-fingerprint.sh and is in flight upstream. Listing -# `claude` before this role also matters: it is the only role that creates -# both ~/.local and ~/.local/share, whose absence would otherwise move the -# digest by a directory name on the first install. +# The target is not part of the environment's identity, and every path arc +# writes for it is pruned from the capture or never walked - including +# ~/.bun/install/cache, which smithy#27 now prunes wholesale. So a capture +# taken after this role has the same core digest as one taken before it. The +# header of ansible/roles/metafactory_cortex/tasks/main.yaml carries the full +# argument and points at upstream's receipt for the fix. +# +# Two things it does not cover. Listing `claude` before this role matters: it +# is the only role that creates both ~/.local and ~/.local/share, whose +# absence would otherwise move the digest by a directory name on the first +# install. And the pin fixes cortex's own commit only - its dependency repos +# install unpinned (an arc-side gap, the-metafactory/arc#398). That no longer +# moves the digest, but it does mean the source tree under reposDir is not +# fully determined by the pin. # # assay_env goes LAST. It installs nothing: it fingerprints whatever the roles # before it put on the machine and writes that identity to From 02052203c0065677144b45660a2a5e1b425ec5f0 Mon Sep 17 00:00:00 2001 From: Andreas Date: Thu, 3 Sep 2026 07:04:01 +1200 Subject: [PATCH 4/4] metafactory_cortex: stop --check rejecting a fresh host, and qualify 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 Claude-Session: https://claude.ai/code/session_018PtJarXm8d8bSHDbhmskVm --- .../roles/metafactory_cortex/tasks/main.yaml | 116 +++++++++++++++--- inventory-example.yaml | 26 ++-- 2 files changed, 112 insertions(+), 30 deletions(-) diff --git a/ansible/roles/metafactory_cortex/tasks/main.yaml b/ansible/roles/metafactory_cortex/tasks/main.yaml index 7ce686e..47c3bc5 100644 --- a/ansible/roles/metafactory_cortex/tasks/main.yaml +++ b/ansible/roles/metafactory_cortex/tasks/main.yaml @@ -16,9 +16,11 @@ # thing under test moved could not answer the one question the digest exists # for - "were these two runs performed under the same conditions?" - and # "same environment, two target versions" is the comparison the whole -# exercise is built on. THE INVARIANT HOLDS on current main. Every path arc -# writes for the target is pruned from the capture or never walked at all, -# and the list below is the whole of it. +# exercise is built on. THE INVARIANT HOLDS on current main WHEN THE PARENT +# DIRECTORIES THE CAPTURE WALKS ALREADY EXIST - see the qualifier below, and +# residual 1 at the foot of this file. Every path arc writes for the target +# is pruned from the capture or never walked at all, and the list below is +# the whole of it; what is not pruned is the parents those paths hang from. # # Out of reach: # @@ -55,13 +57,21 @@ # smithy#27 closed it by pruning the cache wholesale, the same call already # made for `bin` and `share/metafactory`. # -# So a capture taken before this role runs and one taken after have an -# identical CORE digest, and the digest-identity acceptance criterion this -# role was written against (crucible#26: "a capture taken before and after -# this role runs has an identical core digest") is satisfied by the -# fingerprint script as it stands. Nothing in this role has to make that -# pass; nothing in this role can break it either, which is the point - the -# guarantee lives above the seam. +# So on a host where ~/.local and ~/.local/share already exist, a capture +# taken before this role runs and one taken after have an identical CORE +# digest, and the digest-identity acceptance criterion this role was written +# against (crucible#26: "a capture taken before and after this role runs has +# an identical core digest") is satisfied by the fingerprint script as it +# stands. Nothing in this role has to make that pass; nothing in this role +# can break it either - the guarantee lives above the seam. +# +# THE QUALIFIER IS NOT DECORATION. On a spec that omits the `claude` role - +# a perfectly valid spec - those parents may not exist, and the FIRST +# install of the target creates them. The capture then moves by exactly +# those directory path lines. The example ordering in inventory-example.yaml +# happens to list `claude` first, but a commented example is not a +# dependency contract and must not be read as one. Residual 1 at the foot of +# this file has the mechanism. # # Upstream's own receipt for the fix is the check, not this paragraph: # evidence/op-20260901-post-pr-27-fingerprint-diff.md diffs a capture taken @@ -72,11 +82,12 @@ # # The target is therefore recorded by REFERENCE - name plus resolved commit - # in the run receipt rather than by inclusion in the digest, which is what -# metafactory_cortex_installed_commit below is for. Two edges remain that the -# digest no longer feels: cortex's dependency repos install unpinned -# (the-metafactory/arc#398), and the first install can create parent -# directories the prune does not cover. Both are in the RESIDUAL note at the -# end of this file. +# metafactory_cortex_installed_commit below is for. Two edges remain, and +# they are not the same kind: the first install can create parent +# directories the prune does not cover, which DOES move the core digest on a +# host lacking them, and cortex's dependency repos install unpinned +# (the-metafactory/arc#398), which does not. Both are in the RESIDUAL note +# at the end of this file. # # THREE THINGS ABOUT arc v0.45.0 THAT SHAPE EVERY TASK BELOW, all read out of # the pinned source rather than assumed: @@ -276,6 +287,28 @@ - metafactory_cortex_head_before.rc | default(1) != 0 or (metafactory_cortex_head_before.stdout | default('', true) | trim) != metafactory_cortex_pin | string +# CHECK MODE, and the one case it used to get wrong (smithy#30 review). +# +# Ansible skips the install task below under --check, which is correct - it +# writes. But every read in the proof block after it carries +# `check_mode: false` and therefore DOES run. On a host where the target is +# not installed yet, those reads describe a machine the install was never +# allowed to touch: arc still reports no package, and the "Refuse to report a +# commit" gate fires. A dry run would reject a host a real run provisions +# perfectly well, which is the opposite of what --check is for. +# +# So a dry run has to MODEL the pending install rather than measure its +# absence. This fact is the switch, and it is deliberately narrow: it is true +# only when check mode and a missing checkout coincide. A --check run against +# a host that already HAS the target keeps taking the full proof path +# unchanged - there the reads describe real disk, the assertions are +# meaningful, and a dry run that verifies an existing install is exactly the +# reassurance worth having. +- name: Note whether this run would install the target + ansible.builtin.set_fact: + metafactory_cortex_install_pending: >- + {{ ansible_check_mode | bool and metafactory_cortex_path_before | length == 0 }} + # Only reached when there is no checkout at all, which is also the only case # in which arc honours --pin. Re-running with the SAME pin skips this task # entirely, which is the role's idempotence claim: a second run reports zero @@ -309,6 +342,7 @@ environment: PATH: "{{ ansible_facts['user_dir'] }}/.bun/bin:{{ ansible_facts['env']['PATH'] }}" register: metafactory_cortex_list_after + when: not metafactory_cortex_install_pending | bool changed_when: false check_mode: false @@ -323,6 +357,7 @@ | map(attribute='installPath') | list | first | default('', true) }} + when: not metafactory_cortex_install_pending | bool - name: Refuse to report a commit for a checkout that is not there ansible.builtin.fail: @@ -334,13 +369,16 @@ metafactory_cortex_package to match it. Nothing has been asserted about this machine, so it holds no verified target commit. arc install said: {{ metafactory_cortex_install.stdout | default('(the install step did not run)', true) }} - when: metafactory_cortex_path | length == 0 + when: + - not metafactory_cortex_install_pending | bool + - metafactory_cortex_path | default('', true) | length == 0 - name: Read the installed checkout's HEAD # noqa: command-instead-of-module ansible.builtin.command: cmd: git rev-parse HEAD chdir: "{{ metafactory_cortex_path }}" register: metafactory_cortex_head + when: not metafactory_cortex_install_pending | bool changed_when: false check_mode: false @@ -349,6 +387,7 @@ cmd: git status --porcelain chdir: "{{ metafactory_cortex_path }}" register: metafactory_cortex_status + when: not metafactory_cortex_install_pending | bool changed_when: false check_mode: false @@ -371,6 +410,7 @@ {{ metafactory_cortex_package }} is at {{ metafactory_cortex_pin }}, verified by git in {{ metafactory_cortex_path }}. quiet: true + when: not metafactory_cortex_install_pending | bool # A commit is only an honest name for the code if the code is still the # commit. `git rev-parse HEAD` says nothing about the working tree, so a @@ -411,6 +451,7 @@ success_msg: >- {{ metafactory_cortex_path }} is clean. quiet: true + when: not metafactory_cortex_install_pending | bool # What the smoke loop reads. Set only after BOTH assertions above, so the # fact exists on a host if and only if the commit was proved on that host and @@ -419,14 +460,39 @@ - name: Record the verified commit for the smoke loop ansible.builtin.set_fact: metafactory_cortex_installed_commit: "{{ metafactory_cortex_head.stdout | trim }}" + when: not metafactory_cortex_install_pending | bool + +# The dry-run counterpart to everything above: say what a real run would do, +# report the host as CHANGED because it would be, and do not pretend to have +# proved anything. metafactory_cortex_installed_commit is deliberately NOT +# set here - 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. +- name: Report the install this dry run would have performed + ansible.builtin.debug: + msg: >- + check mode: {{ metafactory_cortex_package }} is not installed on this + host, so a real run would install it at {{ metafactory_cortex_pin }} + from {{ metafactory_cortex_repo }}. The post-install proof - HEAD + equals the pin, working tree clean - is DEFERRED TO A REAL RUN, + because it reads a checkout that check mode was not allowed to create. + This host is reported changed, not verified, and no verified commit + has been recorded for it. + changed_when: true + when: metafactory_cortex_install_pending | bool # --- RESIDUAL, stated rather than hidden ------------------------------- # # Two things this role cannot close, recorded so the next reader does not have # to re-derive them. A third used to head this list - the ~/.bun/install/cache -# leak, the only one that blocked an acceptance criterion - and it is gone: -# smithy#27 prunes the cache wholesale and is merged. Neither of the two below -# moves the core digest. +# leak, which made the digest move with the target's own dependency tree - +# and it is gone: smithy#27 prunes the cache wholesale and is merged. +# +# Of the two that remain, the FIRST can still move the core digest, on a host +# whose parent directories do not exist yet. It is a narrower failure than +# the cache leak was - a fixed cost paid once, by directory name, not a +# quantity that tracks the target - but it is not zero, and the invariant +# above is stated with that condition attached rather than despite it. # # 1. Beyond the paths arc writes, the prune does NOT cover their PARENT # directories: vm-fingerprint.sh prunes `.local/share/metafactory` and @@ -443,7 +509,17 @@ # is this role. So the safe statement is: list `claude` before this role, # or accept that the first capture on a claude-less spec moves by one # directory name. Either way the difference is a path line, not the -# target's contents. +# target's contents - but "list claude first" is advice in a comment, and +# a spec that ignores it is still a valid spec, so this role cannot +# enforce it and does not claim to. +# +# Where the real fix lands: the planned follow-up to model role +# dependencies explicitly, so an ordering requirement is declared and +# checked rather than written in prose and hoped for. That is upstream's +# refactor to make and is deliberately NOT attempted in this PR - a +# role-local workaround here (pre-creating the parents, say) would put a +# second, quieter answer in the tree and make the real one harder to +# land. # # 2. The pin fixes cortex's own commit and nothing else. cortex's # arc-manifest.yaml declares depends_on.packages - five adapter/renderer diff --git a/inventory-example.yaml b/inventory-example.yaml index 7df270e..03a96cb 100644 --- a/inventory-example.yaml +++ b/inventory-example.yaml @@ -41,17 +41,23 @@ ipv4: dhcp # # The target is not part of the environment's identity, and every path arc # writes for it is pruned from the capture or never walked - including -# ~/.bun/install/cache, which smithy#27 now prunes wholesale. So a capture -# taken after this role has the same core digest as one taken before it. The -# header of ansible/roles/metafactory_cortex/tasks/main.yaml carries the full -# argument and points at upstream's receipt for the fix. +# ~/.bun/install/cache, which smithy#27 now prunes wholesale. So on a host +# where ~/.local and ~/.local/share already exist, a capture taken after this +# role has the same core digest as one taken before it. The header of +# ansible/roles/metafactory_cortex/tasks/main.yaml carries the full argument +# and points at upstream's receipt for the fix. # -# Two things it does not cover. Listing `claude` before this role matters: it -# is the only role that creates both ~/.local and ~/.local/share, whose -# absence would otherwise move the digest by a directory name on the first -# install. And the pin fixes cortex's own commit only - its dependency repos -# install unpinned (an arc-side gap, the-metafactory/arc#398). That no longer -# moves the digest, but it does mean the source tree under reposDir is not +# That condition is why `claude` leads the list below rather than sitting +# anywhere in it: it is the only role that creates both ~/.local and +# ~/.local/share. On a spec that omits it - which is a valid spec - the first +# install of the target creates those directories itself and the core digest +# moves by exactly those path lines. This ordering is an EXAMPLE, not a +# dependency contract; declaring and checking it properly is upstream's +# planned role-dependency follow-up, not something this role can enforce. +# +# Separately, the pin fixes cortex's own commit only - its dependency repos +# install unpinned (an arc-side gap, the-metafactory/arc#398). That one does +# not move the digest, but it does mean the source tree under reposDir is not # fully determined by the pin. # # assay_env goes LAST. It installs nothing: it fingerprints whatever the roles