Skip to content

Commit 905019b

Browse files
os-steveclaude
andauthored
Bound a liveness evidence citation's line, not just its file (#11449)
* Bound a liveness evidence citation's LINE, not just its file check:liveness resolved a `path/to/file.ts:NNN` pointer with existsSync on the path alone. The parser did not even retain the `:NNN` — bareToken() stripped it before classification — so nothing downstream could bound it. A consumer that moved out of a file which still EXISTS therefore kept a passing pointer, was counted under the word "resolved", and left its entry reading as freshly verified. That is worse than a missing file: a dead citation with a precise line survives review because it looks measured, and the next agent re-verifying the entry follows it, finds nothing, and rebuilds the call graph by hand. - evidence.mts retains the line (`localCitations`, deduped on path:line so a file cited at three call sites contributes three), and `checkCitationLines` bounds each one. A range `:12-34` is bounded by its END — a tail past EOF overruns the file even when its head is inside. - The bound is a separate function, not a third parameter on checkEvidence: an optional parameter a caller omits is a silent opt-out, and empty-state.mts shares that resolver without having measured its own citations this way. - check-liveness.mts applies it to `producer` as well as `evidence` — those already share one resolver by decision (#4837), and a standard applied to one and not the other leaves the weaker one as the place a rotted line hides. Zero producer violations today; the hole is closed before it opens. - The run prints citations checked beside citations in range, the #5623 two-number discipline: printing only the second reads as a pass on a run where the parser extracted nothing. Two shipped instances, both repaired, both measured: - permission.tabPermissions cited hono-plugin.ts:1200 in a 717-line file with zero `tabPermissions` occurrences. All THREE of its pointers were dead — one past EOF, and resolve-execution-context.ts:205 / rest-server.ts:1551 within bounds with zero occurrences each. Repointed at the real consumers in current-user-endpoints.ts (the /me/apps rank merge and hidden-drop, and the /auth/me/permissions projection) plus the resolution chain, verifiedAt stamped. Same code movement that #11209 repaired for the sibling entry. - mapping.fieldMapping cited :115-167 of a 164-line file. The consumer never moved; the file shrank under the citation. Part of #11210 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T9cDbY2NBiVJWYx3BpWfH2 * Use fixture paths in the citation-arithmetic tests, not real cross-package files check:cross-package-test-inputs went red on the real scan (self-test 104/104 green throughout): evidence.test.ts named `packages/rest/src/import-mapping.ts` and `packages/plugins/plugin-hono-server/src/hono-plugin.ts`, and @objectstack/spec's declared globs cover neither package — so a change to either would not re-run spec's tests, which is the #7802 class the gate exists to stop. Fixed by removing the reads-that-are-not-reads rather than by widening the globs. These three cases assert the ARITHMETIC of the bound with the line count INJECTED (`const lines = (n) => () => n`) — they open no file, and the 717/164 are stub constants that do not track either real file. Declaring those two packages as spec test inputs would have asserted a dependency that does not exist: every hono-server and rest change would re-run spec's whole suite forever, and the frozen constants would read as if they tracked those files. That is the same declared-≠-actual defect the gate guards, pointed backwards. The coupling that IS real keeps its coverage: `check:liveness` bounds the shipped ledgers in CI, and the two contract tests at the bottom of this file walk the real ledgers and read every cited path — dynamically, out of the ledger data, so no glob could enumerate them and none is claimed. A comment on the describe block records why the paths are synthetic, so the next author does not "restore" the real ones and re-red the gate. Verified in-tree after merging origin/main: node scripts/check-cross-package-test-inputs.mjs --self-test -> exit 0, 104 cases node scripts/check-cross-package-test-inputs.mjs -> exit 0, "OK: 14 package(s) read outside themselves, all declared" vitest scripts/liveness/ -> 9 files, 213 tests pnpm --filter @objectstack/spec check:liveness -> exit 0, 293/293 pnpm --filter @objectstack/spec check:generated -> 14 artifacts current Part of #11210 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T9cDbY2NBiVJWYx3BpWfH2 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 99ccbb9 commit 905019b

7 files changed

Lines changed: 451 additions & 22 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
liveness gate: bound an evidence citation's LINE, not just its file
6+
7+
`check:liveness` resolved a `path/to/file.ts:NNN` pointer with `existsSync` on the
8+
path alone — the parser did not even retain the `:NNN` — so a consumer that moved
9+
out of a file which still exists kept a passing pointer, was counted under the word
10+
"resolved", and left its ledger entry reading as freshly verified. A citation that is
11+
dead but precise-looking is worse than a missing one: it survives review, and the next
12+
agent re-verifying the entry follows it, finds nothing, and rebuilds the call graph
13+
from scratch.
14+
15+
Citations are now bounded by the cited file's length, for `evidence` and `producer`
16+
alike (they already share one resolver). A range `:12-34` is bounded by its END. Every
17+
citation in a `+`-joined multi-consumer entry is bounded, not just the first.
18+
Cross-repo attributions (`objectui: …`, `cloud: …`) are still counted and never
19+
resolved. The run prints how many citations it checked beside how many are in range,
20+
so a parser that degraded to extracting nothing cannot read as a pass.
21+
22+
Two shipped instances, both repaired here and both real:
23+
`permission.tabPermissions` cited `hono-plugin.ts:1200` in a 717-line file that no
24+
longer mentions the property (all three of its pointers were dead — one past EOF, two
25+
within bounds), and `mapping.fieldMapping` cited a range ending three lines past the
26+
end of `import-mapping.ts`.

packages/spec/liveness/mapping.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@
2828
},
2929
"fieldMapping": {
3030
"status": "live",
31-
"verifiedAt": "2026-08-01",
32-
"evidence": "packages/rest/src/import-mapping.ts:98-105, packages/rest/src/import-mapping.ts:115-167",
33-
"note": "the pipeline itself: source/target/transform/params all consumed. none/constant/map/split/join applied in applyMappingToRows (`params.separator` :124, `.value` :132, `.valueMap` :137); `lookup` copies through for the pipeline's metaMap reference resolution; `javascript` is REJECTED with a 400 (no server sandbox — implement-or-reject-loudly). SUB-WALK BOUNDARY, resolved: `params`' lookup-specific keys (`object`/`fromField`/`toField`/`autoCreate`) were read by nothing — reference resolution comes from the target object's own field definitions, not from these — and were REMOVED in the 17.x line (#10329, ADR-0049 enforce-or-remove; strict deletion, so nothing dead remains one level below the drill). Every spelling — the four canonical keys and their eleven ex-aliases — now lands on a guidance prescription at parse; the mapping-lookup-params-removed conversion (protocol 18) strips them from stored sources. The surviving params keys (`value`/`valueMap`/`separator`) are all consumed by applyMappingToRows."
31+
"verifiedAt": "2026-08-23",
32+
"evidence": "packages/rest/src/import-mapping.ts:95-102, packages/rest/src/import-mapping.ts:112-164",
33+
"note": "Lines re-measured 2026-08-23 (#11210): the second range ended at :167 in a 164-line file — the file shrank under the citation and no gate could see it, since the FILE still resolved. The consumer never moved: the javascript rejection is the loop at :95-102 (was :98-105) and the pipeline is applyMappingToRows at :112-164 (was :115-167); the three inline line refs below were off by the same three lines and are corrected with them. the pipeline itself: source/target/transform/params all consumed. none/constant/map/split/join applied in applyMappingToRows (`params.separator` :121, `.value` :129, `.valueMap` :134); `lookup` copies through for the pipeline's metaMap reference resolution; `javascript` is REJECTED with a 400 (no server sandbox — implement-or-reject-loudly). SUB-WALK BOUNDARY, resolved: `params`' lookup-specific keys (`object`/`fromField`/`toField`/`autoCreate`) were read by nothing — reference resolution comes from the target object's own field definitions, not from these — and were REMOVED in the 17.x line (#10329, ADR-0049 enforce-or-remove; strict deletion, so nothing dead remains one level below the drill). Every spelling — the four canonical keys and their eleven ex-aliases — now lands on a guidance prescription at parse; the mapping-lookup-params-removed conversion (protocol 18) strips them from stored sources. The surviving params keys (`value`/`valueMap`/`separator`) are all consumed by applyMappingToRows."
3434
},
3535
"mode": {
3636
"status": "live",

packages/spec/liveness/permission.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,10 @@
151151
},
152152
"tabPermissions": {
153153
"status": "live",
154-
"verifiedAt": "2026-07-30",
154+
"verifiedAt": "2026-08-23",
155155
"proof": "packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts#me-apps-and-everyone-baseline",
156-
"evidence": "packages/plugins/plugin-hono-server/src/hono-plugin.ts:1200 (tabRank merges all four values — hidden/default_off/default_on/visible — across resolved sets, most-visible wins; app hidden ⇒ dropped from /me/apps) + packages/runtime/src/security/resolve-execution-context.ts:205 + packages/rest/src/rest-server.ts:1551 (projection)",
157-
"note": "Re-verified 2026-07-30 — the previous note ('only hidden is read') UNDERSTATED it: the rank merge reads every value. Proof BOUND the same day after the dogfood test was extended to actually AUTHOR the property on a permission set (it had only mentioned tabPermissions in its header): hidden drops the app from /me/apps, a more-visible sibling grant wins it back."
156+
"evidence": "packages/plugins/plugin-hono-server/src/current-user-endpoints.ts:947 (tabRank ranks all four values — hidden 0 < default_off 1 < default_on 2 < visible 3) + packages/plugins/plugin-hono-server/src/current-user-endpoints.ts:952-958 (the merge across the caller's resolved permission sets, most-visible wins; an unrecognised value is skipped rather than defaulted) + packages/plugins/plugin-hono-server/src/current-user-endpoints.ts:963 (the /me/apps drop — tabs[app.name] === 'hidden' ⇒ the app is not returned, and this filter runs BEFORE the fail-open branch, so it survives a missing SecurityPlugin) + packages/plugins/plugin-hono-server/src/current-user-endpoints.ts:772 (the same rank table for the /auth/me/permissions half) + packages/plugins/plugin-hono-server/src/current-user-endpoints.ts:862 (projected onto that response) + packages/plugins/plugin-security/src/permission-set-projection.ts:287 (the sys_permission_set.tab_permissions column parsed onto the resolved set) + packages/core/src/security/assemble-execution-context.ts:328 (onto ExecutionContext.tabPermissions)",
157+
"note": "Re-verified 2026-08-23 (#11210) and every pointer REPLACED — all three of the previous ones were dead, in two different ways, and the gate could see neither. (a) `plugin-hono-server/src/hono-plugin.ts:1200` was past EOF: that file is 717 lines and contains no `tabPermissions` reference at all — the same code movement that killed this entry's sibling `systemPermissions` pointer (#10959/#11209) moved /me/apps and /auth/me/permissions into `current-user-endpoints.ts`. (b) `runtime/src/security/resolve-execution-context.ts:205` and `rest/src/rest-server.ts:1551` were dead WITHIN bounds — both files are long enough for the cited line, and neither mentions `tabPermissions` anywhere (0 occurrences, measured); the handler's own comment at current-user-endpoints.ts:922-926 records why, namely that resolveCtx() carries neither systemPermissions nor tabPermissions, so /me/apps re-resolves the sets itself. THE VERDICT IS UNCHANGED and was never in question — `live` with a bound dogfood proof, and the behaviour still ships; what was dead is the citation, which is exactly the rot #11210's line bound now makes impossible to leave behind silently. The 2026-07-30 reading survives the re-measurement verbatim: the rank merge reads all four values, not only 'hidden'. Two consumers, deliberately both cited: /me/apps ENFORCES (a hidden app is dropped), /auth/me/permissions PROJECTS the merged map for the renderer."
158158
},
159159
"rowLevelSecurity": {
160160
"children": {

packages/spec/scripts/liveness/check-liveness.mts

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,19 @@
5252
// flagged 48 of 227 entries with a ~100% false-positive rate, so failing on it
5353
// would have failed every build. The parse fix is what turned a hit into signal.
5454
//
55+
// CITATION LINES (#11210): a citation written `path/to/file.ts:NNN` also has its
56+
// LINE bounded by the file's length. The existence check alone validated the
57+
// file and dropped the line on the floor — the parser did not even retain it —
58+
// so a consumer that moved out of a file which still exists kept a passing
59+
// pointer, was counted under the word "resolved", and left the entry reading as
60+
// freshly verified. Two shipped instances, both real, neither catchable by any
61+
// gate: `permission.tabPermissions` cited `hono-plugin.ts:1200` in a 717-line
62+
// file that no longer mentions the property, and `mapping.fieldMapping` cited a
63+
// range ending 3 lines past the end of `import-mapping.ts`. A range is bounded
64+
// by its END. The complementary case — a consumer that moved WITHIN its file —
65+
// is out of reach here by construction and is tracked separately; the ✗ text
66+
// below says why its obvious detector is not obviously right.
67+
//
5568
// PRODUCER-SIDE EVIDENCE (`producer`, #4837): `live` means AUTHORING the
5669
// property changes runtime behaviour. A consumer that reads the property is
5770
// necessary and not sufficient — when the effect also depends on a second input
@@ -122,7 +135,7 @@ import {
122135
type VerificationEntry,
123136
type VerificationReport,
124137
} from './verification.mts';
125-
import { checkEvidence } from './evidence.mts';
138+
import { checkCitationLines, checkEvidence, countLines, type EvidenceScan } from './evidence.mts';
126139
import { buildProducerReport, type ProducerEntry, type ProducerReport } from './producer.mts';
127140
import { ORPHAN_GUIDANCE, findOrphanEntries, type Orphan } from './orphans.mts';
128141
import {
@@ -423,6 +436,12 @@ const report: any = {
423436
evidenceLocal: 0, // repo-rooted evidence paths attributed to THIS repo — DECLARED, not yet proven
424437
evidenceMissing: 0, // ...of which this many do not exist here (=== staleEvidence.length) — FAILS the gate
425438
evidenceForeign: 0, // evidence paths attributed to objectui / cloud — not resolvable here, never failed
439+
// The LINE half of a citation. `evidenceMissing` above answers "does the cited
440+
// FILE exist"; nothing answered "does the cited LINE". A consumer that moves out
441+
// of a file which still exists therefore rotted its pointer in complete silence,
442+
// and the summary line reported it under "resolved".
443+
citationsChecked: 0, // local citations written `path:NNN` — the subset a line bound can falsify
444+
citationsOutOfRange: [] as string[], // ...of which this many name a line past EOF — FAILS the gate
426445
};
427446

428447
// Every classified entry, for the `verifiedAt` fold below. Collected during the
@@ -434,6 +453,32 @@ const producerEntries: ProducerEntry[] = [];
434453

435454
const proofFs = { existsSync, readFileSync };
436455

456+
/**
457+
* How many lines the cited file has, or `null` when it cannot be read. `null`
458+
* is not a pass: it means the EXISTENCE check owns this path's verdict already
459+
* (a rotted `evidence` pointer would otherwise be reported twice, once per
460+
* check, which teaches a reader to discount both lists).
461+
*/
462+
function lineCountOf(p: string): number | null {
463+
const f = join(repoRoot, p);
464+
if (!existsSync(f)) return null;
465+
return countLines(readFileSync(f, 'utf8'));
466+
}
467+
468+
/**
469+
* Bound a scanned pointer's `path:NNN` citations by the cited files' lengths.
470+
* `producer` and `evidence` both come through here, deliberately: the producer
471+
* pointer resolves through the SAME resolver as evidence (see below), and a
472+
* standard that applied to one and not the other would leave the weaker one as
473+
* the place a rotted line goes to hide.
474+
*/
475+
function collectOutOfRange(scan: EvidenceScan, label: string): void {
476+
report.citationsChecked += scan.localCitations.length;
477+
for (const c of checkCitationLines(scan, lineCountOf)) {
478+
report.citationsOutOfRange.push(`${label}${c.path}:${c.line} (the file has ${c.lines} lines)`);
479+
}
480+
}
481+
437482
function classify(type: string, path: string, status: string, led: any, cat: any) {
438483
cat.classified++;
439484
cat.byStatus[status] = (cat.byStatus[status] || 0) + 1;
@@ -455,6 +500,7 @@ function classify(type: string, path: string, status: string, led: any, cat: any
455500
if (typeof led?.producer === 'string') {
456501
const pv = checkEvidence(led.producer, (p) => existsSync(join(repoRoot, p)));
457502
for (const miss of pv.missing) report.producerMissing.push(`${type}/${path}${miss}`);
503+
collectOutOfRange(pv, `${type}/${path} [producer]`);
458504
}
459505
if (status === 'live' && led?.evidence) {
460506
// Extract every repo-rooted path the evidence claims and resolve the ones
@@ -466,6 +512,7 @@ function classify(type: string, path: string, status: string, led: any, cat: any
466512
report.evidenceLocal += ev.local.length;
467513
report.evidenceMissing += ev.missing.length;
468514
for (const miss of ev.missing) report.staleEvidence.push(`${type}/${path}${miss}`);
515+
collectOutOfRange(ev, `${type}/${path}`);
469516
}
470517
// ── ADR-0054 prove-it-runs ──
471518
const boundClass = BOUND_PROOF_PATHS.get(`${type}/${path}`);
@@ -702,6 +749,11 @@ const failed =
702749
// that now resolves 330 paths and reports zero. Cross-repo attribution never
703750
// reaches this list: checkEvidence only resolves the LOCAL bucket.
704751
report.staleEvidence.length > 0 ||
752+
// ...and the same verdict for a citation whose FILE exists but whose LINE does
753+
// not. Red rather than ⚠ from the start: unlike the #5623 case there is no
754+
// false-positive era to calibrate against — a line past EOF is arithmetic, and
755+
// the two shipped instances it found were both real (#11210).
756+
report.citationsOutOfRange.length > 0 ||
705757
report.orphanEntries.length > 0 ||
706758
report.verification.errors.length > 0 ||
707759
report.producers.errors.length > 0 ||
@@ -738,6 +790,40 @@ if (asJson) {
738790
(report.evidenceMissing ? `, ${report.evidenceMissing} MISSING` : '') +
739791
`; ${report.evidenceForeign} attributed to another repo (objectui / cloud — not resolvable here).`,
740792
);
793+
// Same two-number discipline one level down, and for the same reason: printing
794+
// only "in range" would read as a pass on a run where the parser had degraded
795+
// to extracting no citations at all.
796+
console.log(
797+
`line citations: ${report.citationsChecked} pointer(s) written \`path:NNN\`, ` +
798+
`${report.citationsChecked - report.citationsOutOfRange.length} inside the cited file` +
799+
(report.citationsOutOfRange.length ? `, ${report.citationsOutOfRange.length} PAST EOF` : '') + '.',
800+
);
801+
if (report.citationsOutOfRange.length) {
802+
console.log(`\n✗ ${report.citationsOutOfRange.length} citation(s) name a line the cited file does not have:`);
803+
report.citationsOutOfRange.forEach((s: string) => console.log(` ${s}`));
804+
console.log(
805+
'\n The file resolves, so the existence check above is happy — and the line is gone,\n' +
806+
' which is the half a moved consumer rots first. This is worse than a missing file:\n' +
807+
' it survives review because it LOOKS precise, and the next agent re-verifying the\n' +
808+
' entry follows it, finds nothing, and has to rebuild the call graph from scratch.\n\n' +
809+
' Repairs, same three as a missing file and picked the same way:\n' +
810+
' • the consumer MOVED (inside this file or out of it) → repoint at the real line,\n' +
811+
' MEASURED — open the file and read it, do not shift the number by the diff — and\n' +
812+
' stamp `verifiedAt` while you have the call graph open;\n' +
813+
' • the consumer moved to ANOTHER repo → attribute it with a realm marker; those\n' +
814+
' are counted, never resolved, and never bounded here;\n' +
815+
' • the consumer is GONE → the verdict is not `live` any more. Re-classify under\n' +
816+
' ADR-0049 enforce-or-remove rather than repointing at a plausible survivor.\n\n' +
817+
' A RANGE (`:12-34`) is bounded by its END: a range whose tail is past EOF overruns\n' +
818+
' the file even when its head is inside.\n\n' +
819+
' What this check does NOT see: a consumer that moved WITHIN the file it is cited to.\n' +
820+
' The line still exists, so nothing here fires. That case needs a different signal\n' +
821+
' (does the cited file mention the property at all) whose false positives are a\n' +
822+
' design problem of their own — camelCase authoring keys are read as snake_case data\n' +
823+
' values throughout this platform, so a naive match on the key misses the consumer\n' +
824+
' for every persisted field. Measured, not assumed: see #11210.',
825+
);
826+
}
741827
if (report.staleEvidence.length) {
742828
console.log(`\n✗ ${report.staleEvidence.length} 'live' entr(ies) cite a file that is missing from THIS repo:`);
743829
report.staleEvidence.forEach((s: string) => console.log(` ${s}`));
@@ -992,7 +1078,8 @@ if (asJson) {
9921078
'\n✓ every governed-type property at the walk\'s one-level granularity is classified, every ' +
9931079
'registered type is governed or explicitly pending, no ledger row outlives its property, ' +
9941080
'every container inheritance is declared, every `live` entry\'s repo-local evidence path ' +
995-
'resolves, all bound high-risk proofs resolve, and the README state table carries a row ' +
1081+
'resolves and every `path:NNN` citation names a line that file actually has, all bound ' +
1082+
'high-risk proofs resolve, and the README state table carries a row ' +
9961083
`for each of the ${report.readmeRowCount} governed type(s) it claims to index.`,
9971084
);
9981085
console.log(

0 commit comments

Comments
 (0)