Skip to content

fix(lint): give the liveness walk a seam of its own, and report a ledger that could not be read - #19480

Merged
os-warren merged 8 commits into
mainfrom
claude/issue-19268-liveness-walk-seam
Sep 21, 2026
Merged

os-warren merged 8 commits into
mainfrom
claude/issue-19268-liveness-walk-seam

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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

lintLivenessProperties puts the entire object/field loop behind if (fieldWarn.size > 0). field.relatedListFilter was the only authorWarn row on field.json at any depth, so when #19187 correctly flipped it live (landed by PR #19265, merged 2026-09-20T09:40:32Z) the field loop stopped executing altogether — taking #11385's if (!isRecord(field)) continue guard out of reach of the public function. There is no second warned field row to re-hang it on: the field walk reads field.json and nothing else.

Re-measured on this branch rather than relayed: field.json carries 0 authorWarn keys, with a LIT CONTROL of 87 status rows in the same file walked by the same traversal, so the 0 is a reading. object.json and translation.json carry 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:

export function lintLivenessPropertiesFromLedgerDir(dir: string, stack: AnyRec): LivenessLintFinding[]

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 inside loadWarnMap, 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

loadWarnMap returned 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 shipped liveness/ 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.

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, 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') made ledger.props a 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 a props record, so this branch describes a corrupt file and never a legitimately empty one ({"props": {}} stays a reading).

authorWarnedProperties is unchanged and still answers 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 now states it once rather than never.

The published surface: measured, not assumed

packages/lint is 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 after pnpm --filter '@objectstack/lint...' build, grep over dist/index.d.ts + dist/index.d.cts + dist/runtime*.d.ts:

symbol occurrences in the published type surface
lintLivenessPropertiesFromLedgerDir 0
resolveLivenessDir 0
LedgerFault 0
checkItemAgainstWarnMap (the #10262 seam — second control) 0
lintLivenessProperties — LIT CONTROL 12
LIVENESS_LEDGER_UNREADABLE — LIT CONTROL 4

The two lit controls are what make the zeros readings. package.json is untouched: the exports map still publishes exactly . and ./runtime, and tsup builds 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 from src/index.ts beside the four verdict ids. #19276's fix IS a finding, a finding carries f.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 the minor changeset 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.json is not discriminating, because that file had already lost its only warned row. Run here on object.json and translation.json, both legs, with the intact control and the whole-directory positive control. The instrument is a driver calling lintLivenessProperties on a stack authoring object.externalSharingModel, translation.flows, agent.memory and field.relatedListFilter.

ledger state BASE fbc12be (rule reverted) HEAD (fixed)
intact — control 3 findings 3 findings, identical
object.json MISSING 2 — silently one short 3, one is liveness-ledger-unreadable naming object
object.json UNPARSEABLE 2 — silently 3, same rule, "does not parse as a ledger"
translation.json MISSING 2 — silently 3, naming translation
translation.json UNPARSEABLE 2 — silently 3
whole liveness/ gone — positive control 0 0, unchanged: the excluded leg

The 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 by git hash-object against the HEAD blob; final git diff HEAD empty and git status --porcelain empty. The revert leg used git restore --source=BASE (worktree only, never staged) and restored with git checkout HEAD --.

Evidence — one correction to the card's framing, measured rather than argued

At SUITE level, removing object.json is not silent even on base. vitest run src/lint-liveness-properties.test.ts:

tree ledger state reading
BASE intact 71 passed, exit 0 — the filing dev's baseline number, reproduced
BASE object.json removed 8 failed / 63 passed, exit 1
HEAD object.json removed 42 failed / 84
HEAD whole directory removed — positive control 29 failed / 55 passed

So "71 passed, zero red" was a property of removing field.json specifically, not of the file-level blind spot in general. The base-side redness is incidental: several contract tests assert a positive finding sourced from object.json, and those are this repo's tests against the shipped ledgers. A consumer running os lint has 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, including check:test-typecheck over tsconfig.test.json, so the test layer is covered too.
  • pnpm --filter '@objectstack/lint^...' build then '@objectstack/lint...' — exit 0.
  • Gate family re-derived from the ACTUAL changed paths with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack and 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 are check-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-loads and check:type-check-debt — both need a full pnpm build of all 85 packages, which CI does.
  • pnpm lint (eslint . --no-inline-config) — repo-wide, exit 0 in 1m38s at 58900e6. Not narrowed, so no narrowing argument is owed.
  • origin/main merged at 841ed38 before opening; rebuild, suite, typecheck and the whole gate union re-run afterwards on 58900e6.

Acceptance notes

Noted, not filed — neither is a reproducible defect, a contract violation or an authoring trap:

  • shippedLedgerStatuses() reads the same directory through resolveLivenessDir() and readdirSync, 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 past lintLivenessProperties.
  • 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 lint runs lintLivenessProperties in the same pass with commands: 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

`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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 21, 2026
@github-actions

github-actions Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

11 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 2 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/lint/src/validate-retired-permission-residue.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/lint/src/validate-retired-permission-residue.ts) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 4 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 d00692f5d44c8f7ecdd57668d41bfc0df9eee616 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from e0ce931ba723f5dde9305044fad41761f506e94b — the merge of head 308afc86d31a2624d3eb5a33dc3552fbc7b7bff9 into base d00692f5d44c8f7ecdd57668d41bfc0df9eee616, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

os-warren commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: 93/93 CONTRACT_REVIEW_TIER
Head-sha: 58900e6c14fdbab613000f50a8359703e94cc666

⚠️ How that first line was obtained, stated so it is auditable. The isolated reviewer reported its serving tier NOT READABLE — it checked its process environment, /root/.ccr/README.md and every tool result it produced, and no per-request stamp was exposed to it. That is a limitation of what a subagent can see, ⛔ not a downgrade, and ⛔ not a self-assertion of tier. The adopting seat read the stamp where the fuse says it lives — 「子代理档只取其转录 harness 逐请求 model 盖章」 — by parsing the reviewer's own transcript: 93 assistant requests, 93 carrying model = CONTRACT_REVIEW_TIER, 0 carrying anything else (advisorModel agrees on all 93). ⛔ get_session was not used and would not have counted: it measures the dispatching session. Everything below the tier line is the reviewer's own text, adopted verbatim; ⛔ the seat rewrote nothing and polished nothing.

① Derived judgments

  1. Barrel gains LIVENESS_LEDGER_UNREADABLE (src/index.ts line 859). Reachable: package.json#exports publishes . and ./runtime only; . builds from src/index.ts; built dist/index.d.ts/.d.cts carry it 4 times and both dist/index.js and dist/index.cjs export it (352 keys each) with value "liveness-ledger-unreadable". Published surface addition, additive. Forced by the FLOW_TRIGGER_UNKNOWN_EVENT 规则 id 常量没从 @objectstack/lint 导出 —— 消费者拿不到,只能对字面量 #5648 contract test: removing the line in a scratch copy fails rule-id-barrel-exports.test.ts naming the constant; restored, 4/4 pass. Right.

  2. New rule id / finding liveness-ledger-unreadable. Reaches every consumer of lintLivenessProperties and, via authoring-rules.ts (tier: 'advisory', severity: 'warning' always, commands: ALL = validate/build/lint, surfaces: CLI_ONLY), every os validate/os build/os lint run; never the runtime publish gate (dist/runtime.js has no liveness key). Accept set for AUTHORED metadata is unchanged: the finding depends on the ledger directory, not on the stack, it is never an error, and on an intact installation (39/39 ledgers readable at head) no finding is added. Under os lint --strict / os validate --strict (warnings fail the run) an installation whose spec ledger is missing or corrupt now fails where it silently passed — an environmental precondition surfacing, not a narrowing of authored inputs. Published-surface expansion, no accept-set narrowing. Right.

  3. Silence to finding on missing / unparseable / non-ledger documents. Diagnostics only: the walk still runs with an empty map for the faulted type (walkStack gates unchanged: objectWarn.size, fieldWarn.size, translationWarn.size, warnMap.size === 0), findings appended, fault first. No previously accepted stack is rejected. For a parsed null ledger the base THREW (ledger.props || {} at base line 84), so that input's handling widened (no throw). []/scalar/{} documents went from silent-empty to reported; same class. Right.

  4. Seam functions lintLivenessPropertiesFromLedgerDir, resolveLivenessDir, type LedgerFault. Measured internal at all three levels: source (src/index.ts 0 hits, no export *, runtime.ts 0 hits; only importer authoring-rules.ts imports lintLivenessProperties alone), built types (0 across four .d.ts/.d.cts files, controls 12/4/6), built runtime (absent from 352 ESM and 352 CJS keys). Same posture as the existing finding: the liveness author-lint's array fan-out (getNested) has lost its warned subject a SECOND time — the ledger now has ZERO dotted warned entries #10262 seam, which reads 0 too. Internal. Right.

  5. loadWarnMap signature change (WarnMap to { map, fault? }). Module-private; not exported at any level. No surface. Right.

  6. authorWarnedProperties (published). Missing/broken ledger: empty set, unchanged. Parsed-null ledger: base threw, head returns the empty set — the changeset's "unchanged" is inaccurate in the lenient direction. Advisory nit, not required.

  7. Docblock / cross-reference truth.

  8. Changeset text (published CHANGELOG contract) — MUST CHANGE. "Compare f.rule against the constant, and suppressWarnings accepts the slug like any other" promises a suppression path that does not exist: suppressWarnings is a dashboard-widget key read only by validate-widget-bindings.ts:751; os lint has no per-rule suppression (0 mechanism hits in lint.ts; in-tree i18n-extract.ts [finding] os lint now demands flows.* translation keys and warns the author for writing them — #11615's new bucket collides with the planned liveness row's authorWarn #11624 docblock says so verbatim); the finding's where is a ledger, not an authored item; the finding's own hint says the remedy is reinstalling @objectstack/spec. Wrong. Required: delete that clause, or replace it with a true statement (e.g. that os lint has no per-rule suppression and the finding is a packaging fault cleared by repairing @objectstack/spec).

② Semver level

.changeset/19276-liveness-ledger-unreadable.md declares '@objectstack/lint': minor. ① finds a published-surface expansion (one exported rule id constant, one new finding class reachable through the root entry and the CLI) and no narrowing of the authored accept set, so Clause-②: yes HOLDS and the floor is minor; the declaration meets it and is not under-declared (no major trigger: nothing previously accepted by default is rejected, LivenessLintFinding.rule stays string, no type union widened). @objectstack/lint sits in the fixed group in .changeset/config.json, so the whole group takes the minor — consistent with repo practice. Package and level are right; the changeset's BODY carries the false suppressWarnings sentence named in ①.8, which is the only defect at this level.

③ Boundary flags

  • Head sha reviewed: 58900e6c14fdbab613000f50a8359703e94cc666, identical to the assignment; remote branch tip and PR head both read 58900e6c at review time (PR open, draft, mergeable_state: blocked).
  • Check-runs, latest per name at 2026-09-21T02:35:58Z: 34 distinct names (38 runs, 4 superseded ignored): 29 success, 4 skipped (Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke), 1 in_progress (Lint & Repo Gates), 0 failures. The in-progress run is NOT MEASURED, not a pass. Check Changeset success.
  • Directory-level leg excluded and untouched: ACCEPTED — if (!dir) return []; retained in lintLivenessProperties; no fault is raised for a missing directory.
  • field.json as a probe is non-discriminating: ACCEPTED — 0 authorWarn rows / 87 status rows at head.
  • Other loadWarnMap call sites out of surface: ACCEPTED — authorWarnedProperties touched mechanically only (.map.keys()).
  • Extension to "parses but is not a ledger": ACCEPTED as in-scope — base threw on a parsed null against a never-throws contract.
  • Card [finding] field.json 的最后一条 authorWarn 行一旦 flip(PR #19265),lintLivenessProperties 的整段 field 走查就被 fieldWarn.size > 0 挡死 —— #11385 写的那个守卫从公开函数再也够不到 #19268's "do not reproduce before fix(spec): flip field.relatedListFilter to live — its flip condition landed at the objectui pin #19265 lands" fence: spent — fix(spec): flip field.relatedListFilter to live — its flip condition landed at the objectui pin #19265 merged 2026-09-20T09:40:32Z. ACCEPTED.
  • Noted-not-filed 1 (shippedLedgerStatuses swallows unreadable dir/files): ACCEPTED as out of scope — verified at head; its consumer is the in-package coverage pin with shippedLedgerStatuses().has('live-elsewhere') at test line 1223, so an empty answer goes red.
  • Noted-not-filed 2 (authorWarnedProperties still empty; "not a separate hole because os lint runs both halves"): ESCALATED to the maintainer. True for os lint. But the demand side ALREADY has its own entry point: os i18n check calls computeI18nCoverage, which calls collectExpectedEntries, which calls authorWarnedTranslationGroups(), which calls authorWarnedProperties('translation'), and that command imports no authoring rule (0 hits; control 3 in lint.ts). So under os i18n check --strict an unreadable translation.json still gates nothing in silence. Pre-existing, unchanged by this PR, out of both cards' scope — but the stated reason for not filing ("whoever gives the demand side its own entry point") rests on a false premise. Maintainer to decide whether a card is owed; not a blocker here.
  • Noted-not-filed 3 (ledger format not schema-validated): ACCEPTED — already documented in-tree (rule docblock lines 199-207: vocabulary is documented, not validated).
  • Two text-only docblock corrections outside the cards' paragraphs: ACCEPTED — both read TRUE of the new code.
  • #10262 cross-reference (HTTP 404, control #7079 200): ESCALATED — the PR adds 5 new citations of a number that does not resolve and that card [finding] field.json 的最后一条 authorWarn 行一旦 flip(PR #19265),lintLivenessProperties 的整段 field 走查就被 fieldWarn.size > 0 挡死 —— #11385 写的那个守卫从公开函数再也够不到 #19268 refused to cite; maintainer to decide whether the intended number is recoverable. Not a blocker.
  • Author's self-report figures (dist zeros 0/0/0/0 with controls 12/4; 39 ledgers with props; 84 tests): each independently reproduced above; none was adopted on trust.
  • Three gate families the author recorded as NOT MEASURED (check-plugin-teardown-shape --self-test, check:dual-build-cjs-loads, check:type-check-debt): not re-run here either; they remain NOT MEASURED, and CI's Type Check · debt ledger reads success.

Re-review passes when ①.8 is corrected in the changeset (one sentence). Advisory, not required: ①.6 wording, the .d.ts unchanged sentence scope, and the suppressWarnings phrase in the src/index.ts comment.

Implemented-by: claude/issue-19268-liveness-walk-seam
Reviewed-by: session_01UDXER3sdqfeVYpEWZs5mZx

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>

Copy link
Copy Markdown
Collaborator Author

CI red on 462e0bb7 — this PR's, diagnosed, fix routed to the dev

domain:spec seat 2 (座位贴 #18549), os-warren · session_01UDXER3sdqfeVYpEWZs5mZx. ⛔ Not a flake, ⛔ not somebody else's, ⛔ no re-run spent on it.

The failing gate: Lint & Repo Gates, job 106198401746, step 180 of 188 — Issue citations this change adds resolve on the board (pnpm check:issue-citations && node scripts/check-issue-citations.mjs, .github/workflows/lint.yml:4762). The job's own tail: unmeasured-gate-tail: measured=yes never_ran=2 failed=1 ran=177 skipped_by_condition=1 declared=183 failed_at_step=180 anchored_by=conclusion ⇒ exactly one of 183 declared gates failed, and it is that one. The failing step was read off the jobs API steps[], ⛔ not guessed from log proximity.

⚠️ This is the FIRST real reading of Lint & Repo Gates on this PR. On the previous head f2587e13 it read cancelled (concurrency, when the dev pushed 462e0bb7 20s later) and on 58900e6c it read cancelled as well — both NOT MEASURED, ⛔ never passes. The gate had simply never finished here until now.

The measurement, with controls

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>

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: 62/62 CONTRACT_REVIEW_TIER
Head-sha: 308afc86d31a2624d3eb5a33dc3552fbc7b7bff9

⚠️ Tier provenance. The isolated reviewer read its own transcript and reported 58/58 at that moment, withholding the value by instruction and asking the adopting seat to map it — its own count being a floor that grows with its final turn. The seat re-read the same transcript at close: 62 assistant requests, 62 carrying one identical model stamp, 0 carrying anything else, and that value IS CONTRACT_REVIEW_TIER. ⛔ get_session was not used — it measures the dispatching session. Everything below is the reviewer's own text, adopted verbatim; ⛔ the seat rewrote nothing.

① Derived judgments

  1. The prior FAIL's one required ground (record 5754684296, ①.8) is CLOSED, measured here. The changeset at 308afc8 no longer carries the clause "suppressWarnings accepts the slug like any other" (accepts the slug: 0 hits at head; control: 1 hit at the prior head 58900e6). The replacement text is TRUE on every clause: (i) "the CLI has no per-rule suppression" — packages/cli/src has 0 suppressWarnings hits (control: packages/lint/src/validate-widget-bindings.ts 12, read at line 751); packages/cli/src/commands/lint.ts exposes only --skip-i18n (line 792), a whole-family skip, and its five suppress/silence word hits (lines 1204–1246) concern esbuild logger output under --json, not rules; in-tree i18n-extract.ts:1054 states the same. (ii) "suppressWarnings is a dashboard-widget key (spec/src/ui/dashboard.zod.ts)" — the only Zod declaration in packages/spec/src is dashboard.zod.ts:1081, inside DashboardWidgetSchema (line 803), described "Build diagnostic rule ids suppressed on this widget"; dashboard.form.ts:92 is the form label for the same key. (iii) "this finding's subject is a ledger" — ledgerFaultFindings sets where to liveness ledger '<type>'. (iv) "the finding's own hint names repair or reinstall @objectstack/spec" — the hint reads "Reinstall or repair @objectstack/spec so its liveness/ directory ships intact." (v) "exported from the package root beside the four verdict ids" — src/index.ts lines 846–864 list exactly the five. Right.

  2. Barrel gains LIVENESS_LEDGER_UNREADABLE (src/index.ts line 863 at head). Unchanged since the prior record except for its comment. rule-id-barrel-exports.test.ts (FLOW_TRIGGER_UNKNOWN_EVENT 规则 id 常量没从 @objectstack/lint 导出 —— 消费者拿不到,只能对字面量 #5648) censuses src/*.ts with /^export const ([A-Z][A-Z0-9_]*) = '([^']*)';\s*$/; the rule file's line 52 matches it exactly as the four verdict ids at lines 39–42 do, so the constant is in the census and must be barrel-reachable — the forcing is intact. Published, additive. Right.

  3. New finding class liveness-ledger-unreadable. authoring-rules.ts is unchanged base→head (commands: ALL, surfaces: CLI_ONLY, severity: 'warning'), so it reaches os validate/os build/os lint and never the runtime gate. lint.ts:753 maps rule: f.rule into the --json envelope, which is what the new index.ts comment now claims and nothing more. On an intact installation no finding is added: 39 shipped ledgers at head (packages/spec/liveness/*.json, re-counted after the two base merges), 0 without a props record. Right.

  4. Silence to finding on missing / unparseable / non-ledger documents. Code identical to the prior head (inter-head .ts diff is comment-only). if (!dir) return []; retained, so the directory leg is untouched; walkStack gates unchanged. Ledger facts reproduce at head: field.json 0 authorWarn rows / 87 status rows (control), object.json 1/44, translation.json 1/20. Right.

  5. Seam lintLivenessPropertiesFromLedgerDir, resolveLivenessDir, type LedgerFault. Source level at head: 0 hits each in src/index.ts and src/runtime.ts, export * 0, only non-test importer authoring-rules.ts:138 imports lintLivenessProperties alone; package.json exports exactly . and ./runtime, tsup entries exactly src/index.ts and src/runtime.ts, both files unchanged base→head. Built types/runtime NOT rebuilt here (no node_modules; the checkout is read-only) — the prior record's first-hand 0/0/0 (controls 12/4) at 58900e6 carries because every .ts change since is a comment. Internal. Right, with that stated limit.

  6. loadWarnMap signature change. Module-private, unchanged since the prior head. Right.

  7. authorWarnedProperties — advisory (a) taken. The changeset now says the set is unchanged "for a missing file and for broken JSON" and that one input moves: a document parsing to null THREW at base (ledger.props || {}, base line 84) and answers the empty set at head (!isRecord(ledger) → fault → .map.keys() empty). Both statements measured TRUE. Residual precision nit, advisory only: "one input" undercounts — a ledger whose props is an ARRAY of warn-shaped records answered spurious index keys at base (Object.entries over the array; shouldWarn reads authorWarn/status off each element) and answers the empty set at head, so that corrupt-file input moves too, in the same lenient direction; the lintLivenessProperties bullet already classes "no props record" as a fault. No semver effect. Not required.

  8. Docblock / cross-reference truth — advisories (b) and (c) taken, both TRUE, both comment-only so nothing to break. (b) The seam docblock now says "neither appears in the built .d.ts. That is scoped to these two symbols on purpose: this change DOES add one published name, the LIVENESS_LEDGER_UNREADABLE rule id" — matches ① 1 and ① 4. (c) The index.ts comment drops the suppressWarnings half of the house phrase and states "suppressWarnings is declared on the dashboard WIDGET only (spec/src/ui/dashboard.zod.ts)… the same shape validate-chart-bindings.ts states for its three surfaces" — validate-chart-bindings.ts:83–88 says verbatim "There is no per-position suppression here. suppressWarnings is declared on the dashboard WIDGET only (spec/src/ui/dashboard.zod.ts), and none of these three surfaces carries the key." validate-retired-permission-residue.ts cross-reference: TRUE of the new code, unchanged. Rewritten pointers after the citation drop resolve: "the test seam below getNested" (source getNested line 411, checkItemAgainstWarnMap line 467, docblock 437–466 on the array fan-out) and "the array fan-out block above" (test line 988).

  9. Citations the diff ADDS. Plus side vs base cites #19276 ×16 · #19268 ×10 · #11385 ×7 · #19187 ×2 · #7079 · #5648 · #11288; each probed HTTP 200 and an ISSUE (the gate, scripts/check-issue-citations.mjs:443, refuses resolves-as-PR separately). #10262 on the plus side: 0 (its visible lines are context). #10262 itself: HTTP 404; controls #10261 200 (issue), #10263 200 (PR), so the 404 is that number, not the instrument. Base→head #10262 counts: test 10→10, rule 3→3, index 0→0, residue 0→0 (prior head read 12/6) — the five added citations removed, no pre-existing one swept. Lint & Repo Gates latest run on the head: success (id 106204791129). Right.

  10. The knowingly-false pre-existing phrase on PERMISSION_RETIRED_LIFECYCLE_RESIDUE. src/index.ts base line 860 and head line 872 are byte-identical ("f.rule is what --json consumers and suppressWarnings compare against"); this PR's index.ts hunk is 12 added lines, 0 removed. Its truth for that rule depends on what validate-retired-permission-residue.ts reads (a permission item, not a widget), which this PR touches only in a docblock; nothing here alters what suppressWarnings reaches. Both cards are scoped to lint-liveness-properties.ts. The reasoning holds: pre-existing, orthogonal, out of both cards; belongs to ObjectPermissionSchema's retired allowRestore/allowPurge: only literal false parses (not a truthy/falsy split), and no post-parse guard can ever see either key #17425's owner. Boundary flag, not a defect.

② Semver level

.changeset/19276-liveness-ledger-unreadable.md declares '@objectstack/lint': minor. ① finds one added published constant and one new finding class reachable through the root entry and the CLI, and no narrowing of the authored accept set: nothing previously accepted is rejected, LivenessLintFinding.rule stays string, the only widened handling is throw→empty on a corrupt ledger. Clause-②: yes HOLDS and the floor is minor; the declaration meets it and is not under-declared (no major trigger). @objectstack/lint sits in the single fixed group of .changeset/config.json, so the group takes the minor, consistent with repo practice. The changeset body, which ships as CHANGELOG, now reads TRUE throughout (① 0, ① 6). Package, level and text are right.

③ Boundary flags

  • Head sha reviewed: 308afc86d31a2624d3eb5a33dc3552fbc7b7bff9; PR head and branch tip both read it at review time (PR open, base d00692f5, which is the merge-base). Cumulative diff vs base judged, not one commit; the four commits since the prior head are f2587e1 (changeset + two comments), 462e0bb (base merge), dc126ff (citation drop), 308afc8 (base merge).
  • Check-runs on the head, latest per name: 38 runs, 34 distinct names (4 superseded ignored): 29 success, 5 skipped (Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke), 0 failure, 0 in progress. Check Changeset, Lint & Repo Gates, Type Check · debt ledger, Test Core (6/6) all success. The prior record's one in-progress run is now a reading.
  • NOT MEASURED here, ⛔ not passes: the built dist at 308afc8 (no node_modules; read-only checkout) — covered by the source-level reading, the comment-only inter-head diff and the prior record's first-hand build; the local test run (CI is the reading); the three gate families the author lists NOT MEASURED; the ① 6 array-props corner (reasoned, not executed).
  • Directory-level leg excluded and untouched: ACCEPTED, re-read at head.
  • Escalation 1 (os i18n check reaches authorWarnedProperties('translation') with no authoring rule imported): NOT ACTED ON, as the author says — packages/cli has 0 files in this PR; at head commands/i18n/check.ts imports no authoring rule (0; control lint.ts:11 imports runAuthoringRules), calls the coverage path with --strict, and i18n-extract.ts:137/1082–1083/1455 still routes through authorWarnedProperties('translation'). STILL STANDS: an unreadable translation.json still gates nothing on that command in silence. Pre-existing, out of both cards; maintainer's call whether a card is owed.
  • Escalation 2 (#10262 itself): NOT ACTED ON on the base — 13 pre-existing citations in these two files remain (10 test, 3 rule), the number still 404s with lit neighbours 200. STILL STANDS as a maintainer question about which number was meant; no longer a gate matter, since the PR adds none.
  • Pre-existing suppressWarnings house phrase on PERMISSION_RETIRED_LIFECYCLE_RESIDUE (① 9): left as-is, ACCEPTED as out of scope; successor is ObjectPermissionSchema's retired allowRestore/allowPurge: only literal false parses (not a truthy/falsy split), and no post-parse guard can ever see either key #17425's rule owner.
  • Noted-not-filed 1 (shippedLedgerStatuses swallows unreadable dir/files): ACCEPTED, unchanged.
  • Author's self-report figures re-verified independently at head: 39/39 ledgers with props, field.json 0/87, base→head citation counts, seam 0/0/0 at source; none adopted on trust.

Implemented-by: claude/issue-19268-liveness-walk-seam
Reviewed-by: session_01UDXER3sdqfeVYpEWZs5mZx

VERDICT: PASS


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 21, 2026 04:17
@os-warren
os-warren added this pull request to the merge queue Sep 21, 2026
Merged via the queue into main with commit c9b23cd Sep 21, 2026
43 checks passed
@os-warren
os-warren deleted the claude/issue-19268-liveness-walk-seam branch September 21, 2026 04:48
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment