Skip to content

Bound a liveness evidence citation's line, not just its file - #11449

Merged
os-steve merged 3 commits into
mainfrom
claude/issue-11210-liveness-line-citation
Aug 23, 2026
Merged

Bound a liveness evidence citation's line, not just its file#11449
os-steve merged 3 commits into
mainfrom
claude/issue-11210-liveness-line-citation

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Part of #11210

check:liveness resolved a path/to/file.ts:NNN evidence pointer with existsSync on the path alone. The parser did not even retain the :NNNbareToken() 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 ledger entry reading as freshly verified.

A dead citation with a precise line is worse than a missing one: it 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 — which is the cost #10959 actually paid.

What the gate now checks

  • evidence.mts retains the line (localCitations), deduped on path:line rather than on path, so a file cited at three call sites contributes three citations instead of one.
  • A range :12-34 is bounded by its END. A tail past EOF overruns the file even when its head is inside; a head past EOF implies a tail past EOF, so the end is both the stricter bound and the one that subsumes the other. This is what catches the second instance below.
  • Every citation in a +-joined multi-consumer entry is bounded, not only the first — that concatenated form is the house style for a property with several consumers, and seeing only its head would leave the rest of each chain exactly as unfalsifiable as before. Pinned in both the unit tests and the gate self-test.
  • producer is bounded too, not just evidence. Those two already resolve through one resolver by decision (liveness 台账把「消费端存在读取代码」当作 live 的证据,会漏掉「没有任何生产者传值」的死键(Seed.env 即如此) #4837: a call-site claim must not get a weaker standard than the consumer pointer it completes), and a bound applied to one and not the other leaves the weaker one as the place a rotted line goes to hide. Zero producer violations today (5 producer citations, all in range) — the hole is closed before it opens.
  • Cross-repo attributions are untouched.objectui: … / cloud: … / packages/services/service-ai/… are counted, never resolved, and now never bounded either — those files are legitimately absent here, and bounding them would make the gate unsatisfiable for every renderer-side property. Pinned by a self-test case.
  • Two numbers, not one, the check:liveness 的 stale-evidence 检测只 ⚠ 不判红 —— ADR-0087 证据路径可以静默腐烂,且摘要行的 “N resolved” 数的是路径总数不是解析成功数 #5623 discipline one level down: the run prints how many citations it checked beside how many are in range, so a parser that degraded to extracting nothing cannot read as a pass.

, not , from the start. #5623's evidence check earned its warning phase because the pre-#3857 parser flagged 48 of 227 entries at a ~100% false-positive rate; a line past EOF is arithmetic, and both shipped instances are real.

The measurement

Over the post-#11209 ledgers, using the gate's own parser:

readingvalue
live evidence citations written path:NNN284
producer citations written path:NNN5
past EOF2 (both repaired here)
after repair293 citations, 293 in range

The card's figure was 252; 284 is what the gate's parser actually sees, because it counts every citation in a multi-consumer entry rather than the head of each entry. No ratchet baseline was needed: the standing set is two instances, and repairing both is smaller and less durable debt than a shrink-only file.

The two repairs, measured

permission.tabPermissions — all three of its pointers were dead, in two different ways:

  • plugin-hono-server/src/hono-plugin.ts:1200 — past EOF (the file is 717 lines) and zero tabPermissions occurrences in it. Same code movement that killed the sibling systemPermissions pointer, repaired in chore(spec): adopt the adjudicated proof→ledger bindings, and correct the stale systemPermissions note #11209.
  • runtime/src/security/resolve-execution-context.ts:205 and rest/src/rest-server.ts:1551 — 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 — resolveCtx() carries neither systemPermissions nor tabPermissions, so /me/apps re-resolves the sets itself.

Repointed at the real consumers, read rather than inferred: the /me/apps rank table (:947), the most-visible-wins merge across resolved sets (:952-958), the hidden-drop that runs before the fail-open branch (:963), the /auth/me/permissions half (:772, :862), and the resolution chain (permission-set-projection.ts:287, assemble-execution-context.ts:328). verifiedAt stamped per the gate's own remedy text. The verdict is unchanged and was never in questionlive, bound proof, behaviour still ships; what was dead is the citation.

mapping.fieldMapping — cited :115-167 of a 164-line file. The consumer never moved; the file shrank under the citation. Repointed to the measured spans (:95-102 javascript rejection, :112-164applyMappingToRows), and the three inline line refs in its own note, off by the same three lines, corrected with them. Reported here rather than silently: this is a second instance the dispatch did not name, found by the new check and repaired because it is the same mechanical class.

Why no key-occurrence WARN in this PR

The card offers an optional second signal — warn when the cited file contains no occurrence of the property's own key — to catch the move-within-bounds case a line bound structurally cannot. I measured it rather than guessing, and it is not a one-line addition:

  • It fires on 14 of 403 (entry, cited-file) pairs — permanently non-empty at landing. evidence.mts's own header records what that costs: the 48-of-227 era made a warning nobody read, and the one real rot it was burying sat unnoticed.
  • Its false positives are structural, not incidental. This platform mandates camelCase authoring keys read as snake_case data values (AGENTS.md Prime Directive Implement ObjectStack protocol specification with Zod schemas and TypeScript interfaces #3), so a naive word-boundary match on the authoring key misses the consumer for every persisted field: email_template.bodyHtml is read as body_html, permission.managedBy as managed_by. That entry's own evidence string even writes the mapping out — (bodyHtml→body_html). At least 5 of the 14 are exactly this.
  • Its true positives are real and deserve their own card, with a repair budget. permission.objects.allowExport cites hono-plugin.ts (annotateEffectiveApiOperations …) and that symbol now lives in current-user-endpoints.ts — the same movement as this card's named instance, and invisible to any line bound because that citation carries no line at all.

So: ship the arithmetic bound as a hard failure, and file the census as a finding rather than bolt a noisy warning onto a tightening. The guidance text says this in-repo so the next reader is told why the obvious detector is not obviously right.

Verification

  • pnpm --filter @objectstack/spec check:livenessred before the repairs, naming exactly the two instances and their real file lengths; green after, 293 pointer(s) written path:NNN, 293 inside the cited file.
  • Reverse verification against the committed state, via --ledger-root on a copy so no repo file is mutated: a fabricated past-EOF citation on a file that exists reds with ✗ 1; a rotted line in the tail of a +-joined entry reds; the same line under an objectui: marker stays green.
  • Unit pins for range-end semantics, the wc -l boundary (> not >=), multi-citation retention, per-path dedupe, and the "cannot read the file → say nothing, the existence check owns that verdict" case.
  • Contract test against the real ledgers, mirroring the existing one for paths: every shipped citation is in range, with a non-vacuity floor so a parser that stopped retaining lines cannot satisfy it by extracting nothing.

packages/spec/liveness/** is in the package's files array, so the ledger repair ships in the tarball — this takes a changeset (patch, @objectstack/spec) rather than the skip-changeset label.


Generated by Claude Code

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
@github-actions

github-actionsBot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/liveness/mapping.json, packages/spec/liveness/permission.json) — pages documenting those are invisible to this run

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

…ckage 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
@os-steve
os-steve marked this pull request as ready for review August 23, 2026 21:16
@os-steve
os-steve added this pull request to the merge queueAug 23, 2026
Merged via the queue into main with commit 905019bAug 23, 2026
33 checks passed
@os-steve
os-steve deleted the claude/issue-11210-liveness-line-citation branch August 23, 2026 21:33
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-steve@claude