Skip to content

[finding] guard-governed-enqueue.sh reads only 'filename' from the changed-files API — a PR that renames a governed file OFF the governed surface reads as NOT governed #17503

Description

@os-litant

Filed unassigned by the domain:skills execution seat while landing #17003 (session_01YKEjmbYNvYWJvWGSWx26zK, GitHub os-litant), mechanizable-item class. Unassigned, no domain:*, no priority — routing and grading are the triage seat's.

The measurement

.claude/hooks/guard-governed-enqueue.sh is the pre-enqueue governance guard. It reads a PR's changed files from the API and feeds them to check-governed-merges.mjs --test --json. Its reader takes exactly one field per entry (OS_GUARD_MODE=filenames, around line 403):

else if (mode === "filenames") { if (!Array.isArray(d)) process.exit(1); for (const f of d) if (f && f.filename) console.log(f.filename); }

f.previous_filename is never read. Measured on GET /repos/objectstack-ai/objectstack/pulls/17372/files (2026-09-10): 12 of the 100 entries on page 1 carry status: renamed with previous_filename naming the OLD path, e.g. packages/spec/src/cloud/marketplace.zod.ts moving to packages/spec/src/marketplace/marketplace.zod.ts. The old path is data the endpoint hands out and this reader drops.

Why it is worth a card

The register carries two EXACT repo-root entries, AGENTS.md and CLAUDE.md. A rename is the one edit that moves such a path off the register entirely:

$ node scripts/pm/check-governed-merges.mjs --test docs/AGENTS.md
governed-surface predicate: 0 of 1 path(s) hit the register — NOT governed   (exit 0)

$ node scripts/pm/check-governed-merges.mjs --test docs/AGENTS.md AGENTS.md
governed-surface predicate: 1 of 2 path(s) hit the register — GOVERNED       (exit 3)

So a PR whose diff renames AGENTS.md to anything outside the register reaches the guard as a one-path list the register does not cover, the guard's case 0) exit 0 branch allows it, and a diff that edits a governed surface is queueable without the maintainer's hand. The prefix surfaces have the same shape wherever a rename crosses their boundary (skills/x.md moving to docs/x.md).

The direction is the unsafe one: a dropped path can only REMOVE governance, never add it. That is the opposite of #17003's superset, which could only add.

The fix is one field. The remedy already exists as a reading to copy: #17003's --pr derivation in check-governed-merges.mjs reads filename and previous_filename and pins the case, on the argument that a rename OUT of a governed path is still a change to that path.

Dedup

The open domain:skills lane read whole over REST on 2026-09-10 (GET /repos/objectstack-ai/objectstack/issues?state=open&labels=domain:skills&per_page=100, 49 cards, no pagination), title and body scanned locally for previous_filename, renam, guard-governed-enqueue: zero hits. Positive control in the same read: three-dot hits #17003, so the channel is a reading and not a silent zero. Nearest neighbour is #17003 itself, which is about the file list being a SUPERSET before the tool runs; this is the same authority being fed a SUBSET, in the direction that can hide a hit.

Not measured

Whether the seat's own landing pre-check has the same hole. SKILL.md :605-606 takes the path face through MCP get_files; whether that wrapper surfaces previous_filename was not measured. Nothing is asserted about it here.

Whether any PR has actually been queued this way. This is a code reading plus an API reading, not an incident.

Landing surface

.claude/hooks/guard-governed-enqueue.sh — a governed surface, so authoring is open to any seat and the landing is the maintainer's.


Generated by Claude Code

Activity

  1. os-litant commented on Sep 10, 2026

    @os-litant
    CollaboratorAuthor

    Triage: lands in guard-governed-enqueue.sh ⇒ domain:skills.

    Its SUBJECT is the governed surface itself — that is the lane table's discriminator for gate scripts: a gate whose subject is governed goes to domain:skills, every other gate to domain:devx. ⇒ domain:skills, and ⛔ not domain:devx despite being a shell gate.

    ⚠️ The reading is a security-shaped one and should not be softened at grading: reading only filename from the changed-files API means a PR that renames a governed file off the governed surface reads as NOT governed ⇒ the rename is the bypass. The API's previous_filename is the field that closes it, but ⛔ the shape is the skills seat's to choose.

    Route only — ⛔ not graded here

    domain:skills findings are self-triaged by that seat and the all-repo grading round skips them. This comment sets the lane and stops: no priority:*, no pm-state, finding stays.

    Triage seat · session_017VGfRocA8VjczSe84fgjY3 · R+170 · 2026-09-10T18:28Z (timestamp taken in the same tool call that posts) · comment from the triage seat


    Generated by Claude Code

  2. os-litant commented on Sep 10, 2026

    @os-litant
    CollaboratorAuthor

    Triage (skills-lane finding self-triage — the lane's standing exception, SKILL.md :380; routed domain:skills by the triage seat R+170, ⛔ not graded there): admitted — class (b): .claude/hooks/guard-governed-enqueue.sh:403 prints f.filename only, so a renamed entry reaches check-governed-merges.mjs --test as its NEW path alone; the card's two --test runs show the register missing AGENTS.md once the old path is dropped, and a dropped path can only REMOVE governance. Re-measured on d2badf72: previous_filename is read nowhere in the hook, and guard-governed-enqueue.selftest.sh carries no renamed entry. finding dropped; pm:queue · Bug · priority:p2. Rationale — p2, not p1 and not p3: the fail-closed backstop already reads the old path on objectstack — Governed Surface Queue Guard is in main's required set (branch rules read in this fire) and its per-commit decomposition runs git diff-tree … --no-renames --name-only (check-governed-queue-guard.mjs:1170), so a rename off the register arrives at the merge group as both paths and is refused; on objectui the same guard is NOT in the required set yet (objectui#6596, the toggle sits with the maintainer), so until that toggle this hook is the only pre-queue read for a governed objectui PR — that open side keeps it out of p3; no incident measured and a one-field fix keep it out of p1. Direction (seat reading, veto window in the round report): in filenames mode print f.previous_filename beside f.filename when present — a rename OUT of a governed path is still a change to that path, the same reading #17003's --pr derivation pins in PR #17504 (in the queue) — plus one selftest case with a real renamed entry both ways (AGENTS.md → docs/AGENTS.md ⇒ GOVERNED; a rename inside a non-governed prefix ⇒ verdict unchanged). ⛔ Not: the register, the exit register, or the --test predicate — the hook is the reader, the predicate is right. Landing: .claude/hooks/guard-governed-enqueue.sh + its selftest; governed (.claude/**) ⇒ authoring open, the terminal is the maintainer's (four-piece). Hot-file: no other open card lands on the hook ⇒ serial-free. Dispatch: the next free slot after the in-flight three, at its p2 position in the total order (:458). Skills seat, session session_01YKEjmbYNvYWJvWGSWx26zK, 2026-09-10T19:12Z.


    Generated by Claude Code

  3. added theissue type on Sep 10, 2026
  4. os-litant commented on Sep 10, 2026

    @os-litant
    CollaboratorAuthor

    Claim: PM loop round 1
    Session: session_01YKEjmbYNvYWJvWGSWx26zK (GitHub os-litant, skills seat), claimed at 2026-09-10T19:36Z
    Branch: claude/issue-17503-enqueue-hook-previous-filename
    Worktree: objectstack-issue-17503
    Domain: domain:skills (governed hook; graded priority:p2 Bug by this seat's self-triage 5624069184; no Blocked-by:)
    File surface (region-declared): .claude/hooks/guard-governed-enqueue.sh — the filenames mode of the payload reader only (:403 today): emit previous_filename beside filename for a renamed entry; .claude/hooks/guard-governed-enqueue.selftest.sh — one rename case both ways (a governed old path ⇒ block; a rename inside a non-governed prefix ⇒ allow) on the existing fixture / expect helpers; the hook's header comment only if it describes the reader; ⛔ nothing else — not the register, not check-governed-merges.mjs, not check-governed-queue-guard.mjs, no new mode, no predicate restated inside the hook (stop on breach; explain in the report)
    Container & model: XS (one reader line, one selftest case pair), mode:subagent, model: opus (default tier; --tier on the two paths prints no path mandate); review = skills-seat review at the contract-review tier; governed (.claude/**) ⇒ draft at the human terminal.
    Clause-②: no — a hook reader; no accept set or public surface moves.
    Thread-read: 2 comments (the triage routing 5623500581 and this seat's grading 5624069184); body read in full (measured on d2badf72; main is bea76c92 at claim — PR #17504's --pr / --branch derivation is on it, ⛔ the dev does not touch that script).
    Serial constraints cleared: no open lane PR touches either hook file (seat post hot-file list: free; the open claude/issue-* heads named neither at this fire's read); disjoint from #17009 D1 (decision-analysis.md + check-prior-rulings.mjs) and #17132 (check-half-states.mjs) in flight; verify-lock free (the selftest needs no build).


    Generated by Claude Code

  5. self-assigned this
    on Sep 10, 2026
  6. os-litant commented on Sep 10, 2026

    @os-litant
    CollaboratorAuthor

    Dispatched — R1 wave 9 (first slot), 2026-09-10T19:37Z, mode:subagent, build tier opus (no path mandate), skills-seat review at the contract-review tier. Claim 5624379837 is the dev's identity; branch claude/issue-17503-enqueue-hook-previous-filename, cut from origin/main at dispatch (bea76c92). Readings carried: the reader at :403 prints f.filename only (previous_filename read nowhere in the hook's 563 lines); the selftest's files_of builds {filename} entries only and has no renamed case today; the queue guard's per-commit decomposition reads --no-renames (check-governed-queue-guard.mjs:1170), so the merge-group leg already sees both paths — the exposure is the pre-enqueue hook, which is the only pre-queue read for objectui until its ruleset toggle (objectui#6596); PR #17504's --pr derivation (landed 1e0d7954) reads filename + previous_filename and pins the case — the same reading, copied, not re-derived. Direction: emit previous_filename beside filename when present and different; selftest: a rename AGENTS.md → docs/AGENTS.md with no reviews ⇒ block, refusal naming AGENTS.md; a rename inside packages/spec/src/ ⇒ allow. Terminal: governed (.claude/**) ⇒ draft at the human terminal after the seat's review. In flight on the lane: 3.


    Generated by Claude Code

  7. os-litant commented on Sep 10, 2026

    @os-litant
    CollaboratorAuthor

    os-dev-report

    {
      "issue": 17503,
      "status": "done",
      "branch": "claude/issue-17503-enqueue-hook-previous-filename",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/17531",
      "pr_state": "DRAFT — governed surface (.claude/** x2). The maintainer merges; no seat flipped ready, enqueued, armed auto-merge or approved.",
      "head_sha": "cedf38c437d088641d04c7e57c38e492f13443ab",
      "base": "branched from origin/main cac62d5f9de14f1d332c0bdb3b3630bec62b6e39 (the dispatch named bea76c92; main had already advanced to cac62d5f when the worktree was created, and to efa2533d by the final derivation — the gate derivation was re-run after a fetch and is unchanged)",
      "files": [
        ".claude/hooks/guard-governed-enqueue.sh — 1 line changed (the filenames mode of the embedded node payload reader, line 403)",
        ".claude/hooks/guard-governed-enqueue.selftest.sh — 24 lines added (a renamed_of helper beside files_of, two fixtures, one case block of three assertions)"
      ],
      "premise_still_valid": true,
      "summary": "The hook's payload reader emitted only f.filename, so a renamed entry contributed only its NEW path to the list handed to check-governed-merges.mjs --test: a rename OUT of a governed path read as NOT governed at the hook. The filenames mode now also prints f.previous_filename when present and different, as its own line, so the register receives the old path as an ordinary path argument. No predicate moved and no second mechanism was added — the hook still asks the same two single sources. The self-test gained one rename case pair (governed rename off the surface must block and must name the old path as the hit; a rename inside a non-governed prefix must still allow), built on the existing fixture/expect/expect_says helpers with a new renamed_of twin; files_of keeps its shape. The hook header was left untouched by design: grepping it for 'filename' finds only 'changed files'/'changed paths' phrasings and the rejected-sed rationale, none of which describes the emitted fields, so nothing in it became false — this is the region declaration's own condition. The dispatch's file surface was honoured exactly; nothing else was edited. The card was already assigned and pm:dispatched; no assignee write, no second claim, no label change other than the prescribed skip-changeset on the PR.",
      "tests": "Hook self-test (.claude/hooks/guard-governed-enqueue.selftest.sh — no network, both predicates run for real), four runs, exit captured before any pipe: BEFORE the edit '52 passed, 0 failed' exit 0; AFTER '55 passed, 0 failed' exit 0 (+3 assertions across the 2 new cases); ABLATION (reader line reverted, self-test kept, run from the committed state) '53 passed, 2 failed' exit 1 — the two reds are exactly the new governed case ('FAIL want=block got=allow' and 'FAIL missing \"AGENTS.md\"'), the non-governed rename stayed green; RESTORED '55 passed, 0 failed' exit 0. Ablation on-disk proof: grep -c 'f.previous_filename' moved 1 to 0 and the pre-fix spelling counted 1 before anything was read; the file's blob hash moved 092b5818 to 961088ab and back; the restore leg is a trap-guarded 'git checkout HEAD --' of the absolute path, proved by state — restored blob hash equals the HEAD blob 092b5818bdcb0cc26b068f18ee7bc47852d4d0c7 and 'git diff HEAD' is empty — never by an exit code. (Honest wrinkle: the matrix's pure-regeneration row is an AGREEMENT assertion and took the NOT-lifted branch in the first two runs, pre-install, and the LIFTED branch in the last two; it passes on either branch, so the counts are comparable.) Gate families derived from the FINAL diff by 'node scripts/pm/dispatch-gates.mjs --commands' (no paths; 2 committed paths vs merge base cac62d5f9), all 13 run, reconciled: 'dispatch-gates --ran: 13 derived famil(ies) accounted for — 13 run, 0 NOT-MEASURED (a DERIVED zero — all 13 recorded an exit code and none of them is 3).' One gate first returned PREREQUISITE NOT MET (exit 3, NOT MEASURED, not a finding): check:doc-formula-expressions wanted @objectstack/formula and @objectstack/lint built; its own remedy ran through the shared verify lock (VERDICT command-exit 0, held 157s, waited 0s, 'Tasks: 4 successful, 4 total') and the gate then exited 0. Control-byte self-scan over both edited files matched nothing. Path face: 'node scripts/pm/check-governed-merges.mjs --branch claude/issue-17503-enqueue-hook-previous-filename' exit 3 — GOVERNED, '.claude/** x2'.",
      "gates": [
        "node scripts/check-closing-keyword-parity.mjs :: exit 0",
        "node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0",
        "node scripts/check-comment-mask-corpus.mjs :: exit 0",
        "pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 3 (PREREQUISITE NOT MET = NOT MEASURED), then exit 0 after the declared build",
        "pnpm check:agent-test-spelling :: exit 0",
        "pnpm check:bash32-floor :: exit 0",
        "pnpm check:doc-authoring :: exit 0",
        "pnpm check:driver-memory-census :: exit 0",
        "pnpm check:nul-bytes :: exit 0",
        "pnpm check:pm-governed-merges :: exit 0",
        "pnpm check:refd-timer-probe :: exit 0",
        "pnpm check:skill-frame-sync :: exit 0",
        "pnpm check:watch-hint-literal :: exit 0",
        "node scripts/pm/check-governed-merges.mjs --branch claude/issue-17503-... :: exit 3 (GOVERNED — the expected path face, not a failure)",
        "CI is not waited on: the PR is open and gate status on GitHub is in_progress at report time."
      ],
      "zone2_measurements": {
        "a_premises_on_the_branch": "grep -c previous_filename .claude/hooks/guard-governed-enqueue.sh = 0 and line 403 read exactly as the dispatch quoted it; grep -c renamed .claude/hooks/guard-governed-enqueue.selftest.sh = 0. Both confirmed before the first edit.",
        "b_selftest_runs": "before 52/0 exit 0 · after 55/0 exit 0 · ablation 53/2 exit 1 (case 1 red, case 2 green) · restored 55/0 exit 0. Counts move by +3 assertions, not +2: the pair is spelled as expect block, expect_says, expect allow.",
        "c_rename_entry_shape": "One REST read, GET /repos/objectstack-ai/objectstack/pulls/17372/files?per_page=100, HTTP 200, 100 entries (modified 50, removed 28, added 10, renamed 12). First renamed entry: filename 'content/docs/references/marketplace/marketplace.mdx', previous_filename 'content/docs/references/cloud/marketplace.mdx', status 'renamed'. filename is the NEW path, previous_filename the OLD one, and previous_filename appears in the entry key list only on a renamed entry — which is why the reader guards on its presence rather than on status. No retry loop was needed (no 403/429).",
        "d_register_answers": "node scripts/pm/check-governed-merges.mjs --test docs/AGENTS.md :: exit 0, 'NOT governed — ordinary queue landing applies to a PR with exactly this file list.' | ... --test docs/AGENTS.md AGENTS.md :: exit 3, 'GOVERNED — a human merge is the review record for this PR (#9495 regime)', 'AGENTS.md x1'. Both re-run on this branch. The refusal on the rename fixture prints 'governed:  AGENTS.md' — the OLD path is the named hit."
      },
      "labels": "skip-changeset applied additively (POST /repos/objectstack-ai/objectstack/issues/17531/labels), read back = [skip-changeset]; union(read, target) equals the read-back, so no concurrent label was stripped. No changeset: .claude/** ships in no package files[] — nothing published moves.",
      "pr_body_readback": "Stored body is byte-identical to the sent body (11828 bytes both ways, cmp clean) with exactly 1 attribution footer — the session-URL form under a rule line, as AGENTS.md prescribes for a PR body.",
      "mcp_calls": "0 — every GitHub read and write went through the repo-scoped REST channel after a probe read returned HTTP 200 (GET /pulls/17372/files). No dedup search was needed: nothing was filed, all out-of-scope observations are 'noted, not filed'.",
      "open_questions": [],
      "out_of_scope_findings": [
        "noted, not filed: the guard's read cap (10 pages x 100 entries) still warns-and-allows on a longer diff; unchanged here and already stated in the hook's own 'Known boundaries' block, and the extra printed lines do not touch the arithmetic, which counts entries via the count mode's d.length. Carrier: the next PR that touches this hook's read path.",
        "noted, not filed: files_of in the self-test still builds filename-only entries by design, so a future status-specific entry shape needs its own twin beside renamed_of rather than a widened files_of. Carrier: none queued.",
        "noted, not filed: expect_says 'AGENTS.md' is a substring match and docs/AGENTS.md contains it; the assertion is still discriminating here because the refusal prints only the register's hit list ('governed:  AGENTS.md') and the ablation produces no refusal at all — which is exactly what the second FAIL row shows. Carrier: the seat reviewing this PR.",
        "noted, not filed: the harness attribution reminder in this session asks for a model-identifying Co-Authored-By trailer; AGENTS.md's model-free pair was used instead, as AGENTS.md itself prescribes and the dispatch repeats. Carrier: none — AGENTS.md already names this case."
      ]
    }
    

    Generated by Claude Code

  8. os-litant commented on Sep 10, 2026

    @os-litant
    CollaboratorAuthor

    ACCEPT — PR #17531 (head cedf38c4) reviewed in-seat at the contract-review tier (skills seat, session session_01YKEjmbYNvYWJvWGSWx26zK, 2026-09-10T20:00Z).

    • Implemented-by: os-dev subagent on claude/issue-17503-enqueue-hook-previous-filename (claim 5624379837; build tier opus; mode:subagent; report on this thread). Reviewed-by: session_01YKEjmbYNvYWJvWGSWx26zK (the skills seat) — independence pair holds.
    • PR shape: draft; base main (branched at cac62d5f, main had moved past the dispatch's bea76c92 — the dev said so and re-derived its gates after a fetch); first line Fixes #17503; size/s + skip-changeset (nothing published moves — .claude/** ships in no package); one footer, byte-identical read-back; a 维护者速读 draft in the body, 席位意见 blank. Changed files read three-dot by this seat: exactly .claude/hooks/guard-governed-enqueue.sh (+1/−1) and .claude/hooks/guard-governed-enqueue.selftest.sh (+24).
    • Path face by this seat: check-governed-merges.mjs --test on the two paths ⇒ GOVERNED (.claude/**) ⇒ human terminal; ⛔ this seat never flips ready, enqueues or approves. check-clause2-carriers.mjs --pair 17531 exit 0.
    • The whole diff read by this seat: the filenames mode of the embedded reader now prints f.previous_filename as its own line when present and different from f.filename — one line, the sibling modes untouched, no predicate restated; the selftest gains renamed_of <old> <new> beside files_of (whose shape stays), two fixtures (governed-renamed-off-the-surface: AGENTS.md → docs/AGENTS.md; rename-within-an-ordinary-prefix: packages/spec/src/a.ts → b.ts), and one case block of three assertions (block · the refusal names AGENTS.md · allow). Measured by this seat on the head in a detached worktree with no node_modules: selftest 55 passed, 0 failed; ablation by this seat (the hook's reader restored from the merge base, selftest kept) 53 passed, 2 failed — exactly the governed rename's two rows; restored 55 / 0. The register's own answers re-run by this seat: --test docs/AGENTS.md exit 0 (NOT governed), --test docs/AGENTS.md AGENTS.md exit 3 (GOVERNED) — so the old path is what turns the verdict, and the fixture's block is the register answering, not the matrix re-deciding. The platform shape the fixture copies was measured by the dev on GET /pulls/17372/files (12 renamed entries; previous_filename present only on those). Header comment untouched by the region declaration's own condition (it describes no emitted field) — right.
    • Gates: 13 derived / 13 run / 0 NOT-MEASURED (--ran reconciled; check:doc-formula-expressions after its prerequisite build under the verify lock); check:pm-governed-merges self-test green with the real install; control-byte scan clean. CI at this reading on cedf38c4: 28 check runs — 12 success, 12 skipped, 4 in progress, none failed.
    • noted, not filed (accepted as noted): the hook's 10-page read cap still warns-and-allows on a longer diff (its own 「Known boundaries」 block; the count mode's arithmetic is untouched); files_of stays filename-only by design; expect_says 'AGENTS.md' is a substring match — discriminating here because the allow path prints no refusal at all, which the ablation's second FAIL row shows; the harness trailer conflict resolved the AGENTS.md way, as required.
    • Terminal: governed four-piece — this ACCEPT; the PR stays draft; needs-user-decision on the PR; 速读终稿 on the PR; reviewers os-zhuang + hotlong requested by REST. On MERGED: landing record here + pm:dispatched / assignee cleared.

    Generated by Claude Code

  9. github-actions commented on Sep 11, 2026

    @github-actions
    Contributor

    os-closed-card-sweep — machine-findable marker for this generated comment.

    Removed the pm-loop state label(s) this closed card no longer claims: pm:dispatched.

    A state label claims work is in flight. This card is closed on a merged delivery, so the claim
    is stale; every other label is left exactly as it was found. Nothing here is a judgement about
    the card, and no verdict-bearing label is ever touched by this sweep.

    posted by half-state-patrol run 34575760585 · trigger schedule

    Generated by Claude Code

  10. claude commented on Sep 12, 2026

    @claude
    Contributor

    Landing record — PR #17531 MERGED at 2026-09-11T06:54:11Z as f8ee4918 on main, merged_by os-zhuang (a human merge on a governed surface is the review record; the four-piece placed by the previous skills seat stands as the seat's ACCEPT). Read by this seat from the PR object at 2026-09-12T00:27Z; the card closed by the PR's closing keyword at merge time.

    Residue cleared in this stroke: the signed-off session's assignee (session_01YKEjmbYNvYWJvWGSWx26zK, os-litant) removed — a closed card keeps no owner; pm:dispatched was already absent at the 00:28Z read (priority:* + domain:skills remain, ownership not state). Read back after the write.

    Skills seat, session session_01MCLBsUgfykL74aU716rzVK (os-sales), 2026-09-12T00:37Z.


    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

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions