Skip to content

Move the hand-written-docs list off the governed surface, injected at invocation instead of inlined - #14085

Draft
claude[bot] wants to merge 3 commits into
mainfrom
claude/issue-13591-handwritten-list-degovern
Draft

Move the hand-written-docs list off the governed surface, injected at invocation instead of inlined#14085
claude[bot] wants to merge 3 commits into
mainfrom
claude/issue-13591-handwritten-list-degovern

Conversation

@claude

@claude claude Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes #13591

Moves the hand-written-docs list ALL_HANDWRITTEN off the governed surface. It becomes a
non-governed, generated single-source artifact; the workflow's caller reads it and hands
it to the node:vm body at invocation time, so the sandbox stays exactly as FS-blind as
before. check:docs-audit-scope keeps enforcing two-way equality against that same single
source, so the "one subject, one hand-kept list" bill stays paid.

Verified at 8a82109c4.

The ruling this implements

Maintainer, 2026-09-01, decision batch B, verbatim 「同意」 on option 2. Both halves,
untranslated:

Direction adopted: move ALL_HANDWRITTEN off the governed surface. The list becomes a
non-governed artifact (e.g. a JSON beside the docs tree) as the single source, and the
workflow runner hands it to the node:vm body at invocation time (the sandbox stays
FS-blind; the injection happens outside it). check:docs-audit-scope keeps enforcing
two-way equality against the same single source — #4851's "one subject, one hand-kept list"
bill stays paid. Result: adding a customer documentation page stops forcing a governed edit,
so those PRs return to the ordinary queue path.

Option 3 (carving .claude/workflows/** out of the governed register) is rejected:
narrowing the governance boundary to relieve friction is the expensive kind of cheap, and
this ruling explicitly does not license it.

⚠️ GOVERNED_SURFACES is character-for-character unchangeddocs/adr/** · .claude/**
· skills/** · AGENTS.md · CLAUDE.md, exactly as before. Nothing here narrows the
governed register.

⛔ The exit toll: this PR is itself governed — DRAFT ONLY, human merge

Removing the inline list is one last edit under .claude/**, which forks the whole PR. The
ruling names this and accepts it: "the diff will itself touch .claude/** to remove the
inline list — governed, draft-only, human merge; that one last governed edit is the exit
toll."
Measured on this PR's own file list:

$ node scripts/pm/check-governed-merges.mjs --test PATH... (this PR's 8 paths)  # EXIT=3
  ⛔  GOVERNED — a human merge is the review record for this PR (#9495 regime).
      .claude/** x1 — the agent instruction tree (skills, agents, hooks, settings)
        - .claude/workflows/docs-accuracy-audit.js
  paths not on the register: scripts/docs-audit/check-audit-scope.mjs,
                             scripts/docs-audit/handwritten-docs.json,
                             scripts/pm/check-governed-merges.mjs

⛔ No seat flips this ready, enqueues it, or arms auto-merge.

The measurement the ruling asked for: what CAN be injected

The workflow runner is the Claude Code Workflow tool, which is not in this repo and is
not modifiable here. Its documented invocation surface offers exactly one channel into the
sandbox: the args global, "the value passed as Workflow's args input, verbatim", beside
log/phase/console/budget/timers and agent/parallel/pipeline/workflow. Code
generation is disabled and there is no filesystem.

Injection is feasible, and args is the whole of it. The fallback to option 1 was NOT
taken. What the earlier reading missed is that the body does not have to do the reading:
args is delivered from the invocation, so the file read happens in the caller, outside the
sandbox, and the list arrives as data. The sandbox's constraints are untouched.

One shape was considered and rejected: having the workflow ask an agent to read the
artifact (agents do have a filesystem). That would make the scope a self-report with nothing
to reconcile it against — a truncated answer would produce a "FULL audit" over fewer docs
with nothing able to notice, which is #4851 re-created and unverifiable. args is the honest
channel: the caller supplies data, the body validates its shape and refuses ambiguity.

What changed

file change
scripts/docs-audit/handwritten-docs.json new — THE single source. Generated; its readme block states that, names both consumers, and records that it sits outside every governed glob by ruling.
.claude/workflows/docs-accuracy-audit.js the inline ALL_HANDWRITTEN array and its generated: markers are gone (190 path literals removed). Scope now comes from args.handwritten (full set) or args.docs (caller's narrowing).
scripts/docs-audit/check-audit-scope.mjs reads/writes the artifact instead of splicing the workflow; two-way equality and byte-exactness unchanged; adds checkScopeInjection.
scripts/pm/check-governed-merges.mjs retires the #9866 provenance row (below).
scripts/pm/check-governed-queue-guard.mjs, scripts/docs-audit/README.md, .github/workflows/lint.yml, scripts/docs-audit/affected-docs.mjs prose brought in line.

There is deliberately no "omit args and audit everything" invocation any more, and there
cannot be
: with nothing handed in the body does not know what "everything" is, and the only
shapes it could invent are a silent audit of nothing and a stale list — both report success.
It refuses by name and says what to pass.

⚠️ Reviewer's attention: the #9866 row is retired

GENERATED_SURFACE_EXCEPTIONS carried a row (#10277 Option C, 「10277 同意 C」 2026-08-20,
re-confirmed 「A:按方案 2(最新裁定)」 2026-08-22) that lifted the governed fork for
.claude/workflows/docs-accuracy-audit.js when its bytes equalled the generator's recomputed
splice. That row is removed, because the 2026-09-01 ruling removes its subject: there is
no generated artifact at that path any more. Keeping it would be actively unsafe — a row
still naming that path could only ever be asked to certify hand-authored instruction prose,
which is the thing the fence exists to stop. Removing it is a narrowing: nothing that was
governed became clear, and no glob moved. The retirement is asserted, not remembered:

no-row-lifts-anything-under-.claude-the-retired-9866-row-stays-retired
the-audit-workflow-is-plainly-governed-again-with-no-provenance-consulted
every-row-declares-the-recompute-that-proves-its-paths
a-row-that-declares-no-recompute-fails-closed-rather-than-lifting

The generic machinery (generatedExceptionFor, applyGeneratedExceptions,
recomputeProvenanceFor, sinkGeneratorVerdict, the #11084 co-edit fence) is untouched and
still serves the two #11705 rows; only the splice-specific recompute, which had that one row
as its sole caller, goes with it.

The outcome, measured

The property the card was filed about, before and after — same simulated page-adding PR:

BEFORE  content/docs/.../new-page.mdx + meta.json + .claude/workflows/docs-accuracy-audit.js
        EXIT=3   ⛔ GOVERNED — .claude/** x1

AFTER   content/docs/.../new-page.mdx + meta.json + scripts/docs-audit/handwritten-docs.json
        EXIT=0   ✅ NOT governed — ordinary queue landing applies

Reverse verification

The gate still detects drift in BOTH directions. Run from a committed state; each mutation
proven on disk by an anchored observation before measuring, each restore proven by blob hash
against the HEAD blob and by an empty git diff HEAD — never by an exit code.

HEAD blob of the artifact: dc81dcb245a6b4e37b5fd77eab3429cbe6759493

DIRECTION 1 — a new hand-written page exists, absent from the list
  mutation on disk: fake page present, 72 bytes; artifact still at HEAD
  EXIT=1  "1 hand-written doc(s) exist but are not listed"
            + content/docs/getting-started/zz-reverse-verification-probe.mdx
  restored: page gone; git diff HEAD empty = yes

DIRECTION 2 — the list names a page with no file behind it
  injected ghost entry: 0 -> 1 occurrence; docs count 190 -> 191
  mutation on disk: blob dc81dcb2... -> e93c9d8b...
  EXIT=1  "1 listed path(s) do not exist"
            - content/docs/getting-started/zz-ghost-entry.mdx
  restored: blob back to dc81dcb2...; git status --porcelain: []  (index clean too)

CONTROL — restored tree
  EXIT=0  scope is in sync with content/docs/: 190 hand-written doc(s)

And the NEW guard fails when it should. Moving the list out creates one failure mode that
did not exist before: the body could stop consuming what it is handed, and the artifact would
still be perfectly in sync. checkScopeInjection runs the real workflow against stub agents.
Mutating the body to ignore args.handwritten:

mutation on disk: blob 111ac24f... -> 8010c278...
EXIT=1
  ✓ docs-accuracy-audit scope is in sync with content/docs/: 190 hand-written doc(s)
  ✓ release-owned pages are in scope and read-only: 9 page(s)
  ✗ .claude/workflows/docs-accuracy-audit.js: it does not consume the scope it is handed.
      - the workflow threw on a run whose scope was handed in as args.handwritten: ...
restored: blob back to 111ac24f...; git status --porcelain: []

Note the first two lines stayed green while the third went red — which is the whole point:
an artifact in sync with content/docs/ proves nothing about a body that stopped reading it.
--self-test carries four such mutants (the injected list is ignored, a silent default replaces the no-scope refusal, the FULL-audit line stops naming the scope source, the malformed-list guard is removed); an unapplied anchor fails as loudly as a green mutant.

Gates

Derived union after the last commit — node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 8a82109c4, 39 commands, exit codes captured before
any pipe. 35 green, 4 NOT MEASURED, 0 findings:

command reading
the other 35, incl. check:docs-audit-scope, check:pm-governed-merges, check:pm-dispatch-gates, check:required-contexts, check:cross-package-test-inputs, check:type-check-coverage exit 0
check-test-completeness.mjs exit 3 — PREREQUISITE NOT MET (grades a saved turbo run test log; none named). NOT MEASURED.
check-governed-queue-guard.mjs exit 1 — could not read GITHUB_EVENT_PATH (reads a workflow event payload). NOT MEASURED; its --self-test passes, 129 cases.
check:doc-formula-expressions exit 3 — PREREQUISITE NOT MET (@objectstack/formula not built). NOT MEASURED.
check:type-check-debt exit 3 — PREREQUISITE NOT MET (--re-measure needs 56 built dependency closures). NOT MEASURED. Its self-test and the coverage half both passed.

Beyond the union, because this diff edits gate scripts: check-audit-scope.mjs --self-test
(32 cases, up from 24), check-governed-merges.mjs --self-test (241 assertions),
check-governed-queue-guard.mjs --self-test (129 cases), check:pm-governed-prose (green —
2 instruction surfaces still name all 5 registered surfaces and claim no others).

Lint — a declared narrowing, not a skip. pnpm lint is a whole-repo scan CI owns; this
run was scoped to the diff, and the narrowing excluded nothing:

  1. the checked population was read from eslint's own config via ESLint#isPathIgnored,
    not guessed: 5 of the 8 changed paths are in scope, 3 (.yml, .md, .json) are ignored
    by config;
  2. the count comes from the result array — 5 files linted, errorCount=0 warningCount=0;
  3. invariance for untouched files: eslint.config.mjs's own header records that this repo
    "never enables type-aware linting (no parserOptions.project, no typed
    @typescript-eslint rules) for ANY file"
    , with a positive control — so no edit in this
    diff can move the verdict on a file it does not touch.

Changeset

None: this PR publishes nothing. The diff is confined to .claude/**, .github/workflows/
and scripts/** — no workspace package (packages/*, apps/*, examples/*) is touched.
skip-changeset is applied.

Review

@os-zhuang @hotlong — governed surface, human merge. .github/CODEOWNERS also routes
scripts/pm/check-governed-merges.mjs.

Generated by Claude Code


Generated by Claude Code

…rkflow instead of inlining it

The default scope moves out of the sandboxed workflow body into a generated,
non-governed artifact the caller hands in as args.handwritten.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Msg17tAHJ3jVTYFgHydCm2
… left .claude/

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Msg17tAHJ3jVTYFgHydCm2
…ected scope

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Msg17tAHJ3jVTYFgHydCm2
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 1, 2026
@claude
claude Bot requested review from hotlong and os-zhuang September 1, 2026 03:49
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json afbf271143715707a96d6255aee08261bf9ac15fpackageMentionDocs.

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

Labels

ci/cd documentation Improvements or additions to documentation size/xl skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] Adding a NEW hand-written page under content/docs/ makes a PR human-merge-only — always, and invisibly until the gate runs

1 participant