Skip to content

spec(lint): wire the six inert runtime-create doors — action / hook / report / skill / email_template / mapping - #19517

Merged
os-steve merged 15 commits into
mainfrom
claude/issue-19474-wire-inert-runtime-create-rules
Sep 21, 2026
Merged

os-steve merged 15 commits into
mainfrom
claude/issue-19474-wire-inert-runtime-create-rules

Conversation

@os-steve

@os-steve os-steve commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #19542

Clause-②: no

⚠️ Card re-pointed 2026-09-21: the original card #19474 became unreachable (HTTP 404) when the os-sam account that filed it was banned, so Fixes #19474 now reads Fixes #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 #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 #19275 (5754204885, batch #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 #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 ([finding] The four views[] visibility-predicate rules are CLI-only — a Studio/REST/MCP view write bypasses all of them; if they move to runtime-publish, they must move together #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 #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 fix(lint): give the liveness walk a seam of its own, and report a ledger that could not be read #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:

  • [finding] loadWarnMap 在按类型的 liveness 账本文件缺失或 JSON 坏掉时**静默返回空 map** —— 丢一个文件就关掉该类型的全部作者告警,而目录缺失那一层是响的 #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


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 spec(lint): wire the inert runtime-create rules for action / hook / report / skill / email_template / mapping — six types, one edit (ruling #203/4 group A+C) #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 feat(runtime)!: 退役 ctx.user 的 roles 别名,positions 成为唯一拼法 (#6011) #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 [P2] 运行时授权门扩到 object 写入 + 全量 runtime-safe 规则快照(#4463 P1 之后) #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 (#15254, #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 「#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: #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 #19474 and #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 #19474 was rebuilt verbatim as #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 #19474 sites across ten files, not nine. All 25 re-pointed to #19542.

⭐ ⛔ Not a guess, and verified before editing rather than after: #19542 resolves, its title opens [rebuild of #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.

#19370 is NOT re-pointed, and two assumptions were corrected by measurement

That citation is historical provenance — what #19370's author wrote, and when it was true. Re-pointing it would rewrite history to satisfy a gate.

  1. ⚠️ PR feat(lint): security-role-word crosses to the runtime publish gate, whole (#19370) #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 #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 [#19370] citations that remain in runtime-gate.ts sit on lines this diff does not touch — they are #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

…re-inert-runtime-create-rules

# Conflicts:
#	packages/lint/src/runtime-gate.ts
@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

This PR changes 1 package(s): @objectstack/lint, touching 9 documentable anchor(s).

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/email-templates.mdx (via email_template (symbol, a field of const object TYPE_TO_STACK_KEY), emailTemplates (literal, a string literal in email_template), email_template (literal, a string literal in runtimeTypes))
  • content/docs/concepts/metadata-lifecycle.mdx (via email_template (symbol, a field of const object TYPE_TO_STACK_KEY), email_template (literal, a string literal in runtimeTypes))
  • content/docs/deployment/validating-metadata.mdx (via AUTHORING_RULES (symbol, a top-level const object), runtimeTypes (symbol, a field of const object AUTHORING_RULES))
  • content/docs/getting-started/quick-start.mdx (via emailTemplates (literal, a string literal in email_template))
What this run could not see
  • 3 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 37ed9ae04b7b656114009378fe62cd186ab3b168 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b41949e6da8c54b16074a8c3f0a70c00ad714b32 — the merge of head 1ac5e9779b48c7d038d2073fc3e279131813b1aa into base 37ed9ae04b7b656114009378fe62cd186ab3b168, 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 b41949e6da8c54b16074a8c3f0a70c00ad714b32 && git checkout b41949e6da8c54b16074a8c3f0a70c00ad714b32
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 37ed9ae04b7b656114009378fe62cd186ab3b168 1ac5e9779b48c7d038d2073fc3e279131813b1aa && git checkout -B drift-repro 37ed9ae04b7b656114009378fe62cd186ab3b168 && git merge --no-ff 1ac5e9779b48c7d038d2073fc3e279131813b1aa

node scripts/docs-audit/affected-docs.mjs --json 37ed9ae04b7b656114009378fe62cd186ab3b168

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 37ed9ae04b7b656114009378fe62cd186ab3b168 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 33db31d234aa66fe2f60ceb3ac0a2799ff370434

① Derived judgments

All readings taken in a fresh detached worktree at the head sha (/home/user/objectstack-review-19517, pnpm install --frozen-lockfile exit 0, turbo run build --filter=@objectstack/lint --force exit 0), exit codes captured before any pipe. Nothing below is adopted from the dev's report.

The card's first reading, re-taken through the real door. runRuntimeAuthoringRules from the BUILT dist/runtime.js, one bad write and one good write per type:

type bad write door answer good write
action visible: 'record.status ==' / record.no_such_field (string and {dialect:'cel'} forms) REFUSED expression-invalid, message names the field and the object, hint carries the source clean, rulesRun=[validateStackExpressions]
hook condition: 'record.no_such_field == true' REFUSED expression-invalid clean; previous.status != record.status clean
report unknown dataset · unknown dimension · $and: [] · $gt: 'last_30_days' REFUSED on chart-dataset-unknown / chart-dimension-unknown / filter-empty-combinator / filter-preset-comparand, each pathed reports[0]… clean; an object-bound tabular report with no dataset also clean
skill tools: ['forecast_revenue'] ADVISED ai-skill-tool-unresolved, errors=[] — it cannot refuse platform tool query_data clean; action_* wildcard clean
email_template / mapping a mapping whose targetObject does not exist and whose fieldMapping targets a field nobody has rulesRun=[lintLivenessProperties], errors=[], advisories=[] — dispatched, judges nothing clean

False-refusal probes that stayed clean (good): an action bound to an object outside the snapshot (objectName: 'not_in_snapshot', and sys_user) degrades to syntax-only; an action with no objectName; an action with no context at all; a hook whose object is not in the snapshot. Datasets are not narrowed by narrowObjectsToPackageClosure, so a cross-package report binding is not refused. The production door gathers all four context collections for every gated write (protocol.ts:5037-5043), so a report write does arrive with datasets.

Group C — does "dispatched and silent" honour the declaration? It is what the ruling ordered (「the wiring is the whole deliverable」), and the wiring is real: TYPE_TO_STACK_KEY names the same keys lintLivenessProperties's TYPE_COLLECTIONS reads (emailTemplates / mappings), and I count the ledgers myself at head: email_template.json 13 props / 0 warn, mapping.json 7 / 0 (lit: tool.json 6/1, object.json 35/1). But the PROOF of that wiring is a table pin, not a door reading — see ablations A2/A7 below. Recorded, not a ground.

Ablations — eight, each a single-line mutation with the restore proven blob-identical to HEAD and git status --porcelain empty after (scratchpad/ablate.sh, log kept). Three of these the dev did not run (A1, A2/A7, A5):

# mutation result
A1 validateChartBindings member loses 'report' (entry keeps it) vitest exit 1, 4 red (both chart LIT cases, the lands-in-collection case, the one-member pin)
A2 mapping: 'mappings' → 'mapping' (the seed: 'data' shape) exit 1, 1 red — ONLY the string pin each stack key is the collection…; every behavioural group C case stayed green
A3 suite entry loses 'skill' (member keeps it) exit 1, 4 red
A4 validateStackExpressions loses 'hook' only exit 1, 4 red
A5 validatePresetComparands loses 'report' only, sibling keeps it exit 1, 2 red (preset LIT + report CONTROL)
A6 ledger rule loses 'email_template' only exit 1, 3 red
A7 email_template: 'emailTemplates' → 'emailTemplate' exit 1, 1 red — the string pin only, as A2
A8 ledger rule gains 'object' (against the re-pointed #4716 fence) exit 1, 4 red (fence, object roster, clean-write roster, group C only-two pin)

No wiring line can be removed with the tests staying green. A2/A7 say the group C wiring is held by one toBe('mappings') assertion and nothing through the door — acceptable under the ruling's fence, but it is the honest size of that proof.

Corpus, re-taken from the built door over every example app — including app-multi-package, which the dev did not list (it authors none of the six): showcase 70 actions / 4 hooks / 4 reports / 1 email template / 1 mapping, todo 8/1/5, crm 1/1 — 96 items, 0 errors, 0 advisories, rulesRun non-empty on every one (scratchpad/corpus.mts, exit 0). The shipped corpus still publishes.

Suites and gates at head: pnpm exec vitest run in packages/lint — 107 files / 4073 passed, exit 0. pnpm run typecheck in packages/lint exit 0. @objectstack/metadata-protocol FULL suite (not only the five runtime-gate files) — 184 files / 2627 passed / 19 skipped, exit 0 — and its tsc --noEmit exit 0. check-doc-authoring (self-test + run) exit 0 with the prose-id baseline holding at 819 sites / no growth; check-changeset-no-major --base ea64bbc6e8 exit 0; check-adr-0087-registration exit 0; eslint --no-inline-config over the six changed files exit 0.

Clause-②: git diff --name-only ea64bbc6e8 HEAD | grep -c packages/spec → 0, grep exit 1. The diff never reaches packages/spec/src/**; no is true.

The merge commit, verified independently. Three-way recompute (git merge-file of each shared file from the two parents' merge base c9b23cd066): authoring-rules.ts and runtime-gate.object-writes.test.ts merge clean and the result is byte-identical to HEAD (diff exit 0 each). runtime-gate.ts conflicts in one hunk; HEAD's resolution is the union of both sides — #19486's position: 'positions' / app: 'apps' rows are at :195-196, the validateSecurityRoleWord entry keeps CLI_AND_RUNTIME with its five types, and both object-door rosters still carry it — plus the restated sentence. git merge-base --is-ancestor a227afa415 HEAD exit 0. Nothing of #19486's was dropped or edited around; its own tests are inside the 4073.

The restated sentence. Counted off the table at head: sixteen rows sit above the position / app rows, four of them context collections (objects, permissions, books, datasets) ⇒ twelve non-context. 「Twelve of the sixteen」 is right; 「eight of the twelve」 was right on #19486's table and would have described a table nobody has once this merge lands. Restating it was the correct handling — a stale comment is the defect class #19486 itself corrected in the same file — and the parenthetical keeps who wrote what.

② Semver level

minor on @objectstack/lint is the right LEVEL under the pre-GA rule (check-changeset-no-major exit 0). The DECLARATION is wrong, and this is a ground:

③ Boundary flags

FAIL ground 1 — the skill door ships a false advisory on the ADR-0109 default path, and the PR pins it as expected. Measured on the shipped corpus, not synthetic: examples/app-showcase declares exactly one AI-exposed action that exists ONLY at stack level, showcase_portfolio_snapshot (todo's six exposed actions are mirrored under objects[].actions, so they resolve; crm has none). A skill written through the real door naming action_showcase_portfolio_snapshot, with the four collections the production door gathers as context, answers advisories=['ai-skill-tool-unresolved']; the same member on the whole showcase stack answers [] (scratchpad/exposed.mts). The advisory's text says the reference is 「not a materialised action tool」 and its hint tells the author to 「declare a declarative action … opt it in with ai.exposed: true」 — which is exactly what the author did — and to ignore it only if 「a runtime plugin outside the platform registry」 provides it. That is a false statement with a wrong prescription, on the public wire (SaveMetaItemResponseSchema.advisories, rendered in Studio). Two things make it a ground rather than a boundary: (i) the card's own acceptance is 「a lit control that a good write passes」, and a good skill write naming the corpus's own exposed action does not pass clean at this door; (ii) structurally the rule has no truthful 「unresolved」 verdict at this door at all — both collections it resolves into (stack.tools, stack.actions) are absent from the snapshot, so only its clean verdicts are reliable, and the ADR-0109 bound the PR cites covers tool RECORDS, not the action_NAME family, which is ADR-0109's default path itself. The RECORDED test pins the false advisory green. Fix, either arm: (a) carry actions (and tools) in RuntimeStackContext + CONTEXT_STACK_KEYS, add the CLOSURE_CONTEXT_KEY_BY_TYPE rows and the two listCollection gathers in metadata-protocol, and flip the pin to 「resolves at the door」 — the two-package route the PR itself describes; or (b) hold skill out of this landing (drop 'skill' from the suite entry, from the validateAiToolReferences member and from TYPE_TO_STACK_KEY), record it on #19474 as held on the snapshot-widening reading — the ruling's own group B treatment of tool, which is the same universe obstacle read from the other side — and say so in the changeset. Either way the false advisory does not ship.

FAIL ground 2 — the changeset's breaking declaration, stated in ② with its fix.

The #4716 fence re-point — not a ground, but the claim is false and should be reworded. The new arm asserts, for a fenced rule on the runtime surface, runtimeTypes does not contain 'object'. runtimeAuthoringRulesFor('object') is literally surfaces ∋ 'runtime-publish' ∧ runtimeTypes ∋ 'object' (runtime-gate.ts:498-502), so the arm is implied by the atDoor.has(name) === false assertion two lines above it and adds no discriminating power. Against the clause it replaced it is strictly WEAKER: the old surfaceReason ≥ 40 clause, together with the wiring guard, meant a fenced advisory rule could not sit on the runtime surface at all without prose; now validateRecordTitle (or any of the six) may cross to view, permission or page with no second pin. That relaxation is faithful to #4716's stated remit — the OBJECT door — and A8 shows an object crossing is still refused (4 red), so nothing about the object door is uncaught. But 「the STRONGER of the two」 in the test comment is not true; write it as 「the narrower question, asked directly」.

Other readings, no action owed: the refusal messages for action/hook carry a prose locator as path (stack · action 'acme_close' visible) rather than a JSON path — pre-existing shape of validateStackExpressions at the flow door, actionable (names the action, the property, the field, the object and the source), not this PR's to change. lintLivenessProperties re-reads every ledger file per email_template / mapping publish to judge nothing (uncached by design, #19276) — a few ms on a publish, noted. liveness-ledger-unreadable cancelling in the differential is the dev's routed finding; not re-measured here.

Implemented-by: claude/issue-19474-wire-inert-runtime-create-rules
Reviewed-by: session_01AmH9bKvGoLjiY86Q4Z3og2

VERDICT: FAIL


Generated by Claude Code

… the changeset breaking

The at-tier contract review returned FAIL on two grounds.

Ground 1 — the `skill` door shipped a false advisory on the ADR-0109 default
path and the tests pinned it green. `validateAiToolReferences` resolves into
`stack.tools` and `stack.actions`; a per-write snapshot carries neither, so at
that door the rule has no truthful `unresolved` verdict at all. Measured on the
shipped corpus: app-showcase's only AI-exposed action is stack-level, and a
skill naming it is advised unresolved at the door while the same rule over the
whole stack answers clean -- an advisory that reaches Studio and prescribes what
the author had already done. `skill` is held out of this landing and takes the
ruling's group B treatment of `tool`, the same universe obstacle read from the
other side. Both halves of the wiring are held ABSENT by pins and the
measurement is kept executable beside them. Group A lands as three types.

Ground 2 -- the changeset now opens with the BREAKING-for-runtime-metadata-writes
banner, declares `Clause-②: no (narrowing)` and carries one ADR-0087 marker.

Also corrected, flagged but not a ground: the re-pointed #4716 Q2 fence comment
claimed the new arm was stronger. It is implied by the `atDoor` assertion and is
strictly weaker than the clause it replaced; the comment now says so, and why
the relaxation is still faithful to that fence's object-door remit. And group C's
proof is recorded where a reader meets it as a table pin, not a door reading.

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl and removed size/l labels Sep 21, 2026
`check-adr-0087-registration` refused `not-required (no-migration-prescription)`
against the framed FROM/TO table the changeset carried: a changeset shipping
instructions for rewriting a consumer's content cannot also claim no consumer
rewrites anything. The claim was the thing that was wrong shape, not the marker.

Nothing here is retired or renamed and there is no old spelling to translate:
every defect now refused at the runtime door was ALREADY refused by `os build`,
`os validate` and `os lint`, with the same rule id, severity and fix-it text.
Only the set of doors widens. The section now states which writes join the
refusal set -- the shape the sibling #19370 changeset uses -- and says plainly
that the rule's own hint carries the correction at the moment of refusal, so
there is nothing for `objectstack migrate meta` to reach.

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 72de2b946301aa59f624da807ae7fc383e82b81e

① Derived judgments

Round-2 DELTA review. Own detached worktree at the head sha (/home/user/objectstack-review-19517-r2, pnpm install --frozen-lockfile exit 0, turbo run build --filter=@objectstack/lint --force exit 0, then 72/72 workspace build exit 0), exit codes captured before any pipe, tree git status --porcelain empty at entry and at exit. ⛔ Nothing below is adopted from the dev's report or from round 1; the two round-1 grounds were re-measured from scratch.

The door, re-taken through the BUILT dist/runtime.js. Dispatch and stack key, read off the gate itself:

type runtimeAuthoringRulesFor stackKeyForType
action [validateStackExpressions] actions
hook [validateStackExpressions] hooks
report [validatePresetComparands, validateEmptyCombinators, validateReferenceIntegrity] reports
email_template · mapping [lintLivenessProperties] emailTemplates · mappings
skill [] null
tool · translation · doc · external_catalog [] null

runtimeGatedTypes() is the sixteen: action, app, book, dashboard, dataset, email_template, flow, hook, mapping, object, page, permission, position, report, seed, view — and skill is not among them.

One bad write and one good write per wired type, all through runRuntimeAuthoringRules:

type bad write door answer good write
action visible: 'record.status ==' · record.nope REFUSED expression-invalid, message names the field and the object, hint carries the source clean
hook condition the same, both forms REFUSED expression-invalid clean
report unknown dataset · unknown dimension · $and: [] · $gt: 'last_30_days' REFUSED chart-dataset-unknown / chart-dimension-unknown / filter-empty-combinator / filter-preset-comparand, each pathed reports[0]… clean
email_template · mapping a mapping whose targetObject does not exist and whose fieldMapping targets a field nobody has rulesRun=[lintLivenessProperties], errors=[], advisories=[] — dispatched, judges nothing clean
skill a stack-level action_showcase_portfolio_snapshot reference, and a fictional name rulesRun=[], errors=[], advisories=[] — nothing dispatches n/a

I read the full envelopes, not only the rule ids: every refusal names the rule id, the path, the offending string and an actionable correction (filter-preset-comparand carries the date-macro rewrite verbatim).

Corpus, re-taken from the built door over all four example apps. Each app's config loaded through the CLI's own loadConfig, every item of the six types pushed through the real door with the four context collections the production door gathers: showcase 70 actions / 4 hooks / 4 reports / 1 email template / 1 mapping, todo 8/1/5, crm 1/1, multi-package 0 — 96 items, 0 errors, 0 advisories, rulesRun non-empty on every one, exit 0. The shipped corpus still publishes. No app authors a skill.

Suites and gates at this head. vitest run in packages/lint — 107 files / 4071 passed, exit 0. pnpm run typecheck in packages/lint exit 0. @objectstack/metadata-protocol's runtime-gate suites — protocol.runtime-authoring-gate + protocol.runtime-gate-stored-universe + runtime-authoring-gate.dataset-writes + the gate module's own cases, 53 passed, exit 0. check-adr-0087-registration --base c736eaa40 exit 0. check-changeset-no-major --base c736eaa40 exit 0. eslint --no-inline-config over the five changed source files exit 0.

Clause-②, re-measured at this head: git diff --name-only c736eaa40 HEAD | grep -c packages/spec/src → 0, grep exit 1. The diff is six files, all under .changeset/ and packages/lint/src/. no is true.

Ablations — three, none of them the dev's two, each a single-line mutation with the restore proven blob-identical to HEAD and git status --porcelain empty after. The dev ablated the two halves of the hold-out separately; the dangerous mutation is the FULL re-cross, which neither of those covers.

# mutation result
B1 TYPE_TO_STACK_KEY gains skill: 'skills' ALONE (no rule declares it) exit 1, 1 red — the DARK pin a skill write dispatches NOTHING, and has no stack key. The seed: 'data' half-state is caught.
B2 the full re-cross — the stack-key row + skill on the suite entry + runtimeTypes: ['flow','skill'] on the validateAiToolReferences member, i.e. exactly the shape round 1 FAILed exit 1, 3 red — both DARK pins plus a report write reaches exactly ONE suite member, and a skill write none. The hold-out cannot be silently undone.
B3 RuntimeStackContext gains actions?: readonly unknown[] (the first half of route (a)) exit 0, 4071 green — see ③, flag C.

Route (a), measured rather than argued. collectToolUniverse unions PLATFORM_PROVIDED_TOOL_NAMES ∪ stack.tools ∪ the materialised action family from stack.actions and every object's actions. PLATFORM_PROVIDED_TOOL_NAMES is a STATIC import from @objectstack/spec/system, so it is present at both doors; the limbs the snapshot lacks are exactly stack.tools and stack.actions. On the real showcase stack, the same rule over three universes and three probes:

universe action_showcase_portfolio_snapshot query_data totally_fictional_tool_xyz
the door TODAY (objects only) ai-skill-tool-unresolved clean ai-skill-tool-unresolved
the door + route (a) (objects+actions+tools) clean clean ai-skill-tool-unresolved
the whole stack clean clean ai-skill-tool-unresolved

Round 1's ground-1 measurement reproduces exactly, and route (a) reaches whole-stack PARITY on every probe.

The changeset's category, judged on the merits and not on the gate's exit code. no-migration-prescription is defined in check-adr-0087-registration.mjs as a self-contradiction check — a changeset shipping instructions for rewriting a consumer's code cannot also claim no consumer must rewrite anything. The replaced prose carries no rewrite prescription and no FROM→TO, and its load-bearing factual claim is TRUE: all four crossed gating rules declare commands: ALL, so every defect the changeset lists was already refused by os build, os validate and os lint — no authorable key changes spelling, nothing is retired or renamed, objectstack migrate meta has nothing to reach. The exemption is held by a positive argument, not by a detector miss (the #8277 shape). not-required is the honest category.

Group C's proof size is now recorded in three places — at the table row, at the string assertion, and over the block — and each statement is accurate: those two rules judge nothing at this door, so no behavioural case can tell 'mappings' from a 'mapping' typo, and one toBe assertion is the whole proof. Recorded honestly; it is the ruled end state under 「the wiring is the whole deliverable」.

The #4716 fence wording is now TRUE, on both claims. runtimeAuthoringRulesFor is literally surfaces.includes('runtime-publish') && (runtimeTypes ?? []).includes(type) (runtime-gate.ts:505-509), so inside the surfaces.includes('runtime-publish') branch the new arm follows from the atDoor.has(name) === false assertion above it and adds no discriminating power — as the comment now says. And it is strictly weaker than the clause it replaced: surfaceReason ≥ 40 applied to all six unconditionally, where the new shape asks it only of a rule OFF the runtime surface. Both corrections land.

Contradicting one round-1 confirmation, with the measurement. Round 1 confirmed 「Twelve of the sixteen」 in runtime-gate.ts and was right at head 33db31d234. It is FALSE at this head — see ③, flag B. Everything else round 1 confirmed I re-measured and agree with: Clause-②: no, #19486's rows and rosters intact, the shipped corpus clean, the wiring ablations load-bearing.

② Semver level

minor on @objectstack/lint (a PUBLISHED package, 17.4.0, no private flag) is the right level and the declaration is now correct — this was round-1 ground 2 and it is cleared. The changeset opens **BREAKING for runtime metadata writes**, declares Clause-②: no (narrowing), and carries exactly one adr-0087: not-required (no-migration-prescription) marker. check-adr-0087-registration --base c736eaa40 exits 0 and reads the file as [BREAKING+clause-②-narrowing] not-required (no-migration-prescription), which is the #19486 shape round 1 asked for. check-changeset-no-major exit 0.

⚠️ One wording note, not a ground: the opening sentence says 「five metadata write doors now judge what walks through them」, and two of the five dispatch a rule that judges nothing. The next clause narrows it to 「an action, hook or report publish」 and the body says so twice more explicitly, so a reader of the whole entry is not misled — but the headline alone overstates by two types.

③ Boundary flags

Served at opus. ⚠️ CONTRACT_REVIEW_TIER in scripts/pm/dispatch-gates.mjs:12111 still reads claude-fable-5-1, a model that no longer exists — two earlier dispatches of this same delta review died on their first request with HTTP 429 「You've reached your Fable limit」 — so this round was served at opus under the maintainer's 2026-09-21 ruling 「fable 没有了」, with a separate PR moving the constant and the skill text to match.

⭐ THE SCOPE CHANGE — holding skill out is INSIDE the ruling's logic, and does NOT need the ruling's author to re-decide. This is the round's central question and it is answered here, on the merits, not deferred as process. Four findings carry it:

  1. The ruling's sentence is about card count, not type count. 「ONE execution card for the four, ⛔ not four」 forbids splitting group A into four CARDS, and its Execution paragraph says so in its own words — 「domain:spec seat splits as above (one wiring card A+C, one retirement PR D, two reading cards B)」. It is not an acceptance criterion that all four types must be wired in one landing. The dev's reading is textually right.
  2. The ruling's own premise for group A does not fit skill, measured on origin/main. The ruling says the rules are 「inert only because the gate filters on runtimeTypes first」, with 「TYPE_TO_STACK_KEY rows already present」. I checked the table at origin/main: that is true of action and hook and of NO other member of group A. skill had no stack-key row and no runtimeTypes row anywhere, and the work it actually needs is a two-package snapshot widening the ruling never priced.
  3. The ruling itself supplies the mechanism, for this exact rule. Group B parks tool on 「the tools universe rule that can only remove findings」 — that rule IS validateAiToolReferences, the member at issue, and the obstacle is one universe absent from the door read from two sides: as a universe member for tool, as the subject for skill. Group B's disposition is 「two reading cards … and the wiring follows the readings under this same ruling, ⛔ no second ruling needed unless a reading finds the declaration itself must go」. A reading has not found the declaration must go; it has found a cost question. Regrading skill from group A to group B on evidence produced by the ruling's OWN carried-forward NOT MEASURED item (「whether a wired rule fires on a real write」) is the ruling operating as written, not a seat overriding it.
  4. The prescribed vehicle exists. [finding] the runtime publish door advises ai-skill-tool-unresolved FALSELY for any stack-level tool — its snapshot carries neither stack.tools nor stack.actions, which is why #19474 held skill out #19527 is filed, open, cites the ruling, and pairs itself with the group-B tool reading reading(spec): the tools universe rule can only REMOVE findings — what would a runtime tool create actually be judged by? (ruling #203/4 group B) #19477. And skill's state is byte-for-byte what origin/main has — measured: runtimeAuthoringRulesFor('skill') is [] and stackKeyForType('skill') is null on both. Nothing regressed; one of the six simply did not advance.

⛔ One of the dev's framings is wrong and should not be carried forward. The policy's second branch, 「not honourable ⇒ retired」, means allowRuntimeCreate: false — group D's treatment — and nothing here retires anything. The hold-out is NOT that branch. It is group B's treatment, which is the correct and sufficient argument; the 「second branch」 framing is not, and would be a real scope reduction if anyone acted on it.

Verdict on the four reasons: (1) TRUE and compiler-enforced — NoUnroutedContextCollection in runtime-authoring-gate.ts makes the metadata-protocol half of route (a) non-optional the moment RuntimeStackContext gains a key. (2) TRUE — verified at protocol.ts:5035-5052: the four context collections are gathered by listCollection → foldStoredCollection, one indexed sys_metadata read each, unconditionally on every active publish that reaches the gate, object and flow publishes included; and #17063 already REMOVED a fifth read (pages) for precisely this reason, which is the repo's own precedent. (3) TRUE, and it is the load-bearing one. (4) FALSE — see flag A.

FLAG A — reason 4 is false, and it is recorded on #19527 as something that card 「must decide, ⛔ not inherit」. The claim is that collectToolUniverse has three limbs, route (a) supplies two, and a tool registered by a runtime plugin outside the platform registry still reads unresolved, so route (a) 「narrows the false advisory; it does not remove it」. Measured above: the first limb is PLATFORM_PROVIDED_TOOL_NAMES, a static import present at BOTH doors, and route (a) supplies both limbs that are actually missing — the door's verdicts become identical to the whole stack's on every probe. The runtime-plugin case is the rule's OWN documented blind spot, identical at os lint / os build / os validate and at the door, and it is the stated reason the rule is warning-tier rather than error-tier; it is not a residue route (a) leaves at this door. The hold-out survives this cleanly on reasons 1–3 — but #19527 item 2 will mislead whoever takes it and should be corrected to say that route (a) reaches whole-stack parity and the open question is the two extra reads on the hot path, not an unclosed falsehood.

FLAG B — this delta introduced a NEW false sentence, in the very comment the PR restated because a stale count is a defect. runtime-gate.ts, the position / app block: 「Twelve of the sixteen mappings above name a non-context key」 and 「#19474's four rows landed in the same merge」. Counted off the table at this head: 17 rows, 15 above position, of which objects / permissions / books / datasets are the four context collections ⇒ eleven of the fifteen, and #19474 lands three rows, not four. It was TRUE at round-1 head 33db31d234 (16 above, 12 non-context — round 1's confirmation was correct); dropping skill falsified it. Provenance also checked: #19486 at a227afa415 really did read 「Eight of the twelve」 and that was true then (12 above, 8 non-context), so the restatement MECHANISM is right and only the arithmetic is stale. It contradicts a comment forty lines above in the same file, which correctly says 「The three rows the ADR-0049 ruling … needs」. ⚠️ And it is not source-only: the build does not strip comments, so the sentence is present in the published dist/index.js, dist/runtime.js and both .cjs bundles (measured). It is NOT a ground — it is not a refusal message, an advisory or a changeset sentence, and round 1 set the precedent for comment falsehoods by flagging the #4716 wording rather than failing on it — but it is a new falsehood shipped by this round and the one-line correction is 「Eleven of the fifteen」 / 「#19474's three rows」. Had it been on a wire a user acts on it would have been a ground.

FLAG C — the hold-out's reproduction pin cannot tell the reader when it has gone stale. The ⭐ LIT — one skill, one rule, two universes case hard-codes the door's snapshot shape as { objects, skills } rather than deriving it from CONTEXT_STACK_KEYS. Ablation B3: adding actions to RuntimeStackContext leaves all 4071 lint tests green, so on the day route (a)'s first half lands, this case keeps asserting a door shape that no longer exists while still passing. The cross-package drift guard (CLOSURE_CONTEXT_KEY_BY_TYPE + NoUnroutedContextCollection) does object to the ROUTING gap one package over — read, not executed here — but nothing in packages/lint objects to the READING going stale. Cheap fix if #19527 is taken: derive the left leg's shape from the exported context keys.

Round-1 ground 1 — CLEARED. The false advisory does not ship. A skill write at the real door answers rulesRun: [], errors: [], advisories: [] and has no stack key; ai-skill-tool-unresolved cannot reach SaveMetaItemResponseSchema.advisories from this door at all. The absence is held in all three halves and my B1/B2 ablations red it. Round 1 named routes (a) and (b); the dev took (b), and (b) is what round 1 described — drop skill from the suite entry, from the validateAiToolReferences member and from TYPE_TO_STACK_KEY, record it as held on the snapshot-widening reading, and say so in the changeset. All four were done.

Round-1 ground 2 — CLEARED, on the merits and not only at the gate; stated in ② with the measurements.

Carried from round 1, still true, still no action owed: validateStackExpressions's refusal path for action/hook is a prose locator (stack · action 'a_bad' visible) rather than a JSON path — pre-existing shape at the flow door, and the envelope is actionable (it names the action, the property, the field, the object and the source). lintLivenessProperties re-reads every ledger file per email_template / mapping publish to judge nothing; rulesRun does not reach the wire, so the changeset's 「unchanged in behaviour today」 for those two is true as a user reads it.

Implemented-by: claude/issue-19474-wire-inert-runtime-create-rules
Reviewed-by: session_01AmH9bKvGoLjiY86Q4Z3og2

VERDICT: PASS


Generated by Claude Code

…ect a false count

Test Core shards 4/6 and 5/6 were red on the previous head and neither the dev
nor the review had run the package that hosts them: @objectstack/objectql
declares no dependency on @objectstack/lint, so it was invisible to a
dependents-only sweep, and it is where the runtime write path is exercised.

Root cause, one defect in two tests: `metadata-validation-sweep.test.ts` and
`overlay-precedence.test.ts` each author a `report` that binds a dataset their
tenant does not declare, into a harness whose live universe is permanently
empty (`find` mocked to `[]`, nothing read back). Since the report door opened,
`validateChartBindings` resolves that binding and refuses it —
`chart-dataset-unknown`. The refusal is TRUE: the reports really do bind
nothing. `ReportSchema` refines `dataset` to required, so dropping the binding
is not available — a report either binds a dataset the tenant has or it is not
a report. Both harnesses now seed that dataset into the registry universe, the
landed pattern from `protocol.dashboard-dataset-publish-gate.test.ts`. ⛔ No
test skipped, disabled or quarantined; no rule weakened; every whitelist, hash
and rejection assertion untouched.

Also: `runtime-gate.ts` claimed 「Twelve of the sixteen mappings」 and 「#19474's
four rows」. Dropping `skill` falsified both -- it is eleven of fifteen and
three rows, counted from the table itself, and it contradicted this file's own
correct 「three rows」 68 lines above. Comments ship in dist, so this shipped.
The provenance note about #19370's original wording is kept, with the
withdrawn-row step recorded rather than erased.

Two round-2 review folds: the hold-out's LIT pin now DERIVES the door snapshot
through `buildRuntimeWriteSnapshots` instead of hard-coding `{ objects, skills }`
-- ablated, it now goes red when `actions` joins `RuntimeStackContext`, which
is the signal that `skill` can be crossed; and the changeset headline no longer
says five doors judge when two of them judge nothing.

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
The other half of the red Test Core shards, same defect one package over.
`rest-server-meta-read-org-scope.test.ts` and
`rest-server-meta-history-diff-org-scope.test.ts` both write a `report` whose
`bodyFor` binds `orders_ds`, into a stub engine whose registry double answers
`listItems: () => []` for every type. That was harmless while no rule judged a
report; since the report door opened, `validateChartBindings` resolves the
binding and the write is refused with `chart-dataset-unknown` before the READ
each file exists to exercise is reached -- six cases, all of them the `report`
arm of a per-type sweep.

The refusal is TRUE and `ReportSchema` refines `dataset` to required, so the
fixture needs a tenant to be valid in rather than a binding to drop. Both
doubles now answer `dataset` with `orders_ds`, whose measure name is exactly
what `bodyFor` selects. ⛔ No test skipped, no rule weakened, every org-scope
and history assertion untouched.

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
`runtime-gate.ts` claimed 「Twelve of the sixteen mappings」 and 「#19474's four
rows」. Counted from the table itself: fifteen rows sit above `position`, four
of them context collections, so it is ELEVEN of FIFTEEN -- and #19474 landed
THREE rows, not four. The figures were true at the round-1 head; withdrawing
the `skill` row falsified both, and they contradicted this file's own correct
「The three rows」 68 lines above. The build does not strip comments, so this
sentence ships in dist/index.js, dist/runtime.js and both .cjs.

The provenance note is kept and extended rather than erased: #19370's original
「eight of the twelve」 was true of the table it was written against, and the
withdrawn-row step is now recorded instead of leaving a silent jump. The figure
is arithmetic over the rows above it, so the note says to recount rather than
adjust.

⚠️ This correction was made once before and LOST: it was uncommitted when an
ablation's restore leg ran `git checkout HEAD -- runtime-gate.ts`, which
discarded it silently at exit 0. Caught by reading the committed blob back
instead of trusting the edit. AGENTS.md says it: commit the fix FIRST, then
ablate.

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
`check:issue-citations` reports `[allocated-but-absent]`: cards #19474 and
#19370 were filed by an account since banned, so both answer 404 while their
comments, timelines and landed work all still resolve. ⛔ Neither was deleted.

#19474 -> #19542, the verbatim rebuild. Confirmed rather than guessed: #19542
resolves 200, its title opens `[rebuild of #19474]`, its body states the
original is unreachable and tabulates the same 404s, and the PR already closes
it. TWENTY-FIVE sites, not the nine the gate printed -- its own tail says the
red is a LOWER BOUND because it stops at the first non-zero exit, and the rest
were in the objectql and rest fixtures and the lint tests, all of which sit on
the same judged `packages/**/src/**/*.ts` surface. The changeset is re-pointed
too: it is not a judged surface, but it ships verbatim into CHANGELOG.md, which
is the most durable reader-facing prose this repo has.

#19370 is NOT re-pointed, and that is the point. It is historical provenance --
the sentence records what that card's author wrote and when it was true -- and
it has no rebuild. Its PR is unreachable too (404, same ban), so naming it
would only mint a second dangling reference. Taken instead is the gate's second
remedy: keep the number, say in prose that it no longer resolves, and name the
live record -- the merge commit a227afa, which
carries that card's whole diff.

⛔ No wiring, test or hold-out touched: comment and prose only.

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
The prose remedy alone is not enough: `check:issue-citations` judges every
bare `#N` on an added line against the board, so keeping the hash form and
explaining it still reports `[allocated-but-absent]` -- measured, exit 1 on two
sites. Read the gate's own sentence again and it says what to do: 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, 19370, without the sigil --
with the reason stated inline, and the live record named as the merge commit
a227afa.

⛔ Not evasion of the gate and ⛔ not a qualifier dodge: `owner/repo#N` would
have skipped the probe by declaring a cross-repo reference, which this is not.
The citation is removed because it no longer cites anything.

Measured after: `node scripts/check-issue-citations.mjs` (the diff-scoped form
lint.yml runs, with a token) exits 0 -- 15 citations judged, 15 resolve.

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 1ac5e9779b48c7d038d2073fc3e279131813b1aa

① Derived judgments

Round-3 DELTA review. Own detached worktree at the head sha (/home/user/objectstack-review-19517-r3, removed at exit), pnpm install --frozen-lockfile exit 0, dependency builds exit 0, exit codes captured before any pipe, git status --porcelain empty at entry and after every ablation restore. ⛔ Nothing below is adopted from the dev's report or from rounds 1–2. The delta to packages/lint's shipped source is comment-only — measured: git diff 72de2b9463 HEAD -- authoring-rules.ts reference-integrity-suite.ts runtime-gate.ts, every +/- line a comment, zero non-comment lines — and both intervening merges of main are clean (git show --cc prints nothing for d9dd720b64 and 4acb5f7769, so no conflict resolution was hand-written). ⇒ the wiring, the hold-out, the changeset category and the merge that round 2 passed are untouched, and I re-litigate none of them.

① The red suite, and its root cause — CLEARED, and the fix is the right one.

Reproduced the failure before judging the repair. Ablation A (strip all four seeds at this head, nothing else changed):

package files result
@objectstack/objectql metadata-validation-sweep · overlay-precedence exit 1, 2 red, [chart-dataset-unknown] in the envelope
@objectstack/rest …meta-read-org-scope · …meta-history-diff-org-scope exit 1, 6 red, every one a report arm, PUT /report … expected 422 to be 200

Eight cases, all of them the report arm — the root cause is exactly as stated, and the refusal is TRUE.

(a) The seeded fixtures are truthful, and that is ENFORCED rather than asserted. validateChartBindings does not merely check that the dataset NAME exists — dimensionRef / measureRef (validate-chart-bindings.ts:401-450) refuse an unknown dimension (chart-dimension-unknown, error) and an unknown measure at a query position (chart-measure-unknown, error). Ablation B — keep the seed, rename ONLY the measure the fixture selects (amount_sum → NOT_THE_ONE, order_count → NOT_THE_ONE): exit 1, objectql [invalid_metadata] report/monthly_revenue … reports[0].values[0] [chart-measure-unknown], rest 3 red. ⇒ a bare { name } stub could not have been used; the coherence is load-bearing. Stronger still, I parsed all three seeds through the real DatasetSchema:

seed DatasetSchema.safeParse
invoice_metrics (objectql/overlay-precedence) VALID
sweep_account_metrics (objectql/sweep) VALID
orders_ds (both rest doubles) VALID
control — the landed protocol.dashboard-dataset-publish-gate.test.ts double it copies INVALID (label: Invalid input)

The seeds are authorable metadata — strictly more truthful than the landed pattern they cite. Their dimension/measure names are what rows/values and bodyFor select, their object is the object each harness really writes (sweep_account with stage added beside amount; task, the same object the view fixture binds).

(b) Nothing was weakened. The net diff of the four files across the WHOLE PR is +100 / −3, and the three removed lines are fields: { amount: … } (replaced by { amount, stage }) and two listItems: () => [] (replaced by a type === 'dataset' arm). ⛔ Not one assertion removed, reworded, skipped or quarantined. I also checked the seeds cannot make an assertion vacuous: the rest org-scope sweeps assert over b.rows / historyRowsFor — the persistence store, not the registry — and dataset is not in ORG_OVERRIDABLE, so the seeded orders_ds is inert to every count in both files. metadata-validation-sweep still reports report yes ok ok (valid accepted, invalid refused) with its single expect(failed).toEqual([]) intact.

(c) A report binding a dataset nobody declares is STILL refused. Ablation C — keep the seed, point the fixture at nobody_declares_this: objectql exit 1, reports[0].dataset [chart-dataset-unknown]; rest exit 1, 3 red on the 422. The door still works; what changed is that the fixtures now have a tenant to be valid in.

⭐ The ReportSchema claim — TRUE, with one bound stated. Parsed through the built packages/spec/dist/ui: {name, label, type:'summary', values:[…]} → REFUSED a report needs `dataset` + `values` (measure names).; same for tabular and for the defaulted type; with dataset present → PARSES. The superRefine (report.zod.ts:378-392) makes dataset required on every non-joined arm, so dropping the binding was not available to these fixtures and the fix chose the right branch. The one residual: {type:'joined', blocks:[{name, type:'tabular'}]} PARSES with no dataset anywhere — an escape that exists only by turning each fixture into a different artifact, i.e. by weakening it. See ③.

② The 25 citation re-points, and the one not re-pointed — both choices sound.

Re-pointing to #19542 is sound and rewrites nothing. Verified independently: GET /issues/19474 → 404, GET /issues/19370 → 404, GET /pulls/19486 → 404, while …/issues/19474/comments still resolves (I read the claim, the dev report and the seat correction). #19542 resolves 200, its title opens [rebuild of #19474], and closed_by_pull_requests names PR #19517. I read all 26 re-pointed sites: every one records 「this card dispatched this edit / this card's door」; none records an event tied to the old thread (a claim, a review comment, a timeline). The card is the same content under a live number. Zero #19474 remain anywhere in the tree.

The sigil-less 19370 is honest, not gate-gaming — the gate itself prescribes it. Ablation D — restore the sigil on that one added line: check-issue-citations --base 2306a75ecb exit 2, [allocated-but-absent] runtime-gate.ts:196 #19370, and the refusal's own second remedy reads 「keep the number and say IN PROSE that it no longer resolves and what the live record is」. That is precisely what the line does. The named live record is real: a227afa415f596269ed36aae0a0631c84270ccc9 exists, is titled feat(lint): `security-role-word` crosses to the runtime publish gate, whole (#19370) (#19486), and its diff is where position: 'positions' / app: 'apps' and the original 「Eight of the twelve」 sentence were added — so it genuinely carries what the dead card's reasoning can be read from.

⭐ Refusing the owner/repo#N qualifier was RIGHT, and my measurement is sharper than the dev's phrasing. Ablation E — objectstack-ai/objectstack#19370: exit 2, still [allocated-but-absent]. The qualifier per se is no escape; the gate resolves a self-named repo against this board. Ablation F — someother-org/archive#19370: exit 0, cross-repo-unjudged. ⇒ the ONLY form that would have skipped the probe is a false foreign owner, which is exactly the lie the dev declined («declaring a cross-repo reference, which this is not»). The refusal is right; the reason is right; only the sentence is loose, and it lives in a commit message.

The two surviving [#19370] citations — verified for myself. git blame attributes runtime-gate.ts:170 and :236 to a227afa415f (Sam), not to any commit on this branch; the diff's added-line ranges are 128–169, 192 and 195–215, so both sit outside. The gate judges added lines only, which is why they are unreached. check-issue-citations --base 2306a75ecb at this head: exit 0, 15 citations judged, 15 resolve.

③ The false sentence — counted myself, true now, and it ships.

TYPE_TO_STACK_KEY has 17 rows (flow, object, view, action, page, dashboard, agent, hook, seed, permission, book, dataset, report, email_template, mapping, position, app). Above position (line 219): 15. Context collections: CONTEXT_STACK_KEY_ORDER is {objects, permissions, books, datasets}, compiler-pinned to keyof RuntimeStackContext, whose members are exactly those four ⇒ 11 of 15 name a non-context key. The sentence is TRUE. This card lands report / email_template / mapping = three rows, and the historical figure checks out too: 17 − 3 = 14, i.e. 12 above position when #19370 wrote 「eight of the twelve」 — and a227afa415f's diff shows that exact wording. The arithmetic is consistent end to end.

It ships. Built @objectstack/lint: 「Eleven of the fifteen mappings」 is present in dist/index.js, dist/index.cjs, dist/runtime.js and dist/runtime.cjs; 「Twelve of the sixteen」 appears in none of them. The sigil-less 19370 and the merge-commit sha are in the bundles too. The correction survived being committed, which is the point of the dev's own note about losing it once.

④ The two round-2 folds — both reproduced.

The LIT pin. Ablation G — add actions?: readonly unknown[] to RuntimeStackContext plus its compiler-demanded CONTEXT_STACK_KEY_ORDER row, nothing else: packages/lint exit 1, 1 red, ⭐ LIT — the reason, reproduced: one skill, one rule, two universes failing on expected [ 'objects', 'permissions', …(3) ] to not include 'actions' with the message that tells the next reader to cross skill rather than keep a stale reading. Ablation H — the SAME context widening, with the pin reverted to round 2's hard-coded { objects: OBJECTS, skills: [writtenSkill] }: exit 0, 35 green. ⇒ round 2's flag C measurement reproduces exactly, and the fold is real: the derived pin now fails where the restated one could not.

The changeset headline. 「five metadata write doors that dispatched NOTHING now dispatch the rules already written for them, and three of the five judge what walks through」, then names them — action, hook, report refusable; email_template and mapping wired to a ledger-driven rule that warns on nothing. That agrees with the body's own two readings and with the door table. Round 2's ② wording note is paid.

What I tried that did NOT break it. Full suites at this head, exit 0 every one: @objectstack/lint 107 files / 4074; @objectstack/objectql 304 / 5055; @objectstack/rest 195 / 3262; and — because the round-2 miss was a dependents-only sweep, so I widened rather than repeated it — @objectstack/metadata-protocol 185 / 2645 and @objectstack/runtime 274 / 3865. I swept the repo for the same harness shape (listItems: () => [] beside a dataset-bound report) and ran every package it pointed at; nothing else carries the defect. turbo run typecheck over the three touched packages exit 0. check-issue-citations exit 0, check-adr-0087-registration exit 0 ([BREAKING+clause-②-narrowing] not-required), check-changeset-no-major --base origin/main exit 0. I tried to find a count error in the table and could not; tried to find an assertion the seeds make vacuous and could not; tried to find a residual #19474 and found none; tried the joined escape from the dataset requirement and it only exists by degrading the fixture. CI at this head is green on all six Test Core shards — read from the check runs, not claimed.

② Semver level

minor on @objectstack/lint (published, 17.4.0) is correct and is the only level available: scripts/check-changeset-no-major.mjs refuses a major bump repo-wide because every publishable package sits in the Changesets fixed group, so one major promotes ~70 packages. The BREAKING fact is therefore carried in prose — **BREAKING for runtime metadata writes** — plus Clause-②: no (narrowing) and exactly one ADR-0087 marker, which is the shape check-adr-0087-registration reads back verbatim (exit 0). The round-3 delta by itself is test fixtures, comments and changeset prose — no published behaviour moves — so it neither raises nor lowers the level round 2 judged. Clause-②: no still holds: the delta touches packages/spec/src/** zero times.

③ Boundary flags

⛔ None of these is a ground. None is on a wire a user acts on.

FLAG A — the round's own site count is one low, in the round whose item ③ is a false count. 「25 sites across ten files」 (the dispatch note and commit 19988fccab's message). Measured at 00b5f59dec: 26 occurrences on 26 lines across 10 files — runtime-gate.inert-type-writes.test.ts 8, authoring-rules.ts 5, metadata-validation-sweep.test.ts 3, runtime-gate.ts 3, reference-integrity-suite.ts 2, then one each in runtime-gate.object-writes.test.ts, overlay-precedence.test.ts and the two rest fixtures, plus the changeset. The WORK is complete — zero #19474 survive — only the stated figure is short by one. It lives in commit prose, not in shipped text, which is the only reason it is a flag.

FLAG B — runtime-gate.ts:196 points the wrong way, and the delta introduced the phrasing. 「the card that added the two rows above wrote it. That card is numbered 19370」. #19370's two rows are position: 'positions' / app: 'apps' at lines 219–220 — twenty-three lines BELOW that sentence (git blame → a227afa415f). The two rows immediately above are email_template / mapping at 168–169, which this card added. So the clause is wrong under both readings: wrong direction, or wrong card. The previous wording (「when #19370 wrote it」) had no direction to get wrong; the de-sigiling replaced it with one. One-word fix: below.

FLAG C — one dead card is now spelled two ways in one file, and full consistency was available. [#19370] at lines 170 and 236 (untouched, unjudged) vs bare 19370 at 196 (added, judged), ~26 and ~40 lines apart. The minimal-touch reading is defensible — editing those lines drags their #N tokens onto the judged surface. But I measured the alternative: de-sigiling all three ([#19143 above, card 19370 here], [card 19370]) and re-running the gate exits 0, 16 citations judged, 16 resolve. So the inconsistency is a choice, not a constraint, and a reader meeting [#19370] first will click a 404 before reaching the paragraph that explains it.

FLAG D — the corrected sentence still has no mechanical pin, and it has now gone stale twice in three rounds. 「eight of the twelve」 was falsified by this card's rows; 「twelve of the sixteen」 was falsified 68 lines from a comment that already said 「three rows」, by withdrawing one row. The file's own remedy — ⛔ 「recount them rather than adjusting it」 — addresses a reader, not a gate, and the figure is derivable from two constants the package already exports (TYPE_TO_STACK_KEY, CONTEXT_STACK_KEYS). A three-line assertion in runtime-gate.test.ts reading the count out of the table and comparing it to the number in the comment would end this class. ⛔ Not owed by this card; recorded because the next row added to that table will falsify it a third time, in dist.

FLAG E — the ReportSchema claim has one arm it does not cover, stated so nobody reads it as unconditional. ``ReportSchemarefinesdataset` to REQUIRED` — now written into four test files — is true of every arm these fixtures take, but `{type: 'joined', blocks: [{name, type}]}` parses with no `dataset` on the report or on any block, so a `joined` report can bind nothing at all. Irrelevant to this fix (taking that arm would degrade each fixture into a different artifact), and ⛔ not a defect this PR introduced; it is adjacent enough to the sentence being shipped that a future reader should not be surprised by it.

FLAG F — the changeset file is still named 19474-six-inert-runtime-create-doors.md. Its CONTENT is re-pointed and the filename reaches neither CHANGELOG.md nor any gate, so nothing is owed; noted only so the last 19474 in the tree is a measured leftover rather than an oversight nobody looked at.

Implemented-by: claude/issue-19474-wire-inert-runtime-create-rules
Reviewed-by: session_01AmH9bKvGoLjiY86Q4Z3og2

VERDICT: PASS


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 21, 2026 12:26
@os-steve
os-steve enabled auto-merge September 21, 2026 12:27
@os-steve
os-steve added this pull request to the merge queue Sep 21, 2026
Merged via the queue into main with commit 1f69917 Sep 21, 2026
46 checks passed
@os-steve
os-steve deleted the claude/issue-19474-wire-inert-runtime-create-rules branch September 21, 2026 12:43
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… publish door — the reading says group C, dispatched and silent (objectstack-ai#19596)

Fixes objectstack-ai#19568

Clause-②: yes

## The reading, first — and it is neither of the two arms the card
offered

The card asks which group `datasource` belongs to: **A** (wire the rule)
or **D** (retire the declaration). Measured against the ruling's own
group criteria, it is **neither — it is group C**, the ledger-driven
arm, which that same ruling folds into group A's card. Both offered arms
are refuted, and the second refutation is the one that decided the shape
of this PR.

**Group D is refuted by its own criterion.** That arm is for a
declaration where 「no stack collection exists to create into, so the
declaration is a promise nothing can keep」.
`ObjectStackDefinitionSchema.datasources` is a first-class stack
collection (`packages/spec/src/stack.zod.ts:275`), the type has a live
runtime create path (ADR-0015 Addendum, `origin: 'runtime'`), and
`RUNTIME_CREATE_ALLOWED_TYPES` in
`packages/metadata-protocol/src/protocol.ts` is derived straight from
the registry entry, so `PUT /api/v1/meta/datasource/NAME` really does
mint one. Retiring the flag would withdraw a capability the platform
ships.

**The `skill` hold-out is refuted, and this is the precedent the
dispatch asked me to test against.** `skill` stayed out of the sibling
landing objectstack-ai#19542 because `validateAiToolReferences` reads a skill's tool
references against `stack.tools` and `stack.actions` — collections the
runtime door's snapshot does not carry — so the door produced that
rule's `unresolved` finding while the same rule over the whole stack
produced none. Wiring it would have shipped a false advisory into
Studio; the hold-out is written up on card objectstack-ai#19527. `datasource`'s only
candidate rule reaches into no collection at all:
`lintLivenessProperties` judges each written item's own top-level keys
against that type's liveness ledger, so the door's verdict and the
whole-stack verdict are the same value by construction. That is asserted
in this PR as a comparison, not argued: the same written datasource
judged once in the shape a per-write snapshot has and once with every
collection the door omits present and populated, with an anti-vacuity
leg so two empty lists cannot pass for agreement.

⇒ **wiring is honest here, and it lands as the `email_template` /
`mapping` shape: dispatched and silent.**

## Evidence, re-measured on `origin/main` `1f69917c5c` (after PR objectstack-ai#19517
landed), not adopted from the card

| leg | reading |
| --- | --- |
| registry entries parsed with a multi-line-aware scanner | 27 entries ·
**22** carry `allowRuntimeCreate: true` |
| of those 22, entries written MULTI-LINE | **1** — `datasource`, at
`metadata-plugin.zod.ts:930`. The card's mechanism holds at this commit.
|
| `allowRuntimeCreate: true` types named by **no** live `runtimeTypes`
declaration in `packages/lint/src` | **6** — `datasource`,
`external_catalog`, `translation`, `doc`, `tool`, `skill` |
| lit control, same scan | declarations naming `object`: **12** |
| dark control, same scan | declarations naming `zzznotatype`: **0** |
| candidate rules naming `datasource` anywhere in `packages/lint/src` |
**1** — `lint-liveness-properties.ts:502`, the row `{ type:
'datasource', key: 'datasources' }`, carried since objectstack-ai#4487 |
| `packages/spec/liveness/datasource.json` | 12 props, **0**
`authorWarn` rows (the three textual hits are prose inside notes) |
| lit control, same instrument | `object.json` warns on
`externalSharingModel` |

Of the six, `datasource` was the only one with no carrier: the others
are the ruling's group B readings, its group D retirement, and the
objectstack-ai#19527 hold-out.

## What landed

- `lintLivenessProperties` declares `datasource` in `runtimeTypes`,
beside `email_template` and `mapping`.
- `TYPE_TO_STACK_KEY` gains `datasource: 'datasources'` — ⛔ not a
mapping ahead of its rule: that rule has read `stack.datasources` since
objectstack-ai#4487.
- `runtime-gate.datasource-writes.test.ts` carries the reading in
executable form.
- The registry entry itself now says where its `allowRuntimeCreate:
true` is honoured, that the honouring rule is silent by ledger, and that
the entry's multi-line shape is what hid it from a line-wise census.

**⚠️ Dispatched and silent, on purpose.** With 0 warn keys no datasource
document can be advised at this door today, so the behavioural
acceptance the sibling card used — a real write refused, a good write
passing — is **not available for this type**, exactly as it was not for
group C. The same fence applies: the wiring is the whole deliverable and
⛔ no ledger-population work rides with it. The silence is pinned beside
a lit control on the same instrument in the same process, so it can
never be read as a broken dispatch or an unresolvable ledger directory.

**One place this proof is LARGER than its two siblings'.** Group C's
stack keys rest on a single string assertion, because with an empty warn
map no behavioural case can tell `'mappings'` from a `'mapping'` typo.
This file closes that gap for its own row: it drives the real rule
through its ledger-directory seam over a stack built at
`stackKeyForType('datasource')` itself, with the wrong-key leg asserted
beside it. Ablated (`ablation-replace`, on-disk blob change proven,
restore proven byte-identical to HEAD):

| mutation | result |
| --- | --- |
| `runtimeTypes` loses `'datasource'` | 3 red — the dispatch pin, the
door pin, the roster pin |
| `datasource: 'datasources'` becomes `datasource: 'datasource'` | 2 red
— including the behavioural stack-key case, which is what group C could
not manage |

## What this does NOT reach, stated rather than left to be discovered

The Setup wizard's own route — `POST /api/v1/datasources` — persists
through `DatasourceAdminService.createDatasource`, which writes via
`metadata.register` plus a direct `sys_metadata` row, and never reaches
`saveMetaItem`. So this crossing honours the `/meta` door (REST, MCP, an
AI author), not the wizard's dedicated route. The two doors enforce
disjoint check sets today; that asymmetry is real, is outside this
card's file surface, and is written up under Acceptance notes rather
than repaired here.

## Verification

- `pnpm --filter @objectstack/lint test` — 108 files, 4079 passed, 5
skipped.
- `pnpm --filter @objectstack/lint typecheck` — green, test layer
included.
- `pnpm --filter @objectstack/spec test` — 509 files, 14895 passed.
- **The publish-gate blast radius, not the rule registry's package
graph**: `pnpm --filter @objectstack/metadata-protocol test` (185
passed, 3 skipped), `pnpm --filter @objectstack/objectql test` (303
files, 5050 passed), `pnpm --filter @objectstack/rest test` (194 files,
3254 passed, 1 skipped) — the packages that drive `saveMetaItem` and
declare no dependency on `@objectstack/lint`.
- `pnpm --filter @objectstack/spec build && pnpm --filter
@objectstack/spec check:generated` — all 15 generated artifacts up to
date; the registry comment moved none.
- `pnpm lint` (repo-wide, `eslint . --no-inline-config`) — exit 0.
- Derived gate families (`scripts/pm/dispatch-gates.mjs`, reconciled
with `--ran`): **86 derived, 83 run green, 3 NOT MEASURED, 0 unrun**.
The three are `check:dual-build-cjs-loads`, `check:i18n` and
`check:type-check-debt`, each exiting **3** — PREREQUISITE NOT MET, a
whole-repo build this run did not have. ⛔ Not failures and ⛔ not passes;
CI builds first and runs all three. All measured at `a2596caebf`.

## Acceptance notes

- **The two runtime-create doors for `datasource` enforce disjoint check
sets.** `assertDatasourcePoolSupported`'s own docblock says it is
「Called at every door a `pool` block can come in through — the Setup
wizard's create/update, the boot-time auto-connect pre-pass, and the
driver factory itself」, and the `/meta` write door is a door it does not
list: a datasource minted there is Zod-parsed and gated by the authoring
rules, but never sees `assertValidConfig`,
`assertDatasourcePoolSupported` or the code-origin collision refusal
that `createDatasource` performs before persisting. The record lands in
`sys_metadata` and is met at connect time instead, which is the outcome
`createDatasource`'s own comment says it exists to prevent. Outside this
card's file surface (`packages/services/service-datasource`,
`packages/metadata-protocol`) and a different defect class, so it is
reported for filing rather than repaired here.
- **Group D's criterion is measurably false for one of its two
members.** The ruling retires `doc` and `external_catalog` because 「no
stack collection exists to create into」. At `1f69917c5c` there is no
`externalCatalogs` collection, so that holds for `external_catalog` —
but `ObjectStackDefinitionSchema.docs` exists (`stack.zod.ts:431`).
Whoever takes the group D retirement should re-read the premise for
`doc` before flipping the flag. Carrier: the group D retirement card.
Noted, not filed.
- **`lintLivenessProperties` re-reads the ledger directory on every
call** — `resolveLivenessDir()` plus one `readFileSync` per governed
type, memoised only within a call, and the gate runs its rules twice per
write. Bounded today to the three types that dispatch it, so it is an
observation, not a card.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…lares its object (objectstack-ai#19857)

Fixes objectstack-ai#19586 · **one row**, not the pattern: measured, no other harness
in `packages/objectql/src` pays an empty-universe cost today (census
below), and no shared seed could serve them, because each harness's
universe is whatever its own fixtures name.

Clause-②: no

## What changed

One file: `packages/objectql/src/metadata-validation-sweep.test.ts`,
test-only.

- **The `dataset` row is filled.** It used to print `dataset yes - - no
fixture (skipped)`. It now reads `dataset yes ok ok`: a valid dataset is
accepted and an invalid one is rejected.
- **The harness universe gains the object the dataset is over.**
`makeProtocol` already seeded `sweep_account_metrics` (added for the
`report` door in objectstack-ai#19542). It now also registers `sweep_account` through
`registry.registerObject`, which the gate reads through
`listItems('object')`. Without it the dataset fixture is refused
`object-reference-unknown`, the wall objectstack-ai#19542's dev hit and reverted.
- **The tenant is declared once.** `SWEEP_ACCOUNT` and
`SWEEP_ACCOUNT_METRICS` are named constants. `makeProtocol` seeds clones
of them, and the `object` / `dataset` rows publish them as their `valid`
documents, so the seeds cannot drift from the rows. They are
deliberately not read out of `FIXTURES`: that spelling made removing the
`dataset` fixture crash every row on the missing key, where the skip
note should come back.
- **The invalid leg now asserts which door refused.** The runtime
author-time gate throws the same envelope as the schema door
(`INVALID_METADATA`, 422, `issues[]`), so the old check could not tell
them apart. A refusal now counts only when it has no gate `rule` and a
schema issue names the fixture's `invalidatedField`. Gate issues always
carry `rule` and a stack-rooted path (`datasets.NAME.object`).
- **The `skill` row's `invalidatedField` is now `tools`, not
`description`.** Measured: the schema's only issue on that payload is at
`tools`, and `description` is optional. This relabel is the one row the
new assertion forced.
- The `report` comment "the bound dataset is the fixture directly above"
was false on `main`: it landed in objectstack-ai#19517 while the fixture it named was
reverted. It is true again.

## One row or the pattern: the census (at `afc3b6492`)

- 68 test files in `packages/objectql/src` reference
`ObjectStackProtocolImplementation` / `saveMetaItem`. **9** of them both
call `saveMetaItem` and carry an empty-universe mock (`find` resolving
`[]`, `listItems: () => []`).
- One of the 9 is this file. The other 8 (`overlay-precedence`,
`protocol-commit-history`, `protocol-destructive`,
`protocol-lock-enforcement`, `protocol-meta-types-rich`,
`protocol-meta`, `protocol-publish-package-drafts`,
`protocol-registry-shadow`) run **202/202 green with 0 `authoring
advisory` lines and 0 `*-unknown` / `*-unresolved` rule ids**. Control:
the same log carries 19 other `[Protocol]` warn lines from the same
`dist`, so an advisory would have shown.
- Widened to the whole package (304 files): 7 advisory lines, none from
a reference-resolution rule (`approval-expression-no-empty-policy` x4,
`flow-multi-write-unfiltered` x3, both body-shape rules), and 0
`*-unknown` / `*-unresolved` ids.
- So the pattern's cost is future only, paid when a later door opens,
and it cannot be seeded ahead: each harness's universe is the names its
own fixtures reference. Blind spot: the census matches the mock
spellings named on the card. A harness that builds its engine with no
registry at all would also have an empty universe, and would not be
counted here, only covered by the package-wide advisory count above.
- `packages/rest` harnesses belong to `domain:cli` and are **listed, not
edited**. Five carry the same mock spelling and drive `saveMetaItem`:
`meta-compound-save-force-parity`, `meta-compound-save-mode-parity`,
`public-form-lookup-filter-lowering`, `public-form-lookup-picker`,
`public-form-routes.stored-row`. The two objectstack-ai#19517 already seeded are
`rest-server-meta-history-diff-org-scope` and
`rest-server-meta-read-org-scope`.

## Evidence

All at `b244ec482d` unless marked.

- **Before, `afc3b6492`:** `dataset  yes  -  -  no fixture (skipped)`.
- **Card premise reproduced** (fixture added, no object seed): `dataset
yes fail ok valid: INVALID_METADATA: dataset/sweep_account_metrics
failed author-time validation: 1 issue —
datasets.sweep_account_metrics.object [object-reference-unknown]`.
- **After:** every executed row `ok ok`. The invalid dataset's issues
are exactly `[{"path":"measures","message":"Invalid input: expected
array, received undefined","code":"invalid_type"}]`, from the schema, no
`rule`.
- **Ablation (owed): remove the `dataset` fixture, and the skip note
comes back.** Run through `scripts/ablation-replace.mjs --delete` (WRAP
mode, restore trap). Anchor 1 to 0, blob `a9f40f14c53e` to
`3ff6f86a0252`. Row: `dataset yes - - no fixture (skipped)`; `object` /
`report` still `ok ok`. Restored: blob equals HEAD `a9f40f14c53e`, `git
diff HEAD` empty.
- **Reverse check of the new assertion (one-off, not kept).** The
invalid dataset is made schema-valid but pointed at an undeclared
object, so only the gate can refuse it.
- (A) With the new check: `dataset yes ok fail invalid: 422 is not the
schema refusing \`object\`: [{"rule":"object-reference-unknown",...}]`,
exit 1.
- (B) Same fixture, check reverted to envelope-only: `dataset yes ok
ok`, exit 0, a false pass.
- Both legs were restored to blob equals HEAD, with `git diff HEAD`
empty.
- `pnpm --filter @objectstack/objectql exec vitest run --project local
--maxWorkers=2`: **304 files / 5072 tests passed**, exit 0.
- `pnpm --filter @objectstack/objectql typecheck`: exit 0.
`check:test-typecheck` OK (40 files / 234 errors, all in its ledger).
This file is in `tsconfig.test.json`'s program (`--listFiles`), has 0
error lines, and has no ledger entry.
- **Gates:** `node scripts/pm/dispatch-gates.mjs --commands` (no paths)
derived 54 commands, and all 54 exit 0. Two first answered exit 3
`PREREQUISITE NOT MET` (`check:dual-build-cjs-loads`,
`check:type-check-debt`); both went green after `turbo run build
--filter='./packages/*' --filter='./packages/*/*'` (72/72). The dist
sweeps were rerun at full population. `--ran`: **54 derived, 54 run, 0
NOT-MEASURED, 0 UNRUN**, exit 0.
- **Live `node scripts/check-issue-citations.mjs`:** exit 0, `no issue
citations added ... (0 file(s) read)`. Test files are that gate's
declared deferred surface, so this is not a clearance of the new
citations. The two new targets were checked by hand: objectstack-ai#19143 answers 200
(closed, completed) and objectstack-ai#19586 answers 200.
- **eslint, narrowed to the one file:** `--no-inline-config --format
json`: 1 file, 0 errors, 0 warnings. The file's resolved config
(`--print-config`) has no `parserOptions.project` / `projectService`, so
linting is not type-aware and the edit cannot move a verdict on any
untouched file. The repo-wide `pnpm lint` is CI's.
- **`skip-changeset`, measured:** `@objectstack/objectql` ships `files:
[dist, README.md, CHANGELOG.md]`. Five symbols unique to this diff
(`SWEEP_ACCOUNT_METRICS`, `invalidatedField`, `sweep_account_metrics`,
`metadata-validation-sweep`, the new note text) hit 0 files there, while
the control `class SchemaRegistry` hits 2.

## Bounded in-place fix, declared

The generic invalid-leg assertion and the `skill` relabel go beyond the
one row. All four conditions hold:

1. It is the class this card hit: a sweep verdict produced by an
author-time refusal instead of the schema, and `skill` was a live
instance of a row refused for a reason other than the one it names.
2. It is mechanical, with its shape pinned by the measured issue list of
every executed row.
3. No other claim holds this file.
4. It adds no new verification surface: the same test, the same gate
family.

## Acceptance notes (not filed)

- Seven other runtime-creatable types still print `no fixture
(skipped)`: `book`, `datasource`, `doc`, `external_catalog`, `mapping`,
`position`, `seed`. Each is its own fixture, with its own universe to
measure first. Triage graded this shape as a coverage gap, not a
finding.
- Seven `FIXTURES` entries are never executed, because their types are
not `allowRuntimeCreate`: `field`, `validation`, `approval`, `job`,
`profile`, `role`, `agent`. That is the failure mode this file's own
`api` note describes ("looking like coverage while asserting nothing").
Dead code, noted only.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr)_

---------

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

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants