From 35c7c195d05d1c7a62410842aec7469cb7e0b77d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 13:15:24 +0000 Subject: [PATCH 1/2] feat(spec): make a liveness citation name the property it is evidence for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existence check (#5623) and the line bound (#11210) both bound a citation from the outside. Between them a consumer can move WITHIN the file it is cited to, leaving the file present and every named line in range while the pointer is wrong. Measured before switching anything on: 403 (entry, cited local file) pairs, 11 where the cited file never names the key, 7 of them real rot — all repaired here. The remaining 4 are the mandated camelCase -> snake_case convention; three are folded structurally by the matcher and the one compound child-key remap is a single explicit row in a shrink-only baseline that fails in both directions. Asked of `evidence` only, never `producer`: a producer cites who supplies a second input (#4837), a call site that need not name the key. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NDGG54XF5gbTLdQzCtnaVV --- .changeset/liveness-citation-key-mention.md | 50 +++++ packages/spec/liveness/action.json | 21 +- packages/spec/liveness/field.json | 5 +- packages/spec/liveness/object.json | 4 +- packages/spec/liveness/permission.json | 4 +- .../spec/scripts/liveness/check-liveness.mts | 103 ++++++++- .../liveness/key-mention.baseline.json | 14 ++ .../spec/scripts/liveness/key-mention.mts | 211 ++++++++++++++++++ .../spec/scripts/liveness/key-mention.test.ts | 171 ++++++++++++++ 9 files changed, 563 insertions(+), 20 deletions(-) create mode 100644 .changeset/liveness-citation-key-mention.md create mode 100644 packages/spec/scripts/liveness/key-mention.baseline.json create mode 100644 packages/spec/scripts/liveness/key-mention.mts create mode 100644 packages/spec/scripts/liveness/key-mention.test.ts diff --git a/.changeset/liveness-citation-key-mention.md b/.changeset/liveness-citation-key-mention.md new file mode 100644 index 0000000000..f1ac1d49b4 --- /dev/null +++ b/.changeset/liveness-citation-key-mention.md @@ -0,0 +1,50 @@ +--- +'@objectstack/spec': patch +--- + +liveness gate: a citation must name the property it is evidence for + +Two checks already bounded a `live` entry's citation, and both bounded it from the +outside — the cited file must exist (#5623), and a cited line must be inside it +(#11210). Between them sat a gap neither could see: a consumer that moves *within* +the file it is cited to, or a citation written with no line at all, leaves the file +present and every named line in range. The pointer is wrong and the gate is green. + +Measured over the whole ledger before anything was switched on: 403 (entry, cited +local file) pairs, **11** where the cited file never mentions the property's own key, +and **7 of those 11 were real rot** — repaired here: + +- `permission.objects.allowExport` — `annotateEffectiveApiOperations` moved to + `current-user-endpoints.ts`; the same repos-internal movement that had already + rotted `permission.systemPermissions` and `permission.tabPermissions`. +- `object.tenancy.organizationField` — the resolver was promoted into + `@objectstack/metadata-core`; the cited `audit-writers.ts` says so itself, in the + re-export comment left behind. +- `action.target` / `action.requiredPermissions` — the actions domain was extracted + out of `http-dispatcher.ts`, which retains 0 occurrences of either key. +- `action.bodyShape` / `action.bodyExtra` — client-dispatched keys whose only + consumer has always been the renderer; the in-repo citation could not have been + right at any point. Now attributed to `objectui` with the commit pinned. +- `field.requiredWhen` — cited its *sibling* `record-validator.ts`, which enforces the + static `required` contract; the CEL predicate is evaluated one file over in + `rule-validator.ts`. Both files exist, so nothing could see it. + +The remaining 4 are the `camelCase` → `snake_case` convention this platform mandates +(Prime Directive #3): a property persisted as a column is read as `body_html`, +`managed_by`, never as the authoring key. Three are handled **structurally** — the +matcher folds the key across the naming convention rather than exempting them — and +the match is word-bounded so a prefix cannot satisfy the key (`required` is not +`requiredWhen`, which is precisely how that rot stayed hidden). The one residual is a +compound *child*-key remap (`fromOverride.address` → `from_address`) that no fold of +the parent key reaches, and it is a single explicit row in the shrink-only +`scripts/liveness/key-mention.baseline.json`, which fails in **both** directions: a +row whose pair later anchors must be deleted. + +So the check ships red-capable at zero unexplained hits, which is the whole reason the +census came first — `evidence.mts`'s header records what the alternative costs, when +48 of 227 entries were flagged, every one was a false positive, and the single genuine +rot inside that list sat unread. + +The check asks `evidence` only, never `producer`: a producer cites *who supplies a +second input* (#4837), which is by definition a call site and need not name the key at +all. diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index 55ccb36319..97e2cacb53 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -43,8 +43,9 @@ }, "target": { "status": "live", - "evidence": "packages/runtime/src/http-dispatcher.ts", - "note": "URL/script/flow/endpoint + ${param}/${ctx} interpolation." + "verifiedAt": "2026-08-25", + "evidence": "packages/runtime/src/action-execution.ts:725 (type:'flow' server dispatch — automation.execute(action.target, …), with :718 rejecting an unknown flow name by that same value); packages/runtime/src/action-execution.ts:472 (headlessActionTypeError names the target the client-dispatched types go to instead)", + "note": "URL/script/flow/endpoint + ${param}/${ctx} interpolation. 2026-08-25: REPOINTED — the evidence cited packages/runtime/src/http-dispatcher.ts, which has 0 occurrences of `target`; the action consumer was extracted into domains/actions.ts + action-execution.ts and http-dispatcher.ts now only delegates (handleActions → handleActionsRequest at :1969-1970). The file still existed and the citation carried no line, so nothing in the gate could see it." }, "body": { "status": "live", @@ -107,8 +108,9 @@ }, "requiredPermissions": { "status": "live", - "evidence": "packages/runtime/src/http-dispatcher.ts", - "note": "ADR-0066 D4 dual-surface action gate. Server is source of truth: handleActions rejects (403) when the caller's systemPermissions don't cover action.requiredPermissions; objectui ActionRunner derives the same UI hide/disable. Unit-proven in packages/runtime/src/http-dispatcher.test.ts + packages/spec/src/ui/action.test.ts." + "verifiedAt": "2026-08-25", + "evidence": "packages/runtime/src/domains/actions.ts:172-180 (ADR-0066 D4 dual-surface gate — resolves the action's declared requiredPermissions and rejects 403 when the caller's systemPermissions don't cover them); packages/runtime/src/action-execution.ts (resolveRouteActionDeclaration supplies the declaration the gate reads)", + "note": "ADR-0066 D4 dual-surface action gate. Server is source of truth: handleActionsRequest rejects (403) when the caller's systemPermissions don't cover action.requiredPermissions; objectui ActionRunner derives the same UI hide/disable. Unit-proven in packages/runtime/src/http-dispatcher.test.ts + packages/spec/src/ui/action.test.ts (both re-measured 2026-08-25 and still naming the key). 2026-08-25: REPOINTED — the evidence cited packages/runtime/src/http-dispatcher.ts, which has 0 occurrences of `requiredPermissions` since the actions domain was extracted into domains/actions.ts. Note the split the repoint exposes: the TEST kept the name while the source lost it, so the note's pointer stayed true while the evidence pointer rotted." }, "disabled": { "status": "live", @@ -130,8 +132,10 @@ }, "bodyShape": { "status": "live", - "evidence": "packages/runtime/src/http-dispatcher.ts", - "note": "{wrap} request shaping." + "verifiedAt": "2026-08-25", + "evidenceScope": "cross-repo", + "evidence": "objectui @a76b18cf2: packages/app-shell/src/hooks/useConsoleActionRuntime.tsx:359-360 reads action.bodyShape.wrap and nests the payload under that key, and objectui packages/components/src/renderers/action/action-button.tsx:173 forwards it off the schema onto that runtime call (siblings action-icon.tsx:114, action-group.tsx:264, action-menu.tsx:244)", + "note": "{wrap} request shaping. 2026-08-25: REPOINTED, and the repoint CHANGED THE REALM — the evidence cited packages/runtime/src/http-dispatcher.ts, which has 0 occurrences of `bodyShape`, and so does every other file in this repo outside the schema/conversions/CLI declaration sites. `type:'api'` actions are client-dispatched by design: action-execution.ts:468-477 refuses them server-side with the message \"it dispatches on `target`, not through the action registry\". So the only consumer this key has ever had is the renderer, and the in-repo citation could not have been right at any point." }, "method": { "status": "live", @@ -139,7 +143,10 @@ }, "bodyExtra": { "status": "live", - "evidence": "packages/runtime/src/http-dispatcher.ts" + "verifiedAt": "2026-08-25", + "evidenceScope": "cross-repo", + "evidence": "objectui @a76b18cf2: packages/app-shell/src/hooks/useConsoleActionRuntime.tsx:397-398 merges action.bodyExtra into the request body after resolving page-var tokens, and :474-475 is the form-submit branch merging it into `fields`, with objectui packages/components/src/renderers/action/action-button.tsx:166 forwarding it off the schema (siblings action-icon.tsx:112, action-group.tsx:262, action-menu.tsx:242)", + "note": "The payload key for an inline `type:'api'` action — `params` stays the ActionParam[] definition array (#5777; the object form of `params` is converted to `bodyExtra` by the ADR-0087 entry in packages/spec/src/conversions/registry.ts:5578). 2026-08-25: REPOINTED to the renderer for the same reason as its sibling `bodyShape` — `type:'api'` actions are client-dispatched, so packages/runtime/src/http-dispatcher.ts never read this key." }, "mode": { "status": "live", diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index d7fb0de375..5a31b6ae97 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -77,8 +77,9 @@ }, "requiredWhen": { "status": "live", - "evidence": "packages/objectql/src/validation/record-validator.ts", - "note": "CEL." + "verifiedAt": "2026-08-25", + "evidence": "packages/objectql/src/validation/rule-validator.ts:1792-1810 (evaluates the field's requiredWhen predicate on the write path and reports the unevaluable/unbound cases by name); packages/objectql/src/validation/rule-validator.ts:762-763 (fieldRequiresParentRoot — a parent-scoped predicate widens the binding, #4977)", + "note": "CEL. 2026-08-25: REPOINTED — the evidence cited its SIBLING packages/objectql/src/validation/record-validator.ts, which has 0 occurrences of `requiredWhen`: that module enforces the static ADR-0113 `required` contract, while the CEL predicate is evaluated one file over in rule-validator.ts. Both files exist and neither citation carried a line, so this was invisible to every check the gate had — the plausible-neighbour shape of citation rot rather than a code movement." }, "readonlyWhen": { "status": "live", diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index f132d29b0a..d9e04b4148 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -153,8 +153,8 @@ }, "organizationField": { "status": "live", - "evidence": "packages/plugins/plugin-audit/src/audit-writers.ts", - "note": "STAMP-ONLY by the #8778 maintainer ruling (option A): consulted exclusively by resolveRecordOrganizationField when audit rows are stamped, so a credential table can stay unwalled while its trail follows the record's own organization (sys_api_key.active_organization_id, #8707/#8287). Deliberately read by NO tenant-scoping path; read-neutrality is pinned by tests beside applyTenantScope/injectTenantOnInsert (driver-sql), computeTenantLayer0Filter (plugin-security) and resolveInjectedSystemColumns (spec).", + "evidence": "packages/metadata-core/src/record-organization.ts:177-180 (resolveRecordOrganizationField reads `tenancy.organizationField` as limb 0 of the precedence, and falls through when the object has no such column)", + "note": "STAMP-ONLY by the #8778 maintainer ruling (option A): consulted exclusively by resolveRecordOrganizationField when audit rows are stamped, so a credential table can stay unwalled while its trail follows the record's own organization (sys_api_key.active_organization_id, #8707/#8287). Deliberately read by NO tenant-scoping path; read-neutrality is pinned by tests beside applyTenantScope/injectTenantOnInsert (driver-sql), computeTenantLayer0Filter (plugin-security) and resolveInjectedSystemColumns (spec). 2026-08-25: REPOINTED — the evidence cited plugin-audit/src/audit-writers.ts, which has read the key through `createRecordOrganizationResolver` ever since #10101 promoted the resolver into @objectstack/metadata-core; audit-writers.ts:220 says so in its own re-export comment. The cited file still existed and the citation carried no line, so neither the existence check nor the #11210 line bound could see it. The stamping CALL SITE is audit-writers.ts:863 — kept here in prose rather than as a citation, because a call site that never names the key belongs in `producer`, not in `evidence`.", "verifiedAt": "2026-08-15" } } diff --git a/packages/spec/liveness/permission.json b/packages/spec/liveness/permission.json index 2e626bf962..a4367c6f36 100644 --- a/packages/spec/liveness/permission.json +++ b/packages/spec/liveness/permission.json @@ -77,8 +77,8 @@ "allowExport": { "status": "live", "verifiedAt": "2026-07-30", - "evidence": "packages/rest/src/rest-server.ts (enforceExportPermission — caller-level 403 gate on the bulk-egress route, fail-closed when the security service cannot answer) + packages/plugins/plugin-security/src/security-plugin.ts (canExport → checkObjectPermission('export'), posture-unresolvable → deny) + packages/plugins/plugin-hono-server/src/hono-plugin.ts (annotateEffectiveApiOperations, the /me/permissions projection the frontend renders)", - "note": "#3544 — user-level export axis over read. Re-verified 2026-07-30: enforcement is SERVER-side, not only the projection — the export route calls enforceExportPermission (403), separate from the object-level 405; the annotate path is the display half. Optional/no-default = backward-compatible opt-out (unset inherits read); `false` denies export while keeping read." + "evidence": "packages/rest/src/rest-server.ts (enforceExportPermission — caller-level 403 gate on the bulk-egress route, fail-closed when the security service cannot answer) + packages/plugins/plugin-security/src/security-plugin.ts (canExport → checkObjectPermission('export'), posture-unresolvable → deny) + packages/plugins/plugin-hono-server/src/current-user-endpoints.ts:493-502 (annotateEffectiveApiOperations reads the per-object allowExport bit and the `'*'` wildcard for the /me/permissions projection the frontend renders)", + "note": "#3544 — user-level export axis over read. Re-verified 2026-07-30: enforcement is SERVER-side, not only the projection — the export route calls enforceExportPermission (403), separate from the object-level 405; the annotate path is the display half. Optional/no-default = backward-compatible opt-out (unset inherits read); `false` denies export while keeping read. 2026-08-25: the annotate pointer was REPOINTED — `annotateEffectiveApiOperations` moved out of hono-plugin.ts into current-user-endpoints.ts, the same repos-internal code movement that rotted systemPermissions and tabPermissions. The old citation carried no line, so the #11210 line bound could not see it; the key-mention signal is what found it." }, "allowTransfer": { "status": "live", diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts index 4e34215ca2..b8fc0a0399 100644 --- a/packages/spec/scripts/liveness/check-liveness.mts +++ b/packages/spec/scripts/liveness/check-liveness.mts @@ -136,6 +136,14 @@ import { type VerificationReport, } from './verification.mts'; import { checkCitationLines, checkEvidence, countLines, type EvidenceScan } from './evidence.mts'; +import { + KEY_MENTION_GUIDANCE, + findUnanchoredCitations, + leafKeyOf, + parseKeyMentionBaseline, + reconcileKeyMentions, + type UnanchoredCitation, +} from './key-mention.mts'; import { buildProducerReport, type ProducerEntry, type ProducerReport } from './producer.mts'; import { ORPHAN_GUIDANCE, findOrphanEntries, type Orphan } from './orphans.mts'; import { @@ -442,6 +450,15 @@ const report: any = { // and the summary line reported it under "resolved". citationsChecked: 0, // local citations written `path:NNN` — the subset a line bound can falsify citationsOutOfRange: [] as string[], // ...of which this many name a line past EOF — FAILS the gate + // The WITHIN-file half (#11457). The two counters above bound a citation from + // the outside — the file exists, the line is in range — and a consumer that + // moves WITHIN the cited file satisfies both while pointing at nothing. This + // asks the complementary question: does the cited file name the property at + // all, in any of its camelCase/snake_case spellings? + keyMentionsChecked: 0, // resolvable (live entry, cited local file) pairs asked + keyMentionExempt: 0, // ...of which this many are recorded in the shrink-only baseline + keyMentionUnanchored: [] as string[], // ...and this many are NOT — FAILS the gate + keyMentionStale: [] as string[], // a baseline row whose pair now anchors — also FAILS }; // Every classified entry, for the `verifiedAt` fold below. Collected during the @@ -465,6 +482,20 @@ function lineCountOf(p: string): number | null { return countLines(readFileSync(f, 'utf8')); } +/** + * The cited file's text, or `null` when it cannot be read — same contract, and + * same reason, as `lineCountOf` above: an unreadable path already has a verdict + * from the existence check and must not be reported a second time here. + */ +function contentOf(p: string): string | null { + const f = join(repoRoot, p); + if (!existsSync(f)) return null; + return readFileSync(f, 'utf8'); +} + +/** Unanchored (entry, cited path) pairs the walk observes, reconciled after it. */ +const unanchoredObserved: UnanchoredCitation[] = []; + /** * Bound a scanned pointer's `path:NNN` citations by the cited files' lengths. * `producer` and `evidence` both come through here, deliberately: the producer @@ -513,6 +544,19 @@ function classify(type: string, path: string, status: string, led: any, cat: any report.evidenceMissing += ev.missing.length; for (const miss of ev.missing) report.staleEvidence.push(`${type}/${path} → ${miss}`); collectOutOfRange(ev, `${type}/${path}`); + // The within-file question (#11457), asked of `evidence` ONLY — never of + // `producer`. That asymmetry is deliberate and is not the one + // `collectOutOfRange` deliberately refuses just above: a line bound applies + // to any citation that names a line, whoever wrote it, but `producer` cites + // *who supplies a second input* (#4837), which is by definition a call site + // and need not name the key at all. `seed.env`'s producer is a pointer at + // `resolveEnvConfig`, not at `env`. Demanding the key there would report the + // field's whole population as rot the day it was switched on. + const resolved = ev.local.filter((p) => !ev.missing.includes(p)); + report.keyMentionsChecked += resolved.length; + unanchoredObserved.push( + ...findUnanchoredCitations(`${type}/${path}`, leafKeyOf(path), resolved, contentOf), + ); } // ── ADR-0054 prove-it-runs ── const boundClass = BOUND_PROOF_PATHS.get(`${type}/${path}`); @@ -645,6 +689,20 @@ function classifiedKeysAt(target: string): readonly string[] | null { return children ? Object.keys(children) : null; } +// ── within-file citation anchoring: does the cited file name the key? ── +// The complement of the line bound (#11457). Reconciled against a shrink-only +// baseline so the day-one population is zero unexplained hits — the condition +// `evidence.mts`'s header names as the difference between a check people read +// and a warning list they learn to scroll past. +const keyMentionBaselineFile = join(here, 'key-mention.baseline.json'); +const keyMention = reconcileKeyMentions({ + observed: unanchoredObserved, + baseline: parseKeyMentionBaseline(JSON.parse(readFileSync(keyMentionBaselineFile, 'utf8'))).exemptions, +}); +report.keyMentionUnanchored = keyMention.unanchored.map((c) => `${c.entry} → ${c.path}`); +report.keyMentionStale = keyMention.stale; +report.keyMentionExempt = keyMention.exempt; + const coverage = reconcileContainerCoverage({ observed: observedContainers, baseline: undrilledBaseline.containers, @@ -754,6 +812,14 @@ const failed = // false-positive era to calibrate against — a line past EOF is arithmetic, and // the two shipped instances it found were both real (#11210). report.citationsOutOfRange.length > 0 || + // ...and the within-file half (#11457). Red rather than ⚠ on the strength of + // the census that designed it: the signal was measured over the whole ledger + // BEFORE it was switched on, the seven real rots it found were repaired, the + // one structural false-positive class was folded into the matcher, and the + // single residual is an explicit baseline row. A check that starts at zero can + // be red; that is the whole reason the census came first. + report.keyMentionUnanchored.length > 0 || + report.keyMentionStale.length > 0 || report.orphanEntries.length > 0 || report.verification.errors.length > 0 || report.producers.errors.length > 0 || @@ -816,12 +882,34 @@ if (asJson) { ' ADR-0049 enforce-or-remove rather than repointing at a plausible survivor.\n\n' + ' A RANGE (`:12-34`) is bounded by its END: a range whose tail is past EOF overruns\n' + ' the file even when its head is inside.\n\n' + - ' What this check does NOT see: a consumer that moved WITHIN the file it is cited to.\n' + - ' The line still exists, so nothing here fires. That case needs a different signal\n' + - ' (does the cited file mention the property at all) whose false positives are a\n' + - ' design problem of their own — camelCase authoring keys are read as snake_case data\n' + - ' values throughout this platform, so a naive match on the key misses the consumer\n' + - ' for every persisted field. Measured, not assumed: see #11210.', + ' A consumer that moved WITHIN the file it is cited to leaves every line in range,\n' + + ' so nothing here fires — that complementary case is now the key-mention check\n' + + ' below, which the #11457 census designed rather than bolted on (a naive match on\n' + + ' the authoring key reports the whole camelCase→snake_case class as rot).', + ); + } + // The within-file half. Two numbers again, and for the third time the same + // reason: "all anchored" over a population of zero is what a degraded parser + // prints too. + console.log( + `key-mention anchoring: ${report.keyMentionsChecked} (entry, cited file) pair(s) asked, ` + + `${report.keyMentionsChecked - report.keyMentionUnanchored.length - report.keyMentionExempt} anchored` + + (report.keyMentionExempt ? `, ${report.keyMentionExempt} exempt` : '') + + (report.keyMentionUnanchored.length ? `, ${report.keyMentionUnanchored.length} UNANCHORED` : '') + '.', + ); + if (report.keyMentionUnanchored.length) { + console.log(`\n✗ ${report.keyMentionUnanchored.length} 'live' citation(s) name a file that never names the property:`); + report.keyMentionUnanchored.forEach((s: string) => console.log(` ${s}`)); + console.log('\n' + KEY_MENTION_GUIDANCE.split('\n').map((l) => (l ? ` ${l}` : '')).join('\n')); + } + if (report.keyMentionStale.length) { + console.log(`\n✗ ${report.keyMentionStale.length} stale key-mention exemption(s) — the pair anchors now:`); + report.keyMentionStale.forEach((s: string) => console.log(` ${s}`)); + console.log( + '\n Delete the row from scripts/liveness/key-mention.baseline.json. The file is\n' + + ' shrink-only in BOTH directions on purpose: an exemption that cannot be lost is an\n' + + ' exemption nobody re-reads, and a debt that only ever fails while growing can be\n' + + ' overstated for free.', ); } if (report.staleEvidence.length) { @@ -1078,7 +1166,8 @@ if (asJson) { '\n✓ every governed-type property at the walk\'s one-level granularity is classified, every ' + 'registered type is governed or explicitly pending, no ledger row outlives its property, ' + 'every container inheritance is declared, every `live` entry\'s repo-local evidence path ' + - 'resolves and every `path:NNN` citation names a line that file actually has, all bound ' + + 'resolves, every `path:NNN` citation names a line that file actually has and every cited ' + + 'file names the property it is evidence for (or is a recorded exemption), all bound ' + 'high-risk proofs resolve, and the README state table carries a row ' + `for each of the ${report.readmeRowCount} governed type(s) it claims to index.`, ); diff --git a/packages/spec/scripts/liveness/key-mention.baseline.json b/packages/spec/scripts/liveness/key-mention.baseline.json new file mode 100644 index 0000000000..ce57fd6b88 --- /dev/null +++ b/packages/spec/scripts/liveness/key-mention.baseline.json @@ -0,0 +1,14 @@ +{ + "_note": "SHRINK-ONLY RATCHET (#11457). A `live` ledger entry cites files as proof that something READS the property. Two checks already bound those citations from the outside — the file must exist (#5623) and a cited line must be inside it (#11210) — and neither can see a consumer that moved WITHIN the file it is cited to, or a citation written with no line at all. This file records the (entry, cited path) pairs where the cited file legitimately never names the key, so that everything NOT listed here can fail.", + "_measured": "The census that made this ratchet possible: 403 (entry, cited local file) pairs, 11 where the cited file never mentioned the key. Seven were real rot and were repaired in #11457 (permission.objects.allowExport, object.tenancy.organizationField, action.target, action.requiredPermissions, action.bodyShape, action.bodyExtra, field.requiredWhen). Three more were the camelCase→snake_case naming convention and are handled STRUCTURALLY by the matcher's fold, not by rows here — `body_html`, `body_text`, `managed_by`. That leaves the one row below, so the ratchet ships at zero unexplained hits rather than as a warning list nobody reads.", + "_exemptions": "`exemptions`: pairs where the consumer reads the key under a name no naming-convention fold reaches. Each row must say WHICH name the file actually uses — a row that only asserts 'this is fine' is the reassuring sentence in a `note` that #4956 was filed about. Adding a row is legitimate only when the read is real and the spelling is genuinely unreachable; a citation that is simply STALE gets repointed instead, and one whose consumer is gone gets re-classified under ADR-0049.", + "_shrink_only": "A row leaves by the pair becoming ANCHORED — the citation is repointed, or the consumer starts naming the key. A row whose pair no longer appears in the observed set FAILS the gate, exactly as a drilled container fails `undrilled-containers.baseline.json`: a debt file that only ever fails in the growing direction can be overstated for free, and an exemption nobody can lose is an exemption nobody re-reads.", + "_issue": "https://github.com/objectstack-ai/objectstack/issues/11457", + "exemptions": [ + { + "entry": "email_template/fromOverride", + "path": "packages/plugins/plugin-email/src/email-service.ts", + "why": "A compound CHILD-key remap, which no fold of the parent key can reach: the bridge persists `fromOverride.address` → `from_address` and `fromOverride.name` → `from_name` (bootstrap-declared-email-templates.ts:85-88), so the send path reads `row.from_address` / `row.from_name` at :1282-1283 and the parent key `fromOverride` appears nowhere in the file. The entry's OTHER citation is anchored — bootstrap-declared-email-templates.ts names `tpl.fromOverride` — so the read is not in doubt; only this second, downstream pointer is unfalsifiable by name. Its two siblings `bodyHtml`/`bodyText` need no row because the fold reaches `body_html`/`body_text` directly." + } + ] +} diff --git a/packages/spec/scripts/liveness/key-mention.mts b/packages/spec/scripts/liveness/key-mention.mts new file mode 100644 index 0000000000..74098f7284 --- /dev/null +++ b/packages/spec/scripts/liveness/key-mention.mts @@ -0,0 +1,211 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The WITHIN-FILE half of an evidence citation check. +// +// WHY THIS EXISTS. Two checks already bound a `live` entry's citation, and both +// of them bound it from the OUTSIDE: +// +// * does the cited FILE exist (`checkEvidence`, red since #5623) +// * is the cited LINE inside that file (`checkCitationLines`, #11210) +// +// Between them sits a gap neither can see. A consumer that moves WITHIN the file +// it is cited to — or a citation written with no line at all — leaves the file +// present and every named line in range, so both checks pass and the pointer is +// still wrong. Measured, that gap is not theoretical: of 403 (entry, cited local +// file) pairs, 11 cited a file that never mentions the property's own key, and +// SEVEN of those eleven were real rot. Three were the same repos-internal code +// movement that had already rotted `permission.systemPermissions` and +// `permission.tabPermissions`; two were a key whose only consumer has always +// been the renderer repo; one was a citation to a plausible SIBLING file +// (`record-validator.ts` enforces the static `required` contract, while the +// `requiredWhen` CEL predicate is evaluated one file over in +// `rule-validator.ts`); one was a resolver promoted into another package. +// +// WHY IT IS NOT A GREP. The naive form of this check — `\bkey\b` against the +// cited file — is wrong for a class the platform MANDATES. Prime Directive #3: +// TS config keys are `camelCase`, machine names are `snake_case`. So for every +// property persisted as a column, the consumer names the snake_case form and +// never the authoring key: `email_template.bodyHtml` is read as `body_html`, +// `permission.managedBy` as `managed_by`. A matcher blind to that convention +// reports the entire class as rot, which is the failure mode `evidence.mts`'s +// header records from the other direction — the 48-of-227 era, a warning list +// nobody read, with one genuine rot sitting unnoticed inside it. +// +// Hence the anchor set below is the key plus its cross-convention spellings, +// and the ONE residual the fold cannot reach is carried as an explicit, +// measured, shrink-only baseline row rather than as tolerance in the matcher. +// Widening the matcher to cover it would mean accepting some prefix of the key, +// which buys one exemption at the cost of blinding the check to every future +// pointer that lands in a file merely ADJACENT to the reader — the exact shape +// of the `requiredWhen` rot above. + +/** An evidence citation whose file never names the property it is evidence for. */ +export interface UnanchoredCitation { + /** Ledger coordinate, `type/prop.path` — the same spelling the gate prints elsewhere. */ + entry: string; + /** The cited repo-local path, exactly as the evidence writes it. */ + path: string; +} + +/** A recorded exemption: this pair is unanchored, and that is understood. */ +export interface KeyMentionExemption { + entry: string; + path: string; + why: string; +} + +export interface KeyMentionBaseline { + exemptions: KeyMentionExemption[]; +} + +/** + * The spellings a consumer may legitimately use for one authoring key. + * + * Exactly two transforms, both of them Prime Directive #3 read in the two + * directions it can be read — `camelCase` → `snake_case` for a key persisted as + * a column, and back again for the handful of ledger keys already written in + * snake_case. Deliberately NOT a fuzzy match: every additional tolerance here is + * a citation the check can no longer falsify, and the check's whole value is + * that it falsifies pointers the other two checks cannot. + */ +export function namingVariants(key: string): string[] { + const out = new Set([key]); + out.add(key.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()); + out.add(key.replace(/_([a-z0-9])/g, (_m, c: string) => c.toUpperCase())); + return [...out]; +} + +const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** + * The key a CONSUMER would name, from the ledger coordinate that addresses it. + * + * `objects.children.allowExport` is read as `allowExport`: `children` is the + * ledger's own nesting spelling and appears in no consumer, and the container + * segments name the shape the key sits in rather than the key. Dropping the + * `children` markers before taking the last segment matters — without it a + * drilled entry would be matched against the literal word `children`, which + * some consumer files do contain, and the check would silently pass on the + * entire drilled population. + */ +export function leafKeyOf(ledgerPath: string): string { + const segments = ledgerPath.split('.').filter((s) => s !== 'children'); + return segments[segments.length - 1] ?? ledgerPath; +} + +/** + * Does `content` name this key, in any of its convention spellings, as a WORD? + * + * Word-bounded on purpose: an unbounded substring match would let `required` + * satisfy `requiredWhen`, which is precisely the false negative that kept the + * `field.requiredWhen` citation looking healthy — its cited file mentions + * `required` on twenty lines and `requiredWhen` on none. + */ +export function isKeyMentioned(content: string, key: string): boolean { + return namingVariants(key).some((v) => new RegExp(`\\b${escapeRe(v)}\\b`).test(content)); +} + +/** + * Every resolvable local citation of one entry whose file does not name the key. + * + * `readFile` returns `null` for a path it cannot read, and those are SKIPPED + * rather than reported — a citation into a missing file is already the + * existence check's finding, and reporting one rot under two headings teaches a + * reader to discount both lists (the same reasoning `checkCitationLines` states + * for its own `null` case). + */ +export function findUnanchoredCitations( + entry: string, + key: string, + localPaths: readonly string[], + readFile: (path: string) => string | null, +): UnanchoredCitation[] { + const out: UnanchoredCitation[] = []; + for (const path of localPaths) { + const content = readFile(path); + if (content === null) continue; + if (!isKeyMentioned(content, key)) out.push({ entry, path }); + } + return out; +} + +export function parseKeyMentionBaseline(json: unknown): KeyMentionBaseline { + const doc = json as { exemptions?: unknown } | null; + const rows = doc?.exemptions; + if ( + !Array.isArray(rows) || + rows.some( + (r) => + !r || + typeof (r as KeyMentionExemption).entry !== 'string' || + typeof (r as KeyMentionExemption).path !== 'string' || + typeof (r as KeyMentionExemption).why !== 'string', + ) + ) { + throw new Error( + 'key-mention.baseline.json must have an `exemptions` array of { entry, path, why } objects', + ); + } + return { exemptions: rows as KeyMentionExemption[] }; +} + +export interface KeyMentionReconciliation { + /** Unanchored pairs the baseline does not record — the gate FAILS on these. */ + unanchored: UnanchoredCitation[]; + /** Baseline rows whose pair now anchors (or no longer exists) — also a FAILURE. */ + stale: string[]; + /** Recorded exemptions still in force, for the run's summary line. */ + exempt: number; +} + +/** + * Reconcile what the walk observed against the recorded baseline. + * + * Stale rows FAIL, symmetrically with new ones, for the reason + * `undrilled-containers.baseline.json` states about its own: a debt file that + * only ever fails in the growing direction can be overstated for free, and an + * exemption nobody can lose is an exemption nobody re-reads. A row leaves this + * file by the pair becoming anchored — a repointed citation, or a consumer that + * starts naming the key — never by being deleted for convenience. + */ +export function reconcileKeyMentions(input: { + observed: readonly UnanchoredCitation[]; + baseline: readonly KeyMentionExemption[]; +}): KeyMentionReconciliation { + const key = (c: { entry: string; path: string }): string => `${c.entry} → ${c.path}`; + const observedKeys = new Set(input.observed.map(key)); + const baselineKeys = new Set(input.baseline.map(key)); + + return { + unanchored: input.observed.filter((c) => !baselineKeys.has(key(c))), + stale: input.baseline + .filter((e) => !observedKeys.has(key(e))) + .map((e) => `${key(e)} — the cited file now names the key (or the citation is gone); delete this exemption`), + exempt: input.baseline.filter((e) => observedKeys.has(key(e))).length, + }; +} + +/** Prescription printed under newly-unanchored citations. */ +export const KEY_MENTION_GUIDANCE = [ + 'A `live` entry cites a file that never names the property — in any of its', + 'camelCase/snake_case spellings. The file exists and every cited line is in', + 'range, so neither the existence check nor the line bound can see this; the', + 'citation is nonetheless unfalsifiable as written.', + '', + 'Measured, 7 of the first 11 of these were real rot, so start by assuming it is:', + '', + ' 1. REPOINT it — the usual cause is a consumer that moved. A domain', + ' extraction (`http-dispatcher.ts` → `domains/actions.ts`), a helper', + ' promoted into another package (#10101), or a citation that named a', + ' plausible SIBLING file all leave the old path resolving perfectly.', + ' Find the file that names the key and cite that one, with a line.', + ' 2. RE-CLASSIFY it — if no file in this repo names the key, the consumer may', + ' be in another realm (prefix it `objectui:` and pin the commit, per the', + ' ledger README) or may not exist at all, in which case the honest verdict', + ' is `dead` under ADR-0049 rather than a repoint to a plausible survivor.', + ' 3. EXEMPT it — only when the consumer genuinely reads the key under a name', + ' no naming-convention fold reaches (a compound child-key remap such as', + ' `fromOverride.address` → `from_address`). Add a row to', + ' `scripts/liveness/key-mention.baseline.json` saying WHICH name the file', + ' uses. That file is shrink-only: a row whose pair later anchors FAILS.', +].join('\n'); diff --git a/packages/spec/scripts/liveness/key-mention.test.ts b/packages/spec/scripts/liveness/key-mention.test.ts new file mode 100644 index 0000000000..d53c95a967 --- /dev/null +++ b/packages/spec/scripts/liveness/key-mention.test.ts @@ -0,0 +1,171 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The census that designed this check (#11457) measured 403 (entry, cited file) +// pairs and found 11 whose cited file never named the key — SEVEN real rots and +// one structural false-positive class. These tests pin both halves: the rots the +// matcher must keep reporting, and the class it must never report. Every case +// below is drawn from a measured entry rather than invented, so a future +// loosening of the matcher fails against the population it was calibrated on. + +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { + findUnanchoredCitations, + isKeyMentioned, + leafKeyOf, + namingVariants, + parseKeyMentionBaseline, + reconcileKeyMentions, +} from './key-mention.mts'; + +const here = dirname(fileURLToPath(import.meta.url)); + +/** A `readFile` that serves a fixed map and returns null for anything else. */ +const filesOf = (m: Record) => (p: string) => m[p] ?? null; + +describe('namingVariants — Prime Directive #3, read in both directions', () => { + it('folds a camelCase authoring key to the snake_case machine name', () => { + expect(namingVariants('bodyHtml')).toContain('body_html'); + expect(namingVariants('managedBy')).toContain('managed_by'); + }); + + it('folds a snake_case key back to camelCase', () => { + expect(namingVariants('body_html')).toContain('bodyHtml'); + }); + + it('always keeps the key itself', () => { + expect(namingVariants('allowExport')).toContain('allowExport'); + }); + + it('handles a digit boundary without splitting mid-token', () => { + expect(namingVariants('layer0Filter')).toContain('layer0_filter'); + }); +}); + +describe('isKeyMentioned — the exception CLASS the census found', () => { + // Measured: `email_template.bodyHtml` cites email-service.ts, which reads the + // persisted column and never the authoring key. Reporting this class is what + // made the naive matcher unshippable. + it('accepts the snake_case consumer of a camelCase key (email_template.bodyHtml)', () => { + expect(isKeyMentioned('const html = row.body_html;', 'bodyHtml')).toBe(true); + }); + + it('accepts the snake_case consumer of permission.managedBy', () => { + expect(isKeyMentioned("upsert({ managed_by: 'package' })", 'managedBy')).toBe(true); + }); + + it('accepts a direct mention', () => { + expect(isKeyMentioned('const wildExport = objects?.[\'*\']?.allowExport;', 'allowExport')).toBe(true); + }); + + // The false NEGATIVE guard, and the reason the match is word-bounded. The + // `field.requiredWhen` citation pointed at record-validator.ts, which mentions + // `required` on twenty lines and `requiredWhen` on none — an unbounded + // substring match would have called that rot anchored and missed it. + it('does NOT let a PREFIX of the key satisfy it (required !== requiredWhen)', () => { + const recordValidator = 'if (field.required && isEmpty(value)) reject("required");'; + expect(isKeyMentioned(recordValidator, 'requiredWhen')).toBe(false); + }); + + it('does not let a longer word containing the key satisfy it', () => { + expect(isKeyMentioned('const targetless = 1;', 'target')).toBe(false); + }); + + it('reports a file that names neither spelling (action.target in http-dispatcher)', () => { + expect(isKeyMentioned('return handleActionsRequest(this.domainDeps, path);', 'target')).toBe(false); + }); +}); + +describe('leafKeyOf — the ledger coordinate is not what a consumer names', () => { + it('drops the `children` nesting spelling', () => { + expect(leafKeyOf('objects.children.allowExport')).toBe('allowExport'); + expect(leafKeyOf('tenancy.children.organizationField')).toBe('organizationField'); + }); + + it('leaves a top-level key alone', () => { + expect(leafKeyOf('requiredWhen')).toBe('requiredWhen'); + }); +}); + +describe('findUnanchoredCitations', () => { + it('reports the pair whose file never names the key', () => { + const out = findUnanchoredCitations('action/target', 'target', ['a.ts'], filesOf({ 'a.ts': 'no mention here' })); + expect(out).toEqual([{ entry: 'action/target', path: 'a.ts' }]); + }); + + it('is silent when one of several cited files anchors it — the check is PER PAIR', () => { + // permission.objects.allowExport is exactly this shape: three pointers, one + // rotted. An entry-level check (does ANY cited file name the key) would have + // passed it, which is why the granularity is the pair and not the entry. + const out = findUnanchoredCitations( + 'permission/objects.children.allowExport', + 'allowExport', + ['rest-server.ts', 'hono-plugin.ts'], + filesOf({ 'rest-server.ts': 'enforceExportPermission(allowExport)', 'hono-plugin.ts': 'nothing' }), + ); + expect(out).toEqual([{ entry: 'permission/objects.children.allowExport', path: 'hono-plugin.ts' }]); + }); + + it('SKIPS an unreadable file — the existence check owns that verdict', () => { + // Reporting one rot under two headings teaches a reader to discount both. + expect(findUnanchoredCitations('x/y', 'y', ['gone.ts'], filesOf({}))).toEqual([]); + }); +}); + +describe('reconcileKeyMentions — the shrink-only ratchet', () => { + const observed = [{ entry: 'email_template/fromOverride', path: 'email-service.ts' }]; + + it('passes a pair the baseline records', () => { + const r = reconcileKeyMentions({ + observed, + baseline: [{ entry: 'email_template/fromOverride', path: 'email-service.ts', why: 'from_address' }], + }); + expect(r.unanchored).toEqual([]); + expect(r.stale).toEqual([]); + expect(r.exempt).toBe(1); + }); + + it('FAILS a pair the baseline does not record', () => { + const r = reconcileKeyMentions({ observed, baseline: [] }); + expect(r.unanchored).toEqual(observed); + }); + + it('FAILS a baseline row whose pair now anchors — the debt cannot be overstated', () => { + const r = reconcileKeyMentions({ + observed: [], + baseline: [{ entry: 'action/target', path: 'http-dispatcher.ts', why: 'stale once repointed' }], + }); + expect(r.stale).toHaveLength(1); + expect(r.stale[0]).toContain('action/target → http-dispatcher.ts'); + expect(r.exempt).toBe(0); + }); + + it('matches on the PAIR, not on the entry alone', () => { + const r = reconcileKeyMentions({ + observed: [{ entry: 'e', path: 'rotted.ts' }], + baseline: [{ entry: 'e', path: 'other.ts', why: 'a different pointer of the same entry' }], + }); + expect(r.unanchored).toEqual([{ entry: 'e', path: 'rotted.ts' }]); + expect(r.stale).toHaveLength(1); + }); +}); + +describe('parseKeyMentionBaseline', () => { + it('rejects a row missing `why` — an exemption with no reason is the note #4956 was about', () => { + expect(() => parseKeyMentionBaseline({ exemptions: [{ entry: 'a', path: 'b' }] })).toThrow(/entry, path, why/); + }); + + it('rejects a missing `exemptions` array', () => { + expect(() => parseKeyMentionBaseline({})).toThrow(/exemptions/); + }); + + it('parses the SHIPPED baseline, and every row explains which name the file uses', () => { + const doc = parseKeyMentionBaseline( + JSON.parse(readFileSync(join(here, 'key-mention.baseline.json'), 'utf8')), + ); + expect(doc.exemptions.length).toBeGreaterThan(0); + for (const row of doc.exemptions) expect(row.why.length).toBeGreaterThan(40); + }); +}); From 938512b4055a9404fc1e3149ff3cc8f2b2a6cc89 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 13:22:53 +0000 Subject: [PATCH 2/2] test(spec): pin the missing-file claim by its heading, not by a line three checks share Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NDGG54XF5gbTLdQzCtnaVV --- packages/spec/scripts/liveness/check-liveness.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/spec/scripts/liveness/check-liveness.test.ts b/packages/spec/scripts/liveness/check-liveness.test.ts index 50b8f7481e..309d5fcc7c 100644 --- a/packages/spec/scripts/liveness/check-liveness.test.ts +++ b/packages/spec/scripts/liveness/check-liveness.test.ts @@ -141,8 +141,15 @@ describe('check:liveness — evidence pointers (#5623)', () => { // ✗, not ⚠ — same grading argument as the missing-file case above. expect(output).toMatch(/✗ 1 citation\(s\) name a line/); expect(output).not.toMatch(/⚠ \d+ citation\(s\) name a line/); - // And it must NOT be reported as a missing FILE: two checks, two verdicts. - expect(output).not.toContain('query/limit → packages/spec/scripts/liveness/evidence.mts\n'); + // And it must NOT be reported as a missing FILE: separate checks, separate + // verdicts. Asserted against the missing-file HEADING rather than against + // the bare `entry → path` line, because that line is not unique to one + // check: the key-mention check (#11457) reports the same pair in the same + // shape for its own reason — this fixture cites `evidence.mts` for + // `query.limit`, and that file genuinely never names `limit`, so it is a + // true hit there too. Pinning the heading pins the claim actually being + // made; pinning the line pinned which OTHER checks happened to exist. + expect(output).not.toContain('cite a file that is missing from THIS repo'); }); it('bounds EVERY citation in a concatenated entry, not just the first', () => {