From c1d427f861610bb77d55f0b7448631734884fa07 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 00:45:13 +0000 Subject: [PATCH 1/6] wip: isystem census + gate scripts --- scripts/check-system-context-census.mjs | 937 ++++++++++++++++++++++++ scripts/doc-line-anchors.mjs | 298 ++++++++ scripts/isystem-census.mjs | 358 +++++++++ 3 files changed, 1593 insertions(+) create mode 100644 scripts/check-system-context-census.mjs create mode 100644 scripts/doc-line-anchors.mjs create mode 100644 scripts/isystem-census.mjs diff --git a/scripts/check-system-context-census.mjs b/scripts/check-system-context-census.mjs new file mode 100644 index 0000000000..a74fc2cd24 --- /dev/null +++ b/scripts/check-system-context-census.mjs @@ -0,0 +1,937 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * check-system-context-census -- holds `content/docs/permissions/system-context.mdx` + * to the code it claims to enumerate. + * + * node scripts/check-system-context-census.mjs + * node scripts/check-system-context-census.mjs --self-test + * node scripts/check-system-context-census.mjs --fix # re-anchor rotted lines + * + * That page declares itself "the authority" for every platform behaviour keyed off + * `ExecutionContext.isSystem`, and says it is "built by census over the whole repo, + * not by recall". Nothing held it to either claim. Measured over the 19 days after + * its census was written: + * + * 111 anchors on the page 101 pointed at a line that no longer held + * what the row named; 10 were still correct + * read sites in the code 83 -> 109; 28 arrived, 2 were deleted + * the page's headline "80 sites across 18 packages", while its own + * tables anchored 77 and the code held 109 + * + * ## ⭐ Why the POPULATION check is the mandatory half + * + * The obvious gate resolves every anchor the page writes. That gate would have been + * ALL GREEN at the commit above -- while the page was missing 32 sites and its + * headline was 29 too low. + * + * ⭐ A gate that only checks what the page already says can never find what the + * page failed to say. + * + * So the load-bearing direction is CENSUS -> PAGE: every read site in the code must + * carry an anchor. The other direction (PAGE -> CENSUS) is worth having and cheap, + * but it is the second gate, not the first. + * + * The two deletions are the reason a symbol-name anchor is not sufficient either. + * Both were the `isSystem` propagation inside a `callerContext()` helper; both + * helpers still exist under the same name. **A symbol anchor would still resolve + * and would still be green** while the protection the row described was gone. + * Deletions are caught here by the counts, which are census-derived: lose a site + * and the page's declared 109 stops being true. + * + * ## The four checks + * + * A RESOLUTION every anchor resolves to exactly one tracked file, at a line + * that file has. Ambiguity is an error, never a guess: the + * previous edition had 41 of 111 anchors whose bare basename + * matched two files and could only be placed by reading the + * row's prose. + * B POPULATION every elevation read site the census finds is anchored at its + * exact `file:line`. Zero omissions. ⭐ This is the mandatory one. + * C COUNTS every number the page states about the current tree equals the + * census. A pattern that matches NOTHING is an error, so a + * reworded page cannot silently stop being checked. + * D CLASSIFICATION an anchor that is not a read site must be a declared + * `NON_READ_ANCHORS` row, and that row must still locate the line. + * + * ## Why `NON_READ_ANCHORS` carries needles instead of line numbers + * + * 28 of the page's anchors are deliberately not read sites: the four unrelated + * `isSystem` declarations, the `sys_`-prefix name helpers, a guard block a row + * cites as the thing being skipped, and the prose targets in the "what it does NOT + * do" table. They need an allow-list -- and an allow-list of LINE NUMBERS would rot + * exactly like the anchors this gate exists to stop rotting, silently, because a + * stale row still excuses an anchor. + * + * So each row carries a `needle`: a literal that must appear on exactly one line of + * the file. The gate LOCATES the line and requires the page's anchor to name it. + * That makes every anchor on the page enforced and mechanically repairable, and it + * makes the ledger self-retiring -- a needle that matches zero lines, or more than + * one, is an error naming the row. + * + * ## `--fix` repairs rot and REFUSES to repair population + * + * Per file, when the page's read-anchor count equals the census's site count, the + * two are mapped in line order and the numbers rewritten: that is a pure shift, the + * shape an unrelated edit produces. When the counts differ, the population changed + * -- a site arrived or vanished -- and no mechanical mapping is honest. `--fix` + * leaves those alone and the gate stays red until a human writes the row. + * + * ## Refusals, never quiet passes (#4690) + * + * A page that cannot be read, a census with no sites, zero anchors found, a corpus + * of zero files, a declared-count pattern that matches nothing, and a ledger row + * that locates nothing are all exit 1 naming what could not be read. + */ + +import { readFileSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { isEntrypoint } from './invoked-as.mjs'; +import { runCensus, siteKeys } from './isystem-census.mjs'; +import { extractLineAnchors, extractPathCitations, resolveAnchorFile } from './doc-line-anchors.mjs'; + +const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..'); +export const PAGE = 'content/docs/permissions/system-context.mdx'; + +/** + * ⛔ SHRINK-ONLY. Anchors the page writes that are deliberately NOT elevation read + * sites. `needle` must appear on exactly ONE line of `file`; that line is where the + * page's anchor has to point. + */ +export const NON_READ_ANCHORS = [ + // ── The four declarations that share the identifier ────────────────────────── + { + file: 'packages/spec/src/kernel/execution-context.zod.ts', + needle: 'isSystem: z.boolean().default(false),', + why: 'the elevation flag itself -- a declaration, not a read', + }, + { + file: 'packages/spec/src/data/object.zod.ts', + needle: "isSystem: z.boolean().optional().default(false).describe('Is system object", + why: 'Object.isSystem -- an unrelated metadata field the page names to defuse the collision', + }, + { + file: 'packages/spec/src/system/email-template.zod.ts', + needle: 'isSystem: z.boolean().default(false),', + why: 'EmailTemplate.isSystem -- unrelated metadata field', + }, + { + file: 'packages/spec/src/cloud/environment.zod.ts', + needle: "isSystem: z.boolean().default(false).describe('Whether this is a system environment", + why: 'Environment.isSystem -- unrelated metadata field', + }, + // ── The `sys_` name-prefix family, cited to keep it apart from the flag ────── + { + file: 'packages/runtime/src/action-execution.ts', + needle: 'export function isSystemObjectName(name: string): boolean {', + why: 'keys on the `sys_` NAME PREFIX, not on any flag', + }, + { + file: 'packages/mcp/src/mcp-http-tools.ts', + needle: 'function isSystemObject(name: string): boolean {', + why: 'the same name-prefix helper, MCP side', + }, + // ── Constructs a table row deliberately cites alongside its read ───────────── + { + file: 'packages/plugins/plugin-security/src/security-plugin.ts', + needle: '3.5. [#3004]', + why: 'row 2 -- the `owner_id` guard block that the row-1 short-circuit skips', + }, + { + file: 'packages/objectql/src/engine.ts', + needle: 'if (!hasTx && !hasTenant && !isSystem && !hasTz && !preserveAudit) return base;', + why: 'row 24 -- the early return the tenant-audit read feeds', + }, + { + file: 'packages/objectql/src/engine.ts', + needle: 'if (isSystem && opts.bypassTenantAudit === undefined) {', + why: 'row 24 -- where `bypassTenantAudit` is threaded to the driver', + }, + { + file: 'packages/objectql/src/engine.ts', + needle: 'if (options?.strictReadonlyWrites === true) {', + why: 'row 22 -- the strict-drop refusal that never fires under elevation', + }, + { + file: 'packages/objectql/src/readonly-strict-errors.ts', + needle: 'const READONLY_CLASS_REASONS', + why: 'row 22 -- the reason set the silent refusal would have used', + }, + { + file: 'packages/plugins/plugin-security/src/system-write-guard.ts', + needle: 'if (!isUserContextWrite(context)) return;', + why: 'row 25 -- the bypass expressed through a helper rather than a direct read', + }, + { + file: 'packages/plugins/plugin-sharing/src/sharing-service.ts', + needle: "if (row.source != null && row.source !== 'manual') {", + why: 'row 34 -- the CONFLICT guard `revoke()` deletes in front of', + }, + { + file: 'packages/services/service-automation/src/builtin/crud-nodes.ts', + needle: 'stampSystemInsertOwner(fields, dataCtx, data, objectName);', + why: 'row 60 -- the call site of the compensating owner stamp', + }, + { + file: 'packages/objectql/src/registry.ts', + needle: 'export function applySystemFields(', + why: 'rough edge 5 -- named as if it read the flag; it reads it zero times', + }, + // ── Prose targets: "what `isSystem` does NOT do", and the rough edges ──────── + { + file: 'packages/metadata-protocol/src/seed-loader.ts', + needle: 'so it must carry `skipTriggers` too.', + why: 'the rationale comment the triggers row cites', + }, + { + file: 'packages/metadata-protocol/src/seed-loader.ts', + needle: 'does NOT suppress trigger dispatch, only `skipTriggers` does', + why: 'end of that rationale comment', + }, + { + file: 'packages/metadata-protocol/src/seed-loader.ts', + needle: 'SEED_OPTIONS = { context: { isSystem: true, skipTriggers: true', + why: 'the seed options that carry BOTH flags -- a producer, not a read', + }, + { + file: 'packages/spec/src/automation/flow.zod.ts', + needle: 'Declare `system` to make the elevation explicit.', + why: 'the flow-side declaration of the same distinction', + }, + { + file: 'packages/objectql/src/engine.ts', + needle: '// Runs BEFORE validation on purpose: a value the caller was never', + why: 'start of the strip-before-validation block the validation row cites', + }, + { + file: 'packages/spec/src/data/field.zod.ts', + needle: "readonly: z.boolean().default(false).describe(", + why: '`preserveAudit` is the separate opt-in -- this is the `readonly` declaration', + }, + { + file: 'packages/services/service-automation/src/runtime-identity.ts', + needle: 'const userId = (dataCtx as RunIdentityContext).userId;', + why: 'audit stamping reads `userId`, not the flag', + }, + { + file: 'packages/services/service-automation/src/runtime-identity.ts', + needle: 'if (!userId) return;', + why: 'the user-less system write that stamps nothing', + }, + { + file: 'packages/plugins/plugin-auth/src/last-admin-guard.ts', + needle: 'applies to EVERY context, `isSystem` included', + why: 'the guard that is NOT bypassed -- cited to refute "it bypasses every guard"', + }, + { + file: 'packages/rest/src/rest-server.ts', + needle: '"authenticated". `isSystem` flags are never set on inbound HTTP', + why: 'inbound HTTP cannot set the flag', + }, + { + file: 'packages/rest/src/rest-server.ts', + needle: '`isSystem` is never set on inbound HTTP, so it cannot bypass.', + why: 'the second inbound seam', + }, + { + file: 'packages/runtime/src/domains/actions.ts', + needle: '`isSystem` is never settable from the wire; internal', + why: 'an action body cannot set the flag', + }, +]; + +/** + * Numbers the page states ABOUT THE CURRENT TREE, each tied to the census value it + * must equal. + * + * ⚠️ Deliberately excluded: every figure describing the PREVIOUS edition (111 + * anchors, 101 rotted, 41 ambiguous, the `grep -c` 64, "80 sites across 18 + * packages"). Those are history, they are true of a tree that no longer exists, and + * a gate that "corrected" them would be rewriting the record. + * + * `pattern` must have exactly one capture group -- the number -- and must match at + * least once. A pattern that matches nothing is an ERROR: it means the page was + * reworded out from under the check, which is how a counts gate goes quietly + * vacuous. + */ +export const DECLARED_COUNTS = [ + { + id: 'headline-sites', + pattern: /a single boolean read at \*\*(\d+)\s*\n?\s*distinct sites/, + value: (c) => c.sites.length, + why: 'the headline claim in the opening section', + }, + { + id: 'headline-packages', + pattern: /distinct sites across (\d+) packages\*\*/, + value: (c) => c.packages.length, + why: 'the headline package count', + }, + { + id: 'sharing-share', + pattern: /The largest single consumer — \*\*(\d+) of the \d+ sites\*\*/, + value: (c) => c.sites.filter((s) => s.package.endsWith('plugin-sharing')).length, + why: "section 3's claim about plugin-sharing's share", + }, + { + id: 'sharing-total', + pattern: /The largest single consumer — \*\*\d+ of the (\d+) sites\*\*/, + value: (c) => c.sites.length, + why: 'the denominator of the same claim', + }, + { + id: 'table-lines-total', + pattern: /\| Lines carrying `isSystem` in the corpus \|\s*(\d+) \|/, + value: (c) => c.text.linesTotal, + why: 'the decomposition table: text lines, tests included', + }, + { + id: 'table-lines-tests', + pattern: /\| — in tests \|\s*(\d+) \|/, + value: (c) => c.text.linesInTests, + why: 'the decomposition table: text lines in tests', + }, + { + id: 'table-lines-sources', + pattern: /\| — in non-test sources \|\s*(\d+) \|/, + value: (c) => c.text.linesInSources, + why: 'the decomposition table: text lines in sources', + }, + { + id: 'table-appearances', + pattern: /\| Appearances of the bare identifier `isSystem` in non-test sources \|\s*(\d+) \|/, + value: (c) => c.text.identifierAppearances, + why: 'the decomposition table: identifier appearances', + }, + { + id: 'table-declarations', + pattern: /\| — parsed as a declaration \|\s*(\d+) \|/, + value: (c) => c.roleCounts.declaration, + why: 'the decomposition table: declarations', + }, + { + id: 'table-keys', + pattern: /\| — parsed as an object-literal \/ type key[^|]*\|\s*(\d+) \|/, + value: (c) => c.roleCounts.key, + why: 'the decomposition table: producers and option objects', + }, + { + id: 'table-reads', + pattern: /\| — parsed as a property \*\*read\*\* \|\s*(\d+) \|/, + value: (c) => c.roleCounts.read, + why: 'the decomposition table: property reads', + }, + { + id: 'table-other', + pattern: /\| — parsed in some other syntactic position[^|]*\|\s*(\d+) \|/, + value: (c) => c.roleCounts.other, + why: 'the decomposition table: everything else the parser saw', + }, + { + id: 'table-prose', + pattern: /\| — the remainder: text inside comments and string literals \|\s*(\d+) \|/, + value: (c) => c.text.inCommentsAndStrings, + why: 'the decomposition table: the prose remainder', + }, + { + id: 'table-unrelated-reads', + pattern: /\| Of those reads: reads of one of the unrelated metadata fields \|\s*(\d+) \|/, + value: (c) => c.nonElevationReads.length, + why: 'the decomposition table: the collision subtraction', + }, + { + id: 'table-elevation-reads', + pattern: /\| Of those reads: reads of `ExecutionContext.isSystem` \|\s*\*\*(\d+)\*\* \|/, + value: (c) => c.sites.length, + why: 'the decomposition table: the census answer', + }, + { + id: 'table-packages', + pattern: /\| Packages containing at least one elevation read \|\s*\*\*(\d+)\*\* \|/, + value: (c) => c.packages.length, + why: 'the decomposition table: package count', + }, + { + id: 'table-files', + pattern: /\| Files containing at least one elevation read \|\s*(\d+) \|/, + value: (c) => c.files.length, + why: 'the decomposition table: file count', + }, + { + id: 'ruling-sites', + pattern: /`isSystem` is a published contract with (\d+) read sites/, + value: (c) => c.sites.length, + why: "the #4707 ruling's premise -- it is quoted as a live count, so it must stay one", + }, + { + id: 'ruling-packages', + pattern: /read sites\s*\n?\s*in (\d+) packages\./, + value: (c) => c.packages.length, + why: "the ruling's package count", + }, +]; + +/** Tracked files, for anchor resolution. */ +export function trackedFiles(root = ROOT) { + const files = execFileSync('git', ['-C', root, 'ls-files'], { + encoding: 'utf8', + maxBuffer: 1 << 28, + }) + .split('\n') + .filter(Boolean); + if (files.length === 0) throw new Error('check-system-context-census: `git ls-files` listed nothing'); + return files; +} + +/** + * Locate every `NON_READ_ANCHORS` row by its needle. + * + * @returns {{ located: Map, problems: string[] }} keyed `file:line` + */ +export function locateNonReadAnchors(rows, readFile) { + const located = new Map(); + const problems = []; + for (const row of rows) { + let body; + try { + body = readFile(row.file); + } catch { + problems.push( + `[ledger-unreadable] NON_READ_ANCHORS names ${row.file}, which cannot be read -- ` + + 'the file moved or was deleted; update or drop the row.' + ); + continue; + } + const hits = []; + body.split('\n').forEach((line, i) => { + if (line.includes(row.needle)) hits.push(i + 1); + }); + if (hits.length === 0) { + problems.push( + `[ledger-stale] NON_READ_ANCHORS row for ${row.file} no longer finds its needle ` + + `\`${row.needle}\` -- the construct it excuses is gone or reworded (${row.why}).` + ); + continue; + } + if (hits.length > 1) { + problems.push( + `[ledger-ambiguous] NON_READ_ANCHORS needle \`${row.needle}\` matches ${hits.length} ` + + `lines of ${row.file} (${hits.join(', ')}) -- lengthen it until it is unique.` + ); + continue; + } + located.set(`${row.file}:${hits[0]}`, row); + } + return { located, problems }; +} + +/** + * The whole verdict, as data. Pure, so `--self-test` can drive it on fixtures. + * + * @returns {{ problems: string[], stats: object }} + */ +export function evaluate({ + pageText, + census, + tracked, + readFile, + ledger = NON_READ_ANCHORS, + declaredCounts = DECLARED_COUNTS, +}) { + const problems = []; + + const anchors = extractLineAnchors(pageText); + if (anchors.length === 0) { + problems.push( + '[no-anchors] the page yielded ZERO `file:line` anchors -- the reader stopped ' + + 'recognising the page rather than the page being clean.' + ); + return { problems, stats: { anchors: 0 } }; + } + if (census.sites.length === 0) { + problems.push('[empty-census] the census found ZERO read sites -- refusing to compare against nothing.'); + return { problems, stats: { anchors: anchors.length } }; + } + for (const row of census.staleLedgerRows) { + problems.push( + `[stale-ledger-row] isystem-census NON_ELEVATION_READS names ${row.file} (receiver ` + + `\`${row.receiver}\`) but no such read exists -- delete the row.` + ); + } + + // ── A. RESOLUTION ─────────────────────────────────────────────────────────── + /** @type {Map} `file:line` -> anchors pointing there */ + const anchored = new Map(); + const fileLengths = new Map(); + for (const anchor of anchors) { + const resolved = resolveAnchorFile(anchor.spelling, tracked); + if ('error' in resolved) { + problems.push( + resolved.error === 'ambiguous' + ? `[ambiguous-anchor] ${PAGE}:${anchor.docLine} spells \`${anchor.spelling}\`, which ` + + `matches ${resolved.matches.length} tracked files (${resolved.matches.join(', ')}) -- ` + + 'lengthen the spelling until it is unique.' + : `[unresolved-anchor] ${PAGE}:${anchor.docLine} spells \`${anchor.spelling}\`, which ` + + 'matches no tracked file -- the file moved or was deleted.' + ); + continue; + } + const path = resolved.path; + if (!fileLengths.has(path)) { + try { + fileLengths.set(path, readFile(path).split('\n').length); + } catch { + fileLengths.set(path, -1); + } + } + const length = fileLengths.get(path); + if (length === -1) { + problems.push(`[unreadable-anchor-target] ${path} cannot be read (anchored at ${PAGE}:${anchor.docLine}).`); + continue; + } + if (anchor.line < 1 || anchor.line > length) { + problems.push( + `[out-of-range-anchor] ${PAGE}:${anchor.docLine} anchors ${path}:${anchor.line}, ` + + `but that file has ${length} lines.` + ); + continue; + } + const key = `${path}:${anchor.line}`; + if (!anchored.has(key)) anchored.set(key, []); + anchored.get(key).push(anchor); + } + + for (const citation of extractPathCitations(pageText)) { + const resolved = resolveAnchorFile(citation.spelling, tracked); + if ('error' in resolved) { + problems.push( + `[unresolved-citation] ${PAGE}:${citation.docLine} cites \`${citation.spelling}\`, ` + + `which ${resolved.error === 'ambiguous' ? 'matches several tracked files' : 'matches no tracked file'}.` + ); + } + } + + // ── B. POPULATION — ⭐ the mandatory direction ─────────────────────────────── + const sites = siteKeys(census); + const missing = [...sites].filter((key) => !anchored.has(key)).sort(); + for (const key of missing) { + const site = census.sites.find((s) => `${s.file}:${s.line}` === key); + problems.push( + `[site-without-a-row] ${key} reads \`${site.receiver}.isSystem\` and NO row on the page ` + + `anchors it — \`${site.text.slice(0, 90)}\`. ` + + 'Either the page is missing this elevation behaviour, or an existing row rotted off it.' + ); + } + + // ── D. CLASSIFICATION ─────────────────────────────────────────────────────── + const { located, problems: ledgerProblems } = locateNonReadAnchors(ledger, readFile); + problems.push(...ledgerProblems); + const unexplained = [...anchored.keys()].filter((key) => !sites.has(key) && !located.has(key)).sort(); + for (const key of unexplained) { + problems.push( + `[anchor-is-not-a-read-site] the page anchors ${key}, which the census does not call an ` + + 'elevation read and NON_READ_ANCHORS does not declare. Either the line rotted, or the ' + + 'citation is deliberate and needs a ledger row with a needle.' + ); + } + const unusedLedger = [...located.entries()].filter(([key]) => !anchored.has(key)); + for (const [key, row] of unusedLedger) { + problems.push( + `[ledger-row-unused] NON_READ_ANCHORS excuses ${key} (${row.why}) but no anchor on the page ` + + 'points there -- the row outlived the citation, or the anchor rotted off it.' + ); + } + + // ── C. COUNTS ─────────────────────────────────────────────────────────────── + for (const declared of declaredCounts) { + const match = declared.pattern.exec(pageText); + if (!match) { + problems.push( + `[count-pattern-unmatched] the page no longer carries the \`${declared.id}\` sentence ` + + `(${declared.why}) -- this gate stopped checking a number nobody removed. Update the ` + + 'pattern together with the wording.' + ); + continue; + } + const stated = Number(match[1]); + const actual = declared.value(census); + if (stated !== actual) { + problems.push( + `[declared-count] \`${declared.id}\` says ${stated}, the census says ${actual} (${declared.why}).` + ); + } + } + + return { + problems, + stats: { + anchors: anchors.length, + anchorTargets: anchored.size, + sites: sites.size, + packages: census.packages.length, + files: census.files.length, + nonReadAnchors: located.size, + missing: missing.length, + }, + }; +} + +/** + * Rewrite rotted read-site anchors and ledger anchors in place. + * + * Only pure shifts: per file, the page's read-anchor count must equal the census's + * site count. A population change is left for a human. + * + * @returns {{ text: string, rewrites: string[], refused: string[] }} + */ +export function fixAnchors({ pageText, census, tracked, readFile, ledger = NON_READ_ANCHORS }) { + const anchors = extractLineAnchors(pageText); + const sites = siteKeys(census); + const { located } = locateNonReadAnchors(ledger, readFile); + /** ledger target lines, per file */ + const ledgerByFile = new Map(); + for (const key of located.keys()) { + const at = key.lastIndexOf(':'); + const file = key.slice(0, at); + if (!ledgerByFile.has(file)) ledgerByFile.set(file, []); + ledgerByFile.get(file).push(Number(key.slice(at + 1))); + } + + /** @type {Map} anchor -> resolved path */ + const paths = new Map(); + for (const anchor of anchors) { + const resolved = resolveAnchorFile(anchor.spelling, tracked); + if ('path' in resolved) paths.set(anchor, resolved.path); + } + + /** @type {Map} anchor -> new line */ + const newLine = new Map(); + const refused = []; + const byFile = new Map(); + for (const anchor of anchors) { + const path = paths.get(anchor); + if (!path) continue; + if (!byFile.has(path)) byFile.set(path, []); + byFile.get(path).push(anchor); + } + for (const [path, fileAnchors] of byFile) { + const ledgerLines = new Set(ledgerByFile.get(path) ?? []); + const censusLines = census.sites.filter((s) => s.file === path).map((s) => s.line); + // Anchors already on a ledger line, or on a census line, keep their meaning. + const readAnchors = fileAnchors.filter( + (a) => !ledgerLines.has(a.line) && !(ledgerByFile.get(path) ?? []).includes(a.line) + ); + if (readAnchors.length !== censusLines.length) { + refused.push( + `${path}: page anchors ${readAnchors.length} read site(s), census finds ` + + `${censusLines.length} -- the POPULATION changed, this is not a shift. A row has to be ` + + 'written or deleted by hand.' + ); + continue; + } + const sorted = [...readAnchors].sort((a, b) => a.line - b.line); + const target = [...censusLines].sort((a, b) => a - b); + sorted.forEach((anchor, i) => { + if (anchor.line !== target[i]) newLine.set(anchor, target[i]); + }); + } + + // Ledger anchors: an anchor whose file has exactly one ledger line it is nearest + // to, and which is neither a census site nor already on a ledger line. + for (const [path, fileAnchors] of byFile) { + const ledgerLines = ledgerByFile.get(path) ?? []; + if (ledgerLines.length === 0) continue; + const taken = new Set(fileAnchors.filter((a) => ledgerLines.includes(a.line)).map((a) => a.line)); + const free = ledgerLines.filter((l) => !taken.has(l)); + const orphans = fileAnchors.filter( + (a) => !ledgerLines.includes(a.line) && !sites.has(`${path}:${a.line}`) && !newLine.has(a) + ); + if (free.length === 1 && orphans.length === 1) newLine.set(orphans[0], free[0]); + } + + // Apply, latest anchor first, so earlier offsets stay valid. + const rewrites = []; + let text = pageText; + const ordered = [...newLine.keys()].sort((a, b) => b.docLine - a.docLine || b.raw.length - a.raw.length); + for (const anchor of ordered) { + const to = newLine.get(anchor); + const from = anchor.raw; + const replacement = + anchor.kind === 'full' ? `${anchor.spelling}:${to}` : anchor.kind === 'continuation' ? `:${to}` : `${to}`; + const needle = `\`${from}\``; + const at = text.indexOf(needle, offsetOfDocLine(text, anchor.docLine)); + if (at === -1) { + refused.push(`could not re-find \`${from}\` at ${PAGE}:${anchor.docLine}`); + continue; + } + text = `${text.slice(0, at)}\`${replacement}\`${text.slice(at + needle.length)}`; + rewrites.push(`${PAGE}:${anchor.docLine} \`${from}\` -> \`${replacement}\``); + } + return { text, rewrites, refused }; +} + +function offsetOfDocLine(text, docLine) { + let offset = 0; + for (let n = 1; n < docLine; n += 1) { + const at = text.indexOf('\n', offset); + if (at === -1) return offset; + offset = at + 1; + } + return offset; +} + +function readFileAt(root) { + return (relPath) => readFileSync(join(root, relPath), 'utf8'); +} + +function run({ fix = false } = {}) { + const readFile = readFileAt(ROOT); + let pageText; + try { + pageText = readFile(PAGE); + } catch (error) { + process.stderr.write(`::error::[unreadable-page] ${PAGE} could not be read -- ${error.message}\n`); + return 1; + } + const census = runCensus({ root: ROOT }); + const tracked = trackedFiles(ROOT); + + if (fix) { + const { text, rewrites, refused } = fixAnchors({ pageText, census, tracked, readFile }); + if (rewrites.length > 0) writeFileSync(join(ROOT, PAGE), text); + for (const line of rewrites) process.stdout.write(` re-anchored ${line}\n`); + for (const line of refused) process.stdout.write(` ⛔ NOT fixable: ${line}\n`); + process.stdout.write(`check-system-context-census --fix: ${rewrites.length} anchor(s) rewritten\n`); + pageText = text; + } + + const { problems, stats } = evaluate({ pageText, census, tracked, readFile }); + for (const problem of problems) process.stderr.write(`::error::${problem}\n`); + if (problems.length > 0) { + process.stderr.write( + `\ncheck-system-context-census: ${problems.length} problem(s) over ${stats.anchors} anchors ` + + `and ${stats.sites} census sites.\n` + + `Re-run the census with \`node scripts/isystem-census.mjs --json\`; pure line rot is ` + + `repaired by \`node scripts/check-system-context-census.mjs --fix\`.\n` + ); + return 1; + } + process.stdout.write( + `check-system-context-census: OK — ${stats.sites} elevation read sites in ${stats.packages} ` + + `packages across ${stats.files} files, all anchored; ${stats.anchors} anchors resolve, ` + + `${stats.nonReadAnchors} declared non-read.\n` + ); + return 0; +} + +/* ────────────────────────────── self-test ────────────────────────────────── */ + +const FIXTURE_SOURCE = [ + 'export function handler(ctx: ExecutionContext) {', // 1 + ' if (ctx.isSystem) return ALLOW;', // 2 + ' const other = obj.isSystem;', // 3 + ' return DENY;', // 4 + '}', // 5 + '// the sys_ prefix helper lives here', // 6 + 'export function isSystemObjectName(name: string) { return name.startsWith("sys_"); }', // 7 +].join('\n'); + +const FIXTURE_CENSUS = { + sites: [{ file: 'pkg/a.ts', line: 2, receiver: 'ctx', package: 'pkg', text: 'if (ctx.isSystem) return ALLOW;' }], + nonElevationReads: [{ file: 'pkg/a.ts', line: 3, receiver: 'obj', field: 'Object.isSystem' }], + roleCounts: { read: 2, declaration: 0, key: 0, other: 0 }, + packages: ['pkg'], + files: ['pkg/a.ts'], + staleLedgerRows: [], + scannedFiles: 1, + text: { linesTotal: 3, linesInTests: 0, linesInSources: 3, identifierAppearances: 3, classified: 2, inCommentsAndStrings: 1 }, +}; + +const FIXTURE_LEDGER = [ + { file: 'pkg/a.ts', needle: 'export function isSystemObjectName', why: 'name-prefix helper, not a read' }, +]; + +function fixtureRead(relPath) { + if (relPath === 'pkg/a.ts') return FIXTURE_SOURCE; + throw new Error(`no fixture for ${relPath}`); +} + +const FIXTURE_TRACKED = ['pkg/a.ts', 'other/a.ts']; + +/** A one-row stand-in for `DECLARED_COUNTS`, so the fixtures need one sentence. */ +const FIXTURE_COUNTS = [ + { + id: 'headline-sites', + pattern: /a single boolean read at \*\*(\d+)\s*\n?\s*distinct sites/, + value: (c) => c.sites.length, + why: 'fixture headline', + }, +]; + +function fixturePage({ anchor = 'pkg/a.ts:2', helper = 'pkg/a.ts:7' } = {}) { + return [ + '---', + 'title: fixture', + '---', + '', + 'read at `' + anchor + '` and the name helper at `' + helper + '`.', + '', + '```bash', + 'grep -rn "isSystem" packages # `pkg/a.ts:999` inside a fence is not an anchor', + '```', + '', + ].join('\n'); +} + +function selfTest() { + let failures = 0; + const t = (name, ok, detail = '') => { + if (!ok) failures += 1; + process.stdout.write(`${ok ? ' ok ' : ' FAIL'} ${name}${detail ? ` -- ${detail}` : ''}\n`); + }; + const run = (page, census = FIXTURE_CENSUS, declaredCounts = []) => + evaluate({ + pageText: page, + census, + tracked: FIXTURE_TRACKED, + readFile: fixtureRead, + ledger: FIXTURE_LEDGER, + declaredCounts, + }); + + // ── the GREEN control: a page that is correct ─────────────────────────────── + const green = run(fixturePage()); + t('green control: a correct page reports nothing', green.problems.length === 0, green.problems.join(' | ')); + t('green control: the fenced `pkg/a.ts:999` is not read as an anchor', green.stats.anchors === 2); + + // ── ⭐ the RED that matters: a site the page never mentions ────────────────── + const arrived = { + ...FIXTURE_CENSUS, + sites: [...FIXTURE_CENSUS.sites, { file: 'pkg/a.ts', line: 4, receiver: 'ctx', package: 'pkg', text: 'return DENY;' }], + }; + const missing = run(fixturePage(), arrived); + t( + 'POPULATION: a read site with no row is a finding', + missing.problems.some((p) => p.startsWith('[site-without-a-row] pkg/a.ts:4')) + ); + + // ── the deletion shape: the row stands, the site is gone ──────────────────── + const deleted = { ...FIXTURE_CENSUS, sites: [] }; + const gone = run(fixturePage(), deleted); + t('POPULATION: an empty census refuses rather than passing', gone.problems.some((p) => p.startsWith('[empty-census]'))); + + const shrunk = { + ...FIXTURE_CENSUS, + sites: [{ file: 'pkg/a.ts', line: 4, receiver: 'ctx', package: 'pkg', text: 'return DENY;' }], + }; + const stale = run(fixturePage(), shrunk); + t( + 'DELETION: a row anchoring a line that is no longer a read site is a finding', + stale.problems.some((p) => p.startsWith('[anchor-is-not-a-read-site]') && p.includes('pkg/a.ts:2')) + ); + + // ── rot ──────────────────────────────────────────────────────────────────── + const rotted = run(fixturePage({ anchor: 'pkg/a.ts:4' })); + t('ROT: a shifted anchor is caught from both sides', rotted.problems.length === 2, rotted.problems.join(' | ')); + + // ── resolution ───────────────────────────────────────────────────────────── + const ambiguous = run(fixturePage({ anchor: 'a.ts:2' })); + t('RESOLUTION: a bare basename matching two files is refused', ambiguous.problems.some((p) => p.startsWith('[ambiguous-anchor]'))); + const gonefile = run(fixturePage({ anchor: 'pkg/nope.ts:2' })); + t('RESOLUTION: an anchor to a file that does not exist is refused', gonefile.problems.some((p) => p.startsWith('[unresolved-anchor]'))); + const overrun = run(fixturePage({ anchor: 'pkg/a.ts:999' })); + t('RESOLUTION: a line past end of file is refused', overrun.problems.some((p) => p.startsWith('[out-of-range-anchor]'))); + + // ── ledger ───────────────────────────────────────────────────────────────── + const ledgerStale = evaluate({ + pageText: fixturePage(), + census: FIXTURE_CENSUS, + tracked: FIXTURE_TRACKED, + readFile: fixtureRead, + ledger: [{ file: 'pkg/a.ts', needle: 'no such text anywhere', why: 'x' }], + declaredCounts: [], + }); + t('LEDGER: a needle that matches nothing is a finding', ledgerStale.problems.some((p) => p.startsWith('[ledger-stale]'))); + const ledgerAmbig = evaluate({ + pageText: fixturePage(), + census: FIXTURE_CENSUS, + tracked: FIXTURE_TRACKED, + readFile: fixtureRead, + ledger: [{ file: 'pkg/a.ts', needle: 'return', why: 'x' }], + declaredCounts: [], + }); + t('LEDGER: a needle matching two lines is a finding', ledgerAmbig.problems.some((p) => p.startsWith('[ledger-ambiguous]'))); + const ledgerUnused = evaluate({ + pageText: fixturePage({ helper: 'pkg/a.ts:2' }), + census: FIXTURE_CENSUS, + tracked: FIXTURE_TRACKED, + readFile: fixtureRead, + ledger: FIXTURE_LEDGER, + declaredCounts: [], + }); + t('LEDGER: a row no anchor uses is a finding', ledgerUnused.problems.some((p) => p.startsWith('[ledger-row-unused]'))); + + // ── counts ───────────────────────────────────────────────────────────────── + const countPage = + fixturePage() + '\nit is a single boolean read at **1\ndistinct sites across 1 packages**.\n'; + const countsOk = run(countPage, FIXTURE_CENSUS, FIXTURE_COUNTS); + t( + 'COUNTS: a matching declared count is silent', + !countsOk.problems.some((p) => p.startsWith('[declared-count] `headline-sites`')) + ); + const countBad = + fixturePage() + '\nit is a single boolean read at **7\ndistinct sites across 1 packages**.\n'; + const countsRed = run(countBad, FIXTURE_CENSUS, FIXTURE_COUNTS); + t( + 'COUNTS: a wrong declared count is a finding', + countsRed.problems.some((p) => p.includes('`headline-sites` says 7, the census says 1')) + ); + t( + 'COUNTS: a pattern that matches nothing is a finding, not a silent skip', + run(fixturePage(), FIXTURE_CENSUS, FIXTURE_COUNTS).problems.some((p) => + p.startsWith('[count-pattern-unmatched]') + ) + ); + + // ── absence is loud ──────────────────────────────────────────────────────── + const noAnchors = run('---\ntitle: x\n---\n\nnothing here.\n'); + t('ABSENCE: a page with no anchors refuses', noAnchors.problems.some((p) => p.startsWith('[no-anchors]'))); + + // ── --fix ────────────────────────────────────────────────────────────────── + const fixed = fixAnchors({ + pageText: fixturePage({ anchor: 'pkg/a.ts:4' }), + census: FIXTURE_CENSUS, + tracked: FIXTURE_TRACKED, + readFile: fixtureRead, + ledger: FIXTURE_LEDGER, + }); + t('FIX: a pure shift is rewritten', fixed.text.includes('`pkg/a.ts:2`'), fixed.rewrites.join(' | ')); + const refusedFix = fixAnchors({ + pageText: fixturePage(), + census: arrived, + tracked: FIXTURE_TRACKED, + readFile: fixtureRead, + ledger: FIXTURE_LEDGER, + }); + t( + 'FIX: a population change is REFUSED, never guessed', + refusedFix.rewrites.length === 0 && refusedFix.refused.length === 1, + JSON.stringify(refusedFix.refused) + ); + + process.stdout.write( + failures === 0 + ? '\ncheck-system-context-census --self-test: all cases passed\n' + : `\ncheck-system-context-census --self-test: ${failures} case(s) FAILED\n` + ); + return failures === 0 ? 0 : 1; +} + +if (isEntrypoint(import.meta.url)) { + const argv = process.argv.slice(2); + process.exit(argv.includes('--self-test') ? selfTest() : run({ fix: argv.includes('--fix') })); +} diff --git a/scripts/doc-line-anchors.mjs b/scripts/doc-line-anchors.mjs new file mode 100644 index 0000000000..5cf123cc85 --- /dev/null +++ b/scripts/doc-line-anchors.mjs @@ -0,0 +1,298 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * doc-line-anchors -- the ONE reader for `file:line` anchors written in docs prose. + * + * A docs page that cites source by `` `security-plugin.ts:1560` `` has created a + * two-sided invariant with no owner: the line lives in one tree, the citation in + * another, and nothing relates them. Measured on + * `content/docs/permissions/system-context.mdx` over 19 days, **101 of its 111 + * anchors rotted** -- the construct still existed, the line number no longer + * named it -- while CI stayed green throughout. + * + * This module is the parsing half of the fix, kept separate from the gate that + * uses it because the defect is not specific to one page: any page carrying + * `file:line` anchors has it, and the second page should cost a ledger rather + * than a parser. + * + * ## The three anchor shapes, all of which are real on the corpus + * + * A naive reader finds only the first and undercounts by a third. + * + * FULL `` `objectql/src/engine.ts:10501` `` path + line + * CONTINUATION `` `:1409` `` line only; the file is + * the nearest FULL anchor + * to its left, which is + * how a row cites four + * sites in one file + * RANGE_END `` `2630` `` a bare number whose only + * separation from the + * anchor on its left is a + * dash: `:2483`--`2630` + * + * The measurement that makes this list non-negotiable: `grep -c` for anchor-shaped + * text over the previous edition of that page answered **64**, because it counts + * LINES CARRYING an anchor. The real population was **111**. A gate seeded from + * the 64 would have reported a confident green over 47 citations it never read. + * + * ## Resolution is by unique suffix, and ambiguity is an ERROR + * + * An anchor names as little of the path as it can and still be unique -- + * `read-audit.ts:556` where the basename is unique, `objectql/src/engine.ts:10501` + * where it is not. That is a property a gate can hold: resolve the spelling + * against the tracked file list by path-suffix and REFUSE when two files match. + * The previous edition had **41 of 111** anchors whose bare basename matched two + * files; those are unresolvable mechanically, and the remedy is to lengthen the + * spelling on the page, never to guess with a heuristic. + * + * ## What is deliberately NOT here + * + * No knowledge of what a line should CONTAIN. That is the consuming gate's + * question -- for `system-context.mdx` it is answered by an AST census -- and + * baking any answer in here would make the module single-use. + * + * @module + */ + +import { extname } from 'node:path'; + +/** Extensions an anchor may name. Anything else is prose, not a citation. */ +export const ANCHOR_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.mjs', '.cjs', '.js', '.jsx']; + +/** `path/to/file.ts:1234` -- the whole span, nothing else. */ +const FULL_RE = /^([A-Za-z0-9_.\-/]+)\.([a-z]+):(\d+)$/; +/** `path/to/file.ts` -- a cited file with no line. */ +const PATH_ONLY_RE = /^([A-Za-z0-9_.\-/]+)\.([a-z]+)$/; +/** `:1409` -- a continuation of the anchor to its left. */ +const CONTINUATION_RE = /^:(\d+)$/; +/** `2630` -- a bare number; only an anchor when a dash joins it to one. */ +const BARE_NUMBER_RE = /^(\d+)$/; +/** What may sit between a range start and its end: a dash of any width. */ +const RANGE_JOIN_RE = /^[\s]*[-‐‑‒–—―][\s]*$/; + +/** + * Blank every fenced code block, preserving line count and byte offsets. + * + * Load-bearing: the page this was written for carries a ```bash block whose body + * is the census command, backticks and all. Reading spans out of a fence + * fabricates anchors nobody wrote. + * + * @param {string} text + * @returns {string} the same length, with fence bodies replaced by spaces + */ +export function blankFencedBlocks(text) { + const lines = text.split('\n'); + let inFence = false; + let fenceMark = ''; + const out = lines.map((line) => { + const opener = /^\s*(`{3,}|~{3,})/.exec(line); + if (!inFence && opener) { + inFence = true; + fenceMark = opener[1][0].repeat(3); + return ' '.repeat(line.length); + } + if (inFence) { + const closer = /^\s*(`{3,}|~{3,})\s*$/.exec(line); + if (closer && closer[1][0].repeat(3) === fenceMark) inFence = false; + return ' '.repeat(line.length); + } + return line; + }); + return out.join('\n'); +} + +/** + * Blank a leading YAML frontmatter block, preserving line count and offsets. + * + * @param {string} text + * @returns {string} + */ +export function blankFrontmatter(text) { + if (!text.startsWith('---\n')) return text; + const end = text.indexOf('\n---\n', 3); + if (end === -1) return text; + const head = text.slice(0, end + 5); + return head.replace(/[^\n]/g, ' ') + text.slice(end + 5); +} + +/** + * Every inline code span in document order. + * + * Backtick runs are matched by length, as CommonMark does, so a span containing + * a backtick (`` ` `` inside `` `` ` `` ``) is read whole rather than split. + * + * @param {string} text Fences and frontmatter already blanked. + * @returns {{ value: string, start: number, end: number, line: number }[]} + */ +export function extractCodeSpans(text) { + /** @type {{ value: string, start: number, end: number, line: number }[]} */ + const spans = []; + let i = 0; + while (i < text.length) { + if (text[i] !== '`') { + i += 1; + continue; + } + let run = 0; + while (text[i + run] === '`') run += 1; + const fence = '`'.repeat(run); + const bodyStart = i + run; + let j = bodyStart; + let close = -1; + while (j < text.length) { + const at = text.indexOf(fence, j); + if (at === -1) break; + let after = 0; + while (text[at + after] === '`') after += 1; + if (after === run) { + close = at; + break; + } + j = at + after; + } + if (close === -1) { + i += run; + continue; + } + const raw = text.slice(bodyStart, close); + spans.push({ + value: raw.trim(), + start: i, + end: close + run, + line: countLines(text, i), + }); + i = close + run; + } + return spans; +} + +/** 1-based line number of `offset` in `text`. */ +function countLines(text, offset) { + let n = 1; + for (let i = 0; i < offset; i += 1) if (text[i] === '\n') n += 1; + return n; +} + +/** + * Every `file:line` anchor a docs page writes, in document order. + * + * @param {string} rawText The page source, frontmatter and fences included. + * @returns {{ spelling: string, line: number, kind: 'full'|'continuation'|'range-end', + * raw: string, docLine: number }[]} + */ +export function extractLineAnchors(rawText) { + const text = blankFencedBlocks(blankFrontmatter(rawText)); + const spans = extractCodeSpans(text); + /** @type {{ spelling: string, line: number, kind: string, raw: string, docLine: number }[]} */ + const anchors = []; + let currentFile = null; + let previousSpan = null; + for (const span of spans) { + const full = FULL_RE.exec(span.value); + if (full && ANCHOR_EXTENSIONS.includes(`.${full[2]}`)) { + currentFile = `${full[1]}.${full[2]}`; + anchors.push({ + spelling: currentFile, + line: Number(full[3]), + kind: 'full', + raw: span.value, + docLine: span.line, + }); + previousSpan = span; + continue; + } + const cont = CONTINUATION_RE.exec(span.value); + if (cont && currentFile) { + anchors.push({ + spelling: currentFile, + line: Number(cont[1]), + kind: 'continuation', + raw: span.value, + docLine: span.line, + }); + previousSpan = span; + continue; + } + const bare = BARE_NUMBER_RE.exec(span.value); + if (bare && currentFile && previousSpan && RANGE_JOIN_RE.test(text.slice(previousSpan.end, span.start))) { + anchors.push({ + spelling: currentFile, + line: Number(bare[1]), + kind: 'range-end', + raw: span.value, + docLine: span.line, + }); + previousSpan = span; + continue; + } + // A path with no line resets the inherited file: a following `:N` belongs to + // the file just named, not to the last one that happened to carry a line. + const pathOnly = PATH_ONLY_RE.exec(span.value); + if (pathOnly && ANCHOR_EXTENSIONS.includes(`.${pathOnly[2]}`) && span.value.includes('/')) { + currentFile = span.value; + } + previousSpan = span; + } + return anchors; +} + +/** + * Every cited path that carries no line number, in document order. + * + * These are not anchors -- there is nothing to hold a line to -- but a rename + * still breaks them, so a gate can resolve them for existence. + * + * @param {string} rawText + * @returns {{ spelling: string, docLine: number }[]} + */ +export function extractPathCitations(rawText) { + const text = blankFencedBlocks(blankFrontmatter(rawText)); + const out = []; + for (const span of extractCodeSpans(text)) { + const m = PATH_ONLY_RE.exec(span.value); + if (!m) continue; + if (!ANCHOR_EXTENSIONS.includes(`.${m[2]}`)) continue; + if (!span.value.includes('/')) continue; + out.push({ spelling: span.value, docLine: span.line }); + } + return out; +} + +/** + * Resolve an anchor spelling against the tracked file list. + * + * @param {string} spelling e.g. `objectql/src/engine.ts` or `read-audit.ts` + * @param {readonly string[]} trackedFiles repo-relative paths + * @returns {{ path: string } | { error: 'unresolved' | 'ambiguous', matches: string[] }} + */ +export function resolveAnchorFile(spelling, trackedFiles) { + const needle = `/${spelling}`; + const matches = trackedFiles.filter((f) => f === spelling || f.endsWith(needle)); + if (matches.length === 1) return { path: matches[0] }; + if (matches.length === 0) return { error: 'unresolved', matches: [] }; + return { error: 'ambiguous', matches }; +} + +/** + * The shortest suffix of `path` that resolves uniquely -- the spelling a page + * SHOULD carry. Never a bare basename when the basename is ambiguous. + * + * @param {string} path repo-relative + * @param {readonly string[]} trackedFiles + * @returns {string} + */ +export function shortestUniqueSpelling(path, trackedFiles) { + const parts = path.split('/'); + for (let take = 1; take <= parts.length; take += 1) { + const candidate = parts.slice(parts.length - take).join('/'); + const resolved = resolveAnchorFile(candidate, trackedFiles); + if ('path' in resolved && resolved.path === path) return candidate; + } + return path; +} + +/** True when `p` names a file this module would read an anchor out of. */ +export function isAnchorableExtension(p) { + return ANCHOR_EXTENSIONS.includes(extname(p)); +} diff --git a/scripts/isystem-census.mjs b/scripts/isystem-census.mjs new file mode 100644 index 0000000000..2ebede79b8 --- /dev/null +++ b/scripts/isystem-census.mjs @@ -0,0 +1,358 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * isystem-census -- the committed enumeration of every `ExecutionContext.isSystem` + * READ in non-test sources. + * + * node scripts/isystem-census.mjs # human summary + * node scripts/isystem-census.mjs --json # the whole census, machine-readable + * + * `content/docs/permissions/system-context.mdx` calls itself "the authority" and + * says it is "built by census over the whole repo, not by recall". This file is + * that census, made re-runnable, so the page's claim has an instrument behind it + * instead of a person's afternoon. `check-system-context-census.mjs` is the gate + * that holds the page to what this reports. + * + * ## ⛔ Why this is an AST walk and not a regex, measured rather than asserted + * + * A regex pass over the same corpus **silently lost 6 real read sites** in + * `packages/plugins/plugin-reports/src/report-service.ts` to a quote desync -- an + * apostrophe inside a comment put the scanner inside a string literal for the rest + * of the file -- and 11 more to `(ctx?.session as any)?.isSystem` casts, which do + * not match a receiver-shaped pattern. Both losses are SILENT: fewer findings, a + * clean exit, a smaller number that reads exactly like a smaller truth. + * + * A gate seeded from that reading would be worse than no gate. It would publish a + * baseline that is wrong in the one direction this page cannot survive -- claiming + * the census is complete when it is short -- and then hold the page to it. + * + * ## What counts as a read, and the two ways a count goes wrong + * + * The identifier `isSystem` appears in sources in five syntactic roles, and only + * one of them is a read. Counting the identifier gives ~810; counting lines that + * match `isSystem` gives ~795; the census is the ~115 property reads inside them. + * A count that has not been DECOMPOSED cannot be compared to anything, which is + * why `--json` reports every role and not just the answer. + * + * The second way is the collision. FOUR unrelated declarations share the + * identifier -- `ExecutionContext.isSystem` (the elevation flag, what this census + * is about), plus `Object.isSystem`, `EmailTemplate.isSystem` and + * `Environment.isSystem`, all ordinary metadata fields on a stored document. A + * census that does not subtract those over-reports. + * + * ## How the subtraction is spelled, and why it carries no line numbers + * + * `NON_ELEVATION_READS` below is keyed by (file, receiver expression). It is + * deliberately NOT keyed by line: a ledger of line numbers rots exactly like the + * page anchors this whole mechanism exists to stop rotting, and it rots + * invisibly, because a stale entry subtracts a site that is still there. + * + * The default is the SAFE direction. An unrecognised receiver is counted as an + * elevation read, so a new metadata-field read shows up as a site the page is + * missing -- loud, and fixed by one ledger line. The reverse default would drop + * real elevation sites in silence. + * + * A ledger row that matches nothing is an ERROR, not a shrug: the row's reason has + * expired and the next reader would take it for a live exclusion. + * + * ## Population + * + * `packages/` and `examples/`, tracked files only, `.ts` / `.tsx` / `.mts` / `.cts`, + * excluding `dist/` and tests. A file counts as a test when its path carries + * `.test.` / `.spec.` or a `tests/` / `__tests__/` / `qa/` segment -- the same rule + * the page states, so the page and the instrument cannot disagree about what was + * counted. + * + * Every unread state is a refusal rather than a quiet pass: a corpus that resolves + * to zero files, a source that cannot be read, or a source that does not parse + * (`ts-parse.mjs` refuses -- a file a gate could not read must never be scored as a + * file with nothing to report). + */ + +import { readFileSync, existsSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import ts from 'typescript'; + +import { isEntrypoint } from './invoked-as.mjs'; +import { parseSourceFile } from './ts-parse.mjs'; + +export const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..'); + +/** The identifier the census is about. */ +export const FLAG = 'isSystem'; + +/** + * ⛔ SHRINK-ONLY, and keyed by (file, receiver) -- never by line. + * + * Reads of `isSystem` that are NOT reads of the elevation flag. Each row names the + * declaration it really reads, so the collision is documented where it is applied. + * A row that matches no read in the tree FAILS: it has outlived its reason. + */ +export const NON_ELEVATION_READS = [ + { + file: 'packages/lint/src/validate-security-posture.ts', + receiver: 'obj', + field: 'Object.isSystem', + why: 'linting an object definition: a system OBJECT, not an elevated operation', + }, + { + file: 'packages/lint/src/validate-sharing-rule-enforceability.ts', + receiver: 'obj', + field: 'Object.isSystem', + why: 'same object-definition lint, sharing-rule side', + }, + { + file: 'packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts', + receiver: 'tpl', + field: 'EmailTemplate.isSystem', + why: 'copies the built-in-template marker onto the stored row', + }, + { + file: 'packages/plugins/plugin-security/src/explain-engine.ts', + receiver: 'schema', + field: 'Object.isSystem', + why: 'object schema under explain(), paired with the `sys_` name-prefix test', + }, + { + file: 'packages/plugins/plugin-sharing/src/sharing-service.ts', + receiver: 'schema', + field: 'Object.isSystem', + why: 'object schema, paired with the `sys_` name-prefix test', + }, + { + file: 'packages/runtime/src/system-environment-plugin.ts', + receiver: 'result.project', + field: 'Environment.isSystem', + why: 'platform-infrastructure environment marker', + }, +]; + +/** A file counts as a test by PATH, the same rule the page publishes. */ +export function isTestPath(relPath) { + return /\.(test|spec)\./.test(relPath) || /(^|\/)(tests|__tests__|qa)\//.test(relPath); +} + +/** Every tracked, non-dist TypeScript file of the corpus -- tests included. */ +export function collectCorpus(root = ROOT) { + return execFileSync('git', ['-C', root, 'ls-files', 'packages', 'examples'], { + encoding: 'utf8', + maxBuffer: 1 << 28, + }) + .split('\n') + .filter(Boolean) + .filter((f) => /\.(ts|tsx|mts|cts)$/.test(f) && !f.includes('/dist/')); +} + +/** Tracked, non-test, non-dist TypeScript under `packages/` and `examples/`. */ +export function collectSources(root = ROOT) { + const sources = collectCorpus(root).filter((f) => !isTestPath(f)); + if (sources.length === 0) { + throw new Error( + 'isystem-census: the corpus resolved to ZERO source files -- refusing to report a census ' + + 'over nothing (a walk that found nothing and a tree with nothing to find are different).' + ); + } + return sources; +} + +/** The package directory a source belongs to, by nearest `package.json`. */ +export function packageOf(relPath, root = ROOT) { + let dir = dirname(join(root, relPath)); + while (dir.length > root.length) { + if (existsSync(join(dir, 'package.json'))) return dir.slice(root.length + 1); + dir = dirname(dir); + } + return null; +} + +/** + * Every syntactic role the identifier takes in one parsed source. + * + * @returns {{ role: string, line: number, receiver: string|null, text: string }[]} + */ +export function classifyFile(relPath, text) { + const sourceFile = parseSourceFile(relPath, text); + const lines = text.split('\n'); + /** @type {{ role: string, line: number, receiver: string|null, text: string }[]} */ + const found = []; + + const record = (node, role, receiver) => { + const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); + found.push({ role, line: line + 1, receiver, text: (lines[line] ?? '').trim() }); + }; + + const visit = (node) => { + if (ts.isIdentifier(node) && node.text === FLAG) { + const parent = node.parent; + if (ts.isPropertyAccessExpression(parent) && parent.name === node) { + record(node, 'read', parent.expression.getText(sourceFile).replace(/\s+/g, ' ')); + } else if ( + ts.isPropertySignature(parent) || + ts.isPropertyDeclaration(parent) || + ts.isGetAccessorDeclaration(parent) || + ts.isEnumMember(parent) + ) { + record(node, 'declaration', null); + } else if ( + ts.isPropertyAssignment(parent) || + ts.isShorthandPropertyAssignment(parent) || + ts.isBindingElement(parent) + ) { + record(node, 'key', null); + } else { + record(node, 'other', null); + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return found; +} + +/** True when this read is subtracted by a `NON_ELEVATION_READS` row. */ +function nonElevationRowFor(relPath, receiver) { + return NON_ELEVATION_READS.find((r) => r.file === relPath && r.receiver === receiver) ?? null; +} + +/** + * Run the census. + * + * @returns {{ + * sites: { file: string, line: number, receiver: string, package: string|null }[], + * nonElevationReads: { file: string, line: number, receiver: string, field: string }[], + * roleCounts: Record, + * packages: string[], + * files: string[], + * staleLedgerRows: typeof NON_ELEVATION_READS, + * scannedFiles: number, + * }} + */ +export function runCensus({ root = ROOT } = {}) { + const sources = collectSources(root); + const sites = []; + const nonElevationReads = []; + const roleCounts = { read: 0, declaration: 0, key: 0, other: 0 }; + const usedRows = new Set(); + let scannedFiles = 0; + + for (const relPath of sources) { + let text; + try { + text = readFileSync(join(root, relPath), 'utf8'); + } catch (error) { + throw new Error(`isystem-census: cannot read ${relPath} -- ${error.message}`); + } + if (!text.includes(FLAG)) continue; + scannedFiles += 1; + for (const hit of classifyFile(relPath, text)) { + roleCounts[hit.role] = (roleCounts[hit.role] ?? 0) + 1; + if (hit.role !== 'read') continue; + const row = nonElevationRowFor(relPath, hit.receiver); + if (row) { + usedRows.add(row); + nonElevationReads.push({ + file: relPath, + line: hit.line, + receiver: hit.receiver, + field: row.field, + }); + continue; + } + sites.push({ + file: relPath, + line: hit.line, + receiver: hit.receiver, + package: packageOf(relPath, root), + text: hit.text, + }); + } + } + + sites.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line); + const text = countText(root); + const classified = roleCounts.read + roleCounts.declaration + roleCounts.key + roleCounts.other; + return { + text: { ...text, classified, inCommentsAndStrings: text.identifierAppearances - classified }, + sites, + nonElevationReads, + roleCounts, + packages: [...new Set(sites.map((s) => s.package))].filter(Boolean).sort(), + files: [...new Set(sites.map((s) => s.file))].sort(), + staleLedgerRows: NON_ELEVATION_READS.filter((r) => !usedRows.has(r)), + scannedFiles, + }; +} + +/** + * The TEXT counts, decomposed. + * + * The page's own advice, learned the expensive way: `grep -c` over the previous + * edition answered 64 anchors where there were 111, because it counts LINES + * CARRYING a match rather than matches. So every number here says which of the + * three things it counts -- lines, identifier appearances, or syntactic roles -- + * and the page quotes them with the same wording. + */ +export function countText(root = ROOT) { + const corpus = collectCorpus(root); + const IDENT = /\bisSystem\b/g; + let linesTotal = 0; + let linesInTests = 0; + let identifierAppearances = 0; + for (const relPath of corpus) { + const body = readFileSync(join(root, relPath), 'utf8'); + if (!body.includes(FLAG)) continue; + const hits = body.split('\n').filter((l) => l.includes(FLAG)).length; + linesTotal += hits; + if (isTestPath(relPath)) linesInTests += hits; + else identifierAppearances += (body.match(IDENT) ?? []).length; + } + return { + corpusFiles: corpus.length, + linesTotal, + linesInTests, + linesInSources: linesTotal - linesInTests, + identifierAppearances, + }; +} + +/** `file:line` keys for the elevation sites -- the census's comparable form. */ +export function siteKeys(census) { + return new Set(census.sites.map((s) => `${s.file}:${s.line}`)); +} + +function main(argv) { + const census = runCensus(); + if (argv.includes('--json')) { + process.stdout.write(`${JSON.stringify(census, null, 2)}\n`); + return census.staleLedgerRows.length === 0 ? 0 : 1; + } + const roles = Object.entries(census.roleCounts) + .map(([k, v]) => `${k} ${v}`) + .join(', '); + process.stdout.write( + [ + `isystem-census: ${census.sites.length} ExecutionContext.isSystem read sites`, + ` packages ${census.packages.length} · files ${census.files.length}`, + ` identifier roles: ${roles}`, + ` subtracted as unrelated metadata fields: ${census.nonElevationReads.length}`, + ` sources scanned that mention the flag: ${census.scannedFiles}`, + ` text: lines ${census.text.linesTotal} (tests ${census.text.linesInTests}, ` + + `sources ${census.text.linesInSources}) · identifier appearances in sources ` + + `${census.text.identifierAppearances} · in comments/strings ${census.text.inCommentsAndStrings}`, + '', + ].join('\n') + ); + for (const row of census.staleLedgerRows) { + process.stderr.write( + `::error::[stale-ledger-row] NON_ELEVATION_READS names ${row.file} (receiver ` + + `\`${row.receiver}\`) but no such read exists -- delete the row.\n` + ); + } + return census.staleLedgerRows.length === 0 ? 0 : 1; +} + +if (isEntrypoint(import.meta.url)) process.exit(main(process.argv.slice(2))); From b0721f7331c53e87a8527ec2bbd4704dc8b397ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 00:48:32 +0000 Subject: [PATCH 2/6] docs+gate: census-derived enforcement for the isSystem page --- content/docs/permissions/system-context.mdx | 123 ++++++++++++-------- scripts/check-system-context-census.mjs | 98 +++++++++++++--- 2 files changed, 162 insertions(+), 59 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 026e7d19ba..4e9e1b9c8b 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -25,10 +25,14 @@ context whenever one exists. -**Line anchors are checked by hand, not by CI.** Every anchor below was -re-resolved against `origin/main` at commit `b1a987e4a`. Nothing holds them there: -an unrelated edit to the same file moves them, and the page has no mechanical -tie to the code. Mechanising that is tracked separately — see +**Every anchor and every count on this page is checked by CI.** +`scripts/check-system-context-census.mjs` re-runs the AST census over the whole +repo on each PR and refuses the page when an anchor points at a line that is no +longer the site it names, when a stated count disagrees with the census, or — +the check that matters most — when the code holds an elevation read that no row +here anchors. Pure line rot is repaired by +`node scripts/check-system-context-census.mjs --fix`; a site that arrived or +vanished is deliberately left for a person. See [Maintaining this table](#maintaining-this-table). @@ -82,13 +86,13 @@ that silently does not happen. | # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor | |:--|:---|:---|:---|:---| | 1 | **The whole security middleware short-circuits** before any gate runs | plugin-security | Get: every CRUD/FLS/tenant/owner gate below skipped in one branch. Lose: all of rows 2–6 at once — this is the single largest behaviour on the page | `security-plugin.ts:1560` | -| 2 | **`owner_id` is not auto-stamped on INSERT** (the step 3.5 anchor guard is inside the block row 1 skips) | plugin-security | Lose: the row lands `owner_id = NULL`, so the default `owner_only_writes` policy hides it **from its own creator**. Get: nothing — this is a gap, not a capability | guard at `security-plugin.ts:2483`–`2630`, skipped by `:1560` | -| 3 | Row-level read filter resolves to "no filter" | plugin-security | Get: unscoped reads. Lose: row-level scoping entirely | `security-plugin.ts:4287` | -| 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable. Lose: field masking | `security-plugin.ts:4438` | -| 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `security-plugin.ts:4516` | +| 2 | **`owner_id` is not auto-stamped on INSERT** (the step 3.5 anchor guard is inside the block row 1 skips) | plugin-security | Lose: the row lands `owner_id = NULL`, so the default `owner_only_writes` policy hides it **from its own creator**. Get: nothing — this is a gap, not a capability | guard at `security-plugin.ts:2486` (the step 3.5 block), skipped by `:1560` | +| 3 | Row-level read filter resolves to "no filter" | plugin-security | Get: unscoped reads. Lose: row-level scoping entirely | `security-plugin.ts:4290` | +| 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable. Lose: field masking | `security-plugin.ts:4441` | +| 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `security-plugin.ts:4519` | | 6 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `security-plugin.ts:1387`, `:1409` | | 7 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted | `object-schema-fls.ts:228` | -| 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3800` | +| 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3803` | | 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `anonymous-deny.ts:154` | | 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `permission-set-projection.ts:987` | | 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1251` | @@ -128,7 +132,7 @@ The largest single consumer — **20 of the 109 sites**. | 33 | `grant()` skips the enforcement + manage-shares assertions | Get: the rule evaluator can materialise through the public API. Note it is **not** a bare skip: the system branch asserts the grant is not *inert* instead (a grant on an object no verdict can consult is refused) | `plugin-sharing/src/sharing-service.ts:1179` | | 34 | `revoke()` deletes directly, **before** the non-manual-source guard | Get: the evaluator can revoke its own grants. Lose: the `CONFLICT` guard that warns a rule-materialised grant will be silently re-granted on the next reconcile | `plugin-sharing/src/sharing-service.ts:1257` (guard at `:1282`) | | 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1309` | -| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:991` | +| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1073` | | 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:413`, `:467`, `:471`, `:544`, `:574` | | 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` | | 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` | @@ -152,7 +156,7 @@ The largest single consumer — **20 of the 109 sites**. |:--|:---|:---|:---|:---| | 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` | | 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` | -| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4266`, `:5629`, `:5861`, `:6206`, `:6399` | +| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4239`, `:5602`, `:5834`, `:6179`, `:6372` | | 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` | | 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:982`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `external-datasource-routes.ts:302`, `package-routes.ts:92` | | 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` | @@ -162,7 +166,7 @@ The largest single consumer — **20 of the 109 sites**. | 57 | Automation run-state read, flow-authoring write and unrelated-screen read all pass | runtime | Get: run state, flow writes and screen reads with no grant | `domains/automation.ts:254`, `:545`, `:635` | | 58 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `suggested-audience-bindings.ts:548` | | 59 | Email-template / webhook provenance stamps skipped | plugin-email, plugin-webhooks | Lose: the row is not marked as an admin customization | `email-template-provenance.ts:59`, `webhook-provenance.ts:50` | -| 60 | **Automation flow data nodes re-add the `owner_id` stamp** (the one place row 2's gap is compensated inline) | service-automation | Get: a flow-authored INSERT under system elevation still lands owned, when the run resolved a user. Fill-only — flow-authored values win | `runtime-identity.ts:279`, called from `builtin/crud-nodes.ts:314` | +| 60 | **Automation flow data nodes re-add the `owner_id` stamp** (the one place row 2's gap is compensated inline) | service-automation | Get: a flow-authored INSERT under system elevation still lands owned, when the run resolved a user. Fill-only — flow-authored values win | `runtime-identity.ts:279`, called from `builtin/crud-nodes.ts:318` | | 61 | Inbox caller refusal names `isSystem` as what was carried | service-messaging | Get: nothing — the refusal still fires. The flag only shapes the diagnostic, because privilege is not an authorization subject | `inbox-caller.ts:148` | ### 6. Reads that only carry the flag onward @@ -229,7 +233,7 @@ should recognise it instead of re-deriving it. rule-materialised grant that the next reconcile silently restores. 5. **`applySystemFields` does not read this flag.** It is named as if it did. - `packages/objectql/src/registry.ts:450` is **schema-side column + `packages/objectql/src/registry.ts:459` is **schema-side column provisioning** — which columns an object carries — and consumes `ExecutionContext.isSystem` zero times. The write-time ownership behaviour people attribute to it is row 2, in `plugin-security`. @@ -269,39 +273,45 @@ flag. That is the pattern to follow for any new narrow exemption. ## Maintaining this table -The table's value is exhaustiveness, so it is built by census, not by recall. -To find every candidate: +The table's value is exhaustiveness, so it is built by census, not by recall — +and the census is a committed instrument rather than someone's afternoon: ```bash -grep -rn "isSystem" --include="*.ts" --include="*.tsx" packages examples \ - | grep -v node_modules | grep -v "/dist/" +node scripts/isystem-census.mjs # the summary below +node scripts/isystem-census.mjs --json # every site, role and package ``` -That command is where a census **starts**, not where it ends: it matches text, -so it also returns prose inside comments and strings, the three unrelated +**⛔ Not a `grep`.** A text scan is where a census *starts* and it cannot be where +one ends: it returns prose inside comments and strings, the three unrelated metadata fields, and the `isSystemObject` / `isSystemObjectName` / -`isSystemLedgerObject` name helpers. Classify each hit into a **consumer** of -`ExecutionContext.isSystem` (a table row), a **producer** (`isSystem: true` on a -call — not a behaviour), one of the unrelated fields, a `sys_`-prefix name -helper, a declaration, or a mention in prose. - -Measured on `origin/main` at `b1a987e4a`. A file counts as a test when its path -carries `.test.` / `.spec.` or a `tests/` / `__tests__/` / `qa/` segment: +`isSystemLedgerObject` name helpers. Worse, it loses real sites — a regex pass +over this same corpus silently dropped **6** reads in +`plugin-reports/src/report-service.ts` to a quoting desync and **11** more to +`(ctx?.session as any)?.isSystem` casts. So the census walks the TypeScript AST +and classifies each appearance of the identifier by its syntactic role: +a **read** (a table row here), a **declaration**, an object-literal or type +**key** (a producer, not a behaviour), or something else. + +Corpus: tracked `.ts` / `.tsx` / `.mts` / `.cts` under `packages/` and +`examples/`, excluding `dist/`. A file counts as a test when its path carries +`.test.` / `.spec.` or a `tests/` / `__tests__/` / `qa/` segment. Every number +below is produced by that run and held equal to it by CI: | Measurement | Count | |:---|--:| -| Lines matched by the command above | 1805 | +| Lines carrying `isSystem` in the corpus | 1804 | | — in tests | 1010 | -| — in sources | 795 | -| Appearances of the bare identifier `isSystem` in sources | 810 | -| — classified by the parser as a declaration | 20 | -| — as an object-literal / type key (producers and option objects) | 309 | -| — as a property **read** | 115 | -| — the remainder: mentions inside comments and string literals | 366 | -| Of the 115 reads: reads of one of the unrelated metadata fields | 6 | -| Of the 115 reads: reads of `ExecutionContext.isSystem` | **109** | +| — in non-test sources | 794 | +| Appearances of the bare identifier `isSystem` in non-test sources | 809 | +| — parsed as a declaration | 21 | +| — parsed as an object-literal / type key (producers and option objects) | 308 | +| — parsed as a property **read** | 115 | +| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | +| — the remainder: text inside comments and string literals | 356 | +| Of those reads: reads of one of the unrelated metadata fields | 6 | +| Of those reads: reads of `ExecutionContext.isSystem` | **109** | | — behaviour-bearing (rows 1–61 above) | 105 | -| — carry the flag onward only (rows 62–65) | 4 | +| — carry the flag onward only (rows 62–65 above) | 4 | | Packages containing at least one elevation read | **20** | | Files containing at least one elevation read | 45 | @@ -314,18 +324,39 @@ real anchor population was **111** once continuation anchors (`` `:1409` ``) and range ends are counted. Decompose a text count before comparing it to anything. -**Nothing in CI holds these anchors to the code.** A line-number anchor rots on -every unrelated edit to the same file, silently: re-resolving all 111 anchors of -the previous edition found **101 pointing at a line that no longer held what the -row named**, while only **10** were still correct — and **41 of them named a -basename that matches two files**, so they could not be resolved at all without -reading the row's Package column. This edition spells an ambiguous basename far -enough to be unique (`objectql/src/engine.ts`, not `engine.ts`). A mechanical -tie — symbol-name anchors plus a gate that resolves them — is filed separately -as **#12962**. +### What CI holds, and why it is the population and not just the anchors + +A line-number anchor rots on every unrelated edit to the same file, silently: +re-resolving all 111 anchors of the previous edition found **101 pointing at a +line that no longer held what the row named**, while only **10** were still +correct — and **41 of them named a basename that matches two files**, so they +could not be placed without reading the row's Package column. This edition +spells an ambiguous basename far enough to be unique +(`objectql/src/engine.ts`, not `engine.ts`), which is what makes an anchor +mechanically resolvable at all. + +Resolving anchors is nevertheless the *second* check, not the first. ⭐ **A gate +that only checks what the page already says can never find what the page failed +to say.** Re-resolving every anchor of the previous edition would have passed +while it was missing 32 sites and its headline was 29 too low. So the +load-bearing direction runs census → page: every elevation read in the code must +be anchored here, and a site that vanishes from the code takes the census total +with it, which is what makes a row describing a protection that no longer exists +fail. That matters because the two reads deleted during the last drift lived +inside `callerContext()` helpers that still exist under the same names — **a +symbol-name anchor would have resolved, and would have stayed green.** + +Four checks run, in `scripts/check-system-context-census.mjs`: + +| Check | What fails | +|:---|:---| +| **Population** | an elevation read in the code with no anchor here | +| **Resolution** | an anchor whose spelling matches no tracked file, matches two, or names a line the file does not have | +| **Counts** | any number above that disagrees with the census — including the count-sentence wording, so the check cannot go quietly vacuous | +| **Classification** | an anchor that is not a read site and is not a declared non-read citation | A new read of `ExecutionContext.isSystem` belongs in this table in the same PR -that introduces it. +that introduces it — CI will say so if it is not. ## Related diff --git a/scripts/check-system-context-census.mjs b/scripts/check-system-context-census.mjs index a74fc2cd24..65f8a9eb8f 100644 --- a/scripts/check-system-context-census.mjs +++ b/scripts/check-system-context-census.mjs @@ -349,6 +349,18 @@ export const DECLARED_COUNTS = [ value: (c) => c.sites.length, why: 'the decomposition table: the census answer', }, + { + id: 'table-carry-onward', + pattern: /\| — carry the flag onward only \(rows \d+–\d+ above\) \|\s*(\d+) \|/, + value: (c, page) => carryOnwardRowCount(page), + why: "the decomposition table: how many sites only propagate the flag — held to section 6's own row count", + }, + { + id: 'table-behaviour-bearing', + pattern: /\| — behaviour-bearing \(rows \d+–\d+ above\) \|\s*(\d+) \|/, + value: (c, page) => c.sites.length - carryOnwardRowCount(page), + why: 'the decomposition table: the remaining sites, derived so the two halves must sum to the census', + }, { id: 'table-packages', pattern: /\| Packages containing at least one elevation read \|\s*\*\*(\d+)\*\* \|/, @@ -375,6 +387,28 @@ export const DECLARED_COUNTS = [ }, ]; +/** + * How many numbered rows section 6 ("Reads that only carry the flag onward") has. + * + * The page splits its 109 sites into behaviour-bearing rows and carry-onward rows. + * Neither number is derivable from the census alone -- which row is which is the + * page's own editorial call -- so the split is anchored to the thing that IS + * mechanical: the size of that table. Zero rows is a refusal, not a zero: it means + * the section was renamed and the split stopped being checked. + * + * @param {string} pageText + * @returns {number} + */ +export function carryOnwardRowCount(pageText) { + const start = pageText.indexOf('### 6.'); + if (start === -1) return -1; + const rest = pageText.slice(start); + const end = rest.indexOf('\n---'); + const section = end === -1 ? rest : rest.slice(0, end); + const rows = section.split('\n').filter((line) => /^\|\s*\d+\s*\|/.test(line)); + return rows.length; +} + /** Tracked files, for anchor resolution. */ export function trackedFiles(root = ROOT) { const files = execFileSync('git', ['-C', root, 'ls-files'], { @@ -558,7 +592,14 @@ export function evaluate({ continue; } const stated = Number(match[1]); - const actual = declared.value(census); + const actual = declared.value(census, pageText); + if (!Number.isInteger(actual) || actual < 0) { + problems.push( + `[count-underivable] \`${declared.id}\` could not be derived (${declared.why}) -- the page ` + + 'structure it reads is gone. Fix the reader together with the page.' + ); + continue; + } if (stated !== actual) { problems.push( `[declared-count] \`${declared.id}\` says ${stated}, the census says ${actual} (${declared.why}).` @@ -620,24 +661,25 @@ export function fixAnchors({ pageText, census, tracked, readFile, ledger = NON_R } for (const [path, fileAnchors] of byFile) { const ledgerLines = new Set(ledgerByFile.get(path) ?? []); - const censusLines = census.sites.filter((s) => s.file === path).map((s) => s.line); - // Anchors already on a ledger line, or on a census line, keep their meaning. - const readAnchors = fileAnchors.filter( - (a) => !ledgerLines.has(a.line) && !(ledgerByFile.get(path) ?? []).includes(a.line) - ); - if (readAnchors.length !== censusLines.length) { + const censusLines = [...new Set(census.sites.filter((s) => s.file === path).map((s) => s.line))]; + // A row cites the same line more than once (`:274` appears in the table AND in + // the rough edges), so the comparable unit is a DISTINCT line, not an anchor. + const readAnchors = fileAnchors.filter((a) => !ledgerLines.has(a.line)); + const readLines = [...new Set(readAnchors.map((a) => a.line))].sort((a, b) => a - b); + if (readLines.length !== censusLines.length) { refused.push( - `${path}: page anchors ${readAnchors.length} read site(s), census finds ` + + `${path}: page anchors ${readLines.length} distinct read line(s), census finds ` + `${censusLines.length} -- the POPULATION changed, this is not a shift. A row has to be ` + 'written or deleted by hand.' ); continue; } - const sorted = [...readAnchors].sort((a, b) => a.line - b.line); const target = [...censusLines].sort((a, b) => a - b); - sorted.forEach((anchor, i) => { - if (anchor.line !== target[i]) newLine.set(anchor, target[i]); - }); + const shift = new Map(readLines.map((line, i) => [line, target[i]])); + for (const anchor of readAnchors) { + const to = shift.get(anchor.line); + if (to !== undefined && to !== anchor.line) newLine.set(anchor, to); + } } // Ledger anchors: an anchor whose file has exactly one ledger line it is nearest @@ -650,7 +692,8 @@ export function fixAnchors({ pageText, census, tracked, readFile, ledger = NON_R const orphans = fileAnchors.filter( (a) => !ledgerLines.includes(a.line) && !sites.has(`${path}:${a.line}`) && !newLine.has(a) ); - if (free.length === 1 && orphans.length === 1) newLine.set(orphans[0], free[0]); + const orphanLines = new Set(orphans.map((a) => a.line)); + if (free.length === 1 && orphanLines.size === 1) for (const a of orphans) newLine.set(a, free[0]); } // Apply, latest anchor first, so earlier offsets stay valid. @@ -897,6 +940,35 @@ function selfTest() { ) ); + const sectionPage = [ + '### 6. Reads that only carry the flag onward', + '', + '| # | Site |', + '|:--|:---|', + '| 62 | `pkg/a.ts:2` |', + '| 63 | `pkg/a.ts:2` |', + '', + '---', + '', + '| — carry the flag onward only (rows 62–63 above) | 2 |', + ].join('\n'); + t('COUNTS: the carry-onward split is read from section 6 itself', carryOnwardRowCount(sectionPage) === 2); + t('COUNTS: a renamed section 6 is underivable, not zero', carryOnwardRowCount('nothing here') === -1); + const underivable = evaluate({ + pageText: fixturePage(), + census: FIXTURE_CENSUS, + tracked: FIXTURE_TRACKED, + readFile: fixtureRead, + ledger: FIXTURE_LEDGER, + declaredCounts: [ + { id: 'x', pattern: /helper at `pkg\/a\.ts:(\d+)`/, value: () => carryOnwardRowCount('gone'), why: 'fixture' }, + ], + }); + t( + 'COUNTS: an underivable value is a finding, never compared as -1', + underivable.problems.some((p) => p.startsWith('[count-underivable]')) + ); + // ── absence is loud ──────────────────────────────────────────────────────── const noAnchors = run('---\ntitle: x\n---\n\nnothing here.\n'); t('ABSENCE: a page with no anchors refuses', noAnchors.problems.some((p) => p.startsWith('[no-anchors]'))); From 57cf19e6af4311c01bc62276dc13360dabcd6a52 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 00:49:46 +0000 Subject: [PATCH 3/6] ci: fold the isSystem census gate into the docs-anchors step --- .github/workflows/lint.yml | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 4b46b99ce4..e33435904d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1297,8 +1297,39 @@ jobs: # dependency-free `node` checks), and it is REQUIRED where that lane is # advisory. The four anchors that were already dead were fixed in the same # PR, so this ships with no baseline and no allowlist. + # + # ── FOLDED IN, deliberately: the `file:line` anchor half ────────────── + # + # `content/docs/permissions/system-context.mdx` calls itself "the + # authority" for every behaviour keyed off `ExecutionContext.isSystem` + # and says it is "built by census over the whole repo, not by recall". + # Nothing held it to either claim, and it rotted at a measured rate: 101 + # of its 111 anchors pointed at a line that no longer held what the row + # named, 19 days after the census was written, with CI green throughout. + # + # ⭐ The load-bearing direction is CENSUS -> PAGE, not page -> code. At + # the commit that motivated the card, re-resolving EVERY anchor on the + # page would have passed while it was missing 32 sites and its headline + # was 29 too low: a gate that only checks what a page already says can + # never find what the page failed to say. Measured again here on the + # shipped gate -- with an unanchored read site injected, the resolution + # checks report 0 findings and the population check names the site. + # + # It is a second command in THIS step rather than a step of its own: this + # job has no paths filter, so the census runs on code-only PRs (the ones + # that ADD a read site), and folding costs no new check context, no new + # `check:*` manifest key, and no new required-status entry for one page. + # The `--self-test` invocation is what `check:self-test-wired` requires, + # and it is the only instrument on this gate's matching rules -- a clean + # tree cannot tell a working rule from a weakened one. + # + # Invoked as `node` rather than through a `pnpm check:*` alias: see the + # GATE INVOCATION IDIOM note at the top of this file. - name: Docs anchors resolve to real headings - run: pnpm check:doc-anchors + run: | + pnpm check:doc-anchors + node scripts/check-system-context-census.mjs --self-test + node scripts/check-system-context-census.mjs # #12236 one

per doc page. `DocsTitle` renders the frontmatter `title` # as the page's

unconditionally (apps/docs/app/[lang]/docs/[[...slug]]/ From 687ed07a842a045ed855770dd3dd370435e4a147 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:02:00 +0000 Subject: [PATCH 4/6] docs(system-context): reword the reserved word out of the census prose --- content/docs/permissions/system-context.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 4e9e1b9c8b..ca28562ee1 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -278,7 +278,7 @@ and the census is a committed instrument rather than someone's afternoon: ```bash node scripts/isystem-census.mjs # the summary below -node scripts/isystem-census.mjs --json # every site, role and package +node scripts/isystem-census.mjs --json # every site, its kind and its package ``` **⛔ Not a `grep`.** A text scan is where a census *starts* and it cannot be where @@ -288,7 +288,7 @@ metadata fields, and the `isSystemObject` / `isSystemObjectName` / over this same corpus silently dropped **6** reads in `plugin-reports/src/report-service.ts` to a quoting desync and **11** more to `(ctx?.session as any)?.isSystem` casts. So the census walks the TypeScript AST -and classifies each appearance of the identifier by its syntactic role: +and classifies each appearance of the identifier by where the parser puts it: a **read** (a table row here), a **declaration**, an object-literal or type **key** (a producer, not a behaviour), or something else. From 3128edb8df9cc6238ad23bf9b39b1f88b1beb941 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 04:32:53 +0000 Subject: [PATCH 5/6] docs(system-context): re-derive the census figures on the merged tree `main` moved under the open PR and the page's live numbers went stale: * +5 line rot in `plugin-security/src/security-plugin.ts` (the import block #13065 added at :66 pushed every anchored read down five lines), plus the `field.zod.ts` `readonly` citation `--fix` re-anchored mechanically. * Six decomposition-table counts re-measured against the merged tree: lines 1804->1810, in tests 1010->1012, in sources 794->798, identifier appearances 809->813, keys 308->310, comments/strings 356->358. The census population itself did NOT move: 109 elevation read sites in 20 packages across 45 files, before and after. The gate, its criterion and its self-test are untouched -- the page's numbers were made true, not the check made lenient. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CPrUz21stTFhJRUirdc4yw --- content/docs/permissions/system-context.mdx | 28 ++++++++++----------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index ca28562ee1..16bae7f194 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -85,14 +85,14 @@ that silently does not happen. | # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor | |:--|:---|:---|:---|:---| -| 1 | **The whole security middleware short-circuits** before any gate runs | plugin-security | Get: every CRUD/FLS/tenant/owner gate below skipped in one branch. Lose: all of rows 2–6 at once — this is the single largest behaviour on the page | `security-plugin.ts:1560` | -| 2 | **`owner_id` is not auto-stamped on INSERT** (the step 3.5 anchor guard is inside the block row 1 skips) | plugin-security | Lose: the row lands `owner_id = NULL`, so the default `owner_only_writes` policy hides it **from its own creator**. Get: nothing — this is a gap, not a capability | guard at `security-plugin.ts:2486` (the step 3.5 block), skipped by `:1560` | -| 3 | Row-level read filter resolves to "no filter" | plugin-security | Get: unscoped reads. Lose: row-level scoping entirely | `security-plugin.ts:4290` | -| 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable. Lose: field masking | `security-plugin.ts:4441` | -| 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `security-plugin.ts:4519` | -| 6 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `security-plugin.ts:1387`, `:1409` | +| 1 | **The whole security middleware short-circuits** before any gate runs | plugin-security | Get: every CRUD/FLS/tenant/owner gate below skipped in one branch. Lose: all of rows 2–6 at once — this is the single largest behaviour on the page | `security-plugin.ts:1565` | +| 2 | **`owner_id` is not auto-stamped on INSERT** (the step 3.5 anchor guard is inside the block row 1 skips) | plugin-security | Lose: the row lands `owner_id = NULL`, so the default `owner_only_writes` policy hides it **from its own creator**. Get: nothing — this is a gap, not a capability | guard at `security-plugin.ts:2491` (the step 3.5 block), skipped by `:1565` | +| 3 | Row-level read filter resolves to "no filter" | plugin-security | Get: unscoped reads. Lose: row-level scoping entirely | `security-plugin.ts:4295` | +| 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable. Lose: field masking | `security-plugin.ts:4446` | +| 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `security-plugin.ts:4524` | +| 6 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `security-plugin.ts:1392`, `:1414` | | 7 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted | `object-schema-fls.ts:228` | -| 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3803` | +| 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3808` | | 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `anonymous-deny.ts:154` | | 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `permission-set-projection.ts:987` | | 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1251` | @@ -194,7 +194,7 @@ assuming `isSystem` covers it is a documented source of bugs. | "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1909` (rationale at `:1819`–`1821`, #3760), `flow.zod.ts:685` | | "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) | | "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9377`–`9394` | -| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1436` (#3493 / #6640) | +| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1440` (#3493 / #6640) | | "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` | | "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:262` | | "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1234`, `:1263`; `domains/actions.ts:404` | @@ -299,15 +299,15 @@ below is produced by that run and held equal to it by CI: | Measurement | Count | |:---|--:| -| Lines carrying `isSystem` in the corpus | 1804 | -| — in tests | 1010 | -| — in non-test sources | 794 | -| Appearances of the bare identifier `isSystem` in non-test sources | 809 | +| Lines carrying `isSystem` in the corpus | 1810 | +| — in tests | 1012 | +| — in non-test sources | 798 | +| Appearances of the bare identifier `isSystem` in non-test sources | 813 | | — parsed as a declaration | 21 | -| — parsed as an object-literal / type key (producers and option objects) | 308 | +| — parsed as an object-literal / type key (producers and option objects) | 310 | | — parsed as a property **read** | 115 | | — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | -| — the remainder: text inside comments and string literals | 356 | +| — the remainder: text inside comments and string literals | 358 | | Of those reads: reads of one of the unrelated metadata fields | 6 | | Of those reads: reads of `ExecutionContext.isSystem` | **109** | | — behaviour-bearing (rows 1–61 above) | 105 | From 649295cc0d180d1cb0c34dc33a0d81b94f1d7493 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 06:13:33 +0000 Subject: [PATCH 6/6] fix(scripts): stop enforcing whole-corpus text counts on the isSystem census page The six raw text counts (lines carrying the identifier, the test/source split, identifier appearances, object-literal keys, the prose remainder) are properties of the whole corpus, not of the elevation population the page certifies. They move whenever any file under packages/ or examples/ gains or loses a line mentioning isSystem -- a test, a seed object, a comment -- and because CI scores a PR's MERGE with main and the merge queue re-derives that merge against a newer main on every attempt, a page carrying them races a moving target. Measured on this branch: three unrelated merges to main moved those six numbers eight times in one night, while every census-derived figure held flat across the same refs -- 109 sites, 20 packages, 45 files, 6 ledger subtractions, 21/115/9 role counts, at db39dfc1c9, 8a483b38b8, ca1965f2b5 and the merged tree. So they move out of DECLARED_COUNTS into UNENFORCED_TEXT_COUNTS: still required to be PRESENT on the page and to carry a dated measurement marker, never compared. The POPULATION, RESOLUTION and CLASSIFICATION checks and every census-derived count are untouched, and an empty census still refuses. Seven self-test cases pin the new criterion, two of them over the real lists: every entry of DECLARED_COUNTS must hold still under a whole-corpus text drift, and all six UNENFORCED_TEXT_COUNTS must move under it -- so re-adding a text count to the enforced list fails the self-test by name. Also re-anchors permission-set-projection.ts:987 to :1009 (pure line rot from today's main, repaired by --fix). --- content/docs/permissions/system-context.mdx | 71 ++-- scripts/check-system-context-census.mjs | 338 +++++++++++++++++--- 2 files changed, 342 insertions(+), 67 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 16bae7f194..b563d952d7 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -25,12 +25,14 @@ context whenever one exists. -**Every anchor and every count on this page is checked by CI.** +**Every anchor, and every count about the census population, is checked by CI.** `scripts/check-system-context-census.mjs` re-runs the AST census over the whole repo on each PR and refuses the page when an anchor points at a line that is no -longer the site it names, when a stated count disagrees with the census, or — -the check that matters most — when the code holds an elevation read that no row -here anchors. Pure line rot is repaired by +longer the site it names, when a stated count about the population disagrees +with the census, or — the check that matters most — when the code holds an +elevation read that no row here anchors. Six raw text counts in +[Maintaining this table](#maintaining-this-table) are deliberately **not** +enforced, and say there when they were measured. Pure line rot is repaired by `node scripts/check-system-context-census.mjs --fix`; a site that arrived or vanished is deliberately left for a person. See [Maintaining this table](#maintaining-this-table). @@ -94,7 +96,7 @@ that silently does not happen. | 7 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted | `object-schema-fls.ts:228` | | 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3808` | | 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `anonymous-deny.ts:154` | -| 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `permission-set-projection.ts:987` | +| 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `permission-set-projection.ts:1009` | | 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1251` | | 12 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `perf-timing.ts:474` | | 13 | Permission-set **overlay discard** skips the tenant-admin assertion | plugin-security | Get: an overlay can be discarded with no authenticated tenant administrator | `permission-set-overlay-discard.ts:113` | @@ -295,25 +297,44 @@ a **read** (a table row here), a **declaration**, an object-literal or type Corpus: tracked `.ts` / `.tsx` / `.mts` / `.cts` under `packages/` and `examples/`, excluding `dist/`. A file counts as a test when its path carries `.test.` / `.spec.` or a `tests/` / `__tests__/` / `qa/` segment. Every number -below is produced by that run and held equal to it by CI: - -| Measurement | Count | -|:---|--:| -| Lines carrying `isSystem` in the corpus | 1810 | -| — in tests | 1012 | -| — in non-test sources | 798 | -| Appearances of the bare identifier `isSystem` in non-test sources | 813 | -| — parsed as a declaration | 21 | -| — parsed as an object-literal / type key (producers and option objects) | 310 | -| — parsed as a property **read** | 115 | -| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | -| — the remainder: text inside comments and string literals | 358 | -| Of those reads: reads of one of the unrelated metadata fields | 6 | -| Of those reads: reads of `ExecutionContext.isSystem` | **109** | -| — behaviour-bearing (rows 1–61 above) | 105 | -| — carry the flag onward only (rows 62–65 above) | 4 | -| Packages containing at least one elevation read | **20** | -| Files containing at least one elevation read | 45 | +below was produced by that run. The **CI** column says which of them a check +still holds equal to the census on every pull request: + +| Measurement | Count | CI | +|:---|--:|:--:| +| Lines carrying `isSystem` in the corpus | 1811 | — | +| — in tests | 1013 | — | +| — in non-test sources | 798 | — | +| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — | +| — parsed as a declaration | 21 | ✅ | +| — parsed as an object-literal / type key (producers and option objects) | 310 | — | +| — parsed as a property **read** | 115 | ✅ | +| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ | +| — the remainder: text inside comments and string literals | 358 | — | +| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ | +| Of those reads: reads of `ExecutionContext.isSystem` | **109** | ✅ | +| — behaviour-bearing (rows 1–61 above) | 105 | ✅ | +| — carry the flag onward only (rows 62–65 above) | 4 | ✅ | +| Packages containing at least one elevation read | **20** | ✅ | +| Files containing at least one elevation read | 45 | ✅ | + +The six rows marked — are a **dated decomposition, not a live claim**: they were +measured on 2026-08-29 at `ca1965f2b5` and CI does not re-derive them. They count +raw *text* over the whole corpus — every line, identifier appearance, +object-literal key and prose mention of the string `isSystem` under `packages/` +and `examples/`, tests included — which is a different population from the one +this page certifies, and it churns for reasons that have nothing to do with +elevation: a test that mentions the flag, a seed object carrying +`isSystem: true`, a comment. Enforcing them made this page's own pull request +un-landable, because CI scores a **merge** with `main` and the merge queue +re-derives that merge against a newer `main` on every attempt: three unrelated +merges moved these six numbers eight times in one night, while the census +population — 109 sites in 20 packages across 45 files — did not move once. They +are kept because they are how you get from a raw `grep` to 109, which is the +whole argument of this section; they are unenforced because a count of lines in +test files certifies nothing. ⛔ Do not re-add them to `DECLARED_COUNTS` — a +self-test case in the gate refuses that by name. Re-measure them with +`node scripts/isystem-census.mjs` when you want them current, and move the date. Counting by hand is what made the previous edition wrong in two independent ways, so both are worth naming. Its headline said "80 distinct sites across 18 @@ -352,7 +373,7 @@ Four checks run, in `scripts/check-system-context-census.mjs`: |:---|:---| | **Population** | an elevation read in the code with no anchor here | | **Resolution** | an anchor whose spelling matches no tracked file, matches two, or names a line the file does not have | -| **Counts** | any number above that disagrees with the census — including the count-sentence wording, so the check cannot go quietly vacuous | +| **Counts** | any **census-derived** number above that disagrees with the census — including the count-sentence wording, so the check cannot go quietly vacuous. The six raw text counts are exempt by design, but their rows must still be present and dated | | **Classification** | an anchor that is not a read site and is not a declared non-read citation | A new read of `ExecutionContext.isSystem` belongs in this table in the same PR diff --git a/scripts/check-system-context-census.mjs b/scripts/check-system-context-census.mjs index 65f8a9eb8f..e05a5902c7 100644 --- a/scripts/check-system-context-census.mjs +++ b/scripts/check-system-context-census.mjs @@ -49,12 +49,40 @@ * row's prose. * B POPULATION every elevation read site the census finds is anchored at its * exact `file:line`. Zero omissions. ⭐ This is the mandatory one. - * C COUNTS every number the page states about the current tree equals the - * census. A pattern that matches NOTHING is an error, so a - * reworded page cannot silently stop being checked. + * C COUNTS every CENSUS-DERIVED number the page states equals the census. + * A pattern that matches NOTHING is an error, so a reworded page + * cannot silently stop being checked. The page's whole-corpus + * TEXT counts are deliberately NOT compared -- see the next + * section -- but they are still required to be present and dated. * D CLASSIFICATION an anchor that is not a read site must be a declared * `NON_READ_ANCHORS` row, and that row must still locate the line. * + * ## ⭐ What is enforced, and why the text decomposition is NOT + * + * The page carries two kinds of number and they behave nothing alike. + * + * CENSUS-DERIVED properties of the population this page certifies: the + * elevation read sites, their packages and files, the total + * property reads the census subtracts from, the documented + * collision subtraction, and the page's own split of the sites + * into behaviour-bearing and carry-onward rows. These move + * only when the elevation contract moves -- which is precisely + * when this page must be edited anyway. ⭐ ALL of these stay + * enforced. + * + * WHOLE-CORPUS TEXT how many LINES carry the string `isSystem` anywhere under + * `packages/` and `examples/` (tests included), how many times + * the bare identifier appears, how many of those the parser + * puts in an object-literal key, and the prose remainder. + * ⛔ NOT enforced: `UNENFORCED_TEXT_COUNTS` carries the six, + * with the measurement that moved them there. + * + * The split is not a tolerance. Nothing about the CONTRACT stopped being checked: + * a read site with no row, a deleted site, a rotted anchor and an empty census all + * still fail. What stopped being checked is a set of numbers about a population + * the page does not certify -- and whose churn, measured, was blocking the page + * from ever landing. + * * ## Why `NON_READ_ANCHORS` carries needles instead of line numbers * * 28 of the page's anchors are deliberately not read sites: the four unrelated @@ -81,8 +109,9 @@ * ## Refusals, never quiet passes (#4690) * * A page that cannot be read, a census with no sites, zero anchors found, a corpus - * of zero files, a declared-count pattern that matches nothing, and a ledger row - * that locates nothing are all exit 1 naming what could not be read. + * of zero files, a declared-count pattern that matches nothing, an UNENFORCED row + * that vanished or lost its date, and a ledger row that locates nothing are all + * exit 1 naming what could not be read. */ import { readFileSync, writeFileSync } from 'node:fs'; @@ -253,6 +282,13 @@ export const NON_READ_ANCHORS = [ * packages"). Those are history, they are true of a tree that no longer exists, and * a gate that "corrected" them would be rewriting the record. * + * ⛔ Also deliberately excluded, and for a different reason: the six WHOLE-CORPUS + * TEXT counts, which live in `UNENFORCED_TEXT_COUNTS` below. Everything that + * remains here is CENSUS-DERIVED -- it changes only when the elevation population + * changes. ⭐ That invariant is pinned by a self-test case (`CRITERION`), which + * drifts a fixture census's text figures and requires every entry in this list to + * hold still: re-add a text count here and the self-test names it. + * * `pattern` must have exactly one capture group -- the number -- and must match at * least once. A pattern that matches nothing is an ERROR: it means the page was * reworded out from under the check, which is how a counts gate goes quietly @@ -283,42 +319,12 @@ export const DECLARED_COUNTS = [ value: (c) => c.sites.length, why: 'the denominator of the same claim', }, - { - id: 'table-lines-total', - pattern: /\| Lines carrying `isSystem` in the corpus \|\s*(\d+) \|/, - value: (c) => c.text.linesTotal, - why: 'the decomposition table: text lines, tests included', - }, - { - id: 'table-lines-tests', - pattern: /\| — in tests \|\s*(\d+) \|/, - value: (c) => c.text.linesInTests, - why: 'the decomposition table: text lines in tests', - }, - { - id: 'table-lines-sources', - pattern: /\| — in non-test sources \|\s*(\d+) \|/, - value: (c) => c.text.linesInSources, - why: 'the decomposition table: text lines in sources', - }, - { - id: 'table-appearances', - pattern: /\| Appearances of the bare identifier `isSystem` in non-test sources \|\s*(\d+) \|/, - value: (c) => c.text.identifierAppearances, - why: 'the decomposition table: identifier appearances', - }, { id: 'table-declarations', pattern: /\| — parsed as a declaration \|\s*(\d+) \|/, value: (c) => c.roleCounts.declaration, why: 'the decomposition table: declarations', }, - { - id: 'table-keys', - pattern: /\| — parsed as an object-literal \/ type key[^|]*\|\s*(\d+) \|/, - value: (c) => c.roleCounts.key, - why: 'the decomposition table: producers and option objects', - }, { id: 'table-reads', pattern: /\| — parsed as a property \*\*read\*\* \|\s*(\d+) \|/, @@ -331,12 +337,6 @@ export const DECLARED_COUNTS = [ value: (c) => c.roleCounts.other, why: 'the decomposition table: everything else the parser saw', }, - { - id: 'table-prose', - pattern: /\| — the remainder: text inside comments and string literals \|\s*(\d+) \|/, - value: (c) => c.text.inCommentsAndStrings, - why: 'the decomposition table: the prose remainder', - }, { id: 'table-unrelated-reads', pattern: /\| Of those reads: reads of one of the unrelated metadata fields \|\s*(\d+) \|/, @@ -387,6 +387,107 @@ export const DECLARED_COUNTS = [ }, ]; +/** + * ⛔ The six numbers this gate deliberately does NOT hold to the census, listed + * here so that stays a decision instead of an omission. + * + * Each one counts WHOLE-CORPUS TEXT: lines carrying the string `isSystem` + * anywhere under `packages/` and `examples/` (tests included), appearances of the + * bare identifier in non-test sources, the object-literal keys among them, and the + * prose remainder. None of them is a property of the elevation contract the page + * certifies -- a test that mentions the flag, a seed object carrying + * `isSystem: true`, or a comment moves them. + * + * ## Why they are here rather than in `DECLARED_COUNTS`, measured + * + * They were enforced, and enforcement did not survive contact with the repo. CI + * scores a pull request's MERGE with `main`, and the merge queue re-derives that + * merge against a NEWER `main` on every attempt -- so a page carrying whole-corpus + * text counts races a target that moves roughly eighteen times a working day, and + * can be reddened by a merge it never touched. Measured on this page's own branch, + * over one night, by three unrelated merges to `main`: + * + * base 8cb96ec41b34 -> db39dfc1c9 linesTotal 1804 -> 1810, linesInTests 1010 -> + * 1012, linesInSources 794 -> 798, appearances + * 809 -> 813, keys 308 -> 310, prose 356 -> 358 + * db39dfc1c9 -> 8a483b38b8 linesTotal 1810 -> 1811, linesInTests 1012 -> + * 1013 + * + * ⭐ And the control, over the SAME refs and the same corpus: every census-derived + * figure held flat -- 109 sites, 20 packages, 45 files, 6 ledger subtractions, and + * the role counts 21 declarations / 115 reads / 9 other -- across db39dfc1c9, + * 8a483b38b8, ca1965f2b5 and the merged tree. The population did not move once + * while the text counts moved eight times. That is the whole argument: the + * enforced set is the one that changes when the CONTRACT changes. + * + * ## ⛔ What this is NOT + * + * It is not a tolerance and it is not a narrowing of the contract. The POPULATION + * check (every elevation read must be anchored), the RESOLUTION check, the + * CLASSIFICATION check and the census-derived counts are untouched, and an empty + * census still refuses. What narrows is the set of numbers the page DECLARES about + * a population it does not certify. + * + * Each row is still required to MATCH: a pattern that stops matching is an error + * exactly as it is for an enforced count, so the rows cannot be reworded off the + * page and quietly disappear. What is dropped is only the comparison. + */ +export const UNENFORCED_TEXT_COUNTS = [ + { + id: 'table-lines-total', + pattern: /\| Lines carrying `isSystem` in the corpus \|\s*(\d+) \|/, + value: (c) => c.text.linesTotal, + why: 'the decomposition table: text lines, tests included', + }, + { + id: 'table-lines-tests', + pattern: /\| — in tests \|\s*(\d+) \|/, + value: (c) => c.text.linesInTests, + why: 'the decomposition table: text lines in tests', + }, + { + id: 'table-lines-sources', + pattern: /\| — in non-test sources \|\s*(\d+) \|/, + value: (c) => c.text.linesInSources, + why: 'the decomposition table: text lines in sources', + }, + { + id: 'table-appearances', + pattern: /\| Appearances of the bare identifier `isSystem` in non-test sources \|\s*(\d+) \|/, + value: (c) => c.text.identifierAppearances, + why: 'the decomposition table: identifier appearances', + }, + { + id: 'table-keys', + pattern: /\| — parsed as an object-literal \/ type key[^|]*\|\s*(\d+) \|/, + value: (c) => c.roleCounts.key, + why: 'the decomposition table: producers and option objects', + }, + { + id: 'table-prose', + pattern: /\| — the remainder: text inside comments and string literals \|\s*(\d+) \|/, + value: (c) => c.text.inCommentsAndStrings, + why: 'the decomposition table: the prose remainder', + }, +]; + +/** + * The page must DATE its unenforced decomposition, and the gate holds it to that. + * + * ⚠️ A number nothing enforces rots silently -- which is the disease this whole + * page exists to treat, one level down. Six bare numbers that read as current and + * are checked by nothing would be a worse page than six numbers that say when they + * were true. So the marker is required: no marker, no unenforced table. + * + * ⛔ The DATE and the REF are deliberately not compared to anything. Requiring + * them to be recent would re-introduce exactly the churn this split removes; their + * job is to tell a reader how old the numbers are, not to be fresh. + */ +export const UNENFORCED_MEASURED_AT = { + pattern: /measured on (\d{4}-\d{2}-\d{2}) at `([0-9a-f]{7,40})`/, + why: 'the dated marker on the unenforced decomposition', +}; + /** * How many numbered rows section 6 ("Reads that only carry the flag onward") has. * @@ -475,6 +576,8 @@ export function evaluate({ readFile, ledger = NON_READ_ANCHORS, declaredCounts = DECLARED_COUNTS, + unenforcedCounts = UNENFORCED_TEXT_COUNTS, + measuredAt = UNENFORCED_MEASURED_AT, }) { const problems = []; @@ -607,6 +710,30 @@ export function evaluate({ } } + // ── C2. THE UNENFORCED DECOMPOSITION: present and dated, never compared ────── + // ⛔ The values below are NOT checked against the census -- see + // UNENFORCED_TEXT_COUNTS for the measurement that put them there. What IS + // checked is that the rows still exist and still say when they were true, so + // "not enforced" cannot decay into "not there" or "undated". + for (const row of unenforcedCounts) { + if (!row.pattern.exec(pageText)) { + problems.push( + `[unenforced-count-missing] the page no longer carries the \`${row.id}\` row ` + + `(${row.why}). It is deliberately not held to the census, but it is still ` + + 'required to be there -- delete it from UNENFORCED_TEXT_COUNTS if it is ' + + 'really gone, rather than leaving a row nobody can find.' + ); + } + } + if (unenforcedCounts.length > 0 && measuredAt && !measuredAt.pattern.exec(pageText)) { + problems.push( + `[unenforced-counts-undated] the page states ${unenforcedCounts.length} number(s) this gate ` + + 'does not enforce and no longer says when they were measured ' + + `(${measuredAt.why}). An unenforced number without a date reads as current ` + + 'and is checked by nothing -- restore the marker or delete the numbers.' + ); + } + return { problems, stats: { @@ -830,13 +957,37 @@ function fixturePage({ anchor = 'pkg/a.ts:2', helper = 'pkg/a.ts:7' } = {}) { ].join('\n'); } +/** + * The page's UNENFORCED decomposition, as a fixture: the six rows plus the dated + * marker. Every knob is a way the page could decay -- a number going stale, a row + * reworded away, the date dropped -- so the criterion can be driven from both sides. + */ +function fixtureUnenforcedTable({ linesTotal = 6, dropTestsRow = false, dated = true } = {}) { + return [ + '', + '| Measurement | Count | CI |', + '|:---|--:|:--:|', + '| Lines carrying `isSystem` in the corpus | ' + linesTotal + ' | — |', + ...(dropTestsRow ? [] : ['| — in tests | 1 | — |']), + '| — in non-test sources | 5 | — |', + '| Appearances of the bare identifier `isSystem` in non-test sources | 5 | — |', + '| — parsed as an object-literal / type key (producers) | 1 | — |', + '| — the remainder: text inside comments and string literals | 2 | — |', + '', + dated + ? 'The rows marked — were measured on 2026-08-29 at `ca1965f2b5` and are not enforced.' + : 'The rows marked — are not enforced.', + '', + ].join('\n'); +} + function selfTest() { let failures = 0; const t = (name, ok, detail = '') => { if (!ok) failures += 1; process.stdout.write(`${ok ? ' ok ' : ' FAIL'} ${name}${detail ? ` -- ${detail}` : ''}\n`); }; - const run = (page, census = FIXTURE_CENSUS, declaredCounts = []) => + const run = (page, census = FIXTURE_CENSUS, declaredCounts = [], unenforcedCounts = []) => evaluate({ pageText: page, census, @@ -844,6 +995,8 @@ function selfTest() { readFile: fixtureRead, ledger: FIXTURE_LEDGER, declaredCounts, + unenforcedCounts, + measuredAt: UNENFORCED_MEASURED_AT, }); // ── the GREEN control: a page that is correct ─────────────────────────────── @@ -896,6 +1049,7 @@ function selfTest() { tracked: FIXTURE_TRACKED, readFile: fixtureRead, ledger: [{ file: 'pkg/a.ts', needle: 'no such text anywhere', why: 'x' }], + unenforcedCounts: [], declaredCounts: [], }); t('LEDGER: a needle that matches nothing is a finding', ledgerStale.problems.some((p) => p.startsWith('[ledger-stale]'))); @@ -905,6 +1059,7 @@ function selfTest() { tracked: FIXTURE_TRACKED, readFile: fixtureRead, ledger: [{ file: 'pkg/a.ts', needle: 'return', why: 'x' }], + unenforcedCounts: [], declaredCounts: [], }); t('LEDGER: a needle matching two lines is a finding', ledgerAmbig.problems.some((p) => p.startsWith('[ledger-ambiguous]'))); @@ -914,6 +1069,7 @@ function selfTest() { tracked: FIXTURE_TRACKED, readFile: fixtureRead, ledger: FIXTURE_LEDGER, + unenforcedCounts: [], declaredCounts: [], }); t('LEDGER: a row no anchor uses is a finding', ledgerUnused.problems.some((p) => p.startsWith('[ledger-row-unused]'))); @@ -960,6 +1116,7 @@ function selfTest() { tracked: FIXTURE_TRACKED, readFile: fixtureRead, ledger: FIXTURE_LEDGER, + unenforcedCounts: [], declaredCounts: [ { id: 'x', pattern: /helper at `pkg\/a\.ts:(\d+)`/, value: () => carryOnwardRowCount('gone'), why: 'fixture' }, ], @@ -969,6 +1126,103 @@ function selfTest() { underivable.problems.some((p) => p.startsWith('[count-underivable]')) ); + // ── ⭐ CRITERION: enforced means CENSUS-DERIVED, pinned over the REAL lists ── + // + // The drift below is what an unrelated merge to `main` does to this repo: one + // test line that mentions the flag, one non-test source line carrying an + // `isSystem: true` key, one comment that names it. Nothing about the elevation + // population changes -- same sites, same packages, same files, same ledger. + // + // ⭐ These two cases run over `DECLARED_COUNTS` and `UNENFORCED_TEXT_COUNTS` + // THEMSELVES, not over a fixture stand-in. That is the point: move a text count + // back into the enforced list and the first case names it by id. A criterion + // change with nothing watching it is how the next reader undoes it. + const textDrifted = { + ...FIXTURE_CENSUS, + roleCounts: { ...FIXTURE_CENSUS.roleCounts, key: FIXTURE_CENSUS.roleCounts.key + 1 }, + text: { linesTotal: 6, linesInTests: 1, linesInSources: 5, identifierAppearances: 5, classified: 3, inCommentsAndStrings: 2 }, + }; + const driftPage = fixturePage(); + const driftedEnforced = DECLARED_COUNTS.filter( + (d) => d.value(FIXTURE_CENSUS, driftPage) !== d.value(textDrifted, driftPage) + ); + t( + 'CRITERION: every ENFORCED count holds still under whole-corpus text drift', + driftedEnforced.length === 0, + driftedEnforced.map((d) => d.id).join(', ') + ); + const stuckUnenforced = UNENFORCED_TEXT_COUNTS.filter( + (d) => d.value(FIXTURE_CENSUS, driftPage) === d.value(textDrifted, driftPage) + ); + t( + 'CRITERION: all six UNENFORCED text counts DO move under that same drift', + UNENFORCED_TEXT_COUNTS.length === 6 && stuckUnenforced.length === 0, + `${UNENFORCED_TEXT_COUNTS.length} row(s); unmoved: ${stuckUnenforced.map((d) => d.id).join(', ')}` + ); + + // ── the same criterion, behaviourally, on one page ────────────────────────── + const countSentence = '\nit is a single boolean read at **1\ndistinct sites across 1 packages**.\n'; + const okPage = fixturePage() + countSentence; + const staleText = run( + okPage + fixtureUnenforcedTable({ linesTotal: 999 }), + textDrifted, + FIXTURE_COUNTS, + UNENFORCED_TEXT_COUNTS + ); + t( + 'CRITERION: a stale whole-corpus text count is NOT a finding', + staleText.problems.length === 0, + staleText.problems.join(' | ') + ); + const rowGone = run( + okPage + fixtureUnenforcedTable({ dropTestsRow: true }), + textDrifted, + FIXTURE_COUNTS, + UNENFORCED_TEXT_COUNTS + ); + t( + 'CRITERION: an unenforced row reworded off the page IS a finding', + rowGone.problems.some((p) => p.startsWith('[unenforced-count-missing]') && p.includes('`table-lines-tests`')), + rowGone.problems.join(' | ') + ); + const undated = run( + okPage + fixtureUnenforcedTable({ dated: false }), + textDrifted, + FIXTURE_COUNTS, + UNENFORCED_TEXT_COUNTS + ); + t( + 'CRITERION: unenforced numbers with no date are a finding, not a quiet pass', + undated.problems.some((p) => p.startsWith('[unenforced-counts-undated]')), + undated.problems.join(' | ') + ); + + // ── ⛔ and the half that must NOT have moved: the contract still reds ──────── + const rottedToo = run( + fixturePage({ anchor: 'pkg/a.ts:4' }) + countSentence + fixtureUnenforcedTable({ linesTotal: 999 }), + FIXTURE_CENSUS, + FIXTURE_COUNTS, + UNENFORCED_TEXT_COUNTS + ); + t( + 'CRITERION: a rotted ANCHOR still reds on the very page whose text counts are stale', + rottedToo.problems.some((p) => p.startsWith('[site-without-a-row]')) && + rottedToo.problems.some((p) => p.startsWith('[anchor-is-not-a-read-site]')), + rottedToo.problems.join(' | ') + ); + const grewToo = run( + okPage + fixtureUnenforcedTable({ linesTotal: 999 }), + arrived, + FIXTURE_COUNTS, + UNENFORCED_TEXT_COUNTS + ); + t( + 'CRITERION: a POPULATION change still reds on that same page', + grewToo.problems.some((p) => p.startsWith('[site-without-a-row] pkg/a.ts:4')) && + grewToo.problems.some((p) => p.includes('`headline-sites` says 1, the census says 2')), + grewToo.problems.join(' | ') + ); + // ── absence is loud ──────────────────────────────────────────────────────── const noAnchors = run('---\ntitle: x\n---\n\nnothing here.\n'); t('ABSENCE: a page with no anchors refuses', noAnchors.problems.some((p) => p.startsWith('[no-anchors]')));