Skip to content

fix(pm-tooling): refuse windowed history questions a shallow clone cannot see all of - #10506

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-9902-shallow-clone-history-guard
Aug 21, 2026
Merged

fix(pm-tooling): refuse windowed history questions a shallow clone cannot see all of#10506
os-zhuang merged 2 commits into
mainfrom
claude/issue-9902-shallow-clone-history-guard

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#9902

Three seat-run tools asked windowed history questions (--since over a branch) with no shallow guard. A shallow clone answers those from whatever part of the window happens to be present, exits 0, and prints no warning — so each returned a confidently wrong smaller number. Agent containers clone shallow, and all three run from a seat: every workflow that reads history already checks out with fetch-depth: 0, which is exactly why the exposure survived. CI is not where these run.

The census, re-derived (not taken from the card)

A mechanical sweep of every windowed history question in scripts/** + .github/workflows/**, classified by whether its file carries any horizon guard, run against origin/main (a718ee3) and against this branch:

treeunguarded / total windowed call-site lines
origin/main3 / 5
this branch0 / 10

The three it names are the three the card named, at the same file:line — independently re-derived rather than assumed:

  • scripts/pm/check-governed-merges.mjs:472
  • scripts/check-engine-split-ratio.mjs:63
  • scripts/collect-release-notes.sh:120

Positive control for the zero. A fourth, synthetic, unguarded windowed query injected into scripts/check-role-word.mjs (a file with none) is found: 4/6. So the post-fix zero is a real zero and not a blind detector. The detector also had to be widened once mid-census: written to require a git verb on the same line it missedcollect-release-notes.sh:120, where the window is passed to a shell helper (print_log_split "$CLOUD_ROOT" --since=…) and the log sits in the function. That is how a hand census loses a call site.

Classes deliberately left alone. Range/merge-base questions (A..B) fail loudly in a shallow clone — a missing endpoint is fatal: bad object. Unwindowed path history (git log -- <path>) is the other silent class, and the two live instances are already guarded: check-adr-0087-registration.mjs refuses its ledger-touch signal when --is-shallow-repository (its header records 91 of 92 rows wrong before that), and release-rehearsal-clone.mjs exists for that shape. qa-rollup.mjs is three-valued for the same reason.

dispatch-gates.mjs is NOT in the affected set (asked explicitly). Its changedPathsFromGit reads git merge-base, and its docblock records the measurement: on a shallow checkout whose base is below the graft, merge-base exits 1 with empty output and refuses the two-dot fallback with a deepen remedy. It cannot lie here, only refuse.

The harm, reproduced in this shallow container (floor e7c0d5ef7d @ 2026-06-02, tip 2026-08-21)

1. check-engine-split-ratio --days 90 — the workflow's own invocation. Its window reaches 2026-05-23, ten days below the floor:

engine-core commits: 265
also cross-package: 260
ratio: 98.1% exit 0, no warning

The truth for that window is 282. GitHub lists 18 distinct commits touching engine.ts/registry.ts between 2026-05-23 and the floor; the clone sees exactly one commit dated in that slice, and it is the graft boundary commit itself. A graft commit carries the whole tree, so it "touches" both engine-core files and everything outside packages/objectql/ — a truncated window does not merely lose data points, it fabricates one, and that one lands in the cross-package numerator.

2. check-governed-merges over a window crossing the same floor, rendered by the tool's own renderReport:

governed-merges sweep: 0 governed merge(s) since 2026-05-23 across 1/1 governed repo(s)
✓ audited objectstack-ai/objectstack — …; 1 mainline commit(s) in window
✅ clean window — no governed surface was merged in any governed repo.

GitHub lists 38 commits touching docs/adr/** and 13 touching AGENTS.md in that same window. The green tick printed over ~40 governed merges, under a line claiming the repo was ✓ audited.

3. collect-release-notes.sh section 4, on a depth-5 fixture whose true answer is 21 commits: the shallow clone printed 5, exit 0, under the same ### feat / fix heading. Sixteen release-note items missing, nothing to indicate it.

The shape: refuse when truncated, annotate when not — argued from each consumer

Not one shape for all three; the predicate is shared, the verdict is not.

  • check-governed-merges → UNAUDITED, via the exit-2 channel it already has. Under-enumeration here reads as compliance, the one direction this list must never be wrong in. The tool already holds that "incomplete must never read as clean" (check:react-declaration-parity 是唯一没接进任何 workflow 的源码审计门禁,且无 MANIFEST 时静默 skip 退出 0 —— 它现在永远不可能红 #4690) for an absent checkout; a history that stops inside the window is the same fact wearing different clothes — nothing was looked at — so it reuses that channel and adds no new exit code. A warning was rejected outright: a caveat printed beside ✅ clean window is still a green tick.
  • check-engine-split-ratio → refuse, exit 2 (cannot compute), never exit 1. It is an ADR trigger metric read to decide whether a repo split may proceed. A ratio printed beside a caveat is still a ratio and gets quoted without the caveat; a refusal cannot be quoted at all. 2 and not 1, so "I could not measure" is never confusable with "the engine is not separable". Its total === 0 ? 0 case is now loud for the same reason (check:react-declaration-parity 是唯一没接进任何 workflow 的源码审计门禁,且无 MANIFEST 时静默 skip 退出 0 —— 它现在永远不可能红 #4690).
  • collect-release-notes.sh → withhold the section, and exit 2. The consumer is a human transcribing a list; a short list is indistinguishable from a quiet release, while an absent one cannot be transcribed by accident. The exit code exists because … > material.md succeeding is otherwise the only signal a pipeline gets, and it lies.
  • And on the success path, all three now print the horizon (floor + ref tip) beside the number. These outputs are evidence — a compliance list, an ADR trigger metric, release notes — and evidence carries its provenance whether or not it is short.

None of the three deepens.check-governed-merges sweeps four sibling checkouts and collect-release-notes reads the release engineer's cloud tree; a tool must not move ground it does not own, least of all mid-audit. The remedy is printed as a command instead — and computed by chooseDeepenSince(), so the --shallow-since it prints can never be newer than the floor already present. The naive spelling is measured in git-history.mjs's header shortening a clone by 1380 commits at exit 0, which would make the remedy a second instance of the defect.

Shared helper vs per-call-site guard — the ruling, and its boundary

Shared predicate, per-call-site verdict.

The drift objection ("a helper is a second source of truth") bites when a helper duplicates knowledge the call sites also hold. Here they hold none: they import historyHorizon() and render their own words, so there is exactly one implementation of the predicate and one self-test over real git fixtures. Three hand-rolled guards would be three implementations of a predicate that is measurably easy to get wrong in two independent ways, both already recorded in git-history.mjs's header from #9878:

  1. --is-shallow-repository is the wrong predicate — a shallow clone whose floor predates the window answers exactly, and a guard refusing that refuses correct answers and trains bypass. The question is whether the floor predates the window.
  2. fetch --shallow-since is not monotonic; it shortens too.

A copy that misses either is green and wrong, which is the same failure the card is about. The helper also already existed and was merged (#9878) — writing three fresh guards beside it would have been the second source of truth, not the first.

Boundary of the ruling: the refusal wording and exit-code mapping stay per call site, because what a refusal means is consumer-specific — an UNAUDITED repo, an unmeasurable metric, an incomplete release-notes section are three different facts. Sharing the predicate; not sharing the verdict. collect-release-notes.sh is bash and reaches the same predicate through the helper's ensure --no-fetch CLI — a process boundary, not a copy.

Ablations — and the two defects they found

Every guard was removed, exercised, and restored, with the mutation confirmed on disk (marker counts before/after) rather than by an editor's exit code.

ablationwith the guard removedrestored
check-engine-split-ratio (real container, --days 90)265 / 98.1%, exit 0 — the pre-change tool, exactlyexit 2, names floor + remedy
check-governed-merges (shallow fixture; truth 19)2 governed merges, ✓ audited, exit 0UNAUDITED, refuses to sweep
collect-release-notes wiring pinprinted a tick ← phantomgoes red, exit 1

Two things the ablations caught that reading the code did not:

  • The collect-release-notes wiring pin could not fail. It grepped the script for a literal that also occurs in the assertion itself, so it matched its own source line and passed with section 4 unwired. The needle is now assembled from two adjacent literals so it cannot match its own line, and the ablation reddens it. A pin that cannot fail is worse than no pin, because it is believed.
  • The horizon annotation claimed what it had not checked.(predates the window) was spelled unconditionally, so removing the refusal above it produced a false statement beside the wrong number rather than no statement. It is now conditional on covered.

git-history.mjs was wired to nothing

Grep for it across the repo before this PR: zero references — no package.json script, no workflow, no skill. The remedy #9878 shipped had a self-test that nothing in this repo ever ran, which is the @ts-expect-error-in-an-uncompiled-file shape from AGENTS.md wearing a different hat. lint.yml now runs it, plus the two new adopter self-tests (check-governed-merges's cases run in the existing pnpm check:pm-governed-merges step). Invoked as node/bash, not a pnpm check:* alias: that alias lives in root package.json, fenced by #9465 — same precedent as the release-rehearsal step directly above it.

Gates

Derived with node scripts/pm/dispatch-gates.mjs (no path arguments) on the final commit 2d137b1abf; 12 families matched, all run at that sha:

gateverdict line
check:node-versionNode 22 is in maintenance; supported until 2027-04-30
check:cross-package-test-inputsAll 60 self-test cases passed. / OK: 12 package(s) read outside themselves, all declared
check:pm-governed-merges✓ check-governed-merges --self-test: 90 assertions
check:required-contexts✓ check-required-contexts --self-test: 124 assertions + live run OK
check:shard-attestation✓ …: 92 assertions / OK: 2 aggregate gate(s) count 3 declared leg(s)
check:type-check-coverageOK — 64/77 workspace packages type-checked
check:workflow-status-functions✓ …: 34 assertions / OK (scanned 26 workflow file(s))
node scripts/check-engine-split-ratio.mjs--self-test: all cases passed (this is what lint.yml runs)
check:nul-bytesOK (scanned 6146 text file(s) … no raw ASCII control bytes)

Two declared narrowings, both stated rather than skipped:

  • check:type-check-debt refuses locally — --re-measure cannot run: 55 workspace dependenc(ies) … have no built type entry point on disk, its own documented prerequisite. lint.yml builds the closure before this step; this diff contains zero TypeScript, so no reading it produces can move. Not run locally.
  • node scripts/check-engine-split-ratio.mjs --days 90 as engine-split-metric.yml runs it exits 2 in this container by design — it is a shallow clone and that is the whole point of the change. The CI shape (a checkout that can see the window) is pinned by the self-test's full-clone leg: a complete clone computes the metric, exit 0 / the ratio is the fixture's real 50.0% over all 40 commits.

Not done here, deliberately

  • No changeset. The diff surface is scripts/** + .github/workflows/lint.yml — no published package, nothing to release. skip-changeset applied by publish surface, not by default.
  • A governed edit is proposed, not made.git-history.mjs is still undiscoverable from the instruction files: a seat asking "how many commits landed this month" has no reason to find it. The natural homes — AGENTS.md's guard index and .claude/skills/pm-dispatch/SKILL.md's tool table (which already lists check-governed-merges.mjs) — are governed surfaces, human-merge-only, and never a rider on a code PR. Filed for a maintainer to route.

Generated by Claude Code

os-zhuangand others added 2 commits August 21, 2026 01:14
…ee all of
Three seat-run tools asked `--since` questions over a branch with no shallow
guard. A shallow clone answers those from whatever part of the window is
present, exits 0 and prints no warning, so each returned a confidently wrong
smaller number. Agent containers clone shallow and all three run from a seat;
every workflow that reads history already checks out `fetch-depth: 0`, which is
why the exposure survived.
- git-history.mjs: add `historyHorizon()`, the read-only half of the #9878
helper — one predicate for all adopters, never fetching, and printing a
deepen command computed so it can only ADD history.
- check-governed-merges.mjs: a repo whose history stops inside the window is
UNAUDITED, like an absent checkout. It rendered `clean window` over ~40
governed merges.
- check-engine-split-ratio.mjs: refuse rather than print an ADR trigger metric
over a short denominator; annotate the horizon when the window is whole.
- collect-release-notes.sh: withhold the cloud section rather than print it
short, and exit 2.
- lint.yml: run the three self-tests, including git-history's, which nothing in
this repo ran until now.
Part of #9902
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
… checked
Two defects found by ablating the guards rather than by reading them.
- check-engine-split-ratio: the horizon line spelled "(predates the window)"
unconditionally, so removing the refusal above it produced a false statement
beside a wrong number instead of no statement.
- collect-release-notes --self-test: the wiring pin grepped the script for a
literal that also occurs in the assertion itself, so it matched its own
source line and printed a tick with section 4 unwired. The needle is now
assembled from two adjacent literals, and the ablation reddens it.
Part of #9902
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 21, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 21, 2026 02:09
@os-zhuang
os-zhuang added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit 7f8b360Aug 21, 2026
29 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-9902-shallow-clone-history-guard branch August 21, 2026 02:49
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cdsize/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Three seat-run tools ask windowed history questions with no shallow guard, so each answers plausibly and wrongly in an agent container

1 participant

@os-zhuang