Skip to content

fix(devx): the ablation preflight reads the WHOLE tree, not only dist/ - #13288

Merged
os-elon merged 2 commits into
mainfrom
claude/issue-13265-ablation-baseline-leak
Aug 30, 2026
Merged

fix(devx): the ablation preflight reads the WHOLE tree, not only dist/#13288
os-elon merged 2 commits into
mainfrom
claude/issue-13265-ablation-baseline-leak

Conversation

@os-elon

Copy link
Copy Markdown
Collaborator

Fixes#13265

scripts/ablation-dist-preflight.mjs proved an ablation reached dist/. It said nothing about the working tree — so on a package whose build writes CHECKED-IN artifacts, an ablation is a two-file mutation and the restore leg puts back only one of them. This adds the whole-tree half to the instrument that already runs at exactly that moment.

The defect, reproduced end to end on this branch

One run, packages/spec, plant ablation (osAblationLeakProbe declared on FieldSchema), each leg confirmed on disk by blob hash and marker count before any reading was taken:

stepreading
1 mutatemarker count in the source 0 -> 1; blob d6c5b1a6 -> 34c45842
2 buildtree now carries two modified paths: the source, and packages/spec/authorable-surface/data.json with + "data/Field:osAblationLeakProbe"
4 restore ONLY the mutated sourcegit diff HEAD -- THE_MUTATED_PATH0 lines · git hash-object== HEAD · grep -c marker0 · whole-tree git status --porcelain1 dirty path
5 rebuildexit 11 authorable key(s) disappeared from the contract + the ADR-0049 enforce-or-remove prescription, i.e. the false red, about a key that never existed

Step 4 is the whole finding: all three per-path proofs the ablation discipline names pass on a tree that is not restored. The silence is directional, so a stricter per-path proof cannot close it — only a whole-tree read can.

What changed

readTreeStatus + markerPresence (impure) feed classifyTree / treeVerdict (pure, self-tested), and both modes now print a tree: verdict beside the existing dist: one.

Which leg you are on is DERIVED from the marker, not declared. A dirty tree is correct on a mutate leg and wrong on a restore leg, and the two share a command line (--absent is both the delete-ablation mutate leg and the plant-ablation restore leg). Per dirty path, against HEAD:

  • present mode — a path that gained the marker is the plant → MUTATE leg
  • absent mode — a path that lost the marker is the deleted guard → MUTATE leg
  • neither, on any dirty path → RESTORE leg

On a MUTATE leg every dirty path is listed and nothing is fatal (the build was supposed to write them; this is the earliest moment the restore leg's true size is knowable — it is two files, and here they are). On a RESTORE leg the tree must be clean and every dirty path is fatal and named.

No CLI change, deliberately. Both documented spellings keep their meaning, so the three documents that state this script's invocation stay accurate and untouched — two of them (.claude/agents/os-dev.md, .claude/skills/dogfood-verification/SKILL.md) are governed surfaces no code PR may edit. The fullest statement of the new half lives in the script's own header, which all three already point at.

Whole-tree, not a path list.packages/spec is the instance (gen:schema writes authorable-surface/, json-schema.manifest/, api-surface/), not the class. Measured blast radius: of every workspace build script, two invoke a generator — @objectstack/spec and @objectstack/docs, the latter delegating to spec's gen:schema/gen:docs, which writes content/docs/references/. A fix enumerating known-dirty paths would leave every other generated-artifact package with the same hole; a git status --porcelain read has no list to fall out of date. Untracked paths count too: a sharded artifact gains FILES, and git add -A takes them.

The differential, measured on the same tree

At the restore leg of the run above, both scripts on the same unrestored tree:

  • old (HEAD~)marker still present in 26 built files ... Rebuild the package, exit 1. True, and it sends the agent into a rebuild loop against a build that is refusing for a different reason.
  • new — the same dist: line, plustree: restore leg ... 1 path still differs from HEAD naming packages/spec/authorable-surface/data.json, exit 1.

Following the new refusal's prescription (git checkout HEAD -- packages/spec/authorable-surface/data.json) took the tree to 0 dirty paths, the rebuild to exit 0, and the preflight to dist: green + tree: green. Both verdicts are computed and printed before either exits, deliberately: in the measured incident both were true at once, and reporting only the first is what hides the second.

Local checks — union re-run on the final commit 518eb917df

  • node scripts/ablation-dist-preflight.mjs --self-test — exit 0, all cases pass (36 cases; 13 pre-existing, 23 new). The new ones include a real-git leg that replays this incident: it asserts the per-path diff is EMPTY on the unrestored tree, that the restore leg reds, and that the red NAMES the leaked baseline.
  • gate family derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (12 families, derived from the merge base, not hand-listed) — check:agent-test-spelling, check:bash32-floor, check:cli-command-ids, check:cross-package-test-inputs, check:entry-guard, check:parse-guard, check:pnpm-filter-targets, check:watch-hint-literal, check-ci-filter-parity.mjs, check-shard-attestation.mjs — all exit 0, each quoting its own verdict line.
  • pnpm check:nul-bytes — exit 0, 75 assertions self-test plus scanned 7359 text file(s) ... no raw ASCII control bytes.
  • node scripts/check-test-completeness.mjs — exit 3, PREREQUISITE NOT MET: it grades a saved turbo test log and none was named. Recorded as NOT MEASURED, which its own text prescribes; not a red.
  • Lint, narrowed and declared: eslint --no-inline-config --format json over the one changed file — 1 file linted, 0 errors, 0 warnings, exit 0. The narrowing is a measurement, not a skip: this repo runs one eslint.config.mjs which never enables type-aware linting for any file (no parserOptions.project, no typed rules — its own comment at line 328 states this, and a grep confirms only ecmaVersion/sourceType parser options), so no untouched file's verdict can depend on this diff, and the diff adds no config, no inline disable and no new file. The repo-wide sweep remains CI's.

Declarations

  • Contract accept/reject behaviour: UNCHANGED. No schema, no validator, no runtime path is touched. The one file changed is dev-side agent tooling that CI never runs.
  • Published surface: UNCHANGED.scripts/ ships in no package; nothing under skills/ or packages/ is touched.
  • skip-changeset: this PR releases nothing.

What it still does not cover

An agent that runs no preflight at all on the restore leg. The mechanism is reachable today by the documented procedure (.claude/agents/os-dev.md already prescribes --absent on every restore leg), so no prose change is required for it to fire — but a belt-and-braces sentence in that file would be a governed-surface card, not this one.


Generated by Claude Code

@os-elonos-elon added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 29, 2026 — with Claude
@os-elon
os-elon marked this pull request as ready for review August 30, 2026 06:15
@os-elon
os-elon enabled auto-merge August 30, 2026 06:15
@os-elon
os-elon added this pull request to the merge queueAug 30, 2026
Merged via the queue into main with commit 3dd315aAug 30, 2026
34 checks passed
@os-elon
os-elon deleted the claude/issue-13265-ablation-baseline-leak branch August 30, 2026 06:48
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@os-elon@claude