Skip to content

test(merge-driver): pin the os-regen deferral refusal on the linked-worktree path, and make the self-test exit non-zero when it fails - #18165

Merged
claude[bot] merged 2 commits into
mainfrom
claude/issue-17602-os-regen-zero-exit
Sep 14, 2026
Merged

claude[bot] merged 2 commits into
mainfrom
claude/issue-17602-os-regen-zero-exit

Conversation

@claude

@claude claude Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Fixes #17602

PREMISE P was measured first, and it HOLDS — so the driver is not the defect

The card asks whether a zero exit from merge=os-regen can be trusted. The domain:devx
seat's mechanism reading (comment 5631161285) narrowed that to one falsifiable premise:
is content/docs/references/** really "generated whole", as .gitattributes:84 declares?
If yes, the unconditional deferral is the design and markPending + regeneration closes
the loop; if no, the row is MIXED without a mixed field and the deferral is a silent
deletion — and the defect would be in the declaration.

Measured on origin/main 394ccf549, with the post-driver state reproduced exactly (the
artifact missing main's side while the source still carries it), the generator re-run, and
both controls lit:

                                          before mutation   after mutation   after gen:docs
main's #groupBy whitespace note                  2                0                2     <- P
FIRING CONTROL  (marker injected into the mdx)   0                1                0
EMPTY CONTROL   (a string never written)         0                0                0
blob sha of content/docs/references/ui/view.mdx
  HEAD          2fa435c1a5ee1faf3797b37214ce5e7e426325a3
  mutated       2e40a12d888d9babaa254974a69b3feece62a93a   (the mutation landed on disk)
  regenerated   2fa435c1a5ee1faf3797b37214ce5e7e426325a3   (byte-identical to HEAD)

The note is not hand-written: it is a .describe() at packages/spec/src/ui/view.zod.ts:917,
rendered into the page by gen:docs. The firing control proves the generator rewrites this
file wholesale rather than skipping or appending — the injected marker is gone. Regeneration
reproduces the page byte for byte.

⇒ .gitattributes:84 is true for this family. scripts/git-merge-regen.mjs behaved exactly
as its own comment says it should, and ⛔ nothing in this PR changes the driver's merge
behaviour, weakens a gate, adds an exemption, or makes the driver prefer a side in a new case.

So the work is ask 2 — and the safety net DOES hold, but nothing proved it

The remaining question is the one the ruling names: on the path this incident took — a
human or agent hand-repairing a merge in a linked worktree — does the refusal actually
fire? Measured live, in this worktree, with a real merge of a real routed path:

git merge exit                                      0   (the card's zero exit, reproduced)
marker written to .git/worktrees/.../os-regen-pending YES
the ordinary commit right after                     REFUSED by .githooks/pre-commit
the push                                            REFUSED by .githooks/pre-push

The net holds. What is unguarded is that no test says so. The driver ends every deferral
by printing "The pre-commit hook will not let this commit through until you do", and the two
cases that come closest each stop one step short in the same direction:

  • endToEnd() performs a real merge, asserts the marker exists, and never commits — so git
    never runs a hook.
  • check-regen-pending.mjs's deferred-merge fixture sets core.hooksPath=/dev/null ("the
    fixture drives the script itself") and writes the marker by hand — it grades the checker's
    verdict, never git's willingness to invoke it, and never the path the driver wrote to.

The joint property had no coverage at all, and the one arrangement in which it can break is
the one AGENTS.md Prime Directive #11 makes mandatory. markPending() in the driver and
gitDirPath() in the checker are two independent --absolute-git-dir call sites; in a
primary checkout that directory and --git-common-dir are the same directory, so a
regression to the common dir keeps every single-tree fixture green while every real merge in
every agent's worktree silently loses its refusal.

What landed

1. endToEndWorktreeRefusal() — a sixteenth self-test battery. It builds a throwaway
repo with a linked worktree, reaches the REAL .githooks and the REAL checker through
untracked symlinks (so the relative core.hooksPath resolves per worktree, as
setup-git-hooks.mjs registers it — a hook this file wrote out would test an imitation),
merges a routed path inside the linked worktree, and asserts nine legs:

leg assertion
1 the marker lands in the LINKED worktree's own git dir
2 …and NOT in the shared common git dir — the discrimination only a linked worktree can make
3 the ordinary commit after the merge is REFUSED, by git, through the real pre-commit
4 …naming the artifact, so the refusal is this one and not some other failure
5,6 the push is REFUSED too, by the real pre-push, via its os-regen leg
7,8,9 FIRING CONTROL — with the gate clean the same commit is ACCEPTED, the marker is gone, and the push goes through

Legs 7-9 are what legs 1-6 rest on: a hook that can only fail — a missing interpreter, a
typo'd path, node erroring — satisfies every refusal assertion while proving nothing, and
from the outside that reads exactly like "the net holds".

2. The self-test's exit code now agrees with its own verdict. Found by the ablation below,
not by reading. The --self-test dispatch computed failures from the booleans its callees
return and printed ✗ merge driver wiring is inconsistent — N failure(s) from that count —
then exited 0. The only thing that ever set a non-zero code was fail(), which every
callee happened to call. The two agreed by convention, not by construction, and
pnpm check:merge-driver chains this file with &&: the first callee to report a failure by
returning false alone would have made the whole battery advisory. The verdict line and the
exit code now come from the same count.

This is an in-place fix under the bounded exemption — same defect class as the card (a failed
check reporting as a pass), mechanical, on a file this PR already declares, in the same gate
family, adding no new verification surface.

Ablation — the battery can fail, and the existing cases cannot catch what it catches

markPending() regressed to the shared common git dir (the exact regression class the case
exists for), run from the committed state, mutation proven on disk before the run and the
restore proven byte-identical after:

occurrences of the replaced line   1 -> 0       injected marker  0 -> 1
blob sha  HEAD 4506a6d5662f19e7197eaf7fdce22b587f185164 -> ebd8f1077e725251161a067d50905c3d8b116c10

self-test exit under the ablation                          1     (before fix 2: 0 — see above)
  ✗ endToEndWorktreeRefusal — all six refusal legs red, firing control red
  ✓ end-to-end: conflicting packages/spec/spec-changes.json … recorded as pending   <- STILL GREEN
  ✓ end-to-end (mixed): anchors-only deferred to OURS …                             <- STILL GREEN
  ✓ probe gate: `git merge-tree` reaches the driver and records nothing             <- STILL GREEN

restore: blob sha back to 4506a6d5…, residual injected text 0, `git diff HEAD` empty

Three pre-existing end-to-end cases stay green through a regression that disarms the refusal
for every worktree in the container. That asymmetry is the case's whole justification.

Which of the three unreliable signals this makes trustworthy

Triage 5628590957 records three cards describing one apparatus whose every signal is
unreliable, and asks which one a change repairs. This PR repairs the driver's exit code
being the only evidence the merge was safe
(#17602): after it, the claim "the hook refuses
until regeneration has run" is a test rather than a sentence, on the path real merges take.

It also, incidentally, moves #13799's signal for this one member: the os-regen-merge
self-test (.github/workflows/lint.yml) is named in #13799's population, and fix 2 means that
battery can no longer report a failure at exit 0. ⛔ That is one member, ⛔ not #13799.

It does not touch #16717 — check:merge-driver remains red on macOS for the
runner-missing classifier's reasons, so a green local run of this battery is not evidence
there. The new case is platform-neutral (git, symlinks, pnpm -s on a stub), but it inherits
#16717's blind spot like every other case in the file.

Effect on #16612 (shares this file, serialized behind this card)

Neither easier nor harder — but it is now instrumented. #16612 proposes retiring the only
mixed row, which would drop system-context.mdx onto the unconditional-deferral branch this
card measured. Nothing here changes that branch. What changes is that whoever takes #16612 now
has a case that proves the deferral's refusal actually fires end to end, so the question
"is the safety net behind the deferral real?" is answerable by running pnpm check:merge-driver
instead of by argument. ⛔ Not fixed in passing; ⛔ not re-priced here.

Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived
32 commands from the real diff (1 path, scripts/git-merge-regen.mjs, vs merge base
394ccf549). All 32 run and exit 0, reconciled with --ran. Exit codes landed to disk per
command. pnpm check:merge-driver — the gate that owns this battery — is among them.

pnpm lint's scan was not narrowed and needs no narrowing argument: the whole set ran at
3d8e4c63e — eslint . --no-inline-config --format json over 6752 files, 0 errors,
0 warnings. (The config declares it never enables type-aware linting for any file, so a
one-file diff could not have moved another file's verdict in any case.)

changeset: skip-changeset, measured rather than assumed. The diff is one root
scripts/** file; the root package is private: true; all 70 published packages declare
files[] and none can reach root scripts/; the new symbol occurs in zero published package
sources, against a lit positive control (defineStack hits in packages/spec/src).

Acceptance notes

  • SELF_TEST_BATTERY_FLOOR moved 15 to 16 and the roster/dispatch gained the new callee, as
    that mechanism requires. The callee-count prose in the same block moved with it; one number
    there ("the other thirteen") was already off by one against a roster of fifteen and is now
    correct at fifteen against sixteen. Mechanical, in the block being edited.
  • Noted, not filed: check-regen-pending.mjs's deferred-merge fixture still sets
    core.hooksPath=/dev/null and writes its marker by hand. That is deliberate and correct for
    what it grades (the checker's verdict machine), and the new case covers the half it cannot;
    a second end-to-end fixture there would duplicate it. Successor: whoever next widens that
    fixture's scenario set.
  • Noted, not filed: the driver's --self-test prints its verdict and, before this PR, derived
    its exit code elsewhere. The sibling self-tests chained beside it in check:merge-driver
    (git-env.mjs, check-regen-pending.mjs) were not audited for the same shape — out of scope
    here, and neither is known to have it. Successor: none currently queued.

Generated by Claude Code

… path

`git-merge-regen.mjs` ends every deferral by printing "The pre-commit hook
will not let this commit through until you do." Nothing tested that
sentence: `endToEnd()` asserts the marker was written and never commits, and
`check-regen-pending.mjs`'s fixture sets `core.hooksPath=/dev/null` and
writes the marker by hand, so it grades the checker's verdict rather than
git's willingness to invoke it.

The joint property therefore had no coverage, and the one arrangement in
which it can break is the one this repo mandates: a linked worktree. The
driver's `markPending()` and the checker's `gitDirPath()` are two independent
`--absolute-git-dir` call sites; in a primary checkout that directory and
`--git-common-dir` are the same directory, so a regression to the common dir
stays green in every single-tree fixture while every real merge loses its
refusal.

`endToEndWorktreeRefusal()` builds a throwaway repo with a linked worktree,
reaches the REAL `.githooks` and the REAL checker through untracked symlinks
(so the relative `core.hooksPath` resolves per worktree, as registered),
merges a routed path inside the linked worktree and asserts: the marker lands
in the linked git dir and not the common one; git's own pre-commit and
pre-push refuse; and — the firing control — with the gate clean the same
commit and push go through and the marker is gone.

Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
Co-authored-by: Claude <noreply@anthropic.com>
…verdict

The `--self-test` dispatch computed `failures` from the booleans its callees
return, printed "✗ merge driver wiring is inconsistent — N failure(s)" from
that count, and then exited 0: the only thing that ever set a non-zero code
was `fail()`, which every callee happened to call. The two agreed by
convention, not by construction. `pnpm check:merge-driver` chains this file
with `&&`, so the first callee to report a failure by returning `false`
alone would have made the whole battery advisory — a failed gate reading as
a pass, which is the shape this file exists to prevent.

Measured on the ablation for the case landed alongside this: with
`markPending()` regressed to the shared common git dir, the run printed nine
failing legs and "✗ … 1 failure(s)" and exited 0.

The verdict line and the exit code now come from the same count, and the new
linked-worktree case reports through `fail()` like every other callee.

Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
Co-authored-by: Claude <noreply@anthropic.com>
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 14, 2026
@claude
claude Bot marked this pull request as ready for review September 14, 2026 09:31
@claude
claude Bot added this pull request to the merge queue Sep 14, 2026
Merged via the queue into main with commit 689d606 Sep 14, 2026
37 checks passed
@claude
claude Bot deleted the claude/issue-17602-os-regen-zero-exit branch September 14, 2026 10:03
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ee self-tests — all three immune (objectstack-ai#18896)

Fixes objectstack-ai#18166

Clause-②: no

This is an **audit** card, and the audit's answer is that **all three
self-tests chained in
`check:merge-driver` are immune** to the verdict/exit-code split.
Nothing was repaired, because
nothing is broken. No script in the population changed; the diff is one
audit record under
`docs/audits/`. Written by session `session_019srGWGCBBCBHqcDoRZpQRh`.

## 1. The population, re-derived from the script

`package.json:19` on this branch, read rather than copied from the card,
which said its own list
is a hint:

```
"check:merge-driver": "node scripts/git-env.mjs --self-test && node scripts/git-merge-regen.mjs --self-test && node scripts/check-regen-pending.mjs --self-test"
```

Exactly three files, in this order — `scripts/git-env.mjs`,
`scripts/git-merge-regen.mjs`,
`scripts/check-regen-pending.mjs`. The re-derivation agrees with the
card's hint. They are chained
with `&&`, which is the whole reason the audit matters: a zero from any
one of them **is** the gate
passing.

## 2. The gate's own exit code, before and after

| run | tree | exit |
|:--|:--|--:|
| `pnpm check:merge-driver` **before** | `0b31d90fb` (the branch point)
| **0** |
| `pnpm check:merge-driver` **after** | `e18f4d2a7` (this head) | **0**
|

Unchanged, and it has to be: no file the gate runs was edited.

## 3. The reading — exit code, never output

Per file: is there any path on which the dispatch prints a failing
verdict, or counts a failure,
while the process still exits 0? Thirteen ablations, one mutation each,
landed and verified **on
disk** by `scripts/ablation-replace.mjs` (anchor hit exactly once,
occurrence counts and blob hash
before and after), then restored from `HEAD` with the restore proven by
`blob == HEAD` and an empty
`git diff HEAD`. No editor of the `-i` family was used and no mutation
was left in the tree.

| # | file | the mutation | reading | direction |
|:--|:--|:--|--:|:--|
| A1 | `git-env.mjs` | one registered case reports failure | **1** |
fires |
| A2 | `git-env.mjs` | failing verdict line kept, exit decoupled from
the count | **0** | discriminates |
| A3 | `git-env.mjs` | case floor raised, so the floor summand fires |
**1** | fires |
| A4 | `git-env.mjs` | `selfTest()` returns a value that is not the
verdict | **1** | fires |
| B1 | `git-merge-regen.mjs` | `hookIsExecutable` reports failure by
`return false` **alone** — no `fail()`, and it still prints its own tick
| **1** | fires |
| B2 | `git-merge-regen.mjs` | a failure counted **and** PR objectstack-ai#18165's
`process.exitCode` line removed | **0** | discriminates |
| B3 | `git-merge-regen.mjs` | same forced count, that line left in
place | **1** | fires |
| B4 | `git-merge-regen.mjs` | a `registerCase()` deleted, so the floor
breaches through `fail()` | **1** | fires |
| C1 | `check-regen-pending.mjs` | `decisionTableSelfTest` returns false
while all of its own rows print a tick | **1** | fires |
| C2 | `check-regen-pending.mjs` | a failure counted, verdict line still
printed, exit decoupled | **0** | discriminates |
| C3 | `check-regen-pending.mjs` | same forced count, exit branch intact
| **1** | fires |
| C4 | `check-regen-pending.mjs` | the inline `noDist` assertion forced
false | **1** | fires |
| C5 | `check-regen-pending.mjs` | a `registerCase()` deleted, so the
floor summand fires | **1** | fires |

**Ten firing controls, and they fire.** Every exit sink of every file is
covered: git-env has two
sinks and three failure signals (A1/A3/A4), git-merge-regen has two
(B1/B3 for the count,
B4 for `fail()`), check-regen-pending has one sink fed by three summands
(C1/C4/C5). The record
lists each sink with its line number.

**Three discrimination controls, in the other direction.** Ten non-zero
readings on their own are
also what an instrument that cannot see exit 0 produces. So each file
additionally carries the same
forced failure with the count-to-exit coupling removed, and all three
printed a failing verdict and
exited **0** — the card's defect, reproduced on each of the three files,
by this exact reading:

```text
A2   git-env --self-test: 1 failure(s) [ABLATED: exit decoupled from the count]        exit 0
B2   merge driver wiring is inconsistent - 1 failure(s) (cases and floor); see above.  exit 0
C2   self-test failed -- 1 failure(s) [ABLATED: exit decoupled]                        exit 0
```

B3/C3 pair with B2/C2 on the same forced count and differ only in
whether the coupling is present,
which isolates the coupling as the thing that makes the difference. B2
in particular measures that
PR objectstack-ai#18165's repair line is load-bearing rather than decorative.

## 4. The judgement the card left open

The card raises one shared verdict/exit helper against three copies of
`if (failures > 0)`,
expressly as a judgement on measurements. The measurement puts the
repair population at **zero**,
so neither route earns itself: rewriting the verdict site of three
working instruments would add
risk with no defect to remove, and `check:merge-driver` is itself the
gate that would have to judge
that rewrite. **No gate weakened, no exemption added** — acceptance
clause 2 holds trivially, since
no file the gate reads was touched.

## 5. Gates

Derived from the actual changed path, never from a named list:
`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands`, re-derived after
the `origin/main` merge so the stale-tree warning cleared.

**17 derived families, 17 run, all exit 0, 0 NOT MEASURED** — reconciled
with `--ran`, which
confirms the zero is derived from recorded exit codes rather than
claimed:

> dispatch-gates --ran: 17 derived famil(ies) accounted for — 17 run, 0
NOT-MEASURED (a DERIVED zero — all 17 recorded an exit code and none of
them is 3).

Every exit code was captured by redirecting first and reading `$?`
second, never after a pipe.
One family needed a second pass: `check:doc-formula-expressions` first
answered **3**, its own
`PREREQUISITE NOT MET` code ("Nothing was measured"), because
`@objectstack/formula` and
`@objectstack/lint` were unbuilt in a fresh worktree; after
`turbo run build --filter=@objectstack/formula
--filter=@objectstack/lint` it answered **0**. That
3 is recorded as not-a-finding, not as a failed measurement.
`check:issue-citations --base origin/main` was also run defensively,
since the diff adds four issue
citations: exit 0, and it read **0 files** — `docs/audits/` is outside
its declared surface, so that
is a not-applicable rather than a pass.

**Repo-wide eslint, narrowed — and the narrowing is measured, not
assumed.** Taken at
`e18f4d2a7`, this branch's final commit:

1. **Population, read from eslint's own config:** every config block in
`eslint.config.mjs` scopes
its `files` glob to `ts,tsx,mts,cts,js,jsx,mjs,cjs`. No block names a
Markdown extension.
2. **Count, from `--format json`:** 1 result for the changed path,
carrying exactly one message —
`File ignored because no matching configuration was supplied.` Zero
rules were evaluated on it.
3. **Invariance for untouched files:** `eslint.config.mjs` states, with
its own positive control,
that this repo "never enables type-aware linting (no
`parserOptions.project`, no typed
`@typescript-eslint` rules) for ANY file". A one-file Markdown diff
therefore cannot move the
   verdict of any file it does not contain.

So the narrowing excludes nothing: the only changed path is not in the
examined population at all.
CI still runs the full farm.

## Acceptance notes

Observations from the audit, noted and deliberately **not** filed — none
is a reproducible defect,
a declared-contract violation, or a metadata-authoring trap:

- `check-regen-pending.mjs`'s two inline dispatch assertions (`noDist`,
`noTree`) sit outside the
battery roster. That is a **declared** gap, stated at the roster with
its cost and its reason,
and this audit measures that both feed the same exit sink (C4). Not a
finding. Successor: the
  reshape card the roster comment already points at.
- `fixtureSelfTest()` reduces with `results.every(Boolean)`, which is
`true` over an empty array.
It cannot be reached empty — every path that would skip the pushes
throws out of a `try` with no
`catch`, which exits non-zero — so it is not this card's class either.
Noted for whoever next
  edits that function. Successor: none.
- `main()` in `check-regen-pending.mjs` is the production `pre-commit` /
`pre-push` path and is
outside `check:merge-driver`, which runs `--self-test` only. Its own
verdict/exit coupling was
read statically (its stale printers increment the same `blocked` count
that `decide()` turns into
the returned status) and deliberately **not** ablated. Successor: none.
- Linux only. macOS was not measured; objectstack-ai#16717 records
`check:merge-driver` staying red there for
  unrelated reasons, and objectstack-ai#13799 is not addressed here.

## 维护者速读(草稿)

**改了什么** ——
一个文件:`docs/audits/2026-09-merge-driver-verdict-exit-split.md`。三个被审计的
脚本一个字节都没动。

**为什么改** —— 卡面要的是一次测量,不是一次修复:`check:merge-driver` 用 `&&` 串起三个
`--self-test`,任何一个「打印失败判决却退出 0」就让整条电池悄悄变成 advisory。测量结果是三个都
免疫,所以本 PR 的产物是那份可复核的审计记录本身,而不是代码改动。留在 GitHub 评论里的读数下一
个人复核不到,所以它落进仓库。

**风险与代价(含回滚)** —— 风险接近零:无代码改动、无门禁改动、无豁免、不发布任何包
(`skip-changeset`)。回滚 = 删掉这一个文件。唯一的真实代价是 161 行仓内文档的维护面。

**席位意见** ——

**你要做的** —— 判断这份审计记录该不该留在 `docs/audits/`。如果认为「审计结论写进卡面评论就够
了」,直接关掉本 PR 即可,卡面报告评论已独立承载同一组读数;如果认为该留,照常合并。另:卡面
的验收第 1、3 条(每个文件带点火对照 + 重新推导 population)由这份记录与本正文同时满足。

---
_Generated by [Claude
Code](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

1 participant