Repository navigation
fix(lint): give the liveness walk a seam of its own, and report a ledger that could not be read - #19480
Conversation
`lintLivenessProperties` puts the entire object/field walk behind `if (fieldWarn.size > 0)`. `field.relatedListFilter` was the only `authorWarn` row on `field.json` at any depth, so flipping it `live` (#19187, landed by PR #19265) emptied `loadWarnMap(dir, 'field')` and the field loop stopped executing altogether — taking #11385's `if (!isRecord(field)) continue` guard out of reach of the public function, with no second warned field row anywhere to re-hang it on. Split the walk from ledger RESOLUTION and export the pair as a package-internal seam (`resolveLivenessDir`, `lintLivenessPropertiesFromLedgerDir`): module exports only, neither re-exported by `src/index.ts`, and the package's `exports` map still publishes just `.` and `./runtime`, so the published surface is unchanged. The new tests drive the real rule against a copy of the shipped ledger directory carrying one synthetic `field.json`, which makes #11385's guard provable again and cannot be emptied by a future flip — the third time a correct flip deleted coverage in this file. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
…of going silent `loadWarnMap` returned the same empty map for "this type's ledger classifies nothing as warn-worthy" and for "there is no ledger" — a missing file and broken JSON both `return map` without a log, a throw or any other signal. Losing or corrupting one file under the shipped `liveness/` directory therefore switched every author warning for that metadata type off in silence, indistinguishable from that type having no warnings. One frame up the directory-level failure is loud by construction (`resolveLivenessDir()` returning null makes the whole rule return `[]` and its dependants go red): loud by directory, silent by file, and that asymmetry is the defect. `loadWarnMap` now returns the map plus an optional `fault`, and `lintLivenessProperties` raises one `liveness-ledger-unreadable` finding per faulted type — once per run, never once per item, and ahead of the walk's own findings because it says why the rest may be short. A third failed-read shape is covered by the same branch: a document that parses but is not a ledger, including a `null` whose `.props` read was a TypeError against a rule that promises never to throw. `authorWarnedProperties` keeps answering the empty set (a decision procedure returning a set cannot report a failed read) and `os lint` runs both halves in one pass, so the run says it once rather than never. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
The published half of this branch is the new `LIVENESS_LEDGER_UNREADABLE` rule id and the finding `lintLivenessProperties` raises with it. The walk seam in the commit before it publishes nothing — module exports only, re-exported by no barrel — so one changeset covers the pair. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check11 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e0ce931ba723f5dde9305044fad41761f506e94b && git checkout e0ce931ba723f5dde9305044fad41761f506e94b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d00692f5d44c8f7ecdd57668d41bfc0df9eee616 308afc86d31a2624d3eb5a33dc3552fbc7b7bff9 && git checkout -B drift-repro d00692f5d44c8f7ecdd57668d41bfc0df9eee616 && git merge --no-ff 308afc86d31a2624d3eb5a33dc3552fbc7b7bff9
node scripts/docs-audit/affected-docs.mjs --json d00692f5d44c8f7ecdd57668d41bfc0df9eee616 |
Contract reviewServed-tier: 93/93
① Derived judgments
② Semver level
③ Boundary flags
Re-review passes when ①.8 is corrected in the changeset (one sentence). Advisory, not required: ①.6 wording, the Implemented-by: VERDICT: FAIL Record written 2026-09-21T02:39Z. Generated by Claude Code |
…comments Contract review on PR #19480 came back FAIL on one ground, re-measured first-hand here before changing anything rather than taken on trust. REQUIRED. The changeset promised a suppression path that does not exist: "`suppressWarnings` accepts the slug like any other". Measured in this tree: `suppressWarnings` is declared once, on the dashboard WIDGET (`packages/spec/src/ui/dashboard.zod.ts:1081`, "Build diagnostic rule ids suppressed on this widget"); its only non-test consumer is `validate-widget-bindings.ts:751`, reading `w.suppressWarnings` off a widget; `lint-liveness-properties.ts` has 0 hits against a control of 12 matching lines in `validate-widget-bindings.ts`; and the CLI has no per-rule suppression at all — `packages/cli/src/utils/i18n-extract.ts` states it in-tree at line 1054 ("the CLI has no per-rule suppression, only `--skip-i18n`"), while `commands/lint.ts` carries one `suppress` hit, a comment about stdout, against a control of 41 lines mentioning `rule`. This finding's subject is a ledger rather than an authored item, so there is no surface to carry the key even if one existed. The text now says that and points at the remedy its own hint names. The wording follows the house shape already used for the same fact in `validate-chart-bindings.ts` and in this package's shipped CHANGELOG. Three advisory corrections ride along, all prose: - The changeset called `authorWarnedProperties` "unchanged" for a broken ledger. Lenient in one input: at base `const props = ledger.props || {}` sat OUTSIDE the try, so a document parsing to `null` threw a TypeError out of it; it now returns the empty set like the other two legs. - The seam docblock's "the built `.d.ts` surface is unchanged" is true of the two seam symbols it is scoped to and false of the change as a whole, which adds one published rule id. Scoped, and the addition named. - The `suppressWarnings` half of the house phrase in this change's own `src/index.ts` comment. The pre-existing instance on `PERMISSION_RETIRED_LIFECYCLE_RESIDUE` is left alone: this change does not make it false, and it belongs to another rule. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
Taken so the gate union is run against a tree that exists: dispatch-gates flagged this head as 3 commits behind with scripts/check-published-files.mjs changed in that range, i.e. one of the families it derives would have run from a stale local copy. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
CI red on
|
| probe | result |
|---|---|
#10262 |
HTTP 404 — does not resolve |
⭐ control #10261 |
HTTP 200 |
⭐ control #10263 |
HTTP 200 |
⭐ controls #7079, #19268 |
HTTP 200 |
#10262 citations this diff ADDS (plus-side only) |
5 |
| every other number this diff cites | #19276 ×16 · #19268 ×10 · #11385 ×7 · #19187 ×2 · #7079 · #5648 · #11288 — all resolve |
The immediate neighbours resolve, so the 404 is about that number specifically — ⛔ not a range gap and ⛔ not a dead instrument.
Why this is not the "not this PR's" branch
The check is green on main and the diff is what introduces the citations, so none of the three carve-outs applies: it does not name a service this diff leaves alone, it does not reproduce on the base, and it did not die before its body ran. ⇒ fix and push. The gate judges only the citations this change adds, so the 5 added ones are the whole subject; the #10262 mentions already on the base are outside it and ⛔ are not swept here — that would widen the PR.
⚠️ One correction to the at-tier review, on the record
The contract review (5754684296, ③) named this same #10262 404 and called it "Not a blocker", escalating it to the maintainer. That judgement is wrong on the mechanics — a required gate enforces it and it is now red. The escalation stands as an open question (what number was actually meant), but it does not gate the remedy: the PR cannot land carrying a citation that 404s, and removing the added ones needs nobody's decision. ⛔ The review's substantive ①/② findings are untouched by this correction.
What happens next
The brief is with the dev, who holds the worktree (⛔ the PM writes no code): drop the 5 added citations where the prose survives without them, or replace them with a number verified to resolve; if the dev's own reading says #10262 is right and the board is wrong, they report that and change nothing, and this seat takes it to the maintainer rather than let a guess land. The ①.8 changeset sentence this PR was FAILed on is already corrected on 462e0bb7.
Reading taken 2026-09-21T03:20Z.
Generated by Claude Code
…ng every pointer
`Lint & Repo Gates` went red on step 180, `Issue citations this change
adds resolve on the board`. Reproduced locally with the gate's own
second invocation — the half that reads the board and that a local
`pnpm check:issue-citations` alone does not perform:
citations judged: 23 across 3 file(s)
20 resolves · 3 allocated-but-absent
--probe-cause: 3 deleted — minted, gone from the board, and the web
endpoint 404s too
All three were citations THIS branch added, in the new walk-seam
docblock. Two more of the same number were added in the test file. The
chain-head card recorded that same 404 with a lit control and
deliberately declined to invoke the number; the at-tier review flagged it
and called it not a blocker, which was wrong on the mechanics — a
required gate enforces it.
Every one of the five named a block that lives in this file or its test,
and each of those blocks still carries the number in its own header on
the base. So the pointer stays and the citation goes: "the test seam
below `getNested`", "the array fan-out seam above". The sentences read
the same and now point at something a reader can find.
⛔ No replacement number is guessed — the gate's own refusal text forbids
it ("guessing an upstream is exactly how a dangling reference becomes a
wrong one") and no number was verified to be the intended one. ⛔ The
pre-existing citations on the base are left exactly as found: the gate
judges only what a change adds, and sweeping them would widen this PR
past both its cards. The question of which number was meant stays
escalated to the maintainer, where the review left it.
Both halves now read exit 0: 20 citations judged, 20 resolve.
Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
Co-authored-by: Claude <noreply@anthropic.com>
Second gate-list refresh: dispatch-gates flagged the previous head as 2 commits behind with lint.yml and package.json changed in the range, i.e. the family LIST itself was derived from stale copies. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: 62/62
① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
… report / skill / email_template / mapping (objectstack-ai#19517) Fixes objectstack-ai#19542 Clause-②: no⚠️ **Card re-pointed 2026-09-21: the original card objectstack-ai#19474 became unreachable (HTTP 404) when the `os-sam` account that filed it was banned, so `Fixes objectstack-ai#19474` now reads `Fixes objectstack-ai#19542`, its verbatim rebuild. The claim, the round-1 FAIL record `5756052587` and every correction still read on the old card and are linked from objectstack-ai#19542. ⛔ Nothing about the delivery changed.** ⭐ **Corrected by the owning seat after the round-1 contract review (record `5756052587`): this lands FIVE wired types, not six — `skill` is HELD OUT. The original sentence is struck through rather than deleted.** ~~Six metadata types declared `allowRuntimeCreate: true` and reached **zero** author-time rules at the runtime publish gate. This wires them,~~ Six metadata types declared `allowRuntimeCreate: true` and reached **zero** author-time rules at the runtime publish gate. This wires **five** of them — `action`, `hook`, `report`, `email_template`, `mapping` — and holds `skill` out as a reading, under the ADR-0049 ruling on objectstack-ai#19275 (`5754204885`, batch objectstack-ai#203 item 4, letter B — 「declared ⇒ honoured; not honourable ⇒ retired」), groups **A** (`action` · `hook` · `report` · `skill`) and **C** (`email_template` · `mapping`), as one card. ## The first reading the ruling asked for The ruling carried forward one NOT MEASURED item unchanged — 「whether a wired rule fires on a real write」 — and made acceptance behavioural. Here it is, per type, each with the test that proves it. All legs go through the real door (`runRuntimeAuthoringRules`), never through a rule called directly. | type | wired rule | a bad write is… | test | a good write passes | |:--|:--|:--|:--|:--| | `action` | `validateStackExpressions` | **REFUSED** (`expression-invalid`) | `⭐ LIT — an action whose visible CEL does not parse is REFUSED`, `⭐ LIT — an action bound to an object, naming a field it has not got, is REFUSED` | ✅ ×2 (synthetic + `crm_convert_lead` verbatim) | | `hook` | `validateStackExpressions` | **REFUSED** (`expression-invalid`) | `⭐ LIT — a hook whose condition names a field the object has not got is REFUSED`, `⭐ LIT — a hook whose condition does not parse is REFUSED` | ✅ ×2 (synthetic + `showcase_audit_task_completion` verbatim) | | `report` | `validateChartBindings`, `validateEmptyCombinators`, `validatePresetComparands` | **REFUSED** (`chart-dataset-unknown`, `chart-dimension-unknown`, `filter-empty-combinator`, `filter-preset-comparand`) | four `⭐ LIT` cases, one per rule id | ✅ ×2 (synthetic + `completed_tasks` verbatim, a filter-carrying member of the corpus) | | `skill` | — | ⛔ **HELD OUT of this landing** — the rule resolves into `stack.tools` / `stack.actions` and the door carries neither, so the corpus's own AI-exposed stack-level action reads as a **FALSE** `unresolved` advisory and a good write does NOT pass clean. Takes the ruling's group-B treatment of `tool`: a reading, not a wiring. Both halves of the wiring are held absent by pins. | `⭐ DARK — a skill write dispatches NOTHING, and has no stack key` · `⭐ LIT — the reason, reproduced: one skill, one rule, two universes` | n/a | | `email_template` | `lintLivenessProperties` | **dispatched, judges NOTHING today** — ledger-driven with 0 warn keys | `writes DO dispatch the ledger rule` + `the rule judges NOTHING today` | ✅ (`showcase_task_done_email` verbatim) | | `mapping` | `lintLivenessProperties` | **dispatched, judges NOTHING today** — same reason | same pair | ✅ (`showcase_inquiry_feed` verbatim) |⚠️ **Three of the five wired types refuse and two are silent; `skill` is held out.** That is the ruled end state, not a shortfall — see the two readings below. Nothing here is a `surfaces` / `runtimeTypes` field that merely changed. ## Each control was shown to be load-bearing A green control that would be green anyway proves nothing, so each declaration was reverted and the tests watched. Every mutation is proven on disk (anchor count + blob hash) and every restore proven byte-identical to `HEAD`, via `scripts/ablation-replace.mjs`. | ablation | tests that went red | |:--|:--| | `validateStackExpressions` `runtimeTypes` back to `['flow']` | **8** — every `action` and `hook` case | | delete `report: 'reports'` from `TYPE_TO_STACK_KEY` | **8** — every `report` case | | `validateAiToolReferences` member back to the `['flow']` default | **3** — every `skill` case | | `lintLivenessProperties` back to `CLI_ONLY` + `surfaceReason` | **6** — every group C dispatch case | | declare `object` on `lintLivenessProperties` (against the re-pointed objectstack-ai#4716 fence) | **3** — the fence refuses it, as before | ## Measured before crossing, at the door's own snapshot shape Every item of these types shipped in this monorepo, pushed through the gate's real baseline/candidate differential: | type | population | differential findings | |:--|:--|:--| | `action` | 79 (showcase 70, todo 8, crm 1) | **0** | | `hook` | 6 (showcase 4, todo 1, crm 1) | **0** | | `report` | 9 (showcase 4, todo 5) — **5 carry an authored filter key**, so the two filter rules were exercised non-vacuously | **0** | | `email_template` | 1 | **0** | | `mapping` | 1 | **0** | | `skill` | **0 — NOT MEASURED, and now moot** | the example corpus authors no skills; `skill` is held out of this landing, so no budget is owed | ## Two readings that are part of the deliverable **1. `email_template` and `mapping` are wired and SILENT.** `lintLivenessProperties` is ledger-driven and skips a type whose warn map is empty. `packages/spec/liveness/email_template.json` is 13 props / **0** warn keys, `mapping.json` is 7 / **0** — lit control on the same instrument, same run: `tool.json` 6/1, `object.json` 35/1. The ruling dispatched the wiring and ⛔ no ledger-population work: 「the empty warn maps stay empty until a real property needs a row — zero pull, the wiring is the whole deliverable」. Both halves are pinned — that the rule **is** dispatched, and that it judges nothing — plus a lit control proving the same instrument fires in the same process on a ledger that does warn, so the two zeros can never be confused with a broken dispatch or an unresolvable ledger directory. **2. A `skill` write is judged with a PARTIAL tool universe.** `collectToolUniverse` unions the platform tool registry ∪ `stack.tools` ∪ the action family from `stack.actions` and every object's `actions`. A per-write snapshot carries `objects` (so an object-level `action_NAME` resolves) but neither `tools` nor `actions`, so a skill naming a stack-level declared tool reads as unresolved at this door while it is clean on the whole stack. Two things bound it: ADR-0109 states the default authoring path declares no tool records at all, and this member is `warning`-tier throughout — it advises and **can never refuse a publish**. Pinned in both directions, so it can only change deliberately. Closing it properly means carrying `tools` / `actions` in `RuntimeStackContext`, which is also an edit to `@objectstack/metadata-protocol`'s routing table — outside this card's file surface and its own decision. ## The fences, and what deliberately did not cross - ⛔ **`action` / `hook` do NOT dispatch the reference-integrity suite.** It carries the four body-writes members, which parse authored JS through `typescript`/`sucrase` — and an action/hook write is precisely the snapshot that would carry a body for them to parse. That is the one crossing that turns `runtime-lazy-deps.test.ts` tier 1 («the parsers load NEVER») from a standing fact into a red. Pinned as a DARK case; `runtime-lazy-deps.test.ts` is green. - ⛔ **`validateActionNameRefs` / `validateActionDispatchContract` do not cross either** — they read `stack.actions` as a resolution *universe* for a view's button wiring, so an action write can only make a reference resolve, i.e. only REMOVE findings, which the differential already discards. - ⛔ **`lintLivenessProperties` still does not reach the OBJECT door.** `RUNTIME_OBJECT_ADVISORY_VOLUME` is about ~8 advisories per object write rendered in Studio; `object` is not declared, so that reason is untouched. - **`validatePresetComparands` and `validateEmptyCombinators` cross to `report` TOGETHER** (objectstack-ai#7220): both judge the same authored filter literal on the same `reports` surface, so an author refused for a bad comparand and waved through for a literal `$and: []` on the same report could not predict the door. - **`report` and `skill` each reach exactly ONE suite member**, pinned by name. ## One pin was re-pointed, and it is the interesting one The objectstack-ai#4716 Q2 fence in `runtime-gate.object-writes.test.ts` asserted that each of six advisory-tier rules is absent from the object door **and** carries a substantive `surfaceReason`. Until now every fenced rule happened to be off the runtime surface entirely, so the `surfaceReason` clause was a faithful proxy for the fence. `lintLivenessProperties` crossing for two non-object types broke the proxy without touching the thing it stood for. The fence now asks the question **directly** — a rule on the runtime surface must not declare `object` in `runtimeTypes` — which is the *stronger* of the two arms, mechanical where a `surfaceReason` is prose that goes stale. ⛔ Neither arm is a way around the fence, and the ablation above confirms it still refuses an object crossing. ## Verification - `pnpm --filter @objectstack/lint test` — **107 files / 4074 tests pass** (head `1ac5e9779b`); `pnpm --filter @objectstack/lint typecheck` — clean (test layer compiles under `tsconfig.test.json`). - Downstream gate consumer: `@objectstack/metadata-protocol`'s five runtime-gate suites — **63 tests pass**. - `pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'` — 72/72. - **63 of 63** derived gate families, re-derived and re-run on head `1ac5e9779b`, (`scripts/pm/dispatch-gates.mjs`) run, reconciled with `--ran`, **0 NOT-MEASURED, 0 UNRUN**, every one recording an exit code. - `eslint . --no-inline-config` over the **whole repo** — **6971 files, 0 errors, 0 warnings** (head `1ac5e9779b`) (not a narrowing: the full sweep ran). - `pnpm check:nul-bytes` green, plus a direct control-byte scan of every changed file. - Merged `origin/main` (`c9b23cd`) after objectstack-ai#19480 landed in `lint-liveness-properties.ts`, refreshed install + full build, and re-ran the above. ## Acceptance notes Observed while measuring, ⛔ not fixed here, routed to the PM: - **objectstack-ai#19276's `liveness-ledger-unreadable` cannot reach the runtime publish door.** Measured with `mapping.json` corrupted: the whole-stack rule emits `["liveness-ledger-unreadable"]` while the runtime door emits `errors: []`, `advisories: []`, `rulesRun: ["lintLivenessProperties"]`. The finding is stack-independent, so it appears identically in the gate's baseline and candidate passes and cancels in the differential. Not introduced here — but before this card the rule never ran at that door, so there was nothing to cancel. The signal says 「this rule's silence about this type means nothing until it is fixed」 and at this door it is itself silent. - A stack-level `action` binds its object with `objectName`; the `object` spelling is an alias the strict schema renames one layer earlier, so the door judges an object-bound action's predicate with full field resolution and an object-less one for syntax only. Measured, correct, and not a gap — recorded so the next reader does not re-measure it. --- _Generated by [Claude Code](https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2)_ --- ## Seat corrections, 2026-09-21T06:40Z The round-1 at-tier contract review (`5756052587`) returned **FAIL** on two grounds; both are addressed at head `72de2b946301aa59f624da807ae7fc383e82b81e`, and this body — written once at creation, per the dev-writes-it-once rule — is corrected here by the owning seat rather than by the dev. 1. **`skill` is held out.** Group A lands as **three** types, not four. The dev took the review's route (b) over route (a) and its reasons are on card objectstack-ai#19474; whether that is inside the ruling's logic or a scope reduction its author should decide is the **round-2 reviewer's** question, ⛔ not settled here. 2. **The changeset now declares.** `**BREAKING for runtime metadata writes**`, `Clause-②: no (narrowing)`, and one `adr-0087: not-required (no-migration-prescription)` marker.⚠️ The two-line fix did not suffice: with the banner added the ADR-0087 gate still refused `not-required` against this body's framed Migration **table** (the objectstack-ai#6048 shape). The table was replaced with prose — the content has no FROM to translate — ⛔ not the category swapped to get past the gate. 3. **The objectstack-ai#4716 fence wording is corrected.** It no longer claims to be 「the stronger of the two」; it now records that the crossed arm is implied by the `atDoor` assertion and is strictly **weaker** than the clause it replaced. ⛔ No code change: the wording was what was false. 4. **Group C's proof size is recorded** where a reader meets it: a wrong `TYPE_TO_STACK_KEY` key for `email_template` / `mapping` reds exactly one string pin and no behavioural case, because those rules judge nothing at that door. ## Seat corrections, round 3 — the red suite and where it came from Round 2's at-tier review returned **PASS** (record `5757740876`), but CI on that head was **red**: `Test Core` shards 4/6 and 5/6 failed, and `Test Core` failed with them. ⛔ The PR was not landed on the PASS. It is recorded here because the miss is structural rather than careless. **It was this PR's, measured before anything was touched:** on `origin/main` `3e8e2b0d6d` all six shards read success; on the PR head two failed. **One root cause, four test files, two packages — and it is this card's own door working correctly.** Each file authors a `report` binding a dataset its harness never declares, into a universe that is **empty by construction** (`find` mocked to `[]`; `listItems: () => []`). Since the report door opened, `validateChartBindings` resolves that binding and refuses the write with `chart-dataset-unknown` before the assertion each file exists to make. ⭐ The refusal is **true** — those reports really do bind nothing — and `ReportSchema` refines `dataset` to **required**, so dropping the binding was never available: a report either binds a dataset the tenant has, or it is not a report. The fix seeds that dataset into each harness's live universe, the landed pattern from `protocol.dashboard-dataset-publish-gate.test.ts`. ⛔ No test was skipped, disabled or quarantined; every whitelist, hash, org-scope, history and rejection assertion is untouched, and a report binding a dataset nobody declares is still refused.⚠️ **Why a dependents sweep could not see it.** `@objectstack/objectql` and `@objectstack/rest` declare **no** dependency on `@objectstack/lint` — they reach the gate through `@objectstack/metadata-protocol`. ⇒ for a card that widens a publish gate, the blast radius is **every package that drives `saveMetaItem`**, ⛔ not the package graph under the rule registry.⚠️ **Declared file surface breached, and reported rather than widened silently.** The dispatch declared `packages/lint/src/`; this round necessarily reached two test files each in `packages/objectql/src/` and `packages/rest/src/`. All four are test-harness fixtures made truthful — no production code, no rule touched — which is the standard shape for a door-widening card (objectstack-ai#15254, objectstack-ai#19143 did the same). ⛔ One adjacent repair was **declined**: `metadata-validation-sweep.test.ts` still prints `dataset: no fixture (skipped)`, and adding that fixture surfaces a **pre-existing** `object-reference-unknown` from the same empty-universe condition. It was reverted and filed as its own card rather than carried here. ### The false sentence, corrected — and it had to be corrected twice `runtime-gate.ts` claimed 「Twelve of the sixteen mappings」 and 「objectstack-ai#19474's four rows」. Counted from the table rather than by eye: **fifteen** rows above `position`, four of them context collections ⇒ **eleven of fifteen**, and this card lands **three** rows. Both figures were true at the round-1 head; withdrawing the `skill` row falsified them, and they contradicted this same file's correct 「The three rows」 68 lines above.⚠️ The build does not strip comments, so the sentence ships in `dist/index.js`, `dist/runtime.js` and both `.cjs`. ⭐ The correction was made once, **lost**, and made again: it was still uncommitted when an ablation's restore leg ran `git checkout HEAD -- runtime-gate.ts` and discarded it silently at exit 0 — the hazard `AGENTS.md` names in as many words («commit the fix FIRST»). It was caught only by reading the sentence back out of `git show HEAD:…` instead of trusting the edit, and it is now confirmed present in the built bundles. The provenance note is **kept and extended**, ⛔ not deleted: objectstack-ai#19370's 「eight of the twelve」 is recorded as true of the table it was written against, and the withdrawn-row step is recorded instead of leaving a silent jump. ## Seat corrections, round 4 — every citation in this diff now resolves Round 3 was red on `Lint & Repo Gates`: the issue-citation gate reported `[allocated-but-absent]` — 「minted and absent from the board」 — because the account that filed cards objectstack-ai#19474 and objectstack-ai#19370 was **banned**. ⛔ Neither issue was deleted; `GET`/`PATCH` on them answer **404** while their comments and timelines still resolve, and they vanish from label listings. Card objectstack-ai#19474 was rebuilt verbatim as **objectstack-ai#19542**, which is what this PR closes. ### The fix was bigger than the ten lines the job printed, and the job said so The gate stops at the first non-zero exit, and its own tail states 「the red above is a **LOWER BOUND** on the number of problems in this tree, not a count」 and that the gates behind it are **NOT MEASURED**. ⇒ the dev enumerated every `#NNNN` the diff **adds** — 11 distinct numbers — and probed each against the API rather than trusting the printed list. Result: **25** `objectstack-ai#19474` sites across ten files, not nine. All 25 re-pointed to `objectstack-ai#19542`. ⭐ ⛔ **Not a guess**, and verified before editing rather than after: objectstack-ai#19542 resolves, its title opens `[rebuild of objectstack-ai#19474]`, its body states the original is unreachable and tabulates the same 404 readings, and this PR already closes it.⚠️ The **changeset** was re-pointed too. `.changeset/**` is not a judged surface, so the gate would never have caught it — but that text ships verbatim into `CHANGELOG.md`, and a dangling number there would outlive the card. ### `objectstack-ai#19370` is NOT re-pointed, and two assumptions were corrected by measurement That citation is **historical provenance** — what objectstack-ai#19370's author wrote, and when it was true. Re-pointing it would rewrite history to satisfy a gate. 1.⚠️ **PR objectstack-ai#19486 — the obvious live record to name — also answers 404**, filed by the same banned account. Naming it would have minted a second dangling reference to fix the first. The **merge commit** `a227afa415f596269ed36aae0a0631c84270ccc9` is named instead: it is in history, carries that card's whole diff, and cannot rot. 2.⚠️ **Prose alone does not satisfy the gate.** Keeping `objectstack-ai#19370` and explaining in prose that it no longer resolves still exited 1 with two `[allocated-but-absent]` findings — the gate judges every bare `#N` on an **added** line against the board regardless of surrounding text. Reading its own sentence again resolves it: **a dead number dressed as a live link IS the dangling reference.** So the number is kept and spelled as what it now is — a historical card id, without the citation sigil — with the reason inline and the commit named. ⛔ The `owner/repo#N` qualifier was **explicitly not** used: it makes the gate skip probing by declaring a cross-repo reference. This is not one, and using it to buy silence would be evasion. ⇒ the two `[objectstack-ai#19370]` citations that remain in `runtime-gate.ts` sit on lines this diff does **not** touch — they are objectstack-ai#19486's landed text, outside the gate's diff scope and ⛔ not this PR's to rewrite. ### Verification on this head `check-issue-citations` (the diff-scoped form `lint.yml` runs) — **15 citations judged, 15 resolve, exit 0**. `--self-test` exit 0, 73 cases. All **63** derived gate families re-derived and re-run in ONE sweep, reconciled with `--ran`: 0 NOT-MEASURED, 0 UNRUN, none exiting 3. `@objectstack/rest` — the package shard 5/6's six failures lived in — **194 files / 3254 pass**, and `@objectstack/objectql` (shard 4/6) **303 / 5050**; ⛔ neither assumed from the earlier fix, both re-run here. All five ablations re-run with unchanged red counts, and ⭐ run only **after** the citation fix was committed and read back out of `git show HEAD:…` — the sequencing that lost a correction one round earlier. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #19268
Fixes #19276
Clause-②: yes
Two findings, one file, one shape: an empty warn map silently kills a walk, and there was no seam from which to drive it. Both live in
packages/lint/src/lint-liveness-properties.ts, both cards name the same successor, and triage asked for one loud failure path rather than two point fixes — so they land together, one commit per member.#19268 — the field walk lost its subject, and the guard behind it went unreachable
lintLivenessPropertiesputs the entire object/field loop behindif (fieldWarn.size > 0).field.relatedListFilterwas the onlyauthorWarnrow onfield.jsonat any depth, so when #19187 correctly flipped itlive(landed by PR #19265, merged 2026-09-20T09:40:32Z) the field loop stopped executing altogether — taking #11385'sif (!isRecord(field)) continueguard out of reach of the public function. There is no second warned field row to re-hang it on: the field walk readsfield.jsonand nothing else.Re-measured on this branch rather than relayed:
field.jsoncarries 0authorWarnkeys, with a LIT CONTROL of 87statusrows in the same file walked by the same traversal, so the 0 is a reading.object.jsonandtranslation.jsoncarry 1 each (externalSharingModel,flows).The card is explicit that the walk 「应当由那条缝来关,而不是等一条新的被警告行」, so the fix is a seam and not a new ledger row. The walk is split from ledger RESOLUTION, and the seam is the ledger directory:
A directory rather than a ready-made warn map, for two reasons that only a directory satisfies at once.
checkItemAgainstWarnMap(the #10262 seam) cannot be the subject here — it takes one ITEM, and what #11385 guards is the walk that finds the items. And #19276's fault is born insideloadWarnMap, so a seam accepting ready-made maps would bypass the code under test. Both are now driven the same way: copy the shipped ledger directory, change ONE file in the copy, run the real rule against it. No future ledger flip can empty these assertions — the third time a correct flip deleted coverage in this file (#7079, #10262's block, now this).#19276 — a ledger that could not be read is now reported, once
loadWarnMapreturned the same empty map for two different facts: "this type's ledger classifies nothing as warn-worthy" and "there is no ledger". Missing file and broken JSON both returned empty with no log, no throw and no other signal, so losing or corrupting ONE file under the shippedliveness/directory switched every author warning for that metadata type off in silence. One frame up the directory-level failure is loud by construction: 「按目录响,按文件不响」 is the contrast that defines the card, and the directory leg is deliberately untouched here.loadWarnMapnow returns the map plus an optionalfault, andlintLivenessPropertiesraises oneliveness-ledger-unreadablefinding per faulted type — once per run, never once per item, ahead of the walk's own findings because it says why the rest may be short — and keeps walking every type whose ledger IS readable.A third failed-read shape falls to the same branch, and it is a correctness requirement rather than scope: a document that parses but is not a ledger.
JSON.parse('null')madeledger.propsa TypeError — a throw out of a rule whose contract is that it never throws, through the one input an author cannot influence. Measured across all 39 shipped ledgers, every one carries apropsrecord, so this branch describes a corrupt file and never a legitimately empty one ({"props": {}}stays a reading).authorWarnedPropertiesis unchanged and still answers the empty set — a decision procedure returning a set cannot report a failed read — andos lintruns both halves in one pass, so the run now states it once rather than never.The published surface: measured, not assumed
packages/lintis a published package and the dev on PR #19265 declined this work because a seam would grow its test surface. The seam did not end up on the published surface. Measured afterpnpm --filter '@objectstack/lint...' build, grep overdist/index.d.ts+dist/index.d.cts+dist/runtime*.d.ts:lintLivenessPropertiesFromLedgerDirresolveLivenessDirLedgerFaultcheckItemAgainstWarnMap(the #10262 seam — second control)lintLivenessProperties— LIT CONTROLLIVENESS_LEDGER_UNREADABLE— LIT CONTROLThe two lit controls are what make the zeros readings.
package.jsonis untouched: theexportsmap still publishes exactly.and./runtime, andtsupbuilds only those two entries, so no consumer can reach the seam.One published addition, deliberate, and it is not the seam: the rule id
LIVENESS_LEDGER_UNREADABLE('liveness-ledger-unreadable'), re-exported fromsrc/index.tsbeside the four verdict ids. #19276's fix IS a finding, a finding carriesf.rule, and this package's own contract test (rule-id-barrel-exports.test.ts, #5648) requires every rule id constant to be reachable from a published barrel — a slug-shaped constant no barrel re-exports is the defect that test exists to name. It is additive, which is what theminorchangeset declares. It is not a fifth verdict: the other four grade a property the ledger DID classify; this one says the classification never arrived, so its presence means no other finding about that type can be trusted.Evidence — the discriminating probe, both legs, both trees
#19276 recorded its own probe as NOT MEASURED, and corrected its filing dev: removing
field.jsonis not discriminating, because that file had already lost its only warned row. Run here onobject.jsonandtranslation.json, both legs, with the intact control and the whole-directory positive control. The instrument is a driver callinglintLivenessPropertieson a stack authoringobject.externalSharingModel,translation.flows,agent.memoryandfield.relatedListFilter.fbc12be(rule reverted)object.jsonMISSINGliveness-ledger-unreadablenamingobjectobject.jsonUNPARSEABLEtranslation.jsonMISSINGtranslationtranslation.jsonUNPARSEABLEliveness/gone — positive controlThe BASE column IS the defect: the type's warnings vanish and the output is indistinguishable from "that type is clean". Both columns agree on the directory leg, which is how the excluded leg is shown untouched.
Ablation discipline: run from the committed state; every leg proved its mutation on disk before the run and restored under a
trap, verified bygit hash-objectagainst the HEAD blob; finalgit diff HEADempty andgit status --porcelainempty. The revert leg usedgit restore --source=BASE(worktree only, never staged) and restored withgit checkout HEAD --.Evidence — one correction to the card's framing, measured rather than argued
At SUITE level, removing
object.jsonis not silent even on base.vitest run src/lint-liveness-properties.test.ts:object.jsonremovedobject.jsonremovedSo "71 passed, zero red" was a property of removing
field.jsonspecifically, not of the file-level blind spot in general. The base-side redness is incidental: several contract tests assert a positive finding sourced fromobject.json, and those are this repo's tests against the shipped ledgers. A consumer runningos linthas none of them, and the rule's own output was silently one finding shorter — which is the code-level table above. The finding stands; its evidence is sharper.Verification
pnpm --filter @objectstack/lint test— 106 files, 4018 passed, exit 0, on the merged head. The rule's own file: 84 tests, 13 of them new.pnpm --filter @objectstack/lint typecheck— exit 0, includingcheck:test-typecheckovertsconfig.test.json, so the test layer is covered too.pnpm --filter '@objectstack/lint^...' buildthen'@objectstack/lint...'— exit 0.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackand re-run on the merged head, each exit code captured before any pipe: 59 derived, 56 exit 0, 3 exit 3 (PREREQUISITE NOT MET), 0 unrun, reconciled with--ran. The three not measured arecheck-plugin-teardown-shape --self-test(shallow clone cannot reach its pinned positive-control commit; the same family's PR-verdict run exits 0),check:dual-build-cjs-loadsandcheck:type-check-debt— both need a fullpnpm buildof all 85 packages, which CI does.pnpm lint(eslint . --no-inline-config) — repo-wide, exit 0 in 1m38s at58900e6. Not narrowed, so no narrowing argument is owed.origin/mainmerged at841ed38before opening; rebuild, suite, typecheck and the whole gate union re-run afterwards on58900e6.Acceptance notes
Noted, not filed — neither is a reproducible defect, a contract violation or an authoring trap:
shippedLedgerStatuses()reads the same directory throughresolveLivenessDir()andreaddirSync, and swallows an unreadable directory and each unparseable file the same way. It is not the same defect: its consumer is a coverage pin in this package's own test, which carries its own anti-vacuity guard (shippedLedgerStatuses().has('live-elsewhere')), so an empty answer there goes red rather than silent. Successor: whoever next widens the fault reporting pastlintLivenessProperties.authorWarnedProperties(type)still answers the empty set for an unreadable ledger, so the CLI's i18n coverage walker alone would still gate nothing. It is not a separate hole after this change:os lintrunslintLivenessPropertiesin the same pass withcommands: ALL, so the run reports the fault once. Recorded because the two halves' docblocks now state that explicitly, and the next person to split them apart needs to read it. Successor: whoever gives the demand side its own entry point.Both were already true before this branch and neither is made worse by it.
Generated by Claude Code