Skip to content

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

Closed

Description

@os-steve

Found while building the scripts/pm/git-history.mjs helper for #9878. Reported, not fixed — #9878's dispatch scoped the census to "report them, do not fix them all".

The census

37 pre-existing history-reading call sites across 17 files in scripts/ and .github/workflows/, classified by what a shallow clone does to each:

class count behaviour on a shallow clone
windowed date questions over a branch 3 silently wrong — real, plausible, exit 0, no warning
files already carrying an explicit shallow guard 6 files refuse or repair
range / merge-base questions the rest fail loudly, or degrade visibly to ?

The sharpest result: CI is not the exposure, the agent container is

All seven workflows that ask history questions check out with fetch-depth: 0. Every remaining exposure is in seat-run tooling — run by an agent, in a shallow container:

  1. scripts/pm/check-governed-merges.mjs:443 — git log --first-parent --since=(iso) (ref) enumerates governed merges in a window. CI runs only its --self-test; the real enumeration is a PM-seat command. Under-enumeration reads as compliance, which is the worst direction for this particular tool to be wrong in.
  2. scripts/check-engine-split-ratio.mjs:63 — the ADR-0076 D7 repo-split trigger metric. Its own workflow is fetch-depth: 0, so the scheduled run is fine; a seat running it locally gets a truncated window. Worse, total === 0 ? 0 turns an empty scan into a healthy-looking 0.0% ratio.
  3. scripts/collect-release-notes.sh:120 — --since/--until over the cloud checkout. It has no workflow caller at all; it runs at release time, from a seat.

Why this is worth a card rather than a note

The six already-guarded files include #9555's and #9408's fixes. So the repo has been closing this family one instance at a time, and the census says three are left — all in tooling whose output is evidence (compliance enumeration, an ADR trigger metric, release notes).

The remedy now exists and is one line each: scripts/pm/git-history.mjs (landing for #9878) proves window coverage, deepens only when the shallow floor intrudes, re-proves, and otherwise refuses with an empty stdout so a captured number is empty rather than plausible.

Related: #9878 (the helper and the full census) · #9555 · #9408

Generated by Claude Code

Activity

  1. claude commented on Aug 19, 2026

    @claude
    Contributor

    Claim: PM loop round 13
    Session: session_01XqDQYVU5smx29ts9pAErja
    Branch: claude/issue-9902-shallow-guard-seat-tools
    Worktree: objectstack-issue-9902
    Domain: domain:devx
    File surface: scripts/pm/check-governed-merges.mjs, scripts/check-engine-split-ratio.mjs, scripts/collect-release-notes.sh (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: fable (node scripts/pm/dispatch-gates.mjs --tier <the three paths> run this round returned "no path-derived mandate … This line is a FLOOR, never a clearance" — the tier below is set by the CONTENT limb, not by that line)
    Clause-②: yes
    Serial constraints cleared: swept all open PRs for any touching check-governed-merges / check-engine-split-ratio / collect-release-notes — none. Predecessors on the first of those, PR #9897 (#9777), is MERGED; PR #9903 (#9878), which shipped scripts/pm/git-history.mjs, is MERGED and is this card's intended tool.

    Why yes, and why the dispatch tier is contract-review tier: check-governed-merges.mjs is a CI gate (check:pm-governed-merges). A shallow guard that REFUSES where the tool currently answers changes its accept/reject behaviour — the same shape as #9698, where a new lint rule refusing previously-clean metadata was correctly declared yes. Dispatching AT contract-review tier rather than below it, so the 条款② entry gate's "派发档位低于契约复审档位" limb does not bind and this PR is not held out of the queue the way PR #9890 is.


    Generated by Claude Code

  2. claude commented on Aug 19, 2026

    @claude
    Contributor

    PM: dispatch aborted — Fable 5 quota exhausted mid-run. Releasing this card back to pm:queue, unstarted.

    The agent I dispatched at contract-review tier was killed by the API before doing any work: "You've reached your Fable 5 limit." No branch was created, no worktree, no commits — nothing to salvage. Un-assigning and restoring pm:queue.

    Why I am NOT re-dispatching this on opus right now

    The 額度耗尽豁免 (maintainer 2026-08-13: 「fable 如果用完了,可以用 opus」) does cover this — the exemption's object is dispatch, and fable is measurably unavailable. So an opus re-dispatch would be sanctioned.

    But the consequence is fixed by the same rule, and it is not worth taking at the end of a session:

    双肢命中任一 且派发档位低于契约复审档位 ⇒ ⛔ 禁止入队

    This card's claim declares Clause-②: yes (a shallow guard that refuses where check:pm-governed-merges currently answers changes its accept/reject behaviour). Dispatch it at opus and the finished PR is held out of the queue, needing a contract-review-tier seat that is not this one to clear it — and

    契约复审 ⛔ 不适用额度耗尽豁免降档(豁免的对象是派发;复审的存在意义就是补偿一次低于地板的派发)

    ⇒ the reviewing seat cannot downgrade either. So the PR would sit behind the same fable availability that just blocked the dispatch, plus a second seat's round trip. PR #9890 is already in that queue.

    The judgement

    卡在队列外等待是安全态 — held is a safe state, not a failure. But producing a second held PR at end of session, when the work could instead go to a card that can merge today, is worse throughput for identical effort. The lesson from #9890 was pay the tier at dispatch time; the corollary is when you cannot pay it, do not start.

    For whoever picks this up

    • Dispatch at contract-review tier when fable is available again — that is the cheap path, and it is why the declaration is already on the card.
    • Or dispatch at opus deliberately, accepting needs:contract-review and the review round trip. Either is legitimate; the first is cheaper.
    • ⛔ Do not re-declare Clause-②: no to route around the gate. The declaration is a mandatory content-limb declaration, and a wrong no is 记录在案、可审计的假申报 — an auditable false declaration, which is strictly worse than the hold it avoids.

    The dispatch prompt's rulings and hypotheses stand as written; nothing about the card's substance changed.


    Generated by Claude Code

  3. os-zhuang commented on Aug 21, 2026

    @os-zhuang
    Contributor

    Claimed by the domain:devx PM seat (#6023), session session_01DdCnBGcHeufjrq7drTD3wt, branch claude/issue-9902-shallow-clone-history-guard.

    Taken by age at concurrency 5. fold-or-serial: serial. ⛔ #10289 (os-verify-lock.sh spinning forever on bash 3.2) is a neighbouring scripts/pm/** card and is deliberately not folded in — different defect form, different fix.

    ⭐ Flagged to the dev with a first-hand endorsement: this is an indicator that lies rather than errors, and I hit that exact class twice in the last hour. I read merge-queue membership off a gh-readonly-queue ref that only exists while an entry is building, and reported three healthy PRs as dead; then I counted every CI check run instead of the latest per check name, and read a superseded failure as live. Neither wrong answer announced itself.

    ⇒ A shallow-truncated git log behaves identically — it does not fail, it runs out of history and answers anyway, and the smaller number reads exactly as authoritative as a real one. ⚠️ This container is itself a shallow clone, so the harm reproduces directly rather than needing simulation — and the dev has been told not to trust any history-derived number they produce while working the card without first proving the horizon supports it.


    Generated by Claude Code

  4. claude commented on Aug 21, 2026

    @claude
    Contributor
    {
      "issue": 9902,
      "status": "done",
      "branch": "claude/issue-9902-shallow-clone-history-guard",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/10506",
      "premise_still_valid": true,
      "summary": "Census re-derived mechanically against origin/main a718ee3dd rather than taken from the card: 3/5 windowed history call-site lines unguarded, and they are the same three the card named, at the same file:line (check-governed-merges.mjs:472, check-engine-split-ratio.mjs:63, collect-release-notes.sh:120). Post-fix 0/10; positive control: a synthetic fourth query injected into check-role-word.mjs is found (4/6), so the zero is real and not a blind detector. All three now import historyHorizon() from the already-merged scripts/pm/git-history.mjs (new read-only export) and REFUSE when the shallow floor sits inside the window, printing the floor plus a deepen command that chooseDeepenSince() guarantees can only ADD history; when the window is whole they answer as before and print the horizon beside the number. None of the three deepens: two read checkouts they do not own. lint.yml now runs the three self-tests -- git-history.mjs was referenced by NOTHING in the repo before this PR, so the remedy #9878 shipped had a self-test CI never ran. dispatch-gates.mjs is NOT in the affected set: its changedPathsFromGit uses merge-base, which on a shallow checkout exits 1 empty and refuses the two-dot fallback -- it can only refuse, not lie.",
      "tests": "All evidence at final commit 2d137b1abf (git rev-parse --short HEAD), tree clean, gate union derived AFTER it with `node scripts/pm/dispatch-gates.mjs` (no path args): 5 paths, 12 families matched. HARM REPRODUCED IN THIS SHALLOW CONTAINER (floor e7c0d5ef7d @ 2026-06-02, tip 2026-08-21): (a) `node scripts/check-engine-split-ratio.mjs --days 90` -- the workflow's own invocation -- printed `engine-core commits: 265 / ratio: 98.1%` at exit 0 with no warning; truth for that window is 282 (GitHub lists 18 distinct commits touching engine.ts/registry.ts between 2026-05-23 and the floor, while the clone sees exactly ONE commit dated in that slice and it is the graft boundary commit itself, which carries the whole tree and so counts as BOTH an engine-core and a cross-package commit -- a truncated window fabricates a data point, it does not only lose them). (b) check-governed-merges' own renderReport over the same window: `governed-merges sweep: 0 governed merge(s) ... / ✓ audited ... 1 mainline commit(s) in window / ✅ clean window`, exit 0 -- while GitHub lists 38 commits touching docs/adr/** and 13 touching AGENTS.md in that window. (c) collect-release-notes section 4 on a depth-5 fixture whose true answer is 21: printed 5, exit 0, same heading. ABLATIONS (guard removed, exercised, restored; every mutation confirmed ON DISK by marker counts before/after, never by an editor exit code; no build/dist involved -- these are plain node/bash scripts run from source): engine-split with the refusal deleted returned to `265 / 98.1%` exit 0 (marker `if (!horizon.covered)` 1->0, `ABLATED-NO-GUARD` 0->1), restored -> exit 2; governed-merges with the horizon check deleted reported `2 governed merge(s)` and `✓ audited` against a fixture truth of 19, exit 0, restored -> UNAUDITED and refuses; collect-release-notes wiring pin ablated -> exit 1 red, restored -> green. TWO DEFECTS THE ABLATIONS FOUND AND THIS PR FIXES: the collect-release-notes 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 (a pin that cannot fail) -- needle now assembled from two adjacent literals, and the redone ablation reddens it; and the engine-split horizon line spelled `(predates the window)` unconditionally, so removing the refusal above it produced a FALSE statement beside the wrong number -- now conditional on `covered`. GATES, quoting each gate's own verdict line (exit codes captured before any pipe): check:node-version `Node 22 is in maintenance; supported until 2027-04-30 (252 days).`; check:cross-package-test-inputs `All 60 self-test cases passed.` + `OK: 12 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.`; 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` + `✓ check-shard-attestation: 2 aggregate gate(s) count 3 declared leg(s) across 3 attesting job(s).`; check:type-check-coverage `check-type-check-coverage: OK -- 64/77 workspace packages type-checked`; check:workflow-status-functions `✓ ... 34 assertions` + `check-workflow-status-functions: OK (scanned 26 workflow file(s), 45 job(s))`; check:nul-bytes `✓ ... 75 assertions` + `OK (scanned 6146 text file(s) ... no raw ASCII control bytes)`, plus an independent `grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]'` over the five touched files: clean. New/extended self-tests: git-history 28 cases all pass (8 new for historyHorizon), check-engine-split-ratio 16 cases all pass (new file-level self-test), collect-release-notes 8 cases all pass (new), check-governed-merges 90 assertions (was 80). TWO DECLARED NARROWINGS, stated rather than skipped: check:type-check-debt refuses locally with its own documented prerequisite (`--re-measure cannot run: 55 workspace dependenc(ies) ... have no built type entry point on disk`) -- lint.yml builds the closure before that step and this diff contains zero TypeScript, so no reading it produces can move; and `node scripts/check-engine-split-ratio.mjs --days 90` as engine-split-metric.yml runs it exits 2 here BY DESIGN because this container is shallow -- the CI shape is pinned instead 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`). CI is not waited on per the 2026-08-10 ruling; no gate job conclusions are claimed here.",
      "open_questions": [
        {
          "question": "The one remaining piece of #9902's subject needs a GOVERNED edit, which a code PR must never carry: git-history.mjs is still named in no instruction file, so a seat asking a windowed history question has no way to find the tool that answers it safely. Filed as #10508 with suggested wording. Which seat routes it?",
          "options": [
            "A. domain:skills seat -- check-governed-merges.mjs's own register puts both AGENTS.md and .claude/skills/** there (maintainer 2026-08-18: 「skills 相关的应该都归你管,为什么派给了 devx」), and the PM tool table in pm-dispatch SKILL.md already lists check-governed-merges.mjs, so the entry has a home and a precedent.",
            "B. Leave it unrouted -- PR #10506 already wires the helper into three call sites and into lint.yml, so the tool is now reachable by anyone reading those files.",
            "C. Fold it into the next governed-surface batch the maintainer merges by hand, rather than dispatching a card for two lines."
          ],
          "recommendation": "A. B is the option that recreates #9902: the three tools sat one directory from the guard for weeks precisely because nothing pointed at it, and 'reachable by reading the call sites' only helps someone already editing those files -- not the seat about to hand-roll a fourth churn count. A is cheap (one line in the guard index, one row in the tool table), it is the register's own routing, and it needs a human merge whoever writes it."
        }
      ],
      "out_of_scope_findings": [
        "filed as #10508: scripts/pm/git-history.mjs is named in no instruction file (AGENTS.md guard index / pm-dispatch SKILL.md tool table), so the prescribed shallow-history remedy is undiscoverable -- the remedy is a governed edit, proposed with wording, not made",
        "filed as #10509 (finding): collect-release-notes.sh:81's changeset-body fallback silently prints the PREV_REF copy when the deleting commit sits below the shallow floor -- unwindowed path history with a `|| git show` fallback that hides the truncation; different sub-class from this card's windowed questions, left deliberately unwidened",
        "NOT filed, already open as #9450 (assigned, pm:dispatched): range questions degrade SILENTLY, not loudly, when the excluded endpoint's object is present but disconnected by the graft. Measured in this container and added to #9450 as a comment rather than duplicated -- `git cat-file -e 944f18758` says `commit` (object present), `git log 944f18758..origin/main` exits 0 with 5069 lines (the exclusion had NO effect, the walk stopped at the graft first), and `git merge-base --is-ancestor` returns 1. This refines the card's own taxonomy, which classes range questions as failing loudly: they fail loudly only when the endpoint object is ABSENT."
      ]
    }

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions