diff --git a/.claude/workflows/docs-accuracy-audit.js b/.claude/workflows/docs-accuracy-audit.js
index 1ccf73f209..111ac24f79 100644
--- a/.claude/workflows/docs-accuracy-audit.js
+++ b/.claude/workflows/docs-accuracy-audit.js
@@ -1,7 +1,7 @@
export const meta = {
name: 'docs-accuracy-audit',
- description: 'Audit + fix hand-written ObjectStack docs against actual implementation, with adversarial verification. Scope with args.docs; defaults to all hand-written docs.',
- whenToUse: 'Periodic or change-scoped documentation accuracy verification. Pass args.docs = [paths] to scope (e.g. output of scripts/docs-audit/affected-docs.mjs); omit for a full audit of every hand-written doc.',
+ description: 'Audit + fix hand-written ObjectStack docs against actual implementation, with adversarial verification. Scope with args.docs, or hand in the whole set as args.handwritten.',
+ whenToUse: 'Periodic or change-scoped documentation accuracy verification. Pass args.docs = [paths] to scope (e.g. output of scripts/docs-audit/affected-docs.mjs). For a FULL audit pass args.handwritten = the `docs` array of scripts/docs-audit/handwritten-docs.json — this body has no filesystem, so the full set has to be handed in.',
phases: [
{ title: 'Scope Preflight', detail: 'resolve every doc path on disk; abort naming any that does not exist' },
{ title: 'Audit & Fix', detail: 'one agent per doc: read, locate implementation, apply evidence-backed edits — except release-owned pages, which are reviewed read-only and produce findings to file as issues' },
@@ -9,222 +9,31 @@ export const meta = {
],
}
-// Default scope = every hand-written doc (content/docs/** minus references/). Callers
-// normally pass a scoped subset via args.docs (e.g. only docs whose backing packages/
-// code changed).
+// Default scope = every hand-written doc (content/docs/** minus references/). It is NOT
+// inline here: it is HANDED IN as `args.handwritten` by whoever invokes this workflow.
+// Callers normally pass a narrower `args.docs` instead (e.g. only the docs whose backing
+// packages/ code changed).
//
-// The list is inline because it has to be: a workflow script runs in a `node:vm`
-// context whose only globals are log/phase/console/budget/timers plus agent/parallel/
-// pipeline/workflow/args, with code generation disabled — no require, no import, no
-// filesystem. It cannot enumerate content/docs/ itself, nor read a JSON artifact.
+// The read happens OUTSIDE this body, and it has to. A workflow script runs in a
+// `node:vm` context whose only globals are log/phase/console/budget/timers plus
+// agent/parallel/pipeline/workflow/args, with code generation disabled — no require, no
+// import, no filesystem. `args` is the ONE channel through which data reaches this body,
+// so the file read is the caller's and the list arrives at invocation time.
//
-// So it is GENERATED instead: `node scripts/docs-audit/check-audit-scope.mjs --write`
-// derives it from the filesystem, and the same script without --write is a CI gate
-// (`pnpm check:docs-audit-scope`) that fails when the block and content/docs/ disagree
-// in EITHER direction. It used to be hand-kept behind a "keep in sync" comment, and by
-// #4851 had rotted both ways at once — 16 entries pointing at files that no longer
-// existed (the whole renamed protocol/objectos → protocol/kernel directory among them)
-// and 48 existing docs missing from it — with every full-audit run reporting green.
-//
-// GENERATED — do not hand-edit. `node scripts/docs-audit/check-audit-scope.mjs --write`
-// derives this from the filesystem (every content/docs/**/*.mdx except
-// references/, via `affected-docs.mjs --all`); the same script run without
-// --write fails CI when the two disagree in either direction. See #4851: this
-// list was hand-kept, 16 entries pointed at files that no longer existed and 48
-// existing docs were absent from it, and a "FULL audit" reported green over both.
-const ALL_HANDWRITTEN = [
- "content/docs/ai/actions-as-tools.mdx",
- "content/docs/ai/agents.mdx",
- "content/docs/ai/connect-mcp.mdx",
- "content/docs/ai/index.mdx",
- "content/docs/ai/knowledge-rag.mdx",
- "content/docs/ai/natural-language-queries.mdx",
- "content/docs/ai/skills-reference.mdx",
- "content/docs/ai/skills.mdx",
- "content/docs/ai/tools.mdx",
- "content/docs/api/client-sdk.mdx",
- "content/docs/api/data-api.mdx",
- "content/docs/api/data-flow.mdx",
- "content/docs/api/declarative-endpoints.mdx",
- "content/docs/api/environment-routing.mdx",
- "content/docs/api/error-catalog.mdx",
- "content/docs/api/error-handling-client.mdx",
- "content/docs/api/error-handling-server.mdx",
- "content/docs/api/index.mdx",
- "content/docs/api/metadata-api.mdx",
- "content/docs/api/plugin-endpoints.mdx",
- "content/docs/api/wire-format.mdx",
- "content/docs/automation/approvals.mdx",
- "content/docs/automation/connectors.mdx",
- "content/docs/automation/email-templates.mdx",
- "content/docs/automation/flows.mdx",
- "content/docs/automation/hook-bodies.mdx",
- "content/docs/automation/hooks.mdx",
- "content/docs/automation/index.mdx",
- "content/docs/automation/jobs.mdx",
- "content/docs/automation/webhooks.mdx",
- "content/docs/automation/workflows.mdx",
- "content/docs/build-without-code.mdx",
- "content/docs/capabilities/ai.mdx",
- "content/docs/capabilities/analytics.mdx",
- "content/docs/capabilities/approvals.mdx",
- "content/docs/capabilities/automation.mdx",
- "content/docs/capabilities/data.mdx",
- "content/docs/capabilities/forms.mdx",
- "content/docs/capabilities/index.mdx",
- "content/docs/capabilities/integrations.mdx",
- "content/docs/capabilities/permissions.mdx",
- "content/docs/capabilities/request-template.mdx",
- "content/docs/capabilities/views.mdx",
- "content/docs/concepts/architecture.mdx",
- "content/docs/concepts/design-principles.mdx",
- "content/docs/concepts/index.mdx",
- "content/docs/concepts/metadata-driven.mdx",
- "content/docs/concepts/metadata-lifecycle.mdx",
- "content/docs/concepts/north-star.mdx",
- "content/docs/data-modeling/analytics.mdx",
- "content/docs/data-modeling/drivers.mdx",
- "content/docs/data-modeling/external-datasources.mdx",
- "content/docs/data-modeling/field-type-decision-tree.mdx",
- "content/docs/data-modeling/field-types.mdx",
- "content/docs/data-modeling/fields.mdx",
- "content/docs/data-modeling/formulas.mdx",
- "content/docs/data-modeling/import-mappings.mdx",
- "content/docs/data-modeling/index.mdx",
- "content/docs/data-modeling/indexing.mdx",
- "content/docs/data-modeling/object-extensions.mdx",
- "content/docs/data-modeling/objects.mdx",
- "content/docs/data-modeling/queries.mdx",
- "content/docs/data-modeling/relationships.mdx",
- "content/docs/data-modeling/schema-design.mdx",
- "content/docs/data-modeling/seed-data.mdx",
- "content/docs/data-modeling/validation-rules.mdx",
- "content/docs/data-modeling/validation.mdx",
- "content/docs/deployment/backup-restore.mdx",
- "content/docs/deployment/cli.mdx",
- "content/docs/deployment/environment-variables.mdx",
- "content/docs/deployment/index.mdx",
- "content/docs/deployment/production-readiness.mdx",
- "content/docs/deployment/publish-and-preview.mdx",
- "content/docs/deployment/seed-tenancy-repair.mdx",
- "content/docs/deployment/self-hosting.mdx",
- "content/docs/deployment/single-project-mode.mdx",
- "content/docs/deployment/tenancy-modes.mdx",
- "content/docs/deployment/troubleshooting.mdx",
- "content/docs/deployment/validating-metadata.mdx",
- "content/docs/getting-started/build-with-claude-code.mdx",
- "content/docs/getting-started/common-patterns.mdx",
- "content/docs/getting-started/examples.mdx",
- "content/docs/getting-started/glossary.mdx",
- "content/docs/getting-started/how-ai-development-works.mdx",
- "content/docs/getting-started/index.mdx",
- "content/docs/getting-started/quick-reference.mdx",
- "content/docs/getting-started/quick-start.mdx",
- "content/docs/getting-started/your-first-project.mdx",
- "content/docs/index.mdx",
- "content/docs/kernel/architecture.mdx",
- "content/docs/kernel/cluster.mdx",
- "content/docs/kernel/contracts/auth-service.mdx",
- "content/docs/kernel/contracts/cache-service.mdx",
- "content/docs/kernel/contracts/data-engine.mdx",
- "content/docs/kernel/contracts/index.mdx",
- "content/docs/kernel/contracts/metadata-service.mdx",
- "content/docs/kernel/contracts/storage-service.mdx",
- "content/docs/kernel/events.mdx",
- "content/docs/kernel/index.mdx",
- "content/docs/kernel/runtime-services/audit-service.mdx",
- "content/docs/kernel/runtime-services/data-service.mdx",
- "content/docs/kernel/runtime-services/email-service.mdx",
- "content/docs/kernel/runtime-services/examples.mdx",
- "content/docs/kernel/runtime-services/index.mdx",
- "content/docs/kernel/runtime-services/queue-service.mdx",
- "content/docs/kernel/runtime-services/settings-service.mdx",
- "content/docs/kernel/runtime-services/sharing-service.mdx",
- "content/docs/kernel/runtime-services/sms-service.mdx",
- "content/docs/kernel/runtime-services/storage-service.mdx",
- "content/docs/kernel/runtime-services/versioning.mdx",
- "content/docs/kernel/services-checklist.mdx",
- "content/docs/kernel/services.mdx",
- "content/docs/permissions/access-matrix.mdx",
- "content/docs/permissions/access-recipes.mdx",
- "content/docs/permissions/administrator-guide.mdx",
- "content/docs/permissions/attachments-access.mdx",
- "content/docs/permissions/authentication.mdx",
- "content/docs/permissions/authorization.mdx",
- "content/docs/permissions/capabilities.mdx",
- "content/docs/permissions/delegated-administration.mdx",
- "content/docs/permissions/explain.mdx",
- "content/docs/permissions/field-level-security.mdx",
- "content/docs/permissions/index.mdx",
- "content/docs/permissions/permission-metadata.mdx",
- "content/docs/permissions/permission-sets.mdx",
- "content/docs/permissions/permissions-matrix.mdx",
- "content/docs/permissions/positions.mdx",
- "content/docs/permissions/profiles.mdx",
- "content/docs/permissions/record-view-auditing.mdx",
- "content/docs/permissions/rls.mdx",
- "content/docs/permissions/sharing-rules.mdx",
- "content/docs/permissions/sso.mdx",
- "content/docs/permissions/system-context.mdx",
- "content/docs/permissions/tenant-audit-census.mdx",
- "content/docs/plugins/adding-a-metadata-type.mdx",
- "content/docs/plugins/anatomy.mdx",
- "content/docs/plugins/development.mdx",
- "content/docs/plugins/index.mdx",
- "content/docs/plugins/packages.mdx",
- "content/docs/protocol/backward-compatibility.mdx",
- "content/docs/protocol/diagram.mdx",
- "content/docs/protocol/index.mdx",
- "content/docs/protocol/kernel/config-resolution.mdx",
- "content/docs/protocol/kernel/error-handling.mdx",
- "content/docs/protocol/kernel/http-protocol.mdx",
- "content/docs/protocol/kernel/i18n-standard.mdx",
- "content/docs/protocol/kernel/index.mdx",
- "content/docs/protocol/kernel/lifecycle.mdx",
- "content/docs/protocol/kernel/metadata-service.mdx",
- "content/docs/protocol/kernel/plugin-spec.mdx",
- "content/docs/protocol/kernel/realtime-protocol.mdx",
- "content/docs/protocol/knowledge.mdx",
- "content/docs/protocol/objectql/index.mdx",
- "content/docs/protocol/objectql/query-syntax.mdx",
- "content/docs/protocol/objectql/schema.mdx",
- "content/docs/protocol/objectql/security.mdx",
- "content/docs/protocol/objectql/state-machine.mdx",
- "content/docs/protocol/objectql/types.mdx",
- "content/docs/protocol/objectui/actions.mdx",
- "content/docs/protocol/objectui/concept.mdx",
- "content/docs/protocol/objectui/index.mdx",
- "content/docs/protocol/objectui/layout-dsl.mdx",
- "content/docs/protocol/objectui/record-alert.mdx",
- "content/docs/protocol/objectui/widget-contract.mdx",
- "content/docs/releases/implementation-status.mdx",
- "content/docs/releases/index.mdx",
- "content/docs/releases/v12.mdx",
- "content/docs/releases/v13.mdx",
- "content/docs/releases/v14.mdx",
- "content/docs/releases/v15.mdx",
- "content/docs/releases/v16.mdx",
- "content/docs/releases/v17.mdx",
- "content/docs/releases/v9.mdx",
- "content/docs/ui/actions.mdx",
- "content/docs/ui/apps.mdx",
- "content/docs/ui/audience-based-interfaces.mdx",
- "content/docs/ui/create-vs-edit-form.mdx",
- "content/docs/ui/dashboards.mdx",
- "content/docs/ui/doc-pages.mdx",
- "content/docs/ui/field-grouping-and-order.mdx",
- "content/docs/ui/forms.mdx",
- "content/docs/ui/index.mdx",
- "content/docs/ui/pages.mdx",
- "content/docs/ui/public-data-collection.mdx",
- "content/docs/ui/react-pages.mdx",
- "content/docs/ui/reports.mdx",
- "content/docs/ui/setup-app.mdx",
- "content/docs/ui/translations.mdx",
- "content/docs/ui/views.mdx",
- "content/docs/upgrading.mdx",
-]
-//
+// THE single source is the artifact named below: one generated file, derived from the
+// filesystem by `node scripts/docs-audit/check-audit-scope.mjs --write` and held equal to
+// content/docs/ in BOTH directions by `pnpm check:docs-audit-scope`. It is deliberately
+// not kept here — an audit's page list is routine bookkeeping, and keeping it in this tree
+// made every PR that adds a documentation page edit a governed file for no other reason.
+//
+// Before it was generated at all it was hand-kept behind a "keep in sync" comment, and it
+// rotted both ways at once: 16 entries pointing at files that no longer existed (a whole
+// renamed directory among them) and 48 existing docs missing from it — while every
+// full-audit run reported green, because a doc path that resolves to nothing produces an
+// agent that reads nothing and reports "0 fixes". One generated source held equal to the
+// filesystem in both directions is what keeps that from recurring, wherever it lives.
+const SCOPE_SOURCE = 'scripts/docs-audit/handwritten-docs.json'
+const SCOPE_ARG = 'handwritten'
// --- Release-owned pages are IN SCOPE but READ-ONLY (#4920) -------------------
//
@@ -242,13 +51,13 @@ const ALL_HANDWRITTEN = [
//
// The ruling on #4920 was NOT to drop them from scope. Dropping them would leave
// some of the most-read pages in the docs permanently unaudited, and would create a
-// SECOND definition of "which docs does this workflow cover" alongside the generated
-// block above — #4851 had just finished paying for what happens when one subject has
-// two hand-kept lists. So the scope is unchanged and only the DELIVERABLE forks:
+// SECOND definition of "which docs does this workflow cover" alongside the scope
+// source above — the repo had just finished paying for what happens when one subject
+// has two hand-kept lists. So the scope is unchanged and only the DELIVERABLE forks:
// findings to file as issues, instead of edits written to disk.
//
// The fork has to be decidable inside the workflow VM (no filesystem, no require, no
-// import — see the note on the generated block), which a path prefix is. And the
+// import — see the note on the scope source above), which a path prefix is. And the
// prefix is not a curation of the guardrail, it is the guardrail's own path column
// copied verbatim, so there is still exactly one definition of "release-owned".
// `scripts/docs-audit/check-audit-scope.mjs` anchors the two together and goes red if
@@ -257,37 +66,56 @@ const ALL_HANDWRITTEN = [
const RELEASE_OWNED_PREFIX = 'content/docs/releases/'
const isReleaseOwned = (doc) => doc.startsWith(RELEASE_OWNED_PREFIX)
-// Scope resolution. Omitting `args` entirely is the legitimate "audit
-// everything" invocation; supplying `args` but not a usable `args.docs` array
-// is a CALLER BUG and must say so.
+// Scope resolution. Two channels, and NEITHER has a default: `args.docs` is the
+// caller's narrowing, `args.handwritten` is the full hand-written set handed in from
+// the artifact named above. An invocation carrying neither is refused by name.
//
-// This used to be a single `?:` that fell back to ALL_HANDWRITTEN for both
-// cases. The Workflow tool delivers `args` verbatim, so passing a JSON-encoded
-// STRING instead of an object — an easy mistake, and one the tool's own docs
-// warn about — left `args.docs` undefined, the ternary silently widened a
-// 12-doc request to all 147, and the run burned ~294 agents on work nobody
-// asked for. Nothing in the output said the scope had been ignored. Failing
-// loudly costs one retry; failing quietly cost an hour.
-if (args !== undefined && args !== null) {
- if (typeof args === 'string') {
- throw new Error(
- '[docs-accuracy-audit] `args` arrived as a string, not an object — pass a real JSON ' +
- 'value (e.g. {"docs": ["content/docs/a.mdx"]}), not a JSON-encoded string. ' +
- 'Refusing to silently audit all ' + ALL_HANDWRITTEN.length + ' docs instead.',
- )
- }
- if (args.docs !== undefined && (!Array.isArray(args.docs) || args.docs.length === 0)) {
+// There is deliberately no "omit args and audit everything" path any more. There
+// cannot be one — this body has no filesystem, so with nothing handed in it has no
+// idea what "everything" is, and the only shapes it could invent are a silent audit
+// of nothing and a stale list. Refusing costs one retry and says exactly what to pass.
+//
+// Supplying `args` but not a usable array is a CALLER BUG and must say so. This used
+// to be a single `?:` that fell back to the inline list for both cases. The Workflow
+// tool delivers `args` verbatim, so passing a JSON-encoded STRING instead of an object
+// — an easy mistake, and one the tool's own docs warn about — left `args.docs`
+// undefined, the ternary silently widened a 12-doc request to the whole corpus, and
+// the run burned ~294 agents on work nobody asked for. Nothing in the output said the
+// scope had been ignored. Failing loudly costs one retry; failing quietly cost an hour.
+const HOW_TO_PASS =
+ 'Read `' + SCOPE_SOURCE + '` OUTSIDE the workflow and pass its `docs` array as ' +
+ 'args.' + SCOPE_ARG + ' for a full audit, or pass args.docs = [paths] to scope the run.'
+
+if (typeof args === 'string') {
+ throw new Error(
+ '[docs-accuracy-audit] `args` arrived as a string, not an object — pass a real JSON ' +
+ 'value (e.g. {"docs": ["content/docs/a.mdx"]}), not a JSON-encoded string. ' + HOW_TO_PASS,
+ )
+}
+for (const key of ['docs', SCOPE_ARG]) {
+ const value = args === undefined || args === null ? undefined : args[key]
+ if (value !== undefined && (!Array.isArray(value) || value.length === 0 || value.some((d) => typeof d !== 'string'))) {
throw new Error(
- '[docs-accuracy-audit] `args.docs` must be a non-empty array of doc paths; got ' +
- JSON.stringify(args.docs) + '. Omit `args` entirely to audit all ' +
- ALL_HANDWRITTEN.length + ' hand-written docs.',
+ '[docs-accuracy-audit] `args.' + key + '` must be a non-empty array of doc paths; got ' +
+ JSON.stringify(value) + '. ' + HOW_TO_PASS,
)
}
}
-const DOCS = args && Array.isArray(args.docs) && args.docs.length ? args.docs : ALL_HANDWRITTEN
+
+const SCOPED = args && Array.isArray(args.docs) && args.docs.length ? args.docs : null
+const HANDWRITTEN = args && Array.isArray(args[SCOPE_ARG]) && args[SCOPE_ARG].length ? args[SCOPE_ARG] : null
+if (!SCOPED && !HANDWRITTEN) {
+ throw new Error(
+ '[docs-accuracy-audit] no scope was handed in: this invocation carries neither `args.docs` ' +
+ 'nor `args.' + SCOPE_ARG + '`. This body runs in a sandbox with no filesystem, so it cannot ' +
+ 'enumerate content/docs/ or read the list itself — the caller must hand it in. ' + HOW_TO_PASS,
+ )
+}
+
+const DOCS = SCOPED ?? HANDWRITTEN
const WRITABLE_DOCS = DOCS.filter((d) => !isReleaseOwned(d))
const READONLY_DOCS = DOCS.filter(isReleaseOwned)
-log(`scope: ${DOCS.length} doc(s)${DOCS === ALL_HANDWRITTEN ? ' — FULL audit (no args.docs given)' : ''}`)
+log(`scope: ${DOCS.length} doc(s)${DOCS === HANDWRITTEN ? ` — FULL audit (whole hand-written set from ${SCOPE_SOURCE})` : ''}`)
if (READONLY_DOCS.length) {
log(
` of which ${READONLY_DOCS.length} release-owned page(s) under ${RELEASE_OWNED_PREFIX} — ` +
@@ -297,10 +125,10 @@ if (READONLY_DOCS.length) {
// --- Scope preflight: every path in scope must resolve to a real file ---------
//
-// `pnpm check:docs-audit-scope` already keeps ALL_HANDWRITTEN honest in CI, but it
-// can only see the DEFAULT list. A caller-supplied `args.docs` — the normal way this
-// workflow is invoked — is checked by nothing, and a bad path there fails exactly the
-// way #4851's stale list did: the audit agent finds no file, reports `fixCount: 0`,
+// `pnpm check:docs-audit-scope` already holds the scope source equal to content/docs/
+// in CI, but it can only see THAT list. A caller-supplied `args.docs` — the normal way
+// this workflow is invoked — is checked by nothing, and a bad path there fails exactly
+// the way a stale list did before the gate existed: the agent finds no file, reports `fixCount: 0`,
// and the run summary shows a doc that was "audited clean". So resolve the scope
// first and refuse to start if anything in it is missing.
//
@@ -369,10 +197,10 @@ if (preflight.missing.length) {
`[docs-accuracy-audit] ${preflight.missing.length} of ${DOCS.length} doc path(s) in scope ` +
'do not exist:\n ' + preflight.missing.join('\n ') +
'\n\nRefusing to run: an audit agent pointed at a non-existent file reads nothing and ' +
- 'reports "0 fixes", so the run would report success over docs nobody looked at (#4851 — ' +
- 'that is how #4781 and #4817 survived ~2 months of green full audits). If these came from ' +
- 'args.docs, fix the caller; if they came from the default list, run ' +
- '`node scripts/docs-audit/check-audit-scope.mjs --write`.',
+ 'reports "0 fixes", so the run would report success over docs nobody looked at — that is ' +
+ 'how two real accuracy defects survived ~2 months of green full audits. If these came from ' +
+ `args.docs, fix the caller; if they came from ${SCOPE_SOURCE}, that artifact is stale — run ` +
+ '`node scripts/docs-audit/check-audit-scope.mjs --write` and hand in the regenerated list.',
)
}
log(`preflight: all ${preflight.present.length} path(s) resolve (via \`${preflight.command}\`)`)
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 3dfae68f01..e8749c76d3 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -1871,17 +1871,19 @@ jobs:
- name: Template version-time rewriter self-test
run: pnpm check:template-version-sync
- # #4851: the docs-accuracy-audit workflow carries its default scope inline
+ # #4851: the docs-accuracy-audit workflow's default scope is a generated list
# (a workflow script runs in a vm with no filesystem, so it cannot enumerate
- # content/docs/ itself). Hand-kept, that list rotted in BOTH directions —
- # 16 entries pointing at files that no longer existed after the
- # protocol/objectos → protocol/kernel rename, and 48 existing docs missing
- # from it — while every "FULL audit" run reported green, which is how the
- # accuracy defects in #4781 and #4817 survived ~2 months. This regenerates
- # the list from the filesystem and fails when the two disagree either way.
- # It lives in this job deliberately: the change that breaks the list is a
- # docs rename, so a `packages/**` paths filter would blind the gate to
- # exactly its own failure mode.
+ # content/docs/ itself — the caller hands the list in). Hand-kept, that list
+ # rotted in BOTH directions — 16 entries pointing at files that no longer
+ # existed after the protocol/objectos → protocol/kernel rename, and 48
+ # existing docs missing from it — while every "FULL audit" run reported
+ # green, which is how the accuracy defects in #4781 and #4817 survived
+ # ~2 months. This derives the list from the filesystem and fails when it and
+ # scripts/docs-audit/handwritten-docs.json disagree either way; it also runs
+ # the workflow against stub agents to check the body still consumes what it
+ # is handed. It lives in this job deliberately: the change that breaks the
+ # list is a docs rename, so a `packages/**` paths filter would blind the gate
+ # to exactly its own failure mode.
- name: Docs-audit scope is derived, not hand-kept
run: pnpm check:docs-audit-scope
diff --git a/scripts/docs-audit/README.md b/scripts/docs-audit/README.md
index 553d4a6468..aeaf26c581 100644
--- a/scripts/docs-audit/README.md
+++ b/scripts/docs-audit/README.md
@@ -456,17 +456,33 @@ node scripts/docs-audit/check-audit-scope.mjs --write # regenerate the list fr
node scripts/docs-audit/check-audit-scope.mjs --self-test
```
-The `docs-accuracy-audit` workflow (part 3) carries its default scope **inline**, as
-`ALL_HANDWRITTEN`. It has to: a workflow script runs inside a `node:vm` context whose
-only globals are `log`/`phase`/`console`/`budget`/timers plus
-`agent`/`parallel`/`pipeline`/`workflow`/`args`, with code generation disabled — no
-`require`, no `import`, no filesystem. It can neither walk `content/docs/` nor read a
-JSON artifact, so the list cannot be derived *at run time*.
+**THE single source is `scripts/docs-audit/handwritten-docs.json`** — one generated file,
+with two consumers: this gate, and whoever invokes the `docs-accuracy-audit` workflow
+(part 3), who reads it and hands its `docs` array in as `args.handwritten`.
-It is therefore derived at *generation* time instead: `--write` rewrites the block from
+The workflow body cannot read it itself. A workflow script runs inside a `node:vm`
+context whose only globals are `log`/`phase`/`console`/`budget`/timers plus
+`agent`/`parallel`/`pipeline`/`workflow`/`args`, with code generation disabled — no
+`require`, no `import`, no filesystem. It can neither walk `content/docs/` nor open a
+JSON artifact. But it does not have to: `args` **is** an injection channel, delivered
+verbatim from the invocation, so the read happens in the **caller**, outside the sandbox,
+and the list arrives as data.
+
+The list used to live inline in the workflow body, as `ALL_HANDWRITTEN`. `.claude/**` is
+a governed surface (human-merge-only, never armed, never queued), so adding one customer
+documentation page forced a governed edit — through a bookkeeping list that merely
+happened to live there, and invisibly: nothing in such a card's file list showed a
+governed path until this gate ran. Maintainer ruling, 2026-09-01, verbatim 「同意」: move
+it off. ⚠️ The governed register itself is **unchanged** — narrowing it was the option
+that ruling explicitly rejected, and this file is no precedent for relocating anything
+else out of `.claude/**`.
+
+The list is derived at *generation* time: `--write` rewrites the artifact from
`affected-docs.mjs --all` (one definition of "hand-written doc", not two), and the plain
-run is a CI gate in `lint.yml` that fails when the block and `content/docs/` disagree
-**in either direction**.
+run is a CI gate in `lint.yml` that fails when the artifact and `content/docs/` disagree
+**in either direction**. It also runs the workflow against stub agents and checks that the
+body still *consumes* what it is handed — an artifact in sync with `content/docs/` proves
+nothing about a body that has stopped reading it, and that failure would be silent.
Both directions matter, and only one had ever been noticed (#4851):
@@ -642,15 +658,22 @@ a consumer, add it to that list.**
A Claude Code multi-agent workflow (`.claude/workflows/docs-accuracy-audit.js`). For each
doc: an agent reads it, locates the real implementation, and applies evidence-backed
fixes in place; a second **adversarial verifier** re-checks every fix against the code and
-repairs over-corrections. Scope it with `args.docs`; omit for a full audit.
+repairs over-corrections. Scope it with `args.docs`; for a full audit hand in the whole
+set as `args.handwritten`.
```js
// scoped to the docs a code change touched:
Workflow({ name: 'docs-accuracy-audit', args: { docs: [/* output of affected-docs.mjs */] } })
-// full audit of all hand-written docs:
-Workflow({ name: 'docs-accuracy-audit' })
+// full audit of all hand-written docs — read the artifact first, OUTSIDE the sandbox:
+// node -e "console.log(JSON.stringify(require('./scripts/docs-audit/handwritten-docs.json').docs))"
+Workflow({ name: 'docs-accuracy-audit', args: { handwritten: [/* that array */] } })
```
+⛔ There is no "omit `args` and audit everything" invocation, and there cannot be: the
+body has no filesystem, so with nothing handed in it does not know what "everything" is.
+It refuses by name rather than inventing a scope — the two shapes it could invent are a
+silent audit of nothing and a stale list, and both report success.
+
It edits files in place (frontmatter preserved, no moves) and returns a per-doc log of
fixes, verifier repairs, and residual items that couldn't be confirmed against code —
**except** for `content/docs/releases/**`, which is reviewed read-only and returns
diff --git a/scripts/docs-audit/affected-docs.mjs b/scripts/docs-audit/affected-docs.mjs
index f1315ee838..e7dd5f517b 100644
--- a/scripts/docs-audit/affected-docs.mjs
+++ b/scripts/docs-audit/affected-docs.mjs
@@ -236,7 +236,7 @@ let diffBaseRef = null;
* the audit scoping command still returns them and they keep getting audited. #4920
* considered excluding them and REJECTED it: the most-read pages in the docs would go
* permanently unaudited and silently, and a second definition of "docs this tooling
- * covers" would grow next to the generated block. What forks is the DELIVERABLE — the
+ * covers" would grow next to the generated scope artifact. What forks is the DELIVERABLE — the
* drift comment tells the reader to file an issue instead of editing (#6893: a comment
* listing `content/docs/releases/v17.mdx` next to editable pages steers a dev who
* treats the list as a worklist straight into the one edit the repo forbids).
diff --git a/scripts/docs-audit/check-audit-scope.mjs b/scripts/docs-audit/check-audit-scope.mjs
index d784b2dd6b..47721ef476 100644
--- a/scripts/docs-audit/check-audit-scope.mjs
+++ b/scripts/docs-audit/check-audit-scope.mjs
@@ -8,20 +8,39 @@
//
// Usage:
// node scripts/docs-audit/check-audit-scope.mjs # verify; exit 1 naming every drifted entry
-// node scripts/docs-audit/check-audit-scope.mjs --write # regenerate the block in place
-// node scripts/docs-audit/check-audit-scope.mjs --self-test # pin the parser/renderer/differ + the read-only routing
+// node scripts/docs-audit/check-audit-scope.mjs --write # regenerate the artifact from the filesystem
+// node scripts/docs-audit/check-audit-scope.mjs --self-test # pin the parser/renderer/differ + the read-only routing + the injection contract
//
-// ## Why this exists (#4851)
+// ## Where the list lives, and why it moved (#13591; maintainer 2026-09-01, 「同意」)
//
-// `.claude/workflows/docs-accuracy-audit.js` carries the full list of hand-written
-// docs inline, as `ALL_HANDWRITTEN`. It has to: a workflow script runs inside a
-// `node:vm` context whose only globals are `log/phase/console/budget/setTimeout/
-// clearTimeout` plus `agent/parallel/pipeline/workflow/args`, with `codeGeneration`
-// disabled — no `require`, no `import`, no filesystem, no `eval`. The script
-// therefore cannot enumerate `content/docs/**` itself, and cannot read the list
-// from a JSON artifact either.
+// THE single source is `scripts/docs-audit/handwritten-docs.json` — one generated
+// file, derived from the filesystem here and consumed by two readers: this gate, and
+// whoever invokes the `docs-accuracy-audit` workflow, who reads it and hands the list
+// to the workflow body as `args.handwritten`.
//
-// So the list was hand-kept, with a comment asking the next author to "keep in sync
+// It used to be inline in `.claude/workflows/docs-accuracy-audit.js`, as
+// `ALL_HANDWRITTEN`, and the argument for that was real as far as it went: a workflow
+// script runs inside a `node:vm` context whose only globals are `log/phase/console/
+// budget/setTimeout/clearTimeout` plus `agent/parallel/pipeline/workflow/args`, with
+// `codeGeneration` disabled — no `require`, no `import`, no filesystem, no `eval`. The
+// body can neither enumerate `content/docs/**` nor open a JSON file.
+//
+// What that argument missed is that the body does not have to do the reading. `args`
+// IS an injection channel: the runner delivers it verbatim from the invocation, so the
+// file read happens in the CALLER, outside the sandbox, and the list arrives as data.
+// The sandbox stays exactly as FS-blind as before.
+//
+// The cost of the inline form was paid by every unrelated PR. `.claude/**` is a
+// governed surface — human-merge-only, never armed, never queued — so adding one
+// customer documentation page forced an edit to a governed file, through a bookkeeping
+// list that merely happened to live there. Worse, it was invisible at dispatch time:
+// nothing in such a card's file list showed a governed path until the gate ran. The
+// maintainer ruled the list off that surface; the governed register itself is untouched,
+// and narrowing it was considered and explicitly rejected.
+//
+// ## Why the list is generated at all (#4851)
+//
+// It was once hand-kept, with a comment asking the next author to "keep in sync
// with `affected-docs.mjs --all`" — a promise nothing checked. It rotted, in BOTH
// directions, and neither direction announced itself:
//
@@ -51,9 +70,23 @@
// rather than re-deriving it. One definition of "hand-written doc", one place to
// change it; a second walk here would be the next thing to drift out of sync.
//
-// `--write` regenerates the inline block from that derivation, so the array is a
-// generated artifact that happens to live inside a hand-written file. Hand-editing
-// it is never necessary and this check will reject it.
+// `--write` regenerates the artifact from that derivation, byte for byte. Hand-editing
+// it is never necessary and this check will reject it — including a merely reordered
+// list, because a hand-edited artifact has stopped being generated output whatever it
+// happens to contain.
+//
+// ## The injection contract is checked by RUNNING the workflow, not by grepping it
+//
+// Moving the list out creates one failure mode that did not exist before: the workflow
+// could stop reading what it is handed — an edit that renames the arg, reinstates a
+// default, or drops the refusal — and this gate would still report the artifact in
+// sync with `content/docs/` while every full audit ran over nothing, or over whatever
+// stale shape the body invented. The artifact being correct says nothing about the body
+// consuming it. So `checkScopeInjection` runs the real workflow against stub agents and
+// observes what it does with an injected list, with a scoped list, and with neither;
+// `--self-test` then mutates each of those behaviours out of an in-memory copy and
+// requires the check to go red, because a guard nobody has seen fail is a guard nobody
+// has tested (#4868).
//
// ## Release-owned pages: in scope, read-only (#4920)
//
@@ -116,8 +149,22 @@ const AGENTS_REL = 'AGENTS.md';
* a file that does not exist. The self-test pins both halves.
*/
export const ROOT_FILE_WATCH_HINTS = ['AGENTS.md/**'];
-const BEGIN = '// ';
-const END = '// ';
+
+/**
+ * THE single source: the one hand-kept-nowhere, generated-here list of hand-written
+ * docs. Two readers — this gate, and the caller that hands it to the workflow body.
+ * Deliberately outside `.claude/**` and outside every other governed glob (see the
+ * header): an audit's page list is bookkeeping, and it must not make an unrelated
+ * docs PR human-merge-only.
+ */
+export const SCOPE_ARTIFACT_REL = 'scripts/docs-audit/handwritten-docs.json';
+
+/**
+ * The `args` key the workflow body reads its full scope from. Named here because
+ * `checkScopeInjection` asserts against the REAL workflow through it: rename the arg
+ * in one place and the observed run stops matching, which is the point.
+ */
+export const SCOPE_ARG = 'handwritten';
/**
* The release-owned boundary — the path column of AGENTS.md's RELEASE-OWNED guardrail
@@ -129,85 +176,74 @@ export const isReleaseOwned = (doc) => doc.startsWith(RELEASE_OWNED_PREFIX);
const args = process.argv.slice(2);
-// --- block extraction / rendering -------------------------------------------
+// --- the artifact: parsing / rendering ---------------------------------------
/**
- * The generated block's body (everything strictly between the markers), or throw.
+ * The doc paths declared by the artifact, or throw.
*
- * A missing marker is a HARD failure, not a skip. This gate's whole subject is a
- * list that quietly stopped matching reality; a version of it that shrugs when it
- * cannot find that list would reproduce the defect one level up (#4690's shape:
+ * An unreadable or shapeless artifact is a HARD failure, not a skip. This gate's whole
+ * subject is a list that quietly stopped matching reality; a version of it that shrugs
+ * when it cannot find that list would reproduce the defect one level up (#4690's shape:
* no manifest, exit 0).
*/
-export function extractBlock(source) {
- const begin = source.indexOf(BEGIN);
- const end = source.indexOf(END);
- if (begin === -1 || end === -1 || end < begin) {
- throw new Error(
- `${WORKFLOW_REL}: could not find the generated scope block. Expected a region ` +
- `delimited by "${BEGIN}" and "${END}". The audit workflow's default scope is ` +
- `generated by this script; if the block was renamed or removed, restore it (or ` +
- `update the markers here) — do NOT hand-maintain the list.`,
- );
- }
- return source.slice(begin + BEGIN.length, end);
-}
-
-/** The doc paths declared inside the generated block. */
-export function parseBlock(source) {
- const body = extractBlock(source);
- // No doc path contains `]`, so "up to the first bracket" is unambiguous — and it
- // parses the legacy single-line form too, so an old block is reported as drift
- // rather than as a parse crash.
- const m = body.match(/const ALL_HANDWRITTEN = (\[[^\]]*])/);
- if (!m) {
- throw new Error(
- `${WORKFLOW_REL}: the generated scope block contains no \`const ALL_HANDWRITTEN = [...]\` ` +
- `array literal. Regenerate it with \`node scripts/docs-audit/check-audit-scope.mjs --write\`.`,
- );
- }
- // The rendered form keeps a trailing comma on the last entry (smaller diffs when a
- // doc is added); that is the one thing between it and plain JSON, so drop it.
- const json = m[1].replace(/,(\s*])$/, '$1');
+export function parseArtifact(source) {
let parsed;
try {
- parsed = JSON.parse(json);
+ parsed = JSON.parse(source);
} catch (e) {
throw new Error(
- `${WORKFLOW_REL}: ALL_HANDWRITTEN is not a plain array of string literals (${e.message}). ` +
- `It is generated output — regenerate with \`--write\` rather than hand-editing it.`,
+ `${SCOPE_ARTIFACT_REL}: not valid JSON (${e.message}). It is generated output — ` +
+ `regenerate it with \`node scripts/docs-audit/check-audit-scope.mjs --write\` rather ` +
+ `than hand-editing it.`,
+ );
+ }
+ const docs = parsed?.docs;
+ if (!Array.isArray(docs) || docs.some((d) => typeof d !== 'string')) {
+ throw new Error(
+ `${SCOPE_ARTIFACT_REL}: no \`docs\` array of string paths. That array IS the audit ` +
+ `scope — the workflow is handed it as \`args.${SCOPE_ARG}\` and this gate holds it ` +
+ `equal to content/docs/. Regenerate with \`--write\`.`,
);
}
- return parsed;
+ return docs;
}
/**
- * The exact text the block must contain for a given doc set. Byte-comparing against
+ * The exact bytes the artifact must contain for a given doc set. Byte-comparing against
* this is what makes hand-edits — including a merely reordered list — visible.
+ *
+ * The `readme` block is part of the generated bytes on purpose: JSON carries no
+ * comments, and an artifact that cannot say what it is invites the next reader to treat
+ * it as a hand-kept list, which is the exact thing it exists to stop being.
*/
-export function renderBlock(docs) {
- const entries = [...docs].sort();
- return [
- '',
- '// GENERATED — do not hand-edit. `node scripts/docs-audit/check-audit-scope.mjs --write`',
- '// derives this from the filesystem (every content/docs/**/*.mdx except',
- '// references/, via `affected-docs.mjs --all`); the same script run without',
- '// --write fails CI when the two disagree in either direction. See #4851: this',
- '// list was hand-kept, 16 entries pointed at files that no longer existed and 48',
- '// existing docs were absent from it, and a "FULL audit" reported green over both.',
- 'const ALL_HANDWRITTEN = [',
- ...entries.map((d) => ` ${JSON.stringify(d)},`),
- ']',
- '',
- ].join('\n');
-}
-
-/** Splice a freshly rendered block back into the workflow source. */
-export function replaceBlock(source, docs) {
- const begin = source.indexOf(BEGIN);
- const end = source.indexOf(END);
- if (begin === -1 || end === -1 || end < begin) throw new Error('missing markers'); // extractBlock already explains
- return source.slice(0, begin + BEGIN.length) + renderBlock(docs) + source.slice(end);
+export function renderArtifact(docs) {
+ return `${JSON.stringify(
+ {
+ readme: [
+ 'GENERATED — do not hand-edit. `node scripts/docs-audit/check-audit-scope.mjs --write`',
+ 'derives this from the filesystem (every content/docs/**/*.mdx except references/,',
+ 'via `affected-docs.mjs --all`); the same script without --write is a CI gate that',
+ 'fails when this file and content/docs/ disagree in EITHER direction.',
+ '',
+ 'THIS FILE IS THE SINGLE SOURCE for "which docs are hand-written". Two consumers:',
+ ' 1. .claude/workflows/docs-accuracy-audit.js — its body runs in a sandbox with no',
+ ' filesystem, so the CALLER reads this file and passes `docs` as args.handwritten.',
+ ' 2. scripts/docs-audit/check-audit-scope.mjs — the gate that keeps it honest.',
+ 'There is deliberately no second copy anywhere; one subject, one list.',
+ '',
+ 'It lives here, outside .claude/** and outside every other governed surface, by',
+ 'maintainer ruling (2026-09-01). Inline, it made every PR that added a documentation',
+ 'page a human-merge-only PR — a governed edit forced by bookkeeping, invisible until',
+ 'the gate ran. Moving it is NOT a narrowing of the governed register, which is',
+ 'unchanged; do not treat this file as precedent for relocating anything else.',
+ ],
+ generator: 'node scripts/docs-audit/check-audit-scope.mjs --write',
+ gate: 'pnpm check:docs-audit-scope',
+ docs: [...docs].sort(),
+ },
+ null,
+ 2,
+ )}\n`;
}
// --- the comparison ----------------------------------------------------------
@@ -465,6 +501,103 @@ export async function checkReadOnlyRouting(source) {
return problems;
}
+/** The two probe docs, as a list handed in the way a real full audit hands one in. */
+const INJECTION_PROBE = [PROBE_EDITABLE, PROBE_RELEASE];
+
+/**
+ * The injection contract, observed on a real run.
+ *
+ * The artifact being in sync with `content/docs/` says NOTHING about the workflow body
+ * consuming it — that is the one new failure mode created by moving the list out, and
+ * it is silent by construction: a body that ignored `args.${SCOPE_ARG}` would leave this
+ * gate green while every full audit ran over the wrong set. Grepping the source cannot
+ * tell a working read from a dead one, so this observes behaviour: what a handed-in list
+ * is actually audited, what a run with nothing handed in does, and whether the narrowing
+ * channel still narrows.
+ *
+ * Returns a list of human-readable problems; empty means the contract holds.
+ */
+export async function checkScopeInjection(source) {
+ const problems = [];
+ const stub = ({ prompt, phase, schema }) =>
+ phase === 'Scope Preflight' ? preflightResponse(prompt) : stubFor(schema, { doc: '' });
+ const audits = (calls) => calls.filter((c) => c.phase === 'Audit & Fix');
+
+ // 1. The injected list IS the scope, and the run says which list it audited.
+ const injected = await runWorkflow(source, { workflowArgs: { [SCOPE_ARG]: INJECTION_PROBE }, respond: stub });
+ if (injected.error) {
+ problems.push(`the workflow threw on a run whose scope was handed in as args.${SCOPE_ARG}: ${injected.error.message}`);
+ } else {
+ const seen = audits(injected.calls);
+ if (seen.length !== INJECTION_PROBE.length) {
+ problems.push(
+ `args.${SCOPE_ARG} carried ${INJECTION_PROBE.length} doc(s) but ${seen.length} reached an ` +
+ 'audit-phase agent — the body is not auditing what it was handed',
+ );
+ }
+ if (!injected.logs.some((l) => l.includes('FULL audit') && l.includes(SCOPE_ARTIFACT_REL))) {
+ problems.push(
+ `a run scoped by args.${SCOPE_ARG} did not report itself as a FULL audit naming ` +
+ `${SCOPE_ARTIFACT_REL} — the summary has to say which list it audited, or a truncated ` +
+ 'list reads exactly like the whole corpus',
+ );
+ }
+ }
+
+ // 2. Nothing handed in must REFUSE, naming the artifact and the arg. A body that
+ // invents a default here is how the list silently rots back into existence.
+ for (const [label, workflowArgs] of [
+ ['no args at all', undefined],
+ ['an args object carrying neither key', {}],
+ ]) {
+ const bare = await runWorkflow(source, { workflowArgs, respond: stub });
+ if (!bare.error) {
+ problems.push(
+ `${label}: the workflow ran instead of refusing — with no list handed in it cannot know ` +
+ 'what "everything" is, so whatever it audited it would report as a result',
+ );
+ continue;
+ }
+ if (!bare.error.message.includes(SCOPE_ARTIFACT_REL) || !bare.error.message.includes(`args.${SCOPE_ARG}`)) {
+ problems.push(
+ `${label}: the refusal names neither ${SCOPE_ARTIFACT_REL} nor args.${SCOPE_ARG}, so it does ` +
+ `not tell the caller what to pass — got: ${bare.error.message.slice(0, 160)}`,
+ );
+ }
+ if (audits(bare.calls).length) problems.push(`${label}: audit-phase agents were spawned before the refusal`);
+ }
+
+ // 3. A malformed injected list is a CALLER BUG, never an empty audit.
+ for (const [label, value] of [
+ ['an empty array', []],
+ ['a bare string', PROBE_EDITABLE],
+ ['an array holding a non-string', [PROBE_EDITABLE, 7]],
+ ]) {
+ const bad = await runWorkflow(source, { workflowArgs: { [SCOPE_ARG]: value }, respond: stub });
+ if (!bad.error) {
+ problems.push(`args.${SCOPE_ARG} = ${label} did not fail the run — an audit of nothing must never report success`);
+ } else if (!bad.error.message.includes(`args.${SCOPE_ARG}`)) {
+ // It failed, but not BY NAME. A body with no shape guard still dies somewhere
+ // downstream on a malformed list, with a message that sends the caller reading
+ // the audit instead of their own invocation.
+ problems.push(
+ `args.${SCOPE_ARG} = ${label} failed without naming args.${SCOPE_ARG} as the caller bug — ` +
+ `got: ${bad.error.message.slice(0, 160)}`,
+ );
+ }
+ }
+
+ // 4. The narrowing channel still narrows, and does not claim to be a full audit.
+ const scoped = await runWorkflow(source, { workflowArgs: { docs: [PROBE_EDITABLE] }, respond: stub });
+ if (scoped.error) {
+ problems.push(`args.docs no longer scopes a run: ${scoped.error.message}`);
+ } else if (scoped.logs.some((l) => l.includes('FULL audit'))) {
+ problems.push('a run scoped by args.docs called itself a FULL audit — the two channels have collapsed into one');
+ }
+
+ return problems;
+}
+
// --- main --------------------------------------------------------------------
// Exports bindings, so an import for those exports alone must run nothing (#10667).
@@ -483,37 +616,62 @@ if (isEntrypoint(import.meta.url)) {
}
}
+/** The artifact's bytes, or null when it does not exist yet (`--write` seeds it). */
+function readArtifact(artifactPath) {
+ try {
+ return readFileSync(artifactPath, 'utf8');
+ } catch (e) {
+ if (e?.code === 'ENOENT') return null;
+ throw e;
+ }
+}
+
async function main() {
const workflowPath = join(REPO_ROOT, WORKFLOW_REL);
+ const artifactPath = join(REPO_ROOT, SCOPE_ARTIFACT_REL);
const source = readFileSync(workflowPath, 'utf8');
const derived = deriveDocs();
if (args.includes('--write')) {
- const before = parseBlock(source);
- writeFileSync(workflowPath, replaceBlock(source, derived));
+ const existing = readArtifact(artifactPath);
+ const before = existing === null ? [] : parseArtifact(existing);
+ writeFileSync(artifactPath, renderArtifact(derived));
const { dead, unlisted } = diffScope(before, derived);
console.log(
- `✓ regenerated ${WORKFLOW_REL} scope: ${derived.length} hand-written doc(s) ` +
+ `✓ regenerated ${SCOPE_ARTIFACT_REL}: ${derived.length} hand-written doc(s) ` +
`(+${unlisted.length} added, -${dead.length} removed).`,
);
return;
}
- const listed = parseBlock(source);
+ const raw = readArtifact(artifactPath);
+ if (raw === null) {
+ console.error(
+ `✗ ${SCOPE_ARTIFACT_REL} does not exist.\n\n` +
+ ` That file is THE single source for the audit's page scope — the workflow is handed\n` +
+ ` its \`docs\` array as \`args.${SCOPE_ARG}\`, and this gate holds it equal to content/docs/.\n` +
+ ` A missing subject is a RED result, never a pass by vacancy.\n\n` +
+ ` Fix: node scripts/docs-audit/check-audit-scope.mjs --write\n`,
+ );
+ process.exit(1);
+ }
+ const listed = parseArtifact(raw);
const { dead, unlisted, duplicates } = diffScope(listed, derived);
- // Byte-compare too: correct paths in a hand-edited shape still means the block
+ // Byte-compare too: correct paths in a hand-edited shape still means the artifact
// stopped being generated output, and the next `--write` would churn.
- const blockDrift = extractBlock(source) !== renderBlock(derived);
+ const blockDrift = raw !== renderArtifact(derived);
if (!dead.length && !unlisted.length && !duplicates.length && !blockDrift) {
console.log(
- `✓ docs-accuracy-audit scope is in sync with content/docs/: ${listed.length} hand-written doc(s).`,
+ `✓ docs-accuracy-audit scope is in sync with content/docs/: ${listed.length} hand-written doc(s) ` +
+ `(${SCOPE_ARTIFACT_REL}).`,
);
await checkReleaseOwned(source, derived);
+ await checkInjection(source);
return;
}
- console.error(`✗ ${WORKFLOW_REL}: ALL_HANDWRITTEN has drifted from content/docs/.\n`);
+ console.error(`✗ ${SCOPE_ARTIFACT_REL}: the hand-written doc list has drifted from content/docs/.\n`);
if (dead.length) {
console.error(
` ${dead.length} listed path(s) do not exist — an audit agent pointed at one reads\n` +
@@ -536,8 +694,8 @@ async function main() {
}
if (!dead.length && !unlisted.length && !duplicates.length && blockDrift) {
console.error(
- ' The listed paths are correct but the block does not match its rendered form —\n' +
- ' ordering, formatting or the header comment was hand-edited.\n',
+ ' The listed paths are correct but the file does not match its rendered form —\n' +
+ ' ordering, formatting or the readme block was hand-edited.\n',
);
}
console.error(' Fix: node scripts/docs-audit/check-audit-scope.mjs --write');
@@ -611,6 +769,29 @@ async function checkReleaseOwned(source, derived) {
);
}
+/**
+ * The other half of "one list, correctly consumed": the artifact is in sync, and the
+ * workflow really reads what it is handed. Neither implies the other.
+ */
+async function checkInjection(source) {
+ const problems = await checkScopeInjection(source);
+ if (problems.length) {
+ console.error(`✗ ${WORKFLOW_REL}: it does not consume the scope it is handed.\n`);
+ for (const p of problems) console.error(` - ${p}`);
+ console.error(
+ `\n Observed by running the workflow against stub agents. ${SCOPE_ARTIFACT_REL} is THE\n` +
+ ` single source; the body runs in a sandbox with no filesystem, so the caller reads that\n` +
+ ` file and hands the list in as \`args.${SCOPE_ARG}\`. An artifact in sync with content/docs/\n` +
+ ` proves nothing about a body that has stopped reading it.\n`,
+ );
+ process.exit(1);
+ }
+ console.log(
+ `✓ scope injection is live: the workflow audits the list handed in as args.${SCOPE_ARG}, ` +
+ `and refuses an invocation that hands in no scope at all.`,
+ );
+}
+
// --- self-test ---------------------------------------------------------------
/**
@@ -644,32 +825,28 @@ async function selfTest() {
};
const docs = ['content/docs/a.mdx', 'content/docs/b.mdx'];
- const file = `header\n${BEGIN}${renderBlock(docs)}${END}\nfooter\n`;
+ const artifact = renderArtifact(docs);
- check('parseBlock round-trips the rendered block', docs, parseBlock(file));
- check('renderBlock sorts', ['a', 'b'], parseBlock(`${BEGIN}${renderBlock(['b', 'a'])}${END}`));
+ check('parseArtifact round-trips the rendered artifact', docs, parseArtifact(artifact));
+ check('renderArtifact sorts', ['a', 'b'], parseArtifact(renderArtifact(['b', 'a'])));
+ check('renderArtifact is order-insensitive byte-for-byte', artifact, renderArtifact([...docs].reverse()));
+ // The artifact carries no comments (JSON), so the bytes have to say what it is —
+ // otherwise the next reader treats a generated file as a hand-kept one.
check(
- 'replaceBlock preserves the surrounding file',
- ['header', 'footer', ''],
- (() => {
- const out = replaceBlock(file, ['content/docs/c.mdx']);
- return [out.split('\n')[0], out.split('\n').at(-2), out.split('\n').at(-1)];
- })(),
+ 'the rendered artifact declares itself the single source and names its consumer arg',
+ [true, true, true],
+ [artifact.includes('SINGLE SOURCE'), artifact.includes(`args.${SCOPE_ARG}`), artifact.includes('--write')],
);
- check('replaceBlock installs the new list', ['content/docs/c.mdx'], parseBlock(replaceBlock(file, ['content/docs/c.mdx'])));
+ check('…and ends with exactly one trailing newline', [true, false], [artifact.endsWith('}\n'), artifact.endsWith('\n\n')]);
// A gate that cannot find its subject must fail, not pass. This is the exact
// failure mode the gate exists to prevent, applied to the gate itself.
- throws('missing markers throw', () => extractBlock('no markers here'), 'could not find the generated scope block');
+ throws('unparseable JSON throws', () => parseArtifact('{ not json'), 'not valid JSON');
+ throws('an artifact with no docs array throws', () => parseArtifact('{"readme":[]}'), 'no `docs` array of string paths');
throws(
- 'a block with no array literal throws',
- () => parseBlock(`${BEGIN}\n// nothing here\n${END}`),
- 'no `const ALL_HANDWRITTEN = [...]` array literal',
- );
- throws(
- 'a hand-edited (non-JSON) array literal throws',
- () => parseBlock(`${BEGIN}\nconst ALL_HANDWRITTEN = [\n 'a.mdx',\n]\n${END}`),
- 'not a plain array of string literals',
+ 'a docs array holding a non-string throws',
+ () => parseArtifact('{"docs":["content/docs/a.mdx",7]}'),
+ 'no `docs` array of string paths',
);
// Both directions of drift, each pinned on its own — #4851 asked only about
@@ -727,18 +904,32 @@ async function selfTest() {
throws(
'a workflow without the prefix constant throws',
- () => parseReleaseOwnedPrefix('const ALL_HANDWRITTEN = []'),
+ () => parseReleaseOwnedPrefix('const SCOPE_SOURCE = ""'),
'no `const RELEASE_OWNED_PREFIX',
);
const workflowSource = readFileSync(join(REPO_ROOT, WORKFLOW_REL), 'utf8');
+ // The list left this file by ruling, and nothing may quietly bring it back: a second
+ // copy inside the sandbox body is the "one subject, two hand-kept lists" bill again,
+ // and this time it would also restore the governed-edit toll on every docs PR.
+ check(
+ 'the workflow body carries no inline copy of the list',
+ [false, false],
+ [/const ALL_HANDWRITTEN\s*=/.test(workflowSource), workflowSource.includes('generated:docs-audit-scope')],
+ );
+ check(
+ 'the workflow names the artifact it is handed, and the arg it arrives in',
+ [true, true],
+ [workflowSource.includes(SCOPE_ARTIFACT_REL), workflowSource.includes(`const SCOPE_ARG = '${SCOPE_ARG}'`)],
+ );
+
// (1) Still in scope. #4920's rejected option was deleting these pages from the
// audit; that would show up right here, as an empty list.
check(
'release pages are still IN the audit scope',
true,
- parseBlock(workflowSource).filter(isReleaseOwned).length > 0,
+ parseArtifact(readFileSync(join(REPO_ROOT, SCOPE_ARTIFACT_REL), 'utf8')).filter(isReleaseOwned).length > 0,
);
// (2) …and routed read-only, observed on a real run of the workflow.
@@ -768,6 +959,57 @@ async function selfTest() {
}
}
+ // --- the injection contract (#13591) ---------------------------------------
+ //
+ // Same discipline, one layer over: the list now arrives from outside, so the thing
+ // that can silently break is the BODY's consumption of it, not the list. Observed on
+ // the real workflow, then broken four ways in memory — each way is a shape a
+ // plausible future edit takes, and each must be seen to go red.
+ check('the workflow consumes the scope it is handed', [], await checkScopeInjection(workflowSource));
+
+ const injectionMutants = [
+ // The read goes dead: the body is handed a list and ignores it.
+ ['the injected list is ignored', [
+ ['args && Array.isArray(args[SCOPE_ARG]) && args[SCOPE_ARG].length ? args[SCOPE_ARG] : null', 'null'],
+ ]],
+ // The refusal is replaced by a silent default — an audit of nothing, reported as a
+ // result. This is the exact shape the loud-failure rule above exists to stop.
+ ['a silent default replaces the no-scope refusal', [
+ ['if (!SCOPED && !HANDWRITTEN) {', 'if (false) {'],
+ ['const DOCS = SCOPED ?? HANDWRITTEN', 'const DOCS = SCOPED ?? HANDWRITTEN ?? []'],
+ ]],
+ // The summary stops saying WHICH list it audited: a truncated hand-in then reads
+ // exactly like the whole corpus.
+ ['the FULL-audit line stops naming the scope source', [
+ [' — FULL audit (whole hand-written set from ${SCOPE_SOURCE})', ' — FULL audit'],
+ ]],
+ // The shape guard goes: a malformed hand-in dies somewhere downstream instead of
+ // naming the caller's own key.
+ ['the malformed-list guard is removed', [
+ ["for (const key of ['docs', SCOPE_ARG]) {", 'for (const key of []) {'],
+ ]],
+ ];
+ for (const [label, replacements] of injectionMutants) {
+ total++;
+ let mutated = workflowSource;
+ let missed = null;
+ for (const [from, to] of replacements) {
+ const next = mutated.replace(from, to);
+ if (next === mutated) { missed = from; break; }
+ mutated = next;
+ }
+ if (missed !== null) {
+ console.error(` ✗ injection mutation "${label}" did not apply (anchor not found: ${JSON.stringify(missed)}) — it cannot prove anything. Update the mutation to match the current source.`);
+ failed++;
+ continue;
+ }
+ const problems = await checkScopeInjection(mutated);
+ if (!problems.length) {
+ console.error(` ✗ injection mutation "${label}": checkScopeInjection stayed GREEN with the hand-in broken`);
+ failed++;
+ }
+ }
+
if (failed) {
console.error(`\n✗ check-audit-scope self-test failed (${failed} case(s)).`);
process.exit(1);
diff --git a/scripts/docs-audit/handwritten-docs.json b/scripts/docs-audit/handwritten-docs.json
new file mode 100644
index 0000000000..dc81dcb245
--- /dev/null
+++ b/scripts/docs-audit/handwritten-docs.json
@@ -0,0 +1,214 @@
+{
+ "readme": [
+ "GENERATED — do not hand-edit. `node scripts/docs-audit/check-audit-scope.mjs --write`",
+ "derives this from the filesystem (every content/docs/**/*.mdx except references/,",
+ "via `affected-docs.mjs --all`); the same script without --write is a CI gate that",
+ "fails when this file and content/docs/ disagree in EITHER direction.",
+ "",
+ "THIS FILE IS THE SINGLE SOURCE for \"which docs are hand-written\". Two consumers:",
+ " 1. .claude/workflows/docs-accuracy-audit.js — its body runs in a sandbox with no",
+ " filesystem, so the CALLER reads this file and passes `docs` as args.handwritten.",
+ " 2. scripts/docs-audit/check-audit-scope.mjs — the gate that keeps it honest.",
+ "There is deliberately no second copy anywhere; one subject, one list.",
+ "",
+ "It lives here, outside .claude/** and outside every other governed surface, by",
+ "maintainer ruling (2026-09-01). Inline, it made every PR that added a documentation",
+ "page a human-merge-only PR — a governed edit forced by bookkeeping, invisible until",
+ "the gate ran. Moving it is NOT a narrowing of the governed register, which is",
+ "unchanged; do not treat this file as precedent for relocating anything else."
+ ],
+ "generator": "node scripts/docs-audit/check-audit-scope.mjs --write",
+ "gate": "pnpm check:docs-audit-scope",
+ "docs": [
+ "content/docs/ai/actions-as-tools.mdx",
+ "content/docs/ai/agents.mdx",
+ "content/docs/ai/connect-mcp.mdx",
+ "content/docs/ai/index.mdx",
+ "content/docs/ai/knowledge-rag.mdx",
+ "content/docs/ai/natural-language-queries.mdx",
+ "content/docs/ai/skills-reference.mdx",
+ "content/docs/ai/skills.mdx",
+ "content/docs/ai/tools.mdx",
+ "content/docs/api/client-sdk.mdx",
+ "content/docs/api/data-api.mdx",
+ "content/docs/api/data-flow.mdx",
+ "content/docs/api/declarative-endpoints.mdx",
+ "content/docs/api/environment-routing.mdx",
+ "content/docs/api/error-catalog.mdx",
+ "content/docs/api/error-handling-client.mdx",
+ "content/docs/api/error-handling-server.mdx",
+ "content/docs/api/index.mdx",
+ "content/docs/api/metadata-api.mdx",
+ "content/docs/api/plugin-endpoints.mdx",
+ "content/docs/api/wire-format.mdx",
+ "content/docs/automation/approvals.mdx",
+ "content/docs/automation/connectors.mdx",
+ "content/docs/automation/email-templates.mdx",
+ "content/docs/automation/flows.mdx",
+ "content/docs/automation/hook-bodies.mdx",
+ "content/docs/automation/hooks.mdx",
+ "content/docs/automation/index.mdx",
+ "content/docs/automation/jobs.mdx",
+ "content/docs/automation/webhooks.mdx",
+ "content/docs/automation/workflows.mdx",
+ "content/docs/build-without-code.mdx",
+ "content/docs/capabilities/ai.mdx",
+ "content/docs/capabilities/analytics.mdx",
+ "content/docs/capabilities/approvals.mdx",
+ "content/docs/capabilities/automation.mdx",
+ "content/docs/capabilities/data.mdx",
+ "content/docs/capabilities/forms.mdx",
+ "content/docs/capabilities/index.mdx",
+ "content/docs/capabilities/integrations.mdx",
+ "content/docs/capabilities/permissions.mdx",
+ "content/docs/capabilities/request-template.mdx",
+ "content/docs/capabilities/views.mdx",
+ "content/docs/concepts/architecture.mdx",
+ "content/docs/concepts/design-principles.mdx",
+ "content/docs/concepts/index.mdx",
+ "content/docs/concepts/metadata-driven.mdx",
+ "content/docs/concepts/metadata-lifecycle.mdx",
+ "content/docs/concepts/north-star.mdx",
+ "content/docs/data-modeling/analytics.mdx",
+ "content/docs/data-modeling/drivers.mdx",
+ "content/docs/data-modeling/external-datasources.mdx",
+ "content/docs/data-modeling/field-type-decision-tree.mdx",
+ "content/docs/data-modeling/field-types.mdx",
+ "content/docs/data-modeling/fields.mdx",
+ "content/docs/data-modeling/formulas.mdx",
+ "content/docs/data-modeling/import-mappings.mdx",
+ "content/docs/data-modeling/index.mdx",
+ "content/docs/data-modeling/indexing.mdx",
+ "content/docs/data-modeling/object-extensions.mdx",
+ "content/docs/data-modeling/objects.mdx",
+ "content/docs/data-modeling/queries.mdx",
+ "content/docs/data-modeling/relationships.mdx",
+ "content/docs/data-modeling/schema-design.mdx",
+ "content/docs/data-modeling/seed-data.mdx",
+ "content/docs/data-modeling/validation-rules.mdx",
+ "content/docs/data-modeling/validation.mdx",
+ "content/docs/deployment/backup-restore.mdx",
+ "content/docs/deployment/cli.mdx",
+ "content/docs/deployment/environment-variables.mdx",
+ "content/docs/deployment/index.mdx",
+ "content/docs/deployment/production-readiness.mdx",
+ "content/docs/deployment/publish-and-preview.mdx",
+ "content/docs/deployment/seed-tenancy-repair.mdx",
+ "content/docs/deployment/self-hosting.mdx",
+ "content/docs/deployment/single-project-mode.mdx",
+ "content/docs/deployment/tenancy-modes.mdx",
+ "content/docs/deployment/troubleshooting.mdx",
+ "content/docs/deployment/validating-metadata.mdx",
+ "content/docs/getting-started/build-with-claude-code.mdx",
+ "content/docs/getting-started/common-patterns.mdx",
+ "content/docs/getting-started/examples.mdx",
+ "content/docs/getting-started/glossary.mdx",
+ "content/docs/getting-started/how-ai-development-works.mdx",
+ "content/docs/getting-started/index.mdx",
+ "content/docs/getting-started/quick-reference.mdx",
+ "content/docs/getting-started/quick-start.mdx",
+ "content/docs/getting-started/your-first-project.mdx",
+ "content/docs/index.mdx",
+ "content/docs/kernel/architecture.mdx",
+ "content/docs/kernel/cluster.mdx",
+ "content/docs/kernel/contracts/auth-service.mdx",
+ "content/docs/kernel/contracts/cache-service.mdx",
+ "content/docs/kernel/contracts/data-engine.mdx",
+ "content/docs/kernel/contracts/index.mdx",
+ "content/docs/kernel/contracts/metadata-service.mdx",
+ "content/docs/kernel/contracts/storage-service.mdx",
+ "content/docs/kernel/events.mdx",
+ "content/docs/kernel/index.mdx",
+ "content/docs/kernel/runtime-services/audit-service.mdx",
+ "content/docs/kernel/runtime-services/data-service.mdx",
+ "content/docs/kernel/runtime-services/email-service.mdx",
+ "content/docs/kernel/runtime-services/examples.mdx",
+ "content/docs/kernel/runtime-services/index.mdx",
+ "content/docs/kernel/runtime-services/queue-service.mdx",
+ "content/docs/kernel/runtime-services/settings-service.mdx",
+ "content/docs/kernel/runtime-services/sharing-service.mdx",
+ "content/docs/kernel/runtime-services/sms-service.mdx",
+ "content/docs/kernel/runtime-services/storage-service.mdx",
+ "content/docs/kernel/runtime-services/versioning.mdx",
+ "content/docs/kernel/services-checklist.mdx",
+ "content/docs/kernel/services.mdx",
+ "content/docs/permissions/access-matrix.mdx",
+ "content/docs/permissions/access-recipes.mdx",
+ "content/docs/permissions/administrator-guide.mdx",
+ "content/docs/permissions/attachments-access.mdx",
+ "content/docs/permissions/authentication.mdx",
+ "content/docs/permissions/authorization.mdx",
+ "content/docs/permissions/capabilities.mdx",
+ "content/docs/permissions/delegated-administration.mdx",
+ "content/docs/permissions/explain.mdx",
+ "content/docs/permissions/field-level-security.mdx",
+ "content/docs/permissions/index.mdx",
+ "content/docs/permissions/permission-metadata.mdx",
+ "content/docs/permissions/permission-sets.mdx",
+ "content/docs/permissions/permissions-matrix.mdx",
+ "content/docs/permissions/positions.mdx",
+ "content/docs/permissions/profiles.mdx",
+ "content/docs/permissions/record-view-auditing.mdx",
+ "content/docs/permissions/rls.mdx",
+ "content/docs/permissions/sharing-rules.mdx",
+ "content/docs/permissions/sso.mdx",
+ "content/docs/permissions/system-context.mdx",
+ "content/docs/permissions/tenant-audit-census.mdx",
+ "content/docs/plugins/adding-a-metadata-type.mdx",
+ "content/docs/plugins/anatomy.mdx",
+ "content/docs/plugins/development.mdx",
+ "content/docs/plugins/index.mdx",
+ "content/docs/plugins/packages.mdx",
+ "content/docs/protocol/backward-compatibility.mdx",
+ "content/docs/protocol/diagram.mdx",
+ "content/docs/protocol/index.mdx",
+ "content/docs/protocol/kernel/config-resolution.mdx",
+ "content/docs/protocol/kernel/error-handling.mdx",
+ "content/docs/protocol/kernel/http-protocol.mdx",
+ "content/docs/protocol/kernel/i18n-standard.mdx",
+ "content/docs/protocol/kernel/index.mdx",
+ "content/docs/protocol/kernel/lifecycle.mdx",
+ "content/docs/protocol/kernel/metadata-service.mdx",
+ "content/docs/protocol/kernel/plugin-spec.mdx",
+ "content/docs/protocol/kernel/realtime-protocol.mdx",
+ "content/docs/protocol/knowledge.mdx",
+ "content/docs/protocol/objectql/index.mdx",
+ "content/docs/protocol/objectql/query-syntax.mdx",
+ "content/docs/protocol/objectql/schema.mdx",
+ "content/docs/protocol/objectql/security.mdx",
+ "content/docs/protocol/objectql/state-machine.mdx",
+ "content/docs/protocol/objectql/types.mdx",
+ "content/docs/protocol/objectui/actions.mdx",
+ "content/docs/protocol/objectui/concept.mdx",
+ "content/docs/protocol/objectui/index.mdx",
+ "content/docs/protocol/objectui/layout-dsl.mdx",
+ "content/docs/protocol/objectui/record-alert.mdx",
+ "content/docs/protocol/objectui/widget-contract.mdx",
+ "content/docs/releases/implementation-status.mdx",
+ "content/docs/releases/index.mdx",
+ "content/docs/releases/v12.mdx",
+ "content/docs/releases/v13.mdx",
+ "content/docs/releases/v14.mdx",
+ "content/docs/releases/v15.mdx",
+ "content/docs/releases/v16.mdx",
+ "content/docs/releases/v17.mdx",
+ "content/docs/releases/v9.mdx",
+ "content/docs/ui/actions.mdx",
+ "content/docs/ui/apps.mdx",
+ "content/docs/ui/audience-based-interfaces.mdx",
+ "content/docs/ui/create-vs-edit-form.mdx",
+ "content/docs/ui/dashboards.mdx",
+ "content/docs/ui/doc-pages.mdx",
+ "content/docs/ui/field-grouping-and-order.mdx",
+ "content/docs/ui/forms.mdx",
+ "content/docs/ui/index.mdx",
+ "content/docs/ui/pages.mdx",
+ "content/docs/ui/public-data-collection.mdx",
+ "content/docs/ui/react-pages.mdx",
+ "content/docs/ui/reports.mdx",
+ "content/docs/ui/setup-app.mdx",
+ "content/docs/ui/translations.mdx",
+ "content/docs/ui/views.mdx",
+ "content/docs/upgrading.mdx"
+ ]
+}
diff --git a/scripts/pm/check-governed-merges.mjs b/scripts/pm/check-governed-merges.mjs
index a919024ec4..2afe608a24 100644
--- a/scripts/pm/check-governed-merges.mjs
+++ b/scripts/pm/check-governed-merges.mjs
@@ -90,55 +90,64 @@
* `examples/AGENTS.md` (an example-tree file) and not the
* `create-objectstack` template copy (product content).
*
- * ## The generated-artifact exception (#9866; maintainer 2026-08-20 + 2026-08-22)
- *
- * Exactly one file under `.claude/**` is not instruction-tree prose but a
- * required gate's own `--write` artifact: `check:docs-audit-scope` keeps its
- * generated `ALL_HANDWRITTEN` page-scope block inside
- * `.claude/workflows/docs-accuracy-audit.js` (the workflow runs in a `node:vm`
- * sandbox that cannot read files, so the list must live inline). Adding a docs
- * page reddens that gate, the gate's own `--write` regenerates the block, and
- * the block sat inside this fence — so EVERY page-adding docs PR crossed the
- * governed surface (measured 5-for-5 on #9866), and three of the crossings
- * merged with nobody recording them. A fence that fires on routine traffic
- * trains every seat to read past it.
- *
- * The maintainer ruled for the provenance-aware exception — #10277's Option C
- * = #9866's shape 2 — verbatim 「10277 同意 C」 (2026-08-20), re-confirmed over
- * the same day's earlier relocate-the-artifact ruling: 「A:按方案 2(最新裁
- * 定)」 (2026-08-22). Four load-bearing constraints, each pinned by
- * `--self-test`; drop one and the exception is a hole:
- *
- * 1. RECOMPUTE, never compare to a stored baseline. The pass criterion is
- * "the file's diff byte-equals what
- * `node scripts/docs-audit/check-audit-scope.mjs --write` produces on the
- * tree under test" — concretely, the tree's copy of the file must equal
- * the generator's `replaceBlock(baseFile, docsDerivedFromThisTree)`. Any
- * stored or cached comparison can be constructed against.
- * 2. BYTE-EXACT, no shape heuristics. There is no "only the ALL_HANDWRITTEN
- * block changed, so allow it" — a reordered-but-equal list rejects too.
- * 3. A NAMED single-file exception, not a class mechanism.
- * `GENERATED_SURFACE_EXCEPTIONS` is structurally a list, seeded with this
- * one entry by ruling; every other governed-surface judgment — the rest
- * of `.claude/**` included — is character-for-character unchanged.
- * 4. FAIL CLOSED, and the mixed-diff rule is untouched: provenance that
- * cannot be recomputed (no base version, unreadable file, generator
- * failure, empty derivation) keeps the path governed, and a hit on any
- * OTHER governed path still forks the whole PR — 「混合 diff 一条命中即整
- * PR 分叉」.
- *
- * ### The generator co-edit is fenced out (#11084) — a NARROWING, not a widening
- *
- * Constraint 1 makes the recompute run the tree under test's OWN
- * `scripts/docs-audit/**` (it must — the derivation has to reflect the PR's
- * own docs). So a PR that edits the generator AND hand-edits the artifact in
- * one diff could in principle construct a derivation whose recomputed splice
- * byte-equals its hand-edited block: the tree would be certifying itself.
- * Cheap fence, `--test` only: if the submitted path list contains ANY
- * `scripts/docs-audit/**` path, the recompute is SKIPPED and the path stays
- * governed with that reason stated. This only ever moves verdicts toward
- * governed — the pure-regeneration path with an untouched generator lifts
- * exactly as it did before, and no path that was governed becomes clear.
+ * ## The generated-artifact exception (#9866) is RETIRED — its subject moved out
+ *
+ * History worth keeping, because the shape recurs. Exactly one file under
+ * `.claude/**` was not instruction-tree prose but a required gate's own
+ * `--write` artifact: `check:docs-audit-scope` kept its generated
+ * `ALL_HANDWRITTEN` page-scope block inside
+ * `.claude/workflows/docs-accuracy-audit.js`, because the workflow runs in a
+ * `node:vm` sandbox that cannot read files. Adding a docs page reddened that
+ * gate, the gate's `--write` regenerated the block, and the block sat inside
+ * this fence — so EVERY page-adding docs PR crossed the governed surface
+ * (measured 5-for-5 on #9866), and three of the crossings merged with nobody
+ * recording them. A fence that fires on routine traffic trains every seat to
+ * read past it.
+ *
+ * The first remedy was a provenance-aware exception in this register — a row
+ * that lifted that one path when the tree's copy byte-equalled the generator's
+ * own recomputed splice (#10277 Option C, 「10277 同意 C」 2026-08-20, re-confirmed
+ * 「A:按方案 2(最新裁定)」 2026-08-22 over the same day's earlier
+ * relocate-the-artifact proposal).
+ *
+ * ⚖️ On 2026-09-01 the maintainer ruled the other way, verbatim 「同意」 on
+ * #13591's option 2: MOVE THE LIST OFF THE GOVERNED SURFACE. It now lives in
+ * `scripts/docs-audit/handwritten-docs.json`, and the workflow's caller hands it
+ * to the sandbox as `args.handwritten` — the read happens outside the vm, so the
+ * inline copy that made the exception necessary no longer exists. The newer
+ * ruling supersedes the older, so the row is GONE rather than dormant. Keeping
+ * it would have been actively unsafe: with no generated artifact left at that
+ * path, a row still naming it could only ever be asked to certify hand-authored
+ * instruction prose, which is precisely what the fence exists to stop.
+ *
+ * ⚠️ What did NOT change, in either direction: `GOVERNED_SURFACES` is
+ * character-for-character the same list — `.claude/**` included. #13591's option
+ * 3, carving `.claude/workflows/**` out of the register, was considered and
+ * explicitly REJECTED by the same ruling. Removing this row NARROWS what the
+ * exception machinery lifts; nothing that was governed became clear.
+ *
+ * ### The generator co-edit fence (#11084) — a NARROWING, not a widening
+ *
+ * A recompute has to run the tree under test's OWN generator (it must — the
+ * derivation has to reflect the PR's own content). So a PR that edits the
+ * generator AND hand-edits its artifact in one diff could in principle construct
+ * a derivation whose recomputed output byte-equals its hand edit: the tree would
+ * be certifying itself. Cheap fence, `--test` only: if the submitted path list
+ * contains ANY path under that row's own trusted generator tree, the recompute is
+ * SKIPPED and the path stays governed with that reason stated. This only ever
+ * moves verdicts toward governed — a pure regeneration with an untouched
+ * generator lifts exactly as before, and no path that was governed becomes clear.
+ *
+ * The remaining constraints, each pinned by `--self-test`; drop one and an
+ * exception row is a hole:
+ *
+ * 1. RECOMPUTE, never compare to a stored baseline. Any stored or cached
+ * comparison can be constructed against.
+ * 2. BYTE-EXACT, no shape heuristics — a reordered-but-equal artifact rejects.
+ * 3. Rows are NAMED and ruled, never a class mechanism.
+ * 4. FAIL CLOSED, and the mixed-diff rule is untouched: provenance that cannot
+ * be recomputed keeps the path governed, and a hit on any OTHER governed
+ * path still forks the whole PR — 「混合 diff 一条命中即整 PR 分叉」.
*
* The exception applies to the `--test` predicate only. The post-merge SWEEP
* still lists a pure-regeneration merge: under-enumeration is the one
@@ -598,11 +607,9 @@
* with no governed merges the sweep costs ZERO lookups. `--test` never
* touches the network, and spends anything at all in exactly one case: a hit
* on a `GENERATED_SURFACE_EXCEPTIONS` row recomputes that generator's output
- * on the local tree under test (the #9866 row reads the file, `git
- * merge-base`/`git show` for the base version and the docs derivation; a
- * #11705 row runs that generator's own `--check` once, ~3 s, for every path it
- * owns in the diff) — still zero API calls; every other `--test` run reads
- * only the register in this file.
+ * on the local tree under test (a #11705 row runs that generator's own
+ * `--check` once, ~3 s, for every path it owns in the diff) — still zero API
+ * calls; every other `--test` run reads only the register in this file.
*/
import { execFileSync, spawnSync } from 'node:child_process';
@@ -677,15 +684,17 @@ export const ROOT_FILE_WATCH_HINTS = ['AGENTS.md/**', 'CLAUDE.md/**'];
*
* Row fields:
* `path` — an exact repo-relative path, for a generator that owns
- * exactly one file the register can name (the #9866 row).
+ * exactly one file the register can name. No row uses this
+ * today; it is kept because `generatedExceptionFor` reads it
+ * and the shape is the cheaper one when it fits.
* `candidate` — a NARROWING gate for a generator whose output set is
* enumerated at run time: matching it earns a path the
* question, never the answer. See the header — a candidate
* cannot lift anything by itself, in either direction.
* `verify` — the sink-generator recompute (#11705): run this package
* script's `--check` on the tree under test and read the
- * output manifest it declares. Absent ⇒ the #9866 splice
- * recompute.
+ * output manifest it declares. REQUIRED: a row with no
+ * recompute cannot prove anything, so it fails closed.
* `generator` — the command an operator runs to regenerate; rendered in
* every verdict, lifted or not.
* `trustedGeneratorPrefixes` — the tree whose co-edit fences this row
@@ -693,15 +702,6 @@ export const ROOT_FILE_WATCH_HINTS = ['AGENTS.md/**', 'CLAUDE.md/**'];
* by it.
*/
export const GENERATED_SURFACE_EXCEPTIONS = Object.freeze([
- Object.freeze({
- id: 'docs-audit-scope',
- ruling: '#9866',
- path: '.claude/workflows/docs-accuracy-audit.js',
- surfaceId: 'claude-tree',
- generator: 'node scripts/docs-audit/check-audit-scope.mjs --write',
- trustedGeneratorPrefixes: Object.freeze(['scripts/docs-audit/']),
- what: "the docs-audit workflow's generated ALL_HANDWRITTEN scope block (a gate-owned --write artifact, not instruction-tree prose)",
- }),
Object.freeze({
id: 'spec-skill-refs',
ruling: '#11705',
@@ -739,16 +739,6 @@ export function generatedExceptionFor(path) {
return GENERATED_SURFACE_EXCEPTIONS.find((e) => (e.path ? e.path === path : e.candidate.test(path))) ?? null;
}
-/**
- * The generator tree the #9866 row's recompute TRUSTS: constraint 1 executes
- * this directory's `affected-docs.mjs` and imports its `check-audit-scope.mjs`
- * `replaceBlock`, both from the tree under test. A PR that edits anything
- * here is editing the instrument the certificate is measured with. Kept as a
- * named export because it is that row's own prefix; every row carries its own
- * list in `trustedGeneratorPrefixes`.
- */
-export const TRUSTED_GENERATOR_PREFIX = 'scripts/docs-audit/';
-
/**
* The #11084 fence, pure so `--self-test` pins both directions offline. Given
* the PR's submitted path list and the register row under consideration,
@@ -764,12 +754,24 @@ export const TRUSTED_GENERATOR_PREFIX = 'scripts/docs-audit/';
* rather than left to `startsWith` alone.
*
* PER ROW, not global: the fence names the instrument that measures THIS row.
- * A docs PR that also edits the spec generator tree has not touched the
- * docs-audit generator, and fencing it there would refuse a regeneration the
- * ruling allows.
+ * A PR that edits some OTHER generator tree has not touched this row's, and
+ * fencing it there would refuse a regeneration the ruling allows. `entry` is
+ * therefore required — there is no "the" row to default to, and a default would
+ * silently measure one row's diff against another row's instrument.
*/
-export function generatorCoEditProvenance(paths, entry = GENERATED_SURFACE_EXCEPTIONS[0]) {
- const prefixes = entry?.trustedGeneratorPrefixes ?? [TRUSTED_GENERATOR_PREFIX];
+export function generatorCoEditProvenance(paths, entry) {
+ const prefixes = entry?.trustedGeneratorPrefixes ?? [];
+ if (prefixes.length === 0) {
+ // No row, or a row that names no instrument tree: there is nothing to fence
+ // WITH, so the honest answer is not "no co-edit" (which would send the path on
+ // to be recomputed) but fail-closed — the one direction that cannot open a hole.
+ return {
+ pureRegeneration: false,
+ reason:
+ 'the register row names no trusted generator tree, so a generator co-edit cannot be ruled out — ' +
+ 'fail closed: the path stays governed',
+ };
+ }
const coEdited = (Array.isArray(paths) ? paths : []).filter(
(p) => typeof p === 'string' && prefixes.some((prefix) => p.startsWith(prefix) || p.startsWith(`./${prefix}`)),
);
@@ -783,49 +785,9 @@ export function generatorCoEditProvenance(paths, entry = GENERATED_SURFACE_EXCEP
};
}
-/**
- * The byte-exact provenance verdict, pure so `--self-test` pins all four
- * ruled cases offline. Inputs: the file at the PR's merge base
- * (`baseSource`), the file on the tree under test (`prSource`), the doc set
- * derived from that same tree (`derivedDocs`), and the generator's own
- * `replaceBlock`. Pass ⟺ `prSource === replaceBlock(baseSource, derivedDocs)`
- * — which is exactly "the PR's diff to this file equals the diff `--write`
- * produces on the PR's own tree": the generated block must be the recomputed
- * render (rejects in-block hand edits, reorderings included) AND everything
- * outside the block must be untouched relative to base (rejects out-of-block
- * hand edits, which `--write` itself would preserve and so cannot vouch for).
- * Every non-pass answers with a stated reason; there is no heuristic branch.
- */
-export function docsAuditRegenVerdict({ baseSource, prSource, derivedDocs, replaceBlock }) {
- if (typeof prSource !== 'string') {
- return { pureRegeneration: false, reason: 'the file could not be read from the tree under test — fail closed: the path stays governed' };
- }
- if (typeof baseSource !== 'string') {
- return { pureRegeneration: false, reason: 'no base version to diff against (file added, or unreadable at the merge base) — a whole new workflow file is not a regeneration; fail closed' };
- }
- if (!Array.isArray(derivedDocs) || derivedDocs.length === 0) {
- return { pureRegeneration: false, reason: 'the derived doc set is empty or unreadable — refusing to certify a regeneration of nothing (the generator itself refuses the same); fail closed' };
- }
- let expected;
- try {
- expected = replaceBlock(baseSource, derivedDocs);
- } catch (error) {
- return { pureRegeneration: false, reason: `the generator could not splice the base file (${String(error?.message ?? error).split('\n')[0]}) — fail closed` };
- }
- if (prSource === expected) {
- return { pureRegeneration: true, reason: 'byte-equal to the generator output recomputed on this tree (no stored baseline consulted)' };
- }
- return {
- pureRegeneration: false,
- reason:
- 'differs from the generator output recomputed on this tree — byte-exact equality is the only pass, so a hand edit ' +
- '(inside or outside the generated block, a reordering included) keeps the path governed',
- };
-}
-
/**
* Apply the exception register to a `--test` verdict. Pure: provenance
- * arrives as a Map of path → `docsAuditRegenVerdict`-shaped result. A
+ * arrives as a Map of path → `{ pureRegeneration, reason }` result. A
* registered path with verified pure-regeneration provenance is LIFTED from
* the hit set (recorded under `exceptions`, never under `clearPaths` — it is
* on the register, exempted, not absent); a registered path with failed or
@@ -1043,10 +1005,10 @@ export function renderExceptionLines(verdict) {
exceptions
.map((e) =>
e.pureRegeneration
- ? ` ℹ️ generated-surface exception (${e.ruling ?? '#9866'}): ${e.path} is a PURE REGENERATION —\n` +
+ ? ` ℹ️ generated-surface exception (${e.ruling ?? '(unruled row)'}): ${e.path} is a PURE REGENERATION —\n` +
` byte-equal to \`${e.generator}\` recomputed on THIS tree (never a stored baseline),\n` +
` so this path does not govern the PR by itself. Any other governed hit still forks the whole PR.`
- : ` ⛔ generated-surface exception (${e.ruling ?? '#9866'}) did NOT lift ${e.path}:\n` +
+ : ` ⛔ generated-surface exception (${e.ruling ?? '(unruled row)'}) did NOT lift ${e.path}:\n` +
` ${e.reason}\n` +
` The path stays governed; a pure \`${e.generator}\` regeneration is the only thing the exception passes.`,
)
@@ -1081,54 +1043,6 @@ export function renderTestVerdict(verdict) {
);
}
-/**
- * The real recompute behind the exception, on the local tree at `root` (the
- * tree under test — run the PR's own copy of this script from the PR's own
- * checkout, or point `--root` at it). Constraint 1 in code: the expected
- * bytes are produced HERE, from this tree's docs derivation spliced by the
- * generator's own exported `replaceBlock`, never read from anywhere stored.
- * Every failure path answers fail-closed with a stated reason. No network.
- */
-export async function recomputeDocsAuditProvenance(root, exception, { baseRef = null } = {}) {
- const failClosed = (reason) => ({ pureRegeneration: false, reason: `${reason} — fail closed: the path stays governed` });
- let replaceBlock;
- try {
- ({ replaceBlock } = await import('../docs-audit/check-audit-scope.mjs'));
- if (typeof replaceBlock !== 'function') return failClosed('the generator module exports no replaceBlock');
- } catch (error) {
- return failClosed(`could not load the generator module (${String(error?.message ?? error).split('\n')[0]})`);
- }
- let prSource;
- try {
- prSource = readFileSync(join(root, exception.path), 'utf8');
- } catch (error) {
- return failClosed(`could not read ${exception.path} from the tree under test (${String(error?.message ?? error).split('\n')[0]})`);
- }
- let baseSource;
- try {
- // `baseRef` is how a merge-group build names its base (it has no reason to
- // hold `origin/main`); a seat run resolves the merge base itself.
- const base = baseRef ?? git(root, ['merge-base', 'origin/main', 'HEAD']).trim();
- baseSource = git(root, ['show', `${base}:${exception.path}`]);
- } catch (error) {
- return failClosed(`could not read the base version of ${exception.path} (${String(error?.message ?? error).split('\n')[0]})`);
- }
- let derivedDocs;
- try {
- derivedDocs = JSON.parse(
- execFileSync(process.execPath, [join(root, 'scripts/docs-audit/affected-docs.mjs'), '--all', '--json'], {
- cwd: root,
- encoding: 'utf8',
- maxBuffer: 64 * 1024 * 1024,
- stdio: ['ignore', 'pipe', 'ignore'],
- }),
- ).docs;
- } catch (error) {
- return failClosed(`could not derive the doc set on this tree (${String(error?.message ?? error).split('\n')[0]})`);
- }
- return docsAuditRegenVerdict({ baseSource, prSource, derivedDocs, replaceBlock });
-}
-
/**
* The #11705 verdict, pure so `--self-test` pins every branch offline. Inputs:
* the hit `path`, its register `entry`, and `run` — what the generator answered
@@ -1281,7 +1195,17 @@ export async function recomputeProvenanceFor(root, hitsByEntry, { allPaths = [],
for (const path of paths) provenance.set(path, sinkGeneratorVerdict({ path, entry, run }));
continue;
}
- for (const path of paths) provenance.set(path, await recomputeDocsAuditProvenance(root, entry, { baseRef }));
+ // A row with no recompute cannot prove anything about its paths. There is no
+ // "trust the row" branch here — that would lift a governed path on the strength
+ // of a register entry alone, which is the one thing no row is allowed to do.
+ for (const path of paths) {
+ provenance.set(path, {
+ pureRegeneration: false,
+ reason:
+ `the register row \`${entry.id}\` declares no recompute (\`verify\`), so nothing can prove this ` +
+ 'file matches its generator on the tree under test — fail closed: the path stays governed',
+ });
+ }
}
return provenance;
}
@@ -3208,25 +3132,36 @@ async function selfTest() {
assert(`--test-answers-governed-for-${surface.id}`, testVerdict([sample]).governed === true, sample);
}
- // ── the generated-artifact exception (#9866; rulings 2026-08-20 + -22) ───
+ // ── the generated-artifact exception register (#9866 · #11084 · #11705) ──
//
// Safety-relevant merge-gate code: a bug here waves real instruction edits
- // past the fence. The four ruled cases are pinned against the GENERATOR'S
- // OWN exported render/splice functions, not imitations, so the fixtures are
- // real `--write` output; if the generator ever renames its block markers,
- // `replaceBlock` stops splicing these fixtures and the pure-regen case goes
- // red HERE — loud, which is the point.
- const { renderBlock: genRender, replaceBlock: genReplace } = await import('../docs-audit/check-audit-scope.mjs');
- // The register: every row is ruled, and the #9866 row is still the exact
- // single file its ruling named. A row must also name a surface its own paths
- // are actually governed by — an exception for an ungoverned path would be a
- // register row nothing reads.
+ // past the fence. ⚖️ The #9866 docs-audit row RETIRED on 2026-09-01 (header
+ // section above): its subject — the audit workflow's inline page list — was
+ // moved off the governed surface by ruling, so there is no generated artifact
+ // under `.claude/**` for any row to certify, and a row still naming that path
+ // could only ever be asked to vouch for hand-authored instruction prose. The
+ // register's SEMANTICS are pinned exactly as before, against the rows that
+ // remain: the invariants, the lift / reject / fail-closed behaviour of
+ // `applyGeneratedExceptions`, the untouched mixed-diff rule, and the #11084
+ // co-edit fence in both directions.
+ //
+ // The register: every row is ruled, every row can actually prove something,
+ // and every row names a surface its own paths are governed by — an exception
+ // for an ungoverned path would be a register row nothing reads.
assert('every-register-row-cites-the-ruling-that-put-it-there',
GENERATED_SURFACE_EXCEPTIONS.every((e) => typeof e.ruling === 'string' && /^#\d+$/.test(e.ruling)),
JSON.stringify(GENERATED_SURFACE_EXCEPTIONS.map((e) => [e.id, e.ruling])));
- assert('the-9866-row-is-still-the-exact-single-file-its-ruling-named',
- GENERATED_SURFACE_EXCEPTIONS[0].path === '.claude/workflows/docs-accuracy-audit.js' && GENERATED_SURFACE_EXCEPTIONS[0].ruling === '#9866',
- JSON.stringify(GENERATED_SURFACE_EXCEPTIONS[0]));
+ // The retirement, asserted rather than remembered: nothing may quietly
+ // reinstate a lift over the instruction tree. Both row shapes are checked,
+ // because a `candidate` regexp could re-admit the path without a `path` field.
+ assert('no-row-lifts-anything-under-.claude-the-retired-9866-row-stays-retired',
+ GENERATED_SURFACE_EXCEPTIONS.every((e) =>
+ !(e.path ?? '').startsWith('.claude/') &&
+ !(e.candidate?.test('.claude/workflows/docs-accuracy-audit.js') ?? false)),
+ JSON.stringify(GENERATED_SURFACE_EXCEPTIONS.map((e) => [e.id, e.path ?? String(e.candidate)])));
+ assert('the-audit-workflow-is-plainly-governed-again-with-no-provenance-consulted',
+ testVerdict(['.claude/workflows/docs-accuracy-audit.js']).governed === true &&
+ generatedExceptionFor('.claude/workflows/docs-accuracy-audit.js') === null);
assert('a-row-matches-either-an-exact-path-or-a-narrowing-candidate-never-neither-and-never-both',
GENERATED_SURFACE_EXCEPTIONS.every((e) => (typeof e.path === 'string') !== (e.candidate instanceof RegExp)),
JSON.stringify(GENERATED_SURFACE_EXCEPTIONS.map((e) => [e.id, typeof e.path, String(e.candidate)])));
@@ -3239,72 +3174,44 @@ async function selfTest() {
}), JSON.stringify(GENERATED_SURFACE_EXCEPTIONS.map((e) => e.path ?? REGISTER_SAMPLES[e.id])));
assert('every-row-names-its-generator-command-and-its-trusted-instrument-tree',
GENERATED_SURFACE_EXCEPTIONS.every((e) => typeof e.generator === 'string' && e.generator !== '' && (e.trustedGeneratorPrefixes ?? []).length > 0));
- assert('the-exception-names-its-generator-command',
- GENERATED_SURFACE_EXCEPTIONS[0].generator.includes('check-audit-scope.mjs --write'));
-
- const oldDocs = ['content/docs/a.mdx'];
- const newDocs = ['content/docs/a.mdx', 'content/docs/new-page.mdx'];
- const baseWorkflow =
- `// header prose\n// ${genRender(oldDocs)}// \n` +
- `const RELEASE_OWNED_PREFIX = 'content/docs/releases/'\n// footer\n`;
- const pureRegen = genReplace(baseWorkflow, newDocs);
- const regen = (prSource, over = {}) =>
- docsAuditRegenVerdict({ baseSource: baseWorkflow, prSource, derivedDocs: newDocs, replaceBlock: genReplace, ...over });
- assert('the-pure-regen-fixture-is-a-real-diff-not-a-no-op', pureRegen !== baseWorkflow && pureRegen.includes('new-page.mdx'));
- // Ruled case 1: a pure regeneration passes.
- const pureVerdict = regen(pureRegen);
- assert('ruled-case-1-pure-regeneration-passes', pureVerdict.pureRegeneration === true, pureVerdict.reason);
- assert('a-pass-says-it-recomputed-and-consulted-no-stored-baseline', /recomputed on this tree/.test(pureVerdict.reason) && /no stored baseline/.test(pureVerdict.reason), pureVerdict.reason);
- // Ruled case 2: an in-block hand edit rejects.
- const inBlockEdit = pureRegen.replace(' "content/docs/new-page.mdx",', ' "content/docs/new-page.mdx",\n "content/docs/sneaked-in.mdx",');
- assert('the-in-block-mutation-applied', inBlockEdit !== pureRegen);
- assert('ruled-case-2-in-block-hand-edit-rejects', regen(inBlockEdit).pureRegeneration === false);
- // Ruled case 3: an out-of-block edit rejects — `--write` itself would
- // PRESERVE it, which is exactly why the compare runs from the base file.
- const outOfBlockEdit = pureRegen.replace('// footer', '// footer, hand-edited');
- assert('the-out-of-block-mutation-applied', outOfBlockEdit !== pureRegen);
- assert('ruled-case-3-out-of-block-edit-rejects', regen(outOfBlockEdit).pureRegeneration === false);
- // Ruled case 4: mixed regeneration + hand edit rejects.
- const mixedEdit = inBlockEdit.replace('// footer', '// footer, hand-edited');
- assert('ruled-case-4-mixed-regen-plus-hand-edit-rejects', regen(mixedEdit).pureRegeneration === false);
- // Byte-exact means byte-exact: the same doc SET reordered is not a pass —
- // the "only the block changed, and to equivalent content" heuristic is the
- // shape the ruling forbids.
- const reordered = pureRegen.replace(
- ' "content/docs/a.mdx",\n "content/docs/new-page.mdx",',
- ' "content/docs/new-page.mdx",\n "content/docs/a.mdx",',
- );
- assert('the-reorder-mutation-applied', reordered !== pureRegen);
- assert('an-equal-set-in-a-different-order-rejects-no-shape-heuristics', regen(reordered).pureRegeneration === false);
- // Fail-closed inputs, each with its own stated reason.
- assert('no-base-version-rejects-an-added-workflow-file-is-not-a-regeneration', regen(pureRegen, { baseSource: null }).pureRegeneration === false);
- assert('an-unreadable-tree-file-rejects', regen(null).pureRegeneration === false);
- assert('an-empty-derivation-rejects-like-the-generator-itself-refuses-it', regen(pureRegen, { derivedDocs: [] }).pureRegeneration === false);
- assert('a-generator-splice-failure-rejects', regen(pureRegen, { baseSource: 'no markers here at all' }).pureRegeneration === false);
-
- // Applying the exception to a verdict — the fence semantics.
- const wfPath = GENERATED_SURFACE_EXCEPTIONS[0].path;
- const verified = new Map([[wfPath, { pureRegeneration: true, reason: 'byte-equal (fixture)' }]]);
- const liftedVerdict = applyGeneratedExceptions(testVerdict(['content/docs/x.mdx', wfPath]), verified);
+ // A row with no recompute can prove nothing, so it must not exist: the
+ // dispatcher fails such a row closed, and this refuses it one level earlier.
+ assert('every-row-declares-the-recompute-that-proves-its-paths',
+ GENERATED_SURFACE_EXCEPTIONS.every((e) => e.verify && typeof e.verify.gate === 'string' && typeof e.verify.pkg === 'string'),
+ JSON.stringify(GENERATED_SURFACE_EXCEPTIONS.map((e) => [e.id, e.verify ?? null])));
+ {
+ const noVerify = Object.freeze({ id: 'fixture-row', ruling: '#0', trustedGeneratorPrefixes: Object.freeze(['x/']) });
+ const answered = await recomputeProvenanceFor('/nonexistent', new Map([[noVerify, ['skills/x/references/_index.md']]]));
+ const only = [...answered.values()][0];
+ assert('a-row-that-declares-no-recompute-fails-closed-rather-than-lifting',
+ only?.pureRegeneration === false && /declares no recompute/.test(only?.reason ?? ''), JSON.stringify(only));
+ }
+
+ // Applying the exception to a verdict — the fence semantics, on a real
+ // registered path. Provenance is a fixture here on purpose: these cases pin
+ // what the register DOES with a verdict, not how a verdict is reached.
+ const regPath = REGISTER_SAMPLES['spec-skill-refs'];
+ const verified = new Map([[regPath, { pureRegeneration: true, reason: 'byte-equal (fixture)' }]]);
+ const liftedVerdict = applyGeneratedExceptions(testVerdict(['content/docs/x.mdx', regPath]), verified);
assert('a-verified-pure-regen-lifts-the-only-hit-and-the-pr-is-not-governed',
liftedVerdict.governed === false && liftedVerdict.hitPaths.length === 0 && liftedVerdict.matched.length === 0, JSON.stringify(liftedVerdict.hitPaths));
assert('a-lifted-path-is-recorded-as-an-exception-never-as-a-clear-path',
- !liftedVerdict.clearPaths.includes(wfPath) && liftedVerdict.exceptions.length === 1 && liftedVerdict.exceptions[0].pureRegeneration === true, JSON.stringify(liftedVerdict.exceptions));
- const rejectedVerdict = applyGeneratedExceptions(testVerdict([wfPath]), new Map([[wfPath, { pureRegeneration: false, reason: 'differs (fixture)' }]]));
- assert('a-failed-provenance-keeps-the-path-governed', rejectedVerdict.governed === true && rejectedVerdict.hitPaths.join() === wfPath);
- const absentProvenance = applyGeneratedExceptions(testVerdict([wfPath]), new Map());
+ !liftedVerdict.clearPaths.includes(regPath) && liftedVerdict.exceptions.length === 1 && liftedVerdict.exceptions[0].pureRegeneration === true, JSON.stringify(liftedVerdict.exceptions));
+ const rejectedVerdict = applyGeneratedExceptions(testVerdict([regPath]), new Map([[regPath, { pureRegeneration: false, reason: 'differs (fixture)' }]]));
+ assert('a-failed-provenance-keeps-the-path-governed', rejectedVerdict.governed === true && rejectedVerdict.hitPaths.join() === regPath);
+ const absentProvenance = applyGeneratedExceptions(testVerdict([regPath]), new Map());
assert('absent-provenance-fails-closed-never-open', absentProvenance.governed === true && /fail closed/.test(absentProvenance.exceptions[0].reason), JSON.stringify(absentProvenance.exceptions));
// The mixed-diff rule is untouched: one hit on any OTHER governed path
// still forks the whole PR, provenance verified or not.
- const mixedGoverned = applyGeneratedExceptions(testVerdict([wfPath, '.claude/skills/x/SKILL.md']), verified);
+ const mixedGoverned = applyGeneratedExceptions(testVerdict([regPath, '.claude/skills/x/SKILL.md']), verified);
assert('one-hit-on-any-OTHER-governed-path-still-forks-the-whole-pr',
mixedGoverned.governed === true && mixedGoverned.hitPaths.join() === '.claude/skills/x/SKILL.md', JSON.stringify(mixedGoverned.hitPaths));
assert('and-the-lifted-path-is-still-reported-as-lifted-on-a-mixed-diff', mixedGoverned.exceptions[0].pureRegeneration === true);
- // A NAMED single file, not a class: a sibling workflow file never consults
+ // NAMED rows, not a class: a file the register does not cover never consults
// provenance, even provenance that claims to be verified.
const sibling = '.claude/workflows/some-other-workflow.js';
const siblingVerdict = applyGeneratedExceptions(testVerdict([sibling]), new Map([[sibling, { pureRegeneration: true, reason: 'x' }]]));
- assert('a-sibling-workflow-file-is-not-excepted-single-file-not-a-class',
+ assert('an-unregistered-file-is-not-excepted-named-rows-not-a-class',
siblingVerdict.governed === true && siblingVerdict.exceptions.length === 0, JSON.stringify(siblingVerdict.exceptions));
assert('a-verdict-that-never-consulted-the-exception-carries-no-exceptions-field', testVerdict(['AGENTS.md']).exceptions === undefined);
@@ -3312,48 +3219,53 @@ async function selfTest() {
//
// The fence decides whether `--test` recomputes at all, so a bug in either
// direction is load-bearing: too loose re-opens the self-certification, too
- // tight would break the ruled pure-regeneration lift. The register's own
- // generator command must live under the fenced prefix — if the generator
- // ever relocates, the fence must follow, and this goes red first.
- assert('the-fence-covers-the-directory-the-registered-generator-actually-runs-from',
- GENERATED_SURFACE_EXCEPTIONS[0].generator.includes(TRUSTED_GENERATOR_PREFIX), GENERATED_SURFACE_EXCEPTIONS[0].generator);
+ // tight would break the ruled pure-regeneration lift.
+ const fenceRow = GENERATED_SURFACE_EXCEPTIONS.find((e) => e.id === 'spec-skill-refs');
+ const fencePrefix = fenceRow.trustedGeneratorPrefixes[0];
// Direction A — co-edit: the generator is touched, so no recompute happens
// and the artifact stays governed, with the stated reason.
- const coEditPaths = [wfPath, 'scripts/docs-audit/affected-docs.mjs'];
- const coEdit = generatorCoEditProvenance(coEditPaths);
+ const coEditPaths = [regPath, `${fencePrefix}build-skill-references.ts`];
+ const coEdit = generatorCoEditProvenance(coEditPaths, fenceRow);
assert('a-generator-co-edit-answers-fail-closed-before-any-recompute',
coEdit !== null && coEdit.pureRegeneration === false, JSON.stringify(coEdit));
assert('the-co-edit-reason-states-the-ruled-words-and-names-the-co-edited-file',
/modifies the generator this exception trusts — the path stays governed/.test(coEdit?.reason ?? '') &&
- (coEdit?.reason ?? '').includes('scripts/docs-audit/affected-docs.mjs'), String(coEdit?.reason));
+ (coEdit?.reason ?? '').includes(`${fencePrefix}build-skill-references.ts`), String(coEdit?.reason));
// A neutered fence must fail LOUD and READABLE here, not crash the run: the
// absent answer is itself the finding, so it is asserted, never dereferenced.
- const coEditVerdict = applyGeneratedExceptions(testVerdict(coEditPaths), new Map(coEdit ? [[wfPath, coEdit]] : []));
+ const coEditVerdict = applyGeneratedExceptions(testVerdict(coEditPaths), new Map(coEdit ? [[regPath, coEdit]] : []));
assert('a-generator-co-edit-keeps-the-artifact-governed-the-exception-does-not-lift',
- coEditVerdict.governed === true && coEditVerdict.hitPaths.join() === wfPath && coEditVerdict.exceptions[0].pureRegeneration === false,
+ coEditVerdict.governed === true && coEditVerdict.hitPaths.join() === regPath && coEditVerdict.exceptions[0].pureRegeneration === false,
JSON.stringify(coEditVerdict.hitPaths));
const coEditRender = renderTestVerdict(coEditVerdict);
assert('the-co-edit-render-stays-GOVERNED-and-tells-the-seat-why-it-did-not-lift',
coEditRender.includes('GOVERNED') && coEditRender.includes('did NOT lift') &&
coEditRender.includes('modifies the generator this exception trusts'), coEditRender);
assert('the-dot-slash-spelling-of-a-generator-path-is-fenced-too-recognising-more-only-tightens',
- generatorCoEditProvenance([wfPath, './scripts/docs-audit/check-audit-scope.mjs']) !== null);
+ generatorCoEditProvenance([regPath, `./${fencePrefix}build-skill-references.ts`], fenceRow) !== null);
// Direction B — untouched generator: the fence abstains (`null`), the
// recompute runs exactly as before, and a verified pure regeneration still
// lifts. This is the narrowing's whole obligation: nothing else moves.
- const regenOnlyPaths = [wfPath, 'content/docs/new-page.mdx'];
+ const regenOnlyPaths = [regPath, 'packages/spec/src/ui/responsive.zod.ts'];
assert('an-untouched-generator-abstains-so-the-recompute-runs-exactly-as-before',
- generatorCoEditProvenance(regenOnlyPaths) === null);
+ generatorCoEditProvenance(regenOnlyPaths, fenceRow) === null);
const regenOnlyVerdict = applyGeneratedExceptions(testVerdict(regenOnlyPaths), verified);
assert('a-pure-regen-with-an-untouched-generator-still-lifts-and-the-pr-is-not-governed',
regenOnlyVerdict.governed === false && regenOnlyVerdict.hitPaths.length === 0 && regenOnlyVerdict.exceptions[0].pureRegeneration === true,
JSON.stringify(regenOnlyVerdict));
assert('a-docs-only-pr-that-never-hits-the-register-is-untouched-by-the-fence',
- generatorCoEditProvenance(['content/docs/a.mdx']) === null && testVerdict(['content/docs/a.mdx']).governed === false);
+ generatorCoEditProvenance(['content/docs/a.mdx'], fenceRow) === null && testVerdict(['content/docs/a.mdx']).governed === false);
// A prefix on the real directory, not a substring: a sibling directory whose
// name merely starts the same way is not the trusted generator.
assert('a-near-miss-sibling-directory-is-not-mistaken-for-the-generator-tree',
- generatorCoEditProvenance(['scripts/docs-auditing/other.mjs']) === null);
+ generatorCoEditProvenance([`${fencePrefix.replace(/\/$/, '')}-x/other.ts`], fenceRow) === null);
+ // A row that names no instrument tree cannot rule a co-edit out, so it must
+ // fail closed rather than abstain — abstaining would send it on to recompute.
+ {
+ const unfenced = generatorCoEditProvenance([regPath], { id: 'fixture', trustedGeneratorPrefixes: [] });
+ assert('a-row-with-no-trusted-generator-tree-fails-closed-rather-than-abstaining',
+ unfenced !== null && unfenced.pureRegeneration === false && /names no trusted generator tree/.test(unfenced.reason), JSON.stringify(unfenced));
+ }
// ── the #11705 generator-owned rows inside `skills/**` ───────────────────
//
@@ -3433,9 +3345,16 @@ async function selfTest() {
assert('and-the-shared-output-sink-counts-as-the-instrument-too',
generatorCoEditProvenance([genIndex, 'packages/spec/scripts/lib/generated-output.ts'], skillRefs) !== null);
assert('editing-spec-SOURCE-is-not-a-generator-co-edit', generatorCoEditProvenance([genIndex, 'packages/spec/src/ui/view.zod.ts'], skillRefs) === null);
- assert('the-fences-are-per-row-neither-tree-fences-the-other-rows-generator',
- generatorCoEditProvenance(['packages/spec/scripts/build-skill-references.ts'], GENERATED_SURFACE_EXCEPTIONS[0]) === null &&
- generatorCoEditProvenance(['scripts/docs-audit/affected-docs.mjs'], skillRefs) === null);
+ // PER ROW, not global: a row is fenced by ITS OWN instrument tree and by no
+ // other. Pinned against a fixture row rather than a sibling register row,
+ // because the two live rows happen to share a prefix today — and a property
+ // that only holds while two rows differ is not the property being asserted.
+ {
+ const elsewhere = { id: 'fixture', trustedGeneratorPrefixes: ['tools/other-generator/'] };
+ assert('the-fences-are-per-row-neither-tree-fences-the-other-rows-generator',
+ generatorCoEditProvenance(['packages/spec/scripts/build-skill-references.ts'], elsewhere) === null &&
+ generatorCoEditProvenance(['tools/other-generator/gen.ts'], skillRefs) === null);
+ }
// Applied to a verdict: the #11685 shape lifts, and the ruled limit holds.
const specPr = applyGeneratedExceptions(testVerdict(['packages/spec/src/ui/responsive.zod.ts', genIndex]), new Map([[genIndex, genuine]]));
@@ -3482,7 +3401,7 @@ async function selfTest() {
// The words a seat reads.
const liftedRender = renderTestVerdict(liftedVerdict);
assert('a-lifted-render-names-the-exception-the-generator-and-the-recompute',
- liftedRender.includes('PURE REGENERATION') && liftedRender.includes('check-audit-scope.mjs --write') && liftedRender.includes('THIS tree'), liftedRender);
+ liftedRender.includes('PURE REGENERATION') && liftedRender.includes(generatedExceptionFor(regPath).generator) && liftedRender.includes('THIS tree'), liftedRender);
assert('a-lifted-render-still-warns-that-any-other-governed-hit-forks', liftedRender.includes('forks the whole PR'), liftedRender);
const rejectedRender = renderTestVerdict(rejectedVerdict);
assert('a-rejected-render-stays-GOVERNED-and-says-why-the-exception-did-not-lift',
@@ -3494,7 +3413,7 @@ async function selfTest() {
for (const failure of failures) console.error(` • ${failure}`);
process.exit(1);
}
- console.log(`✓ check-governed-merges --self-test: ${checked} assertions (the unified governed predicate + near misses, subject→PR spellings, window parsing, the #12633 landing window — the QS-7 regression pin in both directions, the topological close beyond the budget, the unproven-boundary EDGE, the listed-or-INCOMPLETE invariant over every fixture, the escalating floors, per-repo --since-ref resolution and its named fallback, and the window words — the replay fixtures, the four-repo resolution incl. absent/wrong-origin/relocated checkouts, the attribution channel chain + its proxy-transport re-arm plan and its one named fallback line, the three-way attribution column (resolved · every-channel-failed · NOT LOOKED UP, and the note pointer that belongs to the middle one alone), the --test pre-arm predicate, the generated-artifact provenance exception — the four ruled cases against the generator's own splice, byte-exactness, fail-closed inputs, the untouched mixed-diff rule, single-file-not-a-class, the #11084 generator co-edit fence in both directions, and its render words — the #11705 generator-owned rows inside skills/** (a genuine generated file passes, the same path hand-edited does not, a path no generator declares is hand-authored content, per-row fences, and the enumeration read from the real generator), the exit table, the report wording pins, and the #13307 remote-reachability leg — the pure freshness verdicts in every branch (unreachable · a remote naming no commit · an unreadable local tip · a mirror behind its remote · the two-unreadable-shas degenerate case that must never read as a match), the report words in both directions (an unreachable repo never renders the tick, a reachable one still says a MEASURED zero, and a row with no remote reading never claims one), and the REAL prober on local bare-repo fixtures over the file transport — a live remote, a deleted one, the --exit-code branch, and a mirror the remote moved past — the #13423 identity leg (an origin no slug parses from refuses, pure and end-to-end, with audited reachable only through a parsed matching slug), the #13424 per-repo window resolution (a sibling-only pin resolves in its own repo, the self-only control still errors, and the end-to-end sibling-pin sweep reports instead of exiting 1), the #13307 sweep-code provenance line in all three branches, and the #13836 attribution set — every refusal carries its precondition category on the row, in the footer, and in --json; the shallow-clone path in both directions; and the run-1-vs-run-2 flip reproduced on real fixtures with zero local writes).\n ${liveNote}`);
+ console.log(`✓ check-governed-merges --self-test: ${checked} assertions (the unified governed predicate + near misses, subject→PR spellings, window parsing, the #12633 landing window — the QS-7 regression pin in both directions, the topological close beyond the budget, the unproven-boundary EDGE, the listed-or-INCOMPLETE invariant over every fixture, the escalating floors, per-repo --since-ref resolution and its named fallback, and the window words — the replay fixtures, the four-repo resolution incl. absent/wrong-origin/relocated checkouts, the attribution channel chain + its proxy-transport re-arm plan and its one named fallback line, the three-way attribution column (resolved · every-channel-failed · NOT LOOKED UP, and the note pointer that belongs to the middle one alone), the --test pre-arm predicate, the generated-artifact provenance exception — the register's invariants incl. the RETIRED #9866 row staying retired (no row lifts anything under .claude/**, and the audit workflow is plainly governed again), a row with no recompute failing closed, lift/reject/absent-provenance semantics, the untouched mixed-diff rule, named-rows-not-a-class, the #11084 generator co-edit fence in both directions incl. a row with no instrument tree, and its render words — the #11705 generator-owned rows inside skills/** (a genuine generated file passes, the same path hand-edited does not, a path no generator declares is hand-authored content, per-row fences, and the enumeration read from the real generator), the exit table, the report wording pins, and the #13307 remote-reachability leg — the pure freshness verdicts in every branch (unreachable · a remote naming no commit · an unreadable local tip · a mirror behind its remote · the two-unreadable-shas degenerate case that must never read as a match), the report words in both directions (an unreachable repo never renders the tick, a reachable one still says a MEASURED zero, and a row with no remote reading never claims one), and the REAL prober on local bare-repo fixtures over the file transport — a live remote, a deleted one, the --exit-code branch, and a mirror the remote moved past — the #13423 identity leg (an origin no slug parses from refuses, pure and end-to-end, with audited reachable only through a parsed matching slug), the #13424 per-repo window resolution (a sibling-only pin resolves in its own repo, the self-only control still errors, and the end-to-end sibling-pin sweep reports instead of exiting 1), the #13307 sweep-code provenance line in all three branches, and the #13836 attribution set — every refusal carries its precondition category on the row, in the footer, and in --json; the shallow-clone path in both directions; and the run-1-vs-run-2 flip reproduced on real fixtures with zero local writes).\n ${liveNote}`);
}
/** The exit code `--test` would return for a path list — pinned without spawning. */
diff --git a/scripts/pm/check-governed-queue-guard.mjs b/scripts/pm/check-governed-queue-guard.mjs
index 81e154c14d..5c7027634e 100644
--- a/scripts/pm/check-governed-queue-guard.mjs
+++ b/scripts/pm/check-governed-queue-guard.mjs
@@ -133,15 +133,20 @@
*
* ## The generated-artifact exception is honoured, and that is not optional
*
- * `.claude/workflows/docs-accuracy-audit.js` holds a required gate's own
- * `--write` artifact, so EVERY page-adding docs PR touches the governed
- * surface (measured 5-for-5 on #9866). A guard that refuses those refuses
- * routine traffic, which is the same poison one level down. The #10277 Option-C
- * exception (maintainer 「10277 同意 C」) is therefore applied here through the
- * register's own `applyGeneratedExceptions`, with provenance recomputed
- * BYTE-EXACT against this build's own base sha — never a stored baseline, and
- * fail-closed on every error path, exactly as the four ruled constraints
- * require.
+ * A generator-owned file sitting inside a governed surface makes routine
+ * traffic cross the fence, and a guard that refuses routine traffic is the same
+ * poison one level down. So the register's provenance exception is applied here
+ * through its own `applyGeneratedExceptions`, with provenance recomputed on this
+ * build's own tree — never a stored baseline, and fail-closed on every error
+ * path, exactly as the ruled constraints require.
+ *
+ * ⚖️ The original case for this — `.claude/workflows/docs-accuracy-audit.js`
+ * holding a required gate's own `--write` artifact, so EVERY page-adding docs PR
+ * touched the governed surface (measured 5-for-5 on #9866) — is GONE, and by a
+ * different remedy: on 2026-09-01 the maintainer ruled that list off the governed
+ * surface entirely (#13591, verbatim 「同意」). Its register row retired with it.
+ * The mechanism stays because the #11705 rows still need it, and because the
+ * shape recurs; ⛔ its absence from a diff is not a reason to relax anything.
*
* NOTHING about the exception is decided here: membership is the register's
* `generatedExceptionFor` and the recompute is its `recomputeProvenanceFor`,