Skip to content

feat(spec)!: page requires only on the compiled kinds — refused at parse on react, full and slotted pages (#21459) - #21547

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21459-page-requires-compiled-kinds
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21459-page-requires-compiled-kinds

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21459
Clause-②: yes (narrowing)

Summary

This PR executes ruling A (record 5964312254). PageSchema now accepts requires only when kind is html or jsx, the two kinds the metadata save door compiles. On a react, full or slotted page it is refused at parse, and so is a page that omits kind, which defaults to full. The refusal names the key, the page's kind and the compiled kinds.

  • The refusal: checkPageRequiresKind (packages/spec/src/ui/page.zod.ts). It is an exported object-level check chained onto PageSchema with .superRefine(...), right after checkPageSourceCompleteness. That is the mechanism this file already uses for kind-conditional rules, so no second one is added. Its vocabulary is the new export COMPILED_PAGE_KINDS = ['html', 'jsx']. The issue is code: 'custom' at path: ['requires']. The message opens with "requires is refused on a kind: 'react' page" (or the page's kind), names html and its deprecated alias jsx, says "Delete the key.", and ends with the house os migrate meta --from 17 sentence. It carries no tracker number.
  • The describe now says the key exists only on html / jsx pages and is refused at parse on the other kinds. Its opening sentence, "derived from the source at save — omit it", is unchanged byte for byte, because the reconciliation ledger row quotes it.
  • The kit (ADR-0087):
    • D2 conversion page-requires-non-compiled-kind-removed (step 18, order: 58, retiredFromLoadPath: true, retiredAfter: '17.6.0'). It strips the key from react, full, slotted and kind-less pages.
    • D3 semantic entry page-requires-non-compiled-kind-refused, which carries conversionIds: [page-requires-non-compiled-kind-removed].
    • A hand-written STEP18_RATIONALE fragment (order: 66, placed after ui-ai-chat-window-retired, which landed first at 65). The registry regions were regenerated with gen:migration-registry.
    • No tombstone and no RETIRED_KEYS_BY_MAJOR row: the key stays live on html pages.
  • Ledgers:
    • liveness/page.json: the requires row stays live. Its evidence gains the PARSE reader, its note's per-kind clause shrinks to the compiled kinds, and verifiedAt is now 2026-10-03. The state counts do not move.
    • The reconciliation row in metadata-form-zod-reconciliation.test.ts: its per-kind clause now reads "on every other kind the parse refuses it".
  • Generated: api-surface/ui.json and export-origins/ui.json gain the two exports, and content/docs/references/ui/page.mdx re-renders the describe. All three were produced by check:generated --fix, never edited by hand.
  • Changeset: @objectstack/spec minor with the BREAKING banner, Clause-②: yes (narrowing), a FROM → TO table, and the ADR-0087 marker registered page-requires-non-compiled-kind-removed, page-requires-non-compiled-kind-refused.

No runtime code changes. The save door, the load report and objectui are untouched.

Premise checks (at base c98a72d69e, re-read on the merged tree)

  • A1 census: zero producers. Every requires: hit in examples/**, packages/apps/** (none there), content/docs/** and skills/** is the stack-level capability list. In examples that is app-crm, app-showcase and app-todo's objectstack.config.ts, plus prose comments naming capability tokens. The only page bodies that carry requires are the 11 htmlPage(...) sites in packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts, all kind: 'html', so they are still accepted. The proximity scan (each requires: hit within 15 lines of a page marker) found them, which is its positive control. No test pinned acceptance on a non-compiled kind. objectui at the .objectui-sha pin 89cad75d55 shows page requires only on stamped html fixtures, plus one compile-only type fixture (twins-spec-by-reference-9736.test.ts:158). The ruling's cloud and hotcrm census stands.
  • A2, the default kind. An omitted kind parses as full, and the refinement runs on the parsed value. So { requires: [...] } with no kind is refused as a full page, and the message adds "(full is also the kind of a page that omits kind)". A .shape mirror without the default reaches the check with kind absent and gets the same issue, which the exports-parity fixture pins.
  • A2, the empty array. On a non-compiled kind the save door never looks at []: compileHtmlPage returns undefined, so findHtmlPageSourceGaps answers null and stampHtmlPageRequires returns the body as written. The load report answers [] for it, which reports nothing. So [] had no effect anywhere. The ruling's words refuse the key ("accepts requires only when kind is html or jsx"), not its contents, so [] is refused too, and the conversion strips it too. Both are pinned.

A4: the stored-row disposition is a mechanical drop, so it has a D2 conversion

The drop is lossless. On those kinds nothing derived the list, no renderer read it, and the Studio page editor already drops it on every save. Its one reader was the load report's warning. With the conversion:

  • a stored row at rest replays it at every rehydration seam, loading with one conversion notice instead of a metadata_spec_invalid warning and a _diagnostics badge at every boot;
  • an artifact built by 17.6.0 or earlier replays it too;
  • an authored source is still refused at parse, because the conversion is retired from the load path, and os migrate meta --from 17 lists the edit.

This is pinned at the real seam: loadMetaFromDb over a seeded stored react page carrying requires gives loaded: 1, errors: 0, invalid: 0, one notice naming the conversion, no [page_requires_plugin_absent] line (the manifest lacks the plugin, so an unconverted list would have been reported) and no [metadata_spec_invalid] line.

A6: reader branches the parse boundary now makes unreachable for non-compiled kinds (none changed here)

  • packages/metadata-protocol/src/runtime-authoring-gate.ts:616-627, findPageRequiresAbsentFromManifest, which is "Kind-agnostic on purpose" (TSDoc at :610). A non-compiled page can no longer reach it: the save door refuses the key, and at load the stored-row conversion strips it before reportPageRequiresAbsentAtLoad (protocol.ts:24418, called on the converted body) reads it. The kind-agnostic reach now serves only html / jsx rows, and its TSDoc sentence is a follow-up candidate.
  • packages/metadata-protocol/src/protocol.ts:24526, the reportPageRequiresAbsentAtLoad TSDoc "How a stored page gets here". It is still true, but now only of html rows.
  • runtime-authoring-gate.ts:679 and :728 were already gated to html / jsx by compileHtmlPage (:634). No change.
  • objectui builtinComponents.tsx pageSaveBody (at the pin) deletes requires on every kind. On non-compiled kinds that is now redundant, and harmless.

No test pinned the old acceptance, so no existing test changed meaning.

A9: merge state

PR #21531 landed as 48eb9c193f. This branch merged origin/main twice through scripts/pm/os-regen-merge.sh: dafb0f6d1e after #21531, and e38149267c at 49161683fb.

  • ui/page.zod.ts and migrations/registry.ts merged textually clean. Both rationale fragments are kept (ui-ai-chat-window-retired 65, this one 66), and the regenerated regions match check:migration-registry.
  • The three os-regen artifacts both sides touched were regenerated on the merged tree, and the delta against main is exactly the two exports and the describe row.
  • Sibling entries survive, with equal counts on main and head for 'ui-ai-chat-window-retired' (3), 'ui/AIChatWindowProps' (2) and 'ai:chat_window' (17).
  • None of the 10 open PRs touches ui/page.zod.ts, migrations/registry.ts, conversions/registry.ts, liveness/page.json or the reconciliation test.
  • Since 49161683fb, main gained 1ac7308d7a and 41b13331cd. Neither touches a file in this diff.

Tests (all at head e38149267c unless noted)

  • @objectstack/spec test (vitest run --project local): 604 files, 17888 passed, 1 todo. Run at d7cd797549; the later merge moved nothing under packages/spec.
  • @objectstack/spec typecheck: exit 0. That covers tsc --noEmit, check:scripts-typecheck and check:test-typecheck (52 files / 246 errors held, unchanged).
  • @objectstack/metadata-protocol test: 205 files passed, 3 skipped (3157 tests passed). Typecheck exit 0, and --listFiles includes protocol.runtime-authoring-gate.test.ts.
  • New and changed pins:
    • packages/spec/src/ui/page-requires-compiled-kinds.test.ts: the refusal on react, full and slotted, checked for code, path and named subjects; the omitted-kind default; the empty array; html / jsx controls; no requires on every kind; the stack door envelope (STACK_SCHEMA_INVALID, 422, at pages.1.requires); and the conversion. The conversion pins cover the stored-row strip with a notice, html / jsx kept, strip-iff-refused over the whole kind vocabulary, unknown kind left alone, artifact replay, idempotence and retired-from-load-path. The file also pins the ledger wiring and that there is no tombstone, with ui/Page:assignedProfiles as the control.
    • object-refinement-check-exports.test.ts: the new export is catalogued with 8 fixtures, and the parity, bijection, attachment-by-identifier and barrel-identity legs hold.
    • protocol.runtime-authoring-gate.test.ts: the save door refuses requires on a react, full, slotted or kind-less page with { code: 'INVALID_METADATA', status: 422 }, one custom issue at requires, no jsx-* compile finding, and nothing persisted. The html control still saves and stamps. The load pin is described above.
  • Ablation through scripts/ablation-replace.mjs, run under the verify lock. The mutation replaces the kind condition if ((COMPILED_PAGE_KINDS as readonly string[]).includes(kind)) return; with a bare return;, so the key is accepted on every kind again. The source-resolved spec suites need no rebuild.
    • Mutation landed: anchor 1 → 0, blob 6b13a7df8e44 → 1fd2575e28e3, marker on disk 1.
    • Red leg: 13 failed, 174 passed. The three non-compiled-kind refusal pins are red, along with the omitted-kind, empty-array, stack-door and strip-iff-refused pins and the exports parity and bijection legs. The html / jsx controls stayed green.
    • Restore: blob equals HEAD (6b13a7df8e44) and git diff HEAD is empty. Green leg: 187 passed.

Gates

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 116 commands at e38149267c. All 116 were run and their exit codes recorded. --ran reports: "116 derived famil(ies) accounted for — 115 run, 1 NOT-MEASURED". The NOT-MEASURED one is pnpm check:dual-build-cjs-loads (exit 3, PREREQUISITE NOT MET: it needs every package's dist/, a repo-wide build), and it is declared to CI. check:skill-examples and check:lean-entry-closure first exited 3 for missing client-react / objectql builds. After building those closures they re-ran to exit 0. Highlights, each exit 0:

  • check:generated reports "all up to date" for 15 artifacts;
  • check:liveness, check:migration-registry, check:spec-changes, check:upgrade-guide, check:api-surface, check:export-origins, check:docs;
  • check:adr-0087-registration, check-changeset-no-major (also run with this body as the --event payload), check-empty-changeset;
  • check:doc-authoring, check:nul-bytes, check:cross-package-test-inputs, check:engine-double-contract, check:type-check-debt.

A narrowed eslint run (--no-inline-config --format json) covered the 8 changed .ts files and found 0 errors and 0 warnings. This repo's eslint config enables no type-aware linting, so the diff cannot move a verdict on an untouched file.

Acceptance notes

  • objectui census tripwire. objectui's spec-object-refinements-7715.test.ts census (at the pin) counts PageSchema's object-level checks and lists attached: ['checkPageSourceCompleteness'] for PageNodeSchema. It is built to go red when the spec adds a check. At objectui's next @objectstack/spec bump, that row will ask for checkPageRequiresKind to be attached, or declared not attachable. Runtime behaviour needs no objectui change, as the ruling says, but that census will need one row. The Console Pin Gate only builds objectui, and nothing objectui compiles against changed type, so it is unaffected.
  • examples/app-showcase and every other example author no page-level requires, so no example changed.

Generated by Claude Code

claude added 6 commits October 3, 2026 04:15
…, D3 entry, ledger rows (#21459)

Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM
Co-authored-by: Claude <noreply@anthropic.com>
…rence for the compiled-kind requires check

Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM
Co-authored-by: Claude <noreply@anthropic.com>
… conversion of page requires on non-compiled kinds; add the changeset

Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM
Co-authored-by: Claude <noreply@anthropic.com>
…rder the requires rationale fragment after the ai:chat_window one that landed first

Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/l label Oct 3, 2026
@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 16 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/spec/api-surface/ui.json, packages/spec/export-origins/ui.json, packages/spec/liveness/page.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/automation/flows.mdx (via lead_record (literal, a string literal in fixture))
  • content/docs/protocol/objectui/layout-dsl.mdx (via PageSchema (symbol, a top-level const))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-5.mdx (via retiredAfter (symbol, a field of const object pageRequiresNonCompiledKindRemoved), retiredFromLoadPath (symbol, a field of const object pageRequiresNonCompiledKindRemoved))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/spec/api-surface/ui.json, packages/spec/export-origins/ui.json, packages/spec/liveness/page.json) — pages documenting those are invisible to this run
  • 9 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 — 138 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 41b13331cdf096c8a940e1430bd535c67049cea5 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 41b13331cdf096c8a940e1430bd535c67049cea5

⚠️ 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 41b13331cdf096c8a940e1430bd535c67049cea5 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e38149267c16b962c1298b4b76a9b8b7137b7640
Local-runs: none

Inputs read: card #21459 (body; comments 5964312254 ruling A, 5965316789 claim, 5965934994 dev report), PR #21547 (body, 13-file list, net diff against main, comment 5965922135), the head's 32 check-runs (polled twice, last at 2026-10-03T05:33Z), and PR #21451 at cfa4d740b7 (the requires describe, liveness row and reconciliation row; all three unchanged from there to this PR's merge base 49161683fb). Nothing was built, run or re-run locally.

① Derived judgments

Each accept-set or public-surface change the diff implies, judged against ruling A and the refs.

  1. Accept-set narrowing — right. PageSchema gains .superRefine(checkPageRequiresKind) after checkPageSourceCompleteness; requires is refused at path ['requires'], code: 'custom', when kind is not in COMPILED_PAGE_KINDS = ['html', 'jsx']. That is the ruling's own sentence ("accepts requires only when kind is html or jsx"). The message names the key, the page's kind and both compiled kinds, and carries no tracker number (pinned).
  2. Kind-less page refused as full — right. At head kind: z.enum([...]).default('full') (page.zod.ts:928), so the parsed value the refinement sees is full; the check also reads page.kind ?? 'full' for a .shape mirror without the default. Pinned in both the new test file and the exports-parity matrix.
  3. Empty list refused — right. The ruling refuses the key, not its contents; the dev measured [] as inert at the save door and silent at the load report, so nothing is lost. Pinned.
  4. html / jsx unchanged — right. Controls pinned at the parse, the stack door and the save door (the html control still saves and the compile still stamps). The engine gate is untouched: runtime-authoring-gate.ts:571 holds a Set of html and jsx and :634 returns early for every other kind, so the new spec constant and the save door agree.
  5. Two new public exports on @objectstack/spec/ui — right, additive. checkPageRequiresKind (function) and COMPILED_PAGE_KINDS (const), both landed in api-surface/ui.json and export-origins/ui.json by check:generated --fix; the parity, bijection, attach-by-identifier and barrel-identity legs of object-refinement-check-exports.test.ts are extended to the new check. The engine keeps its private Set rather than importing the spec constant; acceptable, since the claim forbade save-door edits.
  6. D2 conversion page-requires-non-compiled-kind-removed — right. Step 18 is unreleased (@objectstack/spec is 17.6.0 at head); retiredFromLoadPath: true, retiredAfter: '17.6.0' (six step-18 siblings carry that value), order: 58 (new; absent on main). Kind-gated exactly as the refusal is, with kind ?? 'full'; an unknown kind is left as stored; html / jsx keep their list. The strip is lossless: on those kinds the only reader was the load warning. The stored-row seam pin (loadMetaFromDb over a seeded react row: loaded 1, errors 0, invalid 0, one conversion notice, no page_requires_plugin_absent line, no metadata_spec_invalid line) is the disposition the ruling left to the entry to prescribe.
  7. D3 entry page-requires-non-compiled-kind-refused — right. Reason, replacement (the prescription), acceptance criteria and conversionIds present; surface carries no backticks or pipes; the registry copy is byte-identical to the entry file and sits in id order. No tombstone and no RETIRED_KEYS_BY_MAJOR row, correctly: the key stays live on html pages (pinned, with ui/Page:assignedProfiles as the control).
  8. Rationale fragment order: 66 — right. New at head, no collision; 65 is ui-ai-chat-window-retired from PR feat(spec)!: ai:chat_window is retired — refused by name at the schema door, the floating chat overlay is the AI chat entry point (#21504) #21531, which landed first. The duplicate orders already present in MAJOR_18_CONVERSIONS (55, 57) and STEP18_RATIONALE (56, 60, 62) are on main before this PR and are not its doing.
  9. Ledgers narrowed to the compiled kinds — right. liveness/page.json requires stays live, gains the PARSE reader as evidence, its note's per-kind clause shrinks to html / jsx and names the conversion, verifiedAt moves to 2026-10-03; the state counts do not move (Spec property liveness: success). The reconciliation row's why now reads "on every other kind the parse refuses it". Both are exactly PR fix(spec,lint): page requires is live — refused at save, reported at load #21451's rows at cfa4d740b7, narrowed, and nothing else in either ledger moved.
  10. Describe and generated doc — right. The opening sentence "derived from the source at save — omit it" is byte-identical (the reconciliation row quotes it); the kind clause is added after it; content/docs/references/ui/page.mdx is the regenerated render (Build Docs and Check Documentation Links: success).
  11. Nothing outside the ruling moved — right. No runtime file in metadata-protocol, no objectui, no skills, no governed surface (Governed Surface Queue Guard: success). Stack-level requires (the manifest capability list in objectstack.config.ts; also every requires hit in flows.mdx that the drift advisory listed) is a different schema and is untouched; layout-dsl.mdx, the advisory's other page, does not mention requires. The release-owned 17-5.mdx is not in the file list.

② Semver level

  • .changeset/21459-page-requires-compiled-kinds.md: @objectstack/spec: minor, BREAKING banner, a FROM → TO table, and the adr-0087 registered marker naming both the D2 and the D3 id. It matches what the diff publishes: the only package whose published surface moves is @objectstack/spec (a narrowed accept set and two new exports); the metadata-protocol change is test-only. minor for an accept-set narrowing is this repo's launch-window convention (scripts/check-changeset-no-major.mjs: "During the launch window we ship breaking changes as minor"), and the sibling narrowing changeset at head (20274-agent-memory-contract.md) carries the same level and banner. Check Changeset: success.
  • Clause-②: yes (narrowing) — at line 2 of the PR body and in the changeset, and true: the accept set shrinks on three of five kinds.
  • Not skip-changeset, correctly: spec publishes.

③ Boundary flags

Dev deviations (5) and open_questions (0), each answered:

  1. PR assignee not set — not a contract item: the write was refused by a local permission check and the dev stopped rather than re-route. The seat owes the assignee. No verdict weight.
  2. Tests added in packages/metadata-protocol — accepted. The claim's ⛔ was on the save door and the load report as runtime; tests were its carve-out. Only protocol.runtime-authoring-gate.test.ts moved there, adding the public-door refusal pins and the stored-row disposition pin. No runtime file in that package is in the file list.
  3. Second new export COMPILED_PAGE_KINDS — accepted. Additive, ledgered, and it is the one vocabulary the check, the D3 text and the test's enum-minus-compiled pin share.
  4. Rationale order 65 → 66 — verified: 66 is unique at head.
  5. Not re-merged onto 1ac7308d7a / 41b13331cd — verified: git diff --stat 49161683fb 41b13331cd over this PR's 13 paths is empty.

Dev out_of_scope_findings (2), each with its carrier named; neither needs escalation:

  • objectui's spec-object-refinements-7715.test.ts census row will ask for checkPageRequiresKind at the next @objectstack/spec bump. A census row, not a runtime change, so the ruling's "objectui needs no change" holds for runtime and is qualified by one test row. Carrier: the objectui seat at that bump.
  • findPageRequiresAbsentFromManifest's TSDoc "Kind-agnostic on purpose" now describes a reach only html / jsx rows use. Doc drift; carrier: the next PR that touches runtime-authoring-gate.ts.

Check-runs on the head, read as they stand at 2026-10-03T05:33Z: 20 completed success, 2 completed skipped (Console Pin Gate and Packed-tarball smoke, both opt-in), 0 failed, 10 in progress: Lint & Repo Gates; Test Core (1/6) through (5/6); Type Check · consumer gates; Type Check · workspace; Dogfood Regression Gate (1/3) and (2/3). Concluded and relied on above: Type Check · source gates, Check Changeset, Spec property liveness, Build Docs, Check Documentation Links, Governed Surface Queue Guard, Build Core, Test Core (6/6), Dogfood Regression Gate (3/3), Dogfood Verify CLI, Temporal Conformance, Type Check · debt ledger, Check PR Size, and the four claim and path guards. An in-progress gate is not a pass: this record judges the contract, and the merge waits on those ten as it would regardless.

Reads carrying no verdict: the PR is a draft; its mergeable_state: blocked is the draft plus the pending checks, not a conflict.

Implemented-by: claude/issue-21459-page-requires-compiled-kinds
Reviewed-by: session_01YDt3PzwfrkuFzUBF89WPmM

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

ACCEPT — PR #21547 @ e3814926 (#21459)

domain:spec seat 2 (session_01YDt3PzwfrkuFzUBF89WPmM), holder of claim 5965316789 · 2026-10-03T05:51Z

  • Shape (read on GitHub): a draft against main. The first line is Fixes #21459, and Clause-②: yes (narrowing) sits at a line start. 13 files, +641 / −24.
  • Review: the contract review at CONTRACT_REVIEW_TIER, 5966012133, reads PASS on this head. It executes ruling A (5964312254):
    • PageSchema accepts requires only when kind is html or jsx. On react, full and slotted it is refused at requires, and so is a page that omits kind, which defaults to full. The refusal names the key, the page's kind and the compiled kinds, and carries no tracker number. An empty list is refused too, because the ruling refuses the key.
    • The check is the exported checkPageRequiresKind, beside checkPageSourceCompleteness, with COMPILED_PAGE_KINDS as its vocabulary. The engine's save door is untouched and agrees with it.
    • Stored rows: a D2 lossless-strip conversion, page-requires-non-compiled-kind-removed. It is step 18, retired from the load path and replayed for 17.6.0 artifacts. A stored non-compiled page loads clean, with one conversion notice instead of an invalid-row warning on every boot. On those kinds the key's only reader was the load warning, so nothing is lost.
    • The D3 entry page-requires-non-compiled-kind-refused, its step-18 rationale fragment, and the narrowed ledger and reconciliation rows.
    • Release: @objectstack/spec minor with the BREAKING banner, a FROM → TO table and the ADR-0087 marker.
  • No producer is refused. Zero page bodies author requires on a non-compiled kind in this repo, and the ruling's record measured zero in cloud, hotcrm and objectui.
  • CI on e3814926: 33 success and 2 skipped by design (Console Pin Gate, packed-tarball smoke).
  • Governed surface: check-governed-merges --pr 21547 reads NOT governed.
  • Serial: PR feat(spec)!: ai:chat_window is retired — refused by name at the schema door, the floating chat overlay is the AI chat entry point (#21504) #21531 landed first. Both rationale fragments are kept (65 and 66), and the registry regions are regenerated on the merged tree. spec(ui): the ComponentPropsMap rows still type renderer-read members as z.unknown() — navigation on object-map / object-gantt / object-tree and conditionalFormatting on object-kanban accept 42 — the family close-out after #21445 #21464 stage 2 (in flight) also adds a step-18 entry, and runtime strings in the domain:spec packages carry tracker numbers (spec 175 and lint 83 messages): this lane's share of the #20513 A/A burn-down #20749 stage 4 (in flight) edits conversions/registry.ts in other entries; each merges main after this lands.
  • The PR has no assignee. The dev's assignee write was refused by the session's permission check. That is not a contract item, and it is raised with the maintainer rather than worked around here.

Carried, decided here:

  • objectui, at its next @objectstack/spec bump: packages/types/src/__tests__/spec-object-refinements-7715.test.ts lists PageSchema's attached checks by name, and will ask for checkPageRequiresKind (attach it to PageNodeSchema, or declare it not attachable). That is a census row, not a runtime change. → noted on the card at landing, not filed.
  • findPageRequiresAbsentFromManifest's TSDoc ("kind-agnostic on purpose", runtime-authoring-gate.ts:610) now describes a reach only html / jsx rows use. → It rides the next PR that edits that file.

Next: ready, auto-merge, the queue.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 3, 2026 05:52
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 3, 2026 05:52
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit 72af58c Oct 3, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21459-page-requires-compiled-kinds branch October 3, 2026 06:21
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ptions state each decision in words instead of a tracker number (stage 4) (objectstack-ai#21555)

Part of objectstack-ai#20749
Clause-②: no

Stage 4 of the `domain:spec` lane's share of the runtime-string
burn-down (ruling `5902360492`, form D): class (b) of the stage-3
census, the text `packages/spec/src` shows authors and administrators.
Every rewritten string now states in words what the cited decision was,
or drops a citation its sentence already explained. Text only.

## What changed

- **56 ADR-0087 conversion summaries** in
`packages/spec/src/conversions/registry.ts` (68 tracker ids): every
protocol 16 → 17 summary that carried an id. A summary is the "Change"
column of `docs/protocol-upgrade-guide.md`, the `to` text of
`spec-changes.json`'s `converted[]` records and what `os migrate meta
--json` reports under `specChanges`, so it is read by an author
upgrading metadata.
- **Three descriptions** (3 ids): `FieldSchema.autonumberFormat`'s
`.meta()` description (`data/field.zod.ts`), and the route descriptions
of `GET /:type/:name/layers` and `POST /:type/:name/publish`
(`api/plugin-rest-api.zod.ts`).
- **Generated, by `check:generated --fix`** (exactly the three artifacts
it proved stale): `docs/protocol-upgrade-guide.md` (56 rows),
`packages/spec/spec-changes.json` (56 summaries, twice each: the
per-major record and the aggregate), and the `autonumberFormat` rows of
`content/docs/references/data/field.mdx`, `data/object.mdx` and
`system/migration.mdx`.
- One `@objectstack/spec` **patch** changeset, `Clause-②: no`.

## Size: the split (A2)

At this base the class (b) population is unchanged from the stage-3
census: 91 conversion summaries with 108 ids (81 distinct cards) plus 3
descriptions with 3 ids. That is over the ~60-card bar, so this PR
delivers up to a protocol-step boundary, lowest step first:

| protocol step | summaries with ids | ids | distinct cards |
|---|--:|--:|--:|
| toMajor 11 / 13 / 14 / 15 | 0 | 0 | 0 |
| **toMajor 17 (delivered)** | **56** | **68** | **48** |
| toMajor 18 (next stage) | 35 | 40 | 34 (33 not cited in 17) |

The three descriptions ride this part (3 more cards, no overlap): 59
messages, 71 ids, 51 distinct cards delivered. The next stage's exact
list is the 35 toMajor-18 summaries at the end of this body.

## Delivered: each site, the decision read, the new words

Every cited card was read through REST with all comments; the record
column names the comment (or commit) the decision was read from. Where a
summary already said why, the citation is dropped and the sentence kept.
Placeholders `OBJECT_NAME` / `RULE_NAME` below stand for the
angle-bracket spelling in the source.

| conversion (head line) | cited | decision as read (record) | summary
now reads |
|---|---|---|---|
| `action-execute-to-target` (:727) | objectstack-ai#3713 | `execute` is the
deprecated alias of `target`; spec and objectui resolved the pair in
opposite directions; align on the spec rule and drop the alias so the
divergence is unrepresentable (body (closed completed, no comments)) |
action key 'execute' → 'target' (the deprecated handler alias; the spec
and the renderer had resolved the pair in opposite directions, so one
key now names the handler) |
| `field-conditionalRequired-to-requiredWhen` (:767) | objectstack-ai#3754 | same
fold-and-drop as objectstack-ai#3713: `requiredWhen` canonical, alias folded and
dropped from parsed output (body (closed completed, no comments)) |
field key 'conditionalRequired' → 'requiredWhen' (the deprecated
predicate alias, folded into the canonical key so no reader picks its
own precedence) |
| `agent-tools-to-skills` (:822) | objectstack-ai#3894 | ADR-0109 accepted;
`agent.tools[]` removed because it resolved names against the full
registry with no surface check, breaking ADR-0064 (tool set = union of
skills' tools) (PR body (merged)) | agent key 'tools' removed — declare
capability in a skill (ADR-0064: an agent's tools are exactly its
skills' tools, and this inline slot resolved names against the whole
registry with no surface check) |
| `sharing-rule-access-level-full-to-edit` (:881) | objectstack-ai#3865 | route B is
the end state: sharing grants read/edit only; delete, transfer and
re-share come from object permissions, ownership and admin scope; `full`
→ `edit` is lossless (5105498900) | sharing-rule accessLevel 'full' →
'edit' (`full` never granted more than `edit`; a sharing rule grants
read or edit, while delete and transfer come from object permissions and
ownership) |
| `flow-node-crud-object-alias` (:954) | objectstack-ai#3796 | the seven aliases (six
open-coded `??` + the shim's last `object`) graduate straight into the
D2 layer; the `readAliasedConfig` shim is deleted (5125152179) | CRUD
flow-node config key 'object' → 'objectName' (the last alias in the
executors' `readAliasedConfig` shim graduates into this layer, and the
shim is deleted) |
| `flow-node-notify-config-aliases` (:1077) | objectstack-ai#3796, objectstack-ai#4045 | objectstack-ai#3796: `??`
fallbacks graduate, `actionUrl` canonical (downstream chain uses it).
objectstack-ai#4045: `notify.source` was a read-but-undeclared shape → conversion
layer, not configSchema (5125152179; 5127636357, 5138487536) | notify
flow-node config keys 'to' → 'recipients', 'subject' → 'title', 'body' →
'message', 'url' → 'actionUrl' (executor `??` fallbacks graduated into
this layer; `actionUrl` is canonical because the notification chain
downstream already uses it), and nested 'source: {object, id}' →
'sourceObject' / 'sourceId' (a shape the executor read that no config
schema declared) |
| `flow-node-wait-event-config-lift` (:1288) | objectstack-ai#4045 |
`node.config.eventType` etc. were an undeclared second contract beside
the declared `waitEventConfig`; graduate them (objectstack-ai#4161) (5130277099,
5138487536) | wait flow-node loose config keys → the declared
`waitEventConfig` block: 'eventType', 'timerDuration'/'duration' →
'timerDuration', 'signalName'/'signal' → 'signalName', 'timeoutMs' (the
executor also read these keys from the loose config, a second contract
beside the declared block) |
| `flow-node-map-flow-alias` (:1364) | objectstack-ai#4045 | reconciliation found the
`map.flow` alias (undeclared executor fallback); graduated (objectstack-ai#4228)
(5138487536) | map flow-node config key 'flow' → 'flowName' (an
undeclared spelling the executor accepted through a bare fallback; it
graduates into this layer) |
| `flow-node-subflow-flow-alias` (:1421) | objectstack-ai#4278 | reconcile the
schemaless nodes' forms with their executors and check `subflow`; its
bare `flowName ?? flow` fallback graduates (body (closed completed, no
comments) + conversion docblock) | subflow flow-node config key 'flow' →
'flowName' (an undeclared spelling the executor accepted through a bare
fallback, found when the schemaless nodes were reconciled with their
executors; it graduates into this layer) |
| `flow-node-connector-config-lift` (:1529) | objectstack-ai#4045 | executor reads
only `connectorConfig`; the descriptor schema rooted the triple at
`config`, so the Studio form wrote unread keys; descriptor stops
publishing, stored loose keys lift (5132926517, 5138487536) |
connector_action flow-node loose config keys 'connectorId' / 'actionId'
/ 'input' → the declared `connectorConfig` block (the executor reads
only that block; the published designer form had been writing these keys
where nothing read them) |
| `flow-node-script-config-aliases` (:1641) | objectstack-ai#3796 | as above:
open-coded `??` fallbacks graduate into the D2 layer (5125152179) |
script flow-node config keys 'functionName' → 'function', 'input' →
'inputs' (executor `??` fallbacks, graduated into this layer) |
| `app-dead-authoring-keys-removed` (:1742) | objectstack-ai#4001, objectstack-ai#4509, objectstack-ai#4667, objectstack-ai#4709
| liveness audits: keys unread or wrongly encoded are removed; objectstack-ai#4709:
the "no shell read homePageId" premise was false, ruling B keeps the
retirement (an ID cross-reference that dangles is the wrong encoding)
(5158421901 (objectstack-ai#4509), 5159774792 (objectstack-ai#4667), 5164227920 (objectstack-ai#4709 ruling B)) |
app keys
'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation'/'homePageId'
plus contextSelectors 'includeAll'/'placement' and areas 'order' removed
(liveness audits found each one unread or wrongly encoded; sharing/embed
declared a public surface no route enforced, mobileNavigation was fully
unimplemented, includeAll was deliberately disobeyed because an 'All'
row would clear a mandatory scope, homePageId WAS read by objectui's
console before v17 but encoded the landing page as an ID cross-reference
that silently fell back when it dangled — the landing page is the first
nav item (the first retirement record said nothing read it, a premise
since corrected; the retirement stands), and no renderer ever sorted
areas) |
| `app-area-fail-open-gates-removed` (:1853) | objectstack-ai#4651 | ruling B: remove
both keys; removing a fail-open gate is strictly safer than keeping it;
prescription names the two enforced layers (5160072589) |
navigation-area keys 'visible'/'requiredPermissions' removed (ADR-0049 —
FAIL-OPEN access gates: no layer ever read them, so a 'hidden' or
permission-gated area was served and rendered to every user, while the
identically named keys on a navigation ITEM and on the APP are enforced;
gate the items inside the area, or gate the app) |
| `permission-rls-priority-removed` (:1955) | objectstack-ai#3896 | the objectstack-ai#3896
security-audit line: rls.priority promised conflict resolution that
cannot exist (policies OR-combine) and had no reader; removed (card body
+ commits d6bfb3d (objectstack-ai#3990), eb95d97 (objectstack-ai#3998)) | RLS-policy key
'priority' removed (a security audit found no reader: policies
OR-combine, so the promised conflict-resolution semantics cannot exist;
dropping it changes no outcome) |
| `tool-inert-authoring-keys-removed` (:2024) | objectstack-ai#3896 | the audit
close-out removes the four inert tool keys (permissions gated nothing,
active:false withdrew nothing) (commit eb95d97 (objectstack-ai#3998)) | tool keys
'category'/'permissions'/'active'/'builtIn' removed (authorable and
inert, so removed under ADR-0049 enforce-or-remove; permissions gated
nothing, active:false withdrew nothing) |
| `action-inert-keys-removed` (:2137) | objectstack-ai#3896 | close-out sweep:
enforce-or-remove worklist, fourteen inert authoring keys leave the
surface (commit 12a19a8 (objectstack-ai#4054)) | action keys
'shortcut'/'bulkEnabled' removed (inert, removed under ADR-0049
enforce-or-remove: no keydown path dispatches shortcuts; the
multi-select toolbar reads the view's bulkActions) |
| `flow-inert-keys-removed` (:2162) | objectstack-ai#3896 | as above (commit
12a19a8 (objectstack-ai#4054)) | flow keys 'active'/'template', node 'outputSchema'
and errorHandling 'fallbackNodeId' removed (inert, removed under
ADR-0049 enforce-or-remove: active:false never stopped a flow; status is
the enforced lifecycle) |
| `view-inert-keys-removed` (:2221) | objectstack-ai#3896 | as above (commit
12a19a8 (objectstack-ai#4054)) | view keys removed as inert (ADR-0049
enforce-or-remove): list 'responsive'/'performance', form
'defaultSort'/'aria' — no renderer read them (list aria/data and form
data stay live) |
| `view-list-passthrough-keys-removed` (:2267) | objectstack-ai#7176 | maintainer
ruling: retire under ADR-0049; pass-through-only reads are dead in
effect (5236139723) | view list keys removed:
'striped'/'bordered'/'virtualScroll' — every measured reader copied the
key forward and none applied it (a key that is only passed through is
dead in effect; ADR-0049 enforce-or-remove) |
| `view-export-options-pdf-removed` (:2335) | objectstack-ai#8010, objectstack-ai#1301 | option A;
`pdf` leaves the enum (honest narrowing, not a runtime console.warn);
PDF export declined (5270998514; objectstack-ai#1301 is not planned) | list-view
export format 'pdf' removed (PDF export was declined as not planned, and
ObjectGrid dropped the declared format from the menu with only a runtime
console.warn; an honest enum replaces that warning) |
| `dashboard-inert-keys-removed` (:2404) | objectstack-ai#3896 | close-out sweep (as
above) (commit 12a19a8 (objectstack-ai#4054)) | dashboard keys 'aria'/'performance'
and widget 'performance' removed (inert, removed under ADR-0049
enforce-or-remove: no renderer applied any of them) |
| `dashboard-widget-responsive-removed` (:2474) | objectstack-ai#4876, objectstack-ai#11027 | objectstack-ai#4876
ruling A: retire widget `responsive` (no reader); objectstack-ai#11027 ruling B:
retire `page.components[].responsive`, equally unread (5169512655;
5380752244) | dashboard widget key 'responsive' removed (no renderer
ever applied per-widget breakpoint overrides; the
page.components[].responsive key this entry once deferred to was
measured equally unread and retired at protocol 18) |
| `dashboard-widget-action-aria-removed` (:2555) | objectstack-ai#5010 | retire the
four dead widget keys; colorVariant kept (body ruling + 5179556002) |
dashboard widget keys 'actionUrl'/'actionType'/'actionIcon' and 'aria'
removed (no renderer ever drew a per-widget action button, and widget
ARIA attributes never reached the DOM; use header.actions[] and the
widget title/description) |
| `dashboard-widget-compareto-converged` (:2652) | objectstack-ai#5011 | converge
`compareTo` on the implemented executor contract `{ kind, dimension? }`;
`1y` rewrites, other offsets delegated (5173559485) | dashboard widget
'compareTo' converged on the executor's { kind, dimension? } contract
(the shape the dataset executor implements; the bare strings and {
offset: '1y' } rewrite mechanically; other { offset } durations have no
faithful target and are reported, not guessed) |
| `agent-knowledge-removed` (:2726) | objectstack-ai#3896 | close-out sweep (as above)
(commit 12a19a8 (objectstack-ai#4054)) | agent key 'knowledge' removed (inert,
removed under ADR-0049 enforce-or-remove: declaring sources/indexes
never scoped retrieval; restrict at the knowledge-service level) |
| `skill-trigger-phrases-removed` (:2744) | objectstack-ai#3896 | close-out sweep (as
above) (commit 12a19a8 (objectstack-ai#4054)) | skill key 'triggerPhrases' removed
(inert, removed under ADR-0049 enforce-or-remove: activation is
triggerConditions + the agent's skills[] allowlist; phrases were a
dead-end projection) |
| `stack-api-require-auth-removed` (:2782) | objectstack-ai#3963 | delete the
`api.requireAuth` opt-out; anonymous always denied; public surfaces
derive authorization from a declaration (form, share link, book
audience) (body (decision recorded in body)) | stack key
'api.requireAuth' removed — anonymous access is always denied; publish
public surfaces by declaration (a public form, a share link or
`book.audience: 'public'`), which replaced the deployment-wide opt-out |
| `flow-node-wait-timeout-keys-removed` (:2864) | objectstack-ai#4158 | wait never had
a timeout: withdraw the contract (route B), `timeoutMs` moves to
`timerDuration` (body (closed completed) + conversion docblock) |
waitEventConfig keys 'timeoutMs' (→ 'timerDuration', stringified — its
only reader used it as the duration) and 'onTimeout' (removed — zero
readers, so no timeout ever fired): wait never had a timeout, so its
timeout contract is withdrawn rather than built |
| `datasource-inert-blocks-removed` (:2944) | objectstack-ai#4583 | all 20 dead
datasource keys removed; each job already has a different live mechanism
(5157934690) | datasource keys 'retryPolicy'/'healthCheck' and external
'label'/'requirePermission' removed (nothing retried, nothing probed on
a schedule, and the federation label/permission were read by nobody;
each of those jobs already has a live mechanism) |
| `mapping-inert-keys-removed` (:3032) | objectstack-ai#4509 | the three mapping keys
retire (schema defaults made authorWarn impossible; removal is the only
signal) (5158421901, 5158744185) | mapping keys
'extractQuery'/'errorPolicy'/'batchSize' removed (no exporter reads a
mapping, error handling belongs to the import request, and the write
path sizes its own batches) |
| `book-translations-removed` (:3087) | objectstack-ai#4667 | six dead authorWarn keys
retired (5159774792) | book keys 'translations' (book-level and
group-level) removed (no resolver read them; the tree endpoint and
portal render labels verbatim, so a localized book served its authoring
locale to everyone). Localize the docs instead: `doc.translations` is
live |
| `job-id-removed` (:3149) | objectstack-ai#4667 | as above (5159774792) | job key
'id' removed (nothing read it; `name` is the job's identity everywhere,
so two jobs differing only in `id` were the same job, and the key's own
description advertised an override that did not exist) |
| `translation-validation-messages-removed` (:3206) | objectstack-ai#4667, objectstack-ai#3778,
objectstack-ai#14381 | objectstack-ai#4667 retire; objectstack-ai#3778 legacy-key table had pointed `errors` at
it; objectstack-ai#14381 an object-scoped key ships with its reader (ADR-0049
enforced) (5159774792; objectstack-ai#3778 body; 5503980929) | translation key
'validationMessages' removed (no resolver read it, so a translated rule
message was stored and never shown; the legacy-key table of the
translation-bundle migration had been steering retired `errors:` authors
into it). Author the message on the rule itself
(`object.validations[].message`), and translate it under the
object-scoped group
`objects.OBJECT_NAME._validations.RULE_NAME.message`, which the write
path resolves (17.3.0, a translation key shipped together with its
reader) |
| `datasource-capabilities-removed` (:3264) | objectstack-ai#4583 | as above
(5157934690) | datasource key 'capabilities' removed (eleven flags no
code read; pushdown comes from the driver's own supports.*, and
`readOnly` never made anything read-only) |
| `datasource-read-replicas-removed` (:3319) | objectstack-ai#4468 | remove:
read-replica routing is an unbuilt feature (5150771330) | datasource key
'readReplicas' removed (no driver opened a replica connection and no
query path splits reads from writes; front replicas behind one endpoint
and point `config` at it) |
| `datasource-config-driver-key-aliases` (:3419) | objectstack-ai#4456 | the factory's
undeclared `??` fallbacks graduate to a D2 entry and are deleted from
the reader (5157922838) | datasource config keys → canonical per driver:
sqlite 'file'/'database' → 'filename', postgres/mysql 'connectionString'
→ 'url' and 'user' → 'username', mongo 'uri' → 'url' and 'user' →
'username' (undeclared driver-factory `??` fallbacks, graduated into
this layer and deleted from the reader) |
| `flow-node-script-branch-keys-removed` (:3665) | objectstack-ai#4343 | operator
ruling: `script` converges to a pure function-call node; the five branch
keys retire (5151704360) | script flow-node config keys 'actionType' (→
'function' when it was shorthand for one; otherwise removed —
'email'/'slack' were logger-backed stubs that delivered nothing), plus
'template' / 'recipients' / 'variables' (fed those stubs) and 'script'
(inline JS the runtime never executed); script is now a pure
function-call node, the only path that ran real logic |
| `object-managed-by-system-to-system-data` (:3762) | objectstack-ai#3355 | retire
`system`, new value `system-data` (not `platform-data`) (5157022965) |
object managedBy 'system' → 'system-data' (ADR-0103's residual bucket
named the engine-owned half v16 had already moved out to `engine-owned`;
the rename leaves the name describing what the bucket actually holds:
admin/user-writable platform data) |
| `object-enable-trash-mru-removed` (:3829) | objectstack-ai#3207, objectstack-ai#2377 | remove
`enable.trash` / `enable.mru`; soft delete stays parked; last slice of
the dead-property removals (5156966571, 5161298967; 5051634768) | object
capability flags 'enable.trash'/'enable.mru' removed (the last slice of
the dead author-facing property removals: no recycle bin and no MRU
tracking ever ran; both default-true flags gated nothing) |
| `object-index-type-partial-removed` (:3912) | objectstack-ai#5248, objectstack-ai#4943 | remove
both index keys; no DDL consumer; return enforce-first on real demand
(5199336983; 5194762679 (duplicate)) | object index keys
'indexes[].type'/'indexes[].partial' removed (no driver ever read
either: the index method is the dialect's choice and a partial index is
built by a database-layer migration, not declared) |
| `retry-policy-converged` (:4070) | objectstack-ai#4661, objectstack-ai#4964 | one RetryPolicy
declaration, `backoffMs`, merged default 0 / 1 with pre-17 job defaults
written out; flow.errorHandling joins, default 0 (silent retry can
double-write) (5158710540 (analysis); 5173148383) | retry policy unified
across job.retryPolicy, try_catch retry and flow.errorHandling: base
delay 'retryDelayMs' → 'backoffMs', and the pre-17 job defaults
(maxRetries 3, backoffMultiplier 2) written out explicitly now that the
merged default is 0 / 1: two declarations that differed only by accident
became one, and retry is opt-in because a retry replays whatever the
attempt already did |
| `hook-body-crypto-hash-removed` (:4249) | objectstack-ai#4391 | remove the
capability token and its build-time inference (5156969500) | script-body
capability token 'crypto.hash' removed (the sandbox never installed
ctx.crypto.hash, so the token granted a call that always threw; the CLI
inferred it too) |
| `dataset-measure-array-string-agg-removed` (:4419) | objectstack-ai#6188 | retire
`array_agg` / `string_agg`; keep and enforce `count_distinct`
(5219849918) | dataset measure aggregates 'array_agg' / 'string_agg'
removed (no SQL backend compiled them and the v1 dataset runtime refused
them by name, so a measure declaring one never produced a value; the
measure is dropped, and with it any derived measure left referencing it)
|
| `connector-rate-limit-config-removed` (:4544) | objectstack-ai#4911 | outbound
rate-limit vocabulary removed: no engine exists (implementation-first)
(body ruling + 5169405647) | connector key 'rateLimitConfig' removed (no
outbound rate-limiting engine exists; the runtime's only token bucket
limits INBOUND requests, so every knob here was inert while reading like
a configured cap. The whole ConnectorRateLimitConfig shape went with it)
|
| `field-mapping-transform-removed` (:4669) | objectstack-ai#5552, objectstack-ai#3278 |
enforce-or-remove: all five members dead, the union retires; `js`
dialect was retired as redundant with the L2 script body (5199338349;
objectstack-ai#3278 body) | field-mapping key 'transform' removed (the whole
five-member FieldMappingTransform union went with it: no runtime ever
executed constant/cast/lookup/javascript/map, and the javascript member
advertised dialect="js", a dialect already retired because JavaScript
belongs in a script body. The enforced transform pipeline is the import
mapping's string-enum `mapping.fieldMapping[].transform`, which is
unaffected) |
| `theme-inert-token-scales-removed` (:4786) | objectstack-ai#5021 | retire all nine
token groups; re-declare under `customVars` (5175091297) | theme keys
'typography.fontSize'/'fontWeight'/'lineHeight'/'letterSpacing',
'typography.fontFamily.heading'/'mono', 'animation' and 'zIndex' removed
(ADR-0049 — the engine emitted --font-size-*, --font-weight-*,
--line-height-*, --letter-spacing-*, --duration-*, --timing-*, --z-*,
--font-heading and --font-mono faithfully, and no first-party component
or stylesheet has ever read one. Re-declare any variable you actually
consume under customVars, which emits it verbatim) |
| `page-header-subtitle-alias` (:4940) | objectstack-ai#3226 | route B: a D2
conversion rewrites `description` → `subtitle`; the consumer's bare `??`
retires (5160118898, 5194297145) | page-header component prop
'description' → 'subtitle' (the off-spec spelling a renderer tolerated
through a bare `subtitle ?? description` fallback; `subtitle` is the
declared key, and the fallback retires) |
| `record-picker-display-field-to-label-field` (:5165) | objectstack-ai#5775 |
direction A: `labelField` (the delivered spelling) becomes canonical;
`displayField` retires via conversion (5202137085) | record-picker
component prop 'displayField' → 'labelField' (the required key no
renderer read; `labelField ?? 'name'` is what renders the row, so the
delivered spelling became the declared one) |
| `record-picker-inert-keys-removed` (:5287) | objectstack-ai#5775 | `searchFields` /
`multiple` retire (zero readers) (5202137085) | record-picker component
props 'searchFields'/'multiple' removed (the control is a plain
single-select with no search box; neither key had a reader) |
| `page-card-body-to-children` (:5414) | objectstack-ai#5775 | `children` is the one
composition key (5202137085) | page:card component prop 'body' →
'children' (one composition key across every container; the card
renderer already reads both) |
| `inline-action-api-params-to-body-extra` (:5582) | objectstack-ai#5777 | direction
A: the static payload gets its own key (`bodyExtra`); `params` keeps one
meaning (5202138112, 5228796853) | inline type:'api' action prop
'params' (object form) → 'bodyExtra' (a static payload and a parameter
definition are two things, so the payload gets its own key; `params`
stays the ActionParam[] definition array) |
| `page-tabs-type-to-tab-style` (:5847) | objectstack-ai#6776 | Route A: rename to the
spelling the renderer reads (5229120747, 5229693342) | page:tabs
component prop 'type' → 'tabStyle' (a props key named `type` collides
with the node's dispatch key and is unauthorable in flat/JSX carriers;
`tabStyle` is the spelling the renderer reads in all of them) |
| `page-structure-inert-keys-removed` (:6035) | objectstack-ai#6946 | retire three
zero-reader UI keys (body (maintainer ruling) + 5232767409) |
page:header prop 'icon' and page:card prop 'actions' removed (neither
has a renderer read point in objectui; the header resolves icons per
action and the card renders title/children/footer only) |
| `record-details-layout-removed` (:6201) | objectstack-ai#6946 | as above (as above)
| record:details component prop 'layout' removed (the declared
auto\|custom modes were never implemented; the renderer branches only on
inline\|compact, values the schema never permitted, so both legal values
selected nothing) |
| `app-hidden-to-unpublished` (:6343) | objectstack-ai#4829 | A1: a machine-managed
key carries the publish gate; `hidden` back to navigation presentation
only; ADR-0045 amended (5173161521) | stored app publish gate 'hidden' →
'_unpublished' (ADR-0045 amended — `hidden` carried BOTH the publish
gate and 'keep out of the App Switcher', so the built-in Account app was
withheld from every non-builder; the gate is now the machine-managed
`_unpublished`, and `hidden` is navigation presentation only, never an
access gate. Stored rows only — an authored `hidden: true` is left
untouched) |
| `action-global-nav-location-removed` (:6456) | objectstack-ai#6888 | direction 2:
retire `global_nav` (no demand; the designer previewed a surface the
product lacks) (5229990375) | action location 'global_nav' removed (no
running-app surface rendered it; the ⌘K palette reads no action
metadata, while the Studio designer previewed a command-palette frame
for it. The value is stripped and the key kept, so an action left with
no location becomes the documented headless shape `locations: []`) |

Descriptions:

| site | cited | decision as read (record) | change |
|---|---|---|---|
| `data/field.zod.ts` `autonumberFormat` | objectstack-ai#6555 | route 3: `{0000}` is
a declared contract default, and both hand-written fallbacks read it
(5225535766, family done 5240072006) | "⇒ the contract default `{0000}`,
which every driver and the engine fallback read, so one field numbers
alike on every backend." |
| `api/plugin-rest-api.zod.ts` `GET /:type/:name/layers` | objectstack-ai#5882 |
ruling B: its own `/layers` path and schema, one route one shape
(recorded 5216370790) | "…hence its own path and its own response
schema, since one route answers one shape." |
| `api/plugin-rest-api.zod.ts` `POST /:type/:name/publish` | objectstack-ai#7294 |
declare the served publish route's response, the save door's discipline
(5237410722) | "The route was served for a long time with no declaration
behind it — this entry is what makes its response contract nameable, the
same declared-equals-returned rule the save door follows." |

## Text-only proof (A4)

Stage 3's AST-skeleton + string-text tool (`skeleton.cjs`, one line
changed: the TypeScript 6.0.3 load path), BASE `1ac7308d7a` against this
branch: **3 of 3 SAME** on both legs, exit 0 each — `registry.ts` 62346
tokens, 4920 string groups, 56 changed; `field.zod.ts` 8682 / 581 / 1;
`plugin-rest-api.zod.ts` 4912 / 475 / 2 (one literal re-split into three
`+` pieces, which the skeleton reads as one string); parse diagnostics 0
/ 0. Every changed group carried an id before and carries none after;
every other string is byte-identical. Controls on scratch copies of the
head `registry.ts`, each mutation counted on disk first: an identifier
rename → DIFF exit 1; `!==` flipped to `===` → DIFF exit 1; one summary
re-split into two `+` operands → SAME exit 0; a `surface` string (never
carried an id) changed → text leg VIOLATION exit 1. No repo file was
mutated for the controls.

Census after the edit (stage 3's instrument, unchanged): non-test 219 →
160 messages, 429 → 358 ids; class (b) conversion summaries 91 / 108 →
35 / 40 (all toMajor 18); descriptions 3 / 3 → 0.

## Pins and quotes (A6)

- **Tests:** no test asserts a changed summary or description phrase.
The id-bearing fragments and the distinctive phrases of all 59 messages
were searched across every `*.test.*` / `*.spec.*`; the hits are other
files' own prose with their own citations (test titles and comments such
as `(objectstack-ai#3896 close-out)` in `view.test.ts`), not quotes of a summary.
- **`content/docs/**`:** the only quotes are the three generated
`references/**` pages, regenerated.
`content/docs/releases/v17/17-0.mdx:1052` repeats one action-key phrase
with its own `(objectstack-ai#3896 close-out)`; it is release-owned and untouched.
- **`skills/**`:** no quote of any changed text.

## Gates

`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` at `db81973cf6` (9 paths, 493 changed lines): 105 commands,
each run from the worktree with its exit code written before any pipe;
`--ran` → "✓ dispatch-gates --ran: 105 derived famil(ies) accounted for
— 105 run, 0 NOT-MEASURED". Three first exited 3 (prerequisite: unbuilt
lint / client packages) and were re-run green after the full package
build (71 tasks). Plus `@objectstack/spec` build, `check:generated`
("All 15 generated artifacts are up to date"), the package `test`
project (603 files, 17845 passed), 48 of the 51 `test:repo` files, and
`typecheck`. Details are in the report on the card.

## Acceptance notes

- **The `objectstack-ai#3896` citations.** Eight summaries cited `objectstack-ai#3896` as an "audit"
or a "close-out". The card's own body is the sharing-rule REST finding
that opened that security-audit line; the decisions the summaries cited
(remove `rls.priority`, the four inert tool keys, then the fourteen-key
enforce-or-remove sweep) are recorded in the landed commits
`d6bfb3d0ab`, `eb95d97c02` and `12a19a88a5`. Each summary already said
why its key went, so the new words name the rule (ADR-0049
enforce-or-remove) rather than the card.
- Comments in `conversions/registry.ts` still carry tracker ids; they
belong to objectstack-ai#20234's comment stages and are untouched. So is
`migrations/registry.ts`.
- `docs/protocol-upgrade-guide.md` is not a governed surface
(`check-governed-merges`' register).
- **Hot file:** open PR objectstack-ai#21547 (objectstack-ai#21459) also edits
`conversions/registry.ts`: it adds one toMajor-18 entry and its ordering
row, in a region this PR does not touch. A local `git merge-tree` of the
two heads is clean (the file is hand-written, so no merge driver is
involved). Neither PR's spec-changes / upgrade-guide output includes the
other's entries, so whichever lands second merges `main` and regenerates
them.

## Next stage: the 35 toMajor-18 summaries (file:line at this head · ids
· conversion)

- `registry.ts:6550` objectstack-ai#8321 `field-malformed-scale-precision-removed`
- `registry.ts:6658` objectstack-ai#8762 `record-chatter-position-vocabulary`
- `registry.ts:6777` objectstack-ai#9198 `element-input-target-variable-removed`
- `registry.ts:7024` objectstack-ai#9220 `element-filter-removed`
- `registry.ts:7177` objectstack-ai#9249 `element-form-removed`
- `registry.ts:7355` objectstack-ai#15178,objectstack-ai#19620
`translation-per-app-settings-removed`
- `registry.ts:7609` objectstack-ai#9249 `translation-component-submit-label-removed`
- `registry.ts:7782` objectstack-ai#3951,objectstack-ai#9227 `field-column-lists-canonicalized`
- `registry.ts:7909` objectstack-ai#10414 `metric-filters-removed`
- `registry.ts:8104` objectstack-ai#17296 `cube-sub-day-granularities-removed`
- `registry.ts:8237` objectstack-ai#18612 `cube-join-sql-and-relationship-removed`
- `registry.ts:8502` objectstack-ai#10054 `record-highlights-field-icon-removed`
- `registry.ts:8759` objectstack-ai#11027 `page-component-responsive-removed`
- `registry.ts:8860` objectstack-ai#11805 `object-grid-default-sort-removed`
- `registry.ts:9047` objectstack-ai#21445 `object-grid-resizable-columns-removed`
- `registry.ts:9250` objectstack-ai#17260 `object-kanban-quick-add-removed`
- `registry.ts:9563` objectstack-ai#12497,objectstack-ai#1883
`permission-allow-restore-purge-removed`
- `registry.ts:9881` objectstack-ai#6837 `field-reference-to-alias`
- `registry.ts:10301` objectstack-ai#14478 `hook-timeout-to-timeout-ms`
- `registry.ts:10342` objectstack-ai#14478 `job-timeout-to-timeout-ms`
- `registry.ts:10946` objectstack-ai#14478
`api-endpoint-cache-ttl-to-cache-ttl-seconds`
- `registry.ts:11015` objectstack-ai#14478
`dashboard-refresh-interval-to-refresh-interval-seconds`
- `registry.ts:11376` objectstack-ai#14478
`memory-persistence-auto-save-interval-to-ms`
- `registry.ts:11600` objectstack-ai#14478 `turso-config-timeout-to-timeout-ms`
- `registry.ts:11690` objectstack-ai#17063 `view-page-mount-removed`
- `registry.ts:11795` objectstack-ai#17053,objectstack-ai#8221
`list-view-sort-string-clause-to-array`
- `registry.ts:12295` objectstack-ai#19054 `object-tenancy-organization-field-removed`
- `registry.ts:12405` objectstack-ai#20085 `view-item-owner-hidden-removed`
- `registry.ts:12549` objectstack-ai#20230 `view-overlay-owner-hidden-removed`
- `registry.ts:13137` objectstack-ai#6206,objectstack-ai#17321
`page-component-filter-record-to-rule-array`
- `registry.ts:13392` objectstack-ai#20161 `report-joined-chart-removed`
- `registry.ts:13657` objectstack-ai#20221 `form-layout-inline-grid-to-vertical`
- `registry.ts:13813` objectstack-ai#19992 `currency-config-precision-removed`
- `registry.ts:13917` objectstack-ai#20321 `permission-rls-tags-removed`
- `registry.ts:14056` objectstack-ai#15429 `flow-decision-mode-inclusive-explicit`

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ect-calendar take the shape each block reads; object-grid columns held (objectstack-ai#21464, stage 2) (objectstack-ai#21559)

Part of objectstack-ai#21464
Clause-②: yes (narrowing)

## Fix round (contract review `5966757164`, item 1)

The review found that the grid reads `options` off an AUTHORED
`object-grid` column. The group-header formatter
(`plugin-grid/src/ObjectGrid.tsx:2997-3001` at the pin) takes the column
whose `field` is the grouping field, reads `colOverride?.options ||
objectDefField?.options` with the column winning, and draws the
group-header labels from it. objectui pins that as behaviour in
`gridGroupingMembers-8071.test.tsx:260-301`. `ListColumn` declares no
`options`, so the by-reference `columns` narrowing refused a value the
grid draws, a working writer by the seat's test (`5966636964`). This
round:

- returns `object-grid` `columns` to `z.unknown()` and re-adds it to the
enumeration pin's ledger as `held-for-decision`, with the reader
`ObjectGrid.tsx:2158` and `:2997-3001` and the carrier
objectstack-ai/objectui#11544, in the shape the pin uses for kanban
`conditionalFormatting` (objectstack-ai/objectui#11522);
- removes its cases from the companion pin (§1 three, §2 five, §3 one);
- corrects the four texts that said the grid never reads `options`: the
member docblock, the changeset's FROM → TO table, the changeset's "Who
is affected" paragraph, and this body's A3 list. `editable` stays
described as unread, which the review confirmed;
- narrows the D3 entry, its generated registry region and the rationale
fragment to the eight members, and regenerates the reference page.

The other eight members stand as reviewed.

## What this does

Stage 2 (S-list) of the `ComponentPropsMap` `z.unknown()` close-out, per
triage `5961300594`, the seat answer `5963787404` (staging A) and the
claim `5965611825`. Eight members the list blocks read with a fixed
shape were `z.unknown()` (an array of it for the lists). Any value
passed the component-props gate, and the renderer dropped or substituted
an off-shape one with no report. Each member now takes the shape its
block reads, measured at the `.objectui-sha` pin `89cad75d55`. The
family's ninth, `object-grid` `columns`, is held (above).

| row · member | was | now | read point at the pin |
|:--|:--|:--|:--|
| `object-grid` · `columns` | `z.array(z.unknown())` | **held**,
unchanged: `z.array(z.unknown())` |
`plugin-grid/src/ObjectGrid.tsx:2158` `normalizeColumns`; the
group-header formatter reads an authored column's `options`
(`:2997-3001`), which `ListColumn` does not declare
(objectstack-ai/objectui#11544) |
| `object-grid` · `fields` | `z.array(z.unknown())` |
`z.array(z.string())`, the measured shape (no list-view counterpart) |
`plugin-grid/src/ObjectGrid.tsx:1946`; the draw path looks each entry up
as `objectSchema.fields[fieldName]` (`:3969`, `:4012`) |
| `object-grid` · `selection` | `z.unknown()` |
`ListViewSchema.shape.selection` (`SelectionConfigSchema`), by reference
| `.type`, `:4799-4812` |
| `object-grid` · `selectable` | `z.unknown()` | `boolean`, `'single'`
or `'multiple'`, the measured shape | `:4813-4815`, handed to the table
at `:5333`; `components/src/renderers/complex/data-table.tsx:644`
`resolveSelectionMode` |
| `object-grid` · `rowActions` | `z.array(z.unknown())` |
`ListViewSchema.shape.rowActions`, by reference | `:1834-1835`,
`string[]` |
| `object-grid` · `bulkActions` | `z.array(z.unknown())` |
`ListViewSchema.shape.bulkActions`, by reference | `:4763` `batchActions
?? bulkActions`, then `resolveBulkActions` by name |
| `object-grid` · `batchActions` | `z.array(z.unknown())` | the same def
as `bulkActions` | the same read, which takes `batchActions` first |
| `object-kanban` · `columns` | `z.array(z.unknown())` | all bare value
strings, or all lanes `{ id, title, cards?, limit?, className?,
collapsed? }` (module-private `ObjectKanbanLaneSchema`) |
`plugin-kanban/src/ObjectKanban.tsx:1177-1188` dispatches on the first
entry; `index.tsx:129-158` buckets by `id` and keeps static `cards`;
`KanbanImpl.tsx:540`, `:568`, `:742` read `limit`, `className`,
`collapsed` |
| `object-calendar` · `calendar` | `z.unknown()` |
`ListViewSchema.shape.calendar` (`CalendarConfigSchema`), by reference |
`plugin-calendar/src/ObjectCalendar.tsx:294-297` returns the block as
the config; `:857`, `:1056`, `:1141` read its five bindings |

A static kanban card is a record row. Its `id` and `title` are typed,
and the rest of the card is the row's own values. That one new
`z.unknown()` member, `object-kanban columns[].cards[].*`, carries a
`records` line in the enumeration pin.

**`bulkActions` / `batchActions` (A4).** The grid reads
`schema.batchActions ?? schema.bulkActions` (`ObjectGrid.tsx:4763`), so
`batchActions` is the second spelling of one capability, read first.
objectui's own type calls it the legacy alias. `ListViewSchema` declares
only `bulkActions`. Both members now hold `bulkActions`'s def, and
neither is retired here.

**`selection` default.** `SelectionConfigSchema` defaults `type` to
`none`. The grid reads an object with no `type` as ON (objectui#9837,
ruling A-prime: presence enables). That default reaches only a parsed
document, never the bag the grid reads. It is the list view's
declaration either way, and objectui#9837 holds the question. The
member's docblock records it.

## The census (A1), whole

A writer is a page-component node: an object literal naming the type, a
literal annotated with the block's type, a `schema={{…}}` on the block's
React component, a call into a local helper that builds the node, or a
direct parse through the row. Each member's value is resolved through
same-file constants. The control is `objectName` on the same nodes. The
instrument is a TypeScript-AST walk over every `.ts`, `.tsx`, `.js`,
`.json`, `.md`, `.mdx` and `.yaml` file, with fenced code in the
documents parsed too.

| corpus | `object-grid` nodes | `object-kanban` nodes |
`object-calendar` nodes | control `objectName` |
|:--|--:|--:|--:|:--|
| objectstack `49161683fb` (`examples/`, `packages/` incl.
`packages/apps/`, `content/`, `skills/`, `apps/`) | 57 | 30 | 5 | 47 /
27 / 4 |
| objectui `89cad75d55` (whole tree) | 689 | 240 | 160 | 293 / 108 / 98
|

| row · member | objectstack values (parse) | objectui static values
(distinct) · parse · refused · not static |
|:--|:--|:--|
| grid `columns` (held, not narrowed) | 5 | 310 (143) · not parsed by
this PR · 20 not static |
| grid `fields` | 0 | 16 (10) · 13 · 3 · 2 |
| grid `selection` | 0 | 17 (4) · 17 · 0 · 1 |
| grid `selectable` | 0 | 2 (1) · 2 · 0 · 1 |
| grid `rowActions` | 0 | 10 (5) · 10 · 0 · 1 |
| grid `bulkActions` | 0 | 23 (9) · 21 · 2 · 1 |
| grid `batchActions` | 0 | 5 (3) · 5 · 0 · 0 |
| kanban `columns` | 1 (1) | 108 (39) · 107 · 1 · 12 |
| calendar `calendar` | 0 | 60 (26) · 58 · 2 · 6 |

The objectui values were parsed through the built rows on this branch.
Across the eight typed members, objectstack holds one value (the
protocol docs' lane example, which parses), and objectui holds 241
static values: 233 parse and 8 are refused. The grid `columns` row is
listed for completeness. Its 5 objectstack values (the showcase's two
grids among them) and 310 objectui values meet no new shape here.

## Writer parse results (A3)

No refused value is one the renderer draws. Each of the 8 refused
objectui values is a test fixture whose value the renderer drops, skips
or refuses:

- **2 `{ name }` entries in `bulkActions`** (`bulkActionMembers-8071`,
`bulkActionDefsUnusableMember-8730`). The fold skips them, and both
tests assert the skip.
- **3 object entries in `fields`**
(`serverGroupedSelectIdentity-11105`). They copy the node the list view
hands the grid at run time, as that test's own header says, so they are
not an authored page.
- **A lane `color`** (`objectKanbanColumnMembers-8071`). The console
retired it, and the test marks it an undeclared member. The lane now
refuses it with a prescription naming `className`.
- **The calendar's retired `dateField` / `endField` aliases**
(`calendar-date-alias-refusal-8355`). The test asserts their refusal.

The 24 values that are not static are helper parameters, `.map` results
and the run-time hand-offs (`plugin-view/src/ObjectView.tsx:2462`,
`plugin-designer`). None is an authored page.

**The held member.** Under the first head, `object-grid` `columns` was
narrowed by reference, and this list carried 40 refused grid-column
values. Among them, "16 column keys the grid never reads" counted 14
`editable` and 2 `options`, and said no authored-column read names
either key. That was right for `editable` and wrong for `options`: the 2
`options` values (`gridGroupingMembers-8071`) are drawn in the group
headers. So `columns` is held, and its 40 values are no longer refused
by this PR.

Under the triage caveat ("a narrowing that would refuse a measured
writer is reported, not shipped silently"), this list is the report. A3
is applied with the seat's test (`5966636964`): a writer is a value the
renderer draws. A refused fixture that probes the renderer's drop is not
one. On that test the eight members stand, and `columns` is held.

## A2, per member

Typed by reference (4): grid `selection`, `rowActions`, `bulkActions`;
calendar `calendar`. Typed to the same def (1): grid `batchActions`.
Typed to the renderer's read (3): grid `fields`, `selectable`; kanban
`columns`. Held for a ruling (1): grid `columns`
(objectstack-ai/objectui#11544). None is runner-forwarded.

## The pin (A5)

- Eight `staged` lines leave the enumeration pin's ledger, and its
`list-family` stage goes with them. `object-grid` `columns[]` stays as
`held-for-decision`. One `records` line is added for `object-kanban
columns[].cards[].*`.
- **`no-reader` is removed.** It had no user since PR objectstack-ai#21531, the pin's
own checks never require a kind to be in use, and no other file pins its
vocabulary (`git grep no-reader` finds only an unrelated prose use in
`scripts/pm/check-half-states.mjs`).
- The typed members are pinned in their own file,
`component-list-family-typed-members.pin.test.ts`, as objectstack-ai#21445's were. §1
checks that each declared shape parses, byte-identical, or to what the
list view's schema answers where a default materializes. §2 checks each
refusal by code and path, and for the kanban's two-array union by the
arm's own issue. §3 checks identity with the list view's defs and the
measured vocabularies. §4 checks the D3 registration.
- **Ablation, fix round**, at `8b276755c2`: `object-grid` `rowActions`
was reverted to `z.array(z.unknown()).optional()`, with no ledger line.
It landed: anchor x1 to x0, replacement x0 to x1, blob `285edfb205` to
`2b6b2f8643`. Red: **Tests 4 failed | 95 passed (99)**. The enumeration
pin's §1 received exactly `[ 'object-grid rowActions[]' ]`, its
census-equals-ledger control failed, and the companion pin failed its
two `rowActions` cases. The restore was proven: blob after restore
`285edfb205` == blob at HEAD, and `git diff HEAD` was empty. Green
rerun: **Tests 99 passed (99)**.
- **Ablation, first head**, at `870e7327ec`, via `node
scripts/ablation-replace.mjs` in wrap mode. The mutation reverted
`object-grid` `selection` to `z.unknown().optional()`, with no ledger
line. It landed: anchor x1 to x0, replacement x0 to x1, blob
`e60c46e776` to `2111ab1538`. Both pins went red: **Tests 7 failed | 101
passed (108)**. The enumeration pin's §1 received exactly `[
'object-grid selection' ]`, and its census-equals-ledger control failed.
The typed-member pin failed its five `selection` cases. The restore was
proven: blob after restore `e60c46e776` == blob at HEAD, and `git diff
HEAD` was empty. The green rerun showed **Tests 108 passed (108)**. The
pins import `./component.zod` from source, so no build sits between
mutation and run.

## The rest of the kit (A6, A7)

- `component-type-vocabulary.ts`: the `KNOWN_COMPONENT_TYPES` docblock
now lists `ai:chat_window` among the kept retired rows (contract review
`5965171663` item 10). Comment only.
- ADR-0087 D3 entry `ui-object-grid-kanban-calendar-list-members-typed`,
and its step-18 rationale fragment at order 66. objectstack-ai#21459's PR objectstack-ai#21547
landed (`72af58c621`) while this was in review, with its own fragment
`page-requires-non-compiled-kind-refused` also at 66, in a different
gap. The second merge kept both fragments and both D3 entries (each id
found twice in `registry.ts`), and `check:migration-registry` reads the
generated regions current with no regeneration owed. The registry header
allows equal orders, which render in `id` order.
- Regenerated by `check:generated --fix` (only what it proved stale):
`content/docs/references/ui/component.mdx`, and the strictness-ledger
counts for `ui/`. The `ui/` count moves from 189 to 191 sites: +1 strict
(the lane) and +1 passthrough (the card). In the fix round only the
reference page was stale, and its `columns` row is back to `any[]`.
- Changeset: `@objectstack/spec` `minor`, a **BREAKING** banner,
`Clause-②: yes (narrowing)`, a FROM → TO table, the measured census, and
the ADR-0087 marker `registered`.
- The `ObjectGridPropsParsed` docblock had said the parsed state differs
"on exactly one key — `data`". That was already untrue after objectstack-ai#21445, and
this change adds the `selection.type` default. It now names the
defaults.

## Gates, at `8b276755c2` (after merging `origin/main` `ce532184d1`
through `os-regen-merge.sh`)

- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derived 114 commands (9 paths, 688 changed lines, merge base
`ce532184d1`). Every exit code was written to disk before any pipe.
`--ran` reconciled: **114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN**.
- On this head, after a full `turbo run build` (72 tasks), all 114 ran
and every one exited 0. On the first head, six gates answered
`PREREQUISITE NOT MET` (exit 3) before the build; those lines were kept
apart, not counted.
- `pnpm --filter @objectstack/spec build`: exit 0. `check:generated`:
exit 0, "All 15 generated artifacts are up to date". `check:liveness`,
`check:migration-registry`, `check:strictness-ledger`,
`check:authorable-surface` and `check:api-surface`: exit 0.
- `pnpm --filter @objectstack/spec test`: **Test Files 606 passed (606),
Tests 17952 passed, 1 todo**. `typecheck`: exit 0,
`check:test-typecheck: OK`.
- `pnpm --filter @objectstack/lint test` (the one import side of
`ComponentPropsMap`): **119 files, 5620 tests passed**.
- `pnpm check:doc-authoring` and `pnpm check:nul-bytes`: exit 0.
- `check-widening-tells`: `--declaration no` gives exit 4 with 9 T1
tells, all at the new lane schema's keys inside the former `z.unknown()`
bag. That is the shape the gate's own text rules a true refusal, so
declare `yes`. `--declaration yes` gives exit 0.
- The changeset gates were run with this body as the `--event` payload;
their verdict lines are in the dev report.
- NOT MEASURED: the Console Pin Gate, Dogfood and the full `pnpm lint`.
Reason: they are CI-owned. objectui's source indexes
`SpecObjectCalendarProps['data']`, `SpecObjectFormProps['layout']` and
`SpecObjectKanbanProps['swimlaneField']`, none of them a narrowed
member.

## Acceptance notes

- The held `columns`: every `ListColumnSchema` member is read by the
grid at the pin (the draw path, `useColumnSummary` at `:3170-3177` for
`summary`), and the grid reads one key `ListColumn` does not declare,
`options`, in the group headers. Typing `columns` waits on
objectstack-ai/objectui#11544.
- objectui's own tests that probe the dropped shapes (above) will see
the spec refuse those values when objectui bumps `@objectstack/spec`.
The objectui block mirror takes the grid row by reference
(`ObjectGridBlockSchema.properties`). No objectui edit was made here.

objectstack-ai#21464 remains open for S-form, S-metric and S-objectui-held.
object-kanban `conditionalFormatting` stays held on
objectstack-ai/objectui#11522, and object-grid `columns` on
objectstack-ai/objectui#11544.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ion in words instead of a tracker number (stage 5) (objectstack-ai#21568)

Part of objectstack-ai#20749
Clause-②: no

Stage 5 of the `domain:spec` lane's share of the runtime-string
burn-down (ruling `5902360492`, form D): the rest of class (b), the
protocol 17 → 18 conversion summaries in
`packages/spec/src/conversions/registry.ts`. Every rewritten summary now
states in words what the cited decision was, or drops a citation its
sentence already explained. Text only.

## What changed

- **35 ADR-0087 conversion summaries** (40 tracker ids, 34 distinct
cards): every toMajor-18 summary that carried an id, from
`field-malformed-scale-precision-removed` to
`flow-decision-mode-inclusive-explicit`. A summary is what `os migrate
meta --json` reports under `specChanges` (its chain already runs to
protocol 18, `CHAIN_TERMINUS_MAJOR`), and it becomes the upgrade guide's
"Change" column and the `to` text of `spec-changes.json`'s `converted[]`
once protocol 18 ships, so an author upgrading metadata reads it.
- One `@objectstack/spec` **patch** changeset, `Clause-②: no` (message
text only).
- **No generated file changes** (A2 below): no generator projects a
toMajor-18 summary today.

## Census at the base (A1)

Stage 4's instrument (`convtable.cjs`, byte-identical copy, md5
`9b7539067ffc38598172c692de637db5`) at base `e901c27449` (the worktree
before any edit): 127 conversions, **35 summaries with ids, 40 id
occurrences, 34 distinct cards** — stage 4's split unchanged. The
toMajor-18 conversion that PR objectstack-ai#21547 added carries no id. Under the
~60-card bar, so one stage. The stage-3 census re-run at the same base
agrees (class b: 35 messages / 40 ids; nothing else in class b).

| file:line (base) | id | conversion |
|---|---|---|
| `registry.ts:6551` | objectstack-ai#8321 | `field-malformed-scale-precision-removed`
|
| `registry.ts:6659` | objectstack-ai#8762 | `record-chatter-position-vocabulary` |
| `registry.ts:6777` | objectstack-ai#9198 | `element-input-target-variable-removed` |
| `registry.ts:7024` | objectstack-ai#9220 | `element-filter-removed` |
| `registry.ts:7177` | objectstack-ai#9249 | `element-form-removed` |
| `registry.ts:7356` | objectstack-ai#15178 | `translation-per-app-settings-removed` |
| `registry.ts:7356` | objectstack-ai#19620 | `translation-per-app-settings-removed` |
| `registry.ts:7610` | objectstack-ai#9249 |
`translation-component-submit-label-removed` |
| `registry.ts:7782` | objectstack-ai#3951 | `field-column-lists-canonicalized` |
| `registry.ts:7783` | objectstack-ai#9227 | `field-column-lists-canonicalized` |
| `registry.ts:7909` | objectstack-ai#10414 | `metric-filters-removed` |
| `registry.ts:8104` | objectstack-ai#17296 | `cube-sub-day-granularities-removed` |
| `registry.ts:8237` | objectstack-ai#18612 | `cube-join-sql-and-relationship-removed`
|
| `registry.ts:8502` | objectstack-ai#10054 | `record-highlights-field-icon-removed` |
| `registry.ts:8759` | objectstack-ai#11027 | `page-component-responsive-removed` |
| `registry.ts:8860` | objectstack-ai#11805 | `object-grid-default-sort-removed` |
| `registry.ts:9047` | objectstack-ai#21445 | `object-grid-resizable-columns-removed`
|
| `registry.ts:9250` | objectstack-ai#17260 | `object-kanban-quick-add-removed` |
| `registry.ts:9634` | objectstack-ai#12497 | `permission-allow-restore-purge-removed`
|
| `registry.ts:9637` | objectstack-ai#1883 | `permission-allow-restore-purge-removed`
|
| `registry.ts:9954` | objectstack-ai#6837 | `field-reference-to-alias` |
| `registry.ts:10372` | objectstack-ai#14478 | `hook-timeout-to-timeout-ms` |
| `registry.ts:10413` | objectstack-ai#14478 | `job-timeout-to-timeout-ms` |
| `registry.ts:11017` | objectstack-ai#14478 |
`api-endpoint-cache-ttl-to-cache-ttl-seconds` |
| `registry.ts:11086` | objectstack-ai#14478 |
`dashboard-refresh-interval-to-refresh-interval-seconds` |
| `registry.ts:11447` | objectstack-ai#14478 |
`memory-persistence-auto-save-interval-to-ms` |
| `registry.ts:11671` | objectstack-ai#14478 | `turso-config-timeout-to-timeout-ms` |
| `registry.ts:11761` | objectstack-ai#17063 | `view-page-mount-removed` |
| `registry.ts:11866` | objectstack-ai#17053 | `list-view-sort-string-clause-to-array`
|
| `registry.ts:11868` | objectstack-ai#8221 | `list-view-sort-string-clause-to-array`
|
| `registry.ts:12366` | objectstack-ai#19054 |
`object-tenancy-organization-field-removed` |
| `registry.ts:12476` | objectstack-ai#20085 | `view-item-owner-hidden-removed` |
| `registry.ts:12620` | objectstack-ai#20230 | `view-overlay-owner-hidden-removed` |
| `registry.ts:13214` | objectstack-ai#17321 |
`page-component-filter-record-to-rule-array` |
| `registry.ts:13214` | objectstack-ai#6206 |
`page-component-filter-record-to-rule-array` |
| `registry.ts:13463` | objectstack-ai#20161 | `report-joined-chart-removed` |
| `registry.ts:13728` | objectstack-ai#20221 | `form-layout-inline-grid-to-vertical` |
| `registry.ts:13884` | objectstack-ai#19992 | `currency-config-precision-removed` |
| `registry.ts:13988` | objectstack-ai#20321 | `permission-rls-tags-removed` |
| `registry.ts:14128` | objectstack-ai#15429 | `flow-decision-mode-inclusive-explicit`
|

## Projections (A2)

Neither generator projects a toMajor-18 summary at this base.
`build-spec-changes.ts` and `build-upgrade-guide.ts` both loop `major`
from `MIGRATION_SUPPORT_FLOOR + 1` to `PROTOCOL_MAJOR`, which are 16 and
17 here (`PROTOCOL_VERSION = '17.0.0'`), so `spec-changes.json` carries
one `perMajor` record (16 → 17) and the guide one "Protocol 16 → 17"
table. `check:generated` reads all 15 artifacts up to date on this head
with no regeneration, so no generated file is in the diff. Both
projections will pick these summaries up when protocol 18 ships.

## Delivered: each site, the decision read, the new words (A3)

Every cited card was read through REST with all its comments (30
objectstack cards, objectui#3951, objectstack-ai#6206, objectstack-ai#6837, objectstack-ai#8221). The record
column names the comment the decision was read from. Where a summary
already said why, the citation is dropped and the sentence kept; where
an `(#N, ADR-0049 — …)` opener cited both, the card number goes and the
ADR stays, as stage 4 did. In the
`cube-join-sql-and-relationship-removed` row, `ALIAS` stands for the
angle-bracket placeholder in the source.

| conversion (head line) | cited | decision as read (record) | summary
now reads |
|---|---|---|---|
| `field-malformed-scale-precision-removed` (:6550) | objectstack-ai#8321 | refuse a
malformed scale/precision at the producer (z.number().int().min(0)); a
stored malformed value takes the D2 strip so the row stays loadable;
citation dropped, the sentence already said it (body + ACCEPT
5296942541) | malformed field 'scale'/'precision' declarations
(non-integer or negative) are removed — they were silently unenforced;
the schema now refuses them at authoring |
| `record-chatter-position-vocabulary` (:6658) | objectstack-ai#8762 | the row's
vocabulary converges on the renderer's bottom/right/left: one
vocabulary, no mapping layer; the three old spellings take a conversion
(ruling 5299771841) | record:chatter / record:discussion 'position'
respelled to the renderer's vocabulary — 'sidebar' → 'right', 'inline' →
'bottom', 'drawer' → 'right' (one vocabulary, the renderer's, rather
than a mapping layer between two: the renderer compares only
bottom/right/left, and the old set fell through every branch) |
| `element-input-target-variable-removed` (:6778) | objectstack-ai#9198 | ADR-0049
enforce-or-remove: verdict dead (a declarative hint with zero readers),
retired with tombstones and a D2 conversion (ACCEPT 5311252358 (PR body
verdict)) | text-input/record-picker component prop 'targetVariable'
removed (retired under ADR-0049 enforce-or-remove as a declarative hint
nothing read; the live binding resolves from the page variable whose
`source` names the component id) |
| `element-filter-removed` (:7025) | objectstack-ai#9220 | dead at ELEMENT grain (no
renderer anywhere; Studio excludes it from the palette), so the whole
element retires under ADR-0049, not key by key (verdict 5312176877 +
ACCEPT 5312709553) | the whole 'element:filter' element retired
(ADR-0049 enforce-or-remove at element grain, not key by key — no
renderer for it ever shipped in any repo, so every key was a capability
claim nothing kept; list surfaces own their filtering via a view's
userFilters / the list filter builder). All six props are stripped; the
bare node the conversion leaves is refused by name at the parse, with
the prescription to delete the component |
| `element-form-removed` (:7179) | objectstack-ai#9249 | dead at element grain, the
objectstack-ai#9220 precedent: the whole element retires; the palette already names
object-form as the replacement (dev report 5384430470 (verdict re-taken
in the PR body)) | the whole 'element:form' element retired (ADR-0049
enforce-or-remove at element grain, not key by key — no renderer for it
ever shipped in any repo, so every key was a capability claim nothing
kept; use the object-bound 'object-form' block instead — rendered and
designer-publishable). All six props are stripped; the bare node the
conversion leaves is refused by name at the parse, with the prescription
to delete the component |
| `translation-per-app-settings-removed` (:7358) | objectstack-ai#15178, objectstack-ai#19620 |
objectstack-ai#15178: the bundle type splits, the platform bundle keeps `settings`, a
per-app bundle refuses it (settings is a platform key). objectstack-ai#19620 ruling B:
`settings` leaves the translation item too, because the file door and
the item door are two authoring surfaces of one app metadata type and
accept one shape (ruling 5653315643 (objectstack-ai#15178); ruling 5770445203
(objectstack-ai#19620)) | translation group 'settings' removed from both
application-authored faces, the per-app bundle entry and the registered
translation item: settings copy belongs to the platform, and the two
authoring doors of one application translation type accept one shape. It
is keyed by SettingsManifest.namespace and only platform code declares a
manifest. A per-app bundle entry could only fill gaps the platform's own
bundle left in the one merged served tree, and was overwritten wherever
both defined the key; a stored item OVERRODE the platform copy, because
the runtime-authored layer is read over the shipped bundles. Overrides
now give way to the platform copy, gaps fall back to the manifest
literal, and the group stays on the PLATFORM bundle,
PlatformTranslationData |
| `translation-component-submit-label-removed` (:7613) | objectstack-ai#9249 |
`element:form` retired whole because no renderer for it ever shipped
(dead at element grain), which left `submitLabel` with no carrier (dev
report 5384430470) | translation component-copy key 'submitLabel'
removed (retired rather than re-anchored — its only declared carrier,
'element:form', retired whole because no renderer for it ever shipped,
so the resolver no longer overlays it and a stored string was read by
nothing; the live form surface's submit copy is 'object-form''s
'submitText', localized at its own authoring site, and re-anchoring the
key there would only have added a second place to translate one word) |
| `field-column-lists-canonicalized` (:7787) | objectui#3951, objectstack-ai#9227 |
objectui#3951: the published spec spelling `name` wins and the grid
reader is fixed to read it. objectstack-ai#9227: `inlineColumns` gets a strict
name-keyed element schema (an unknown key is a named rejection at
publish, not a blank cell); `relatedListColumns`, checked in the same
pass, takes field-name strings (ruling 5236150020 (objectui#3951);
ruling 5315735776 + ACCEPT 5317488979 (objectstack-ai#9227)) | inline-grid column
entries respelled 'field' → 'name' (the declared spelling wins, and the
grid renderer now reads 'name' too) and related-list column objects
folded to their child field-name string (both lists were z.any(), so a
mis-keyed column published clean and rendered blank cells; inline
columns now take a strict name-keyed shape and related-list columns
plain field names, so a mis-keyed column is refused at publish) |
| `metric-filters-removed` (:7916) | objectstack-ai#10414 | the remove leg of
enforce-or-remove: zero consumers (measured with a positive control) and
a raw-SQL carrier; retire per the playbook; citation dropped, the
sentence already said it (triage grading 5363699539) | cube metric key
'filters' removed (ADR-0049 — no strategy ever read it: the authored
raw-SQL condition was parsed and dropped, and the query returned the
unfiltered aggregate. Filter at query time with `where`, or use an
ADR-0021 dataset measure's structured `filter`; a metric's own `sql` is
a column reference) |
| `cube-sub-day-granularities-removed` (:8111) | objectstack-ai#17296 | each of
second/minute/hour is residue and removed: no layer outside the enum
names them and `queryDateGranularity` cannot advertise them; ADR-0049
prefers removal with no committed roadmap; citation dropped (dev report
5648182389 + landing 5648923185) | cube dimension granularities 'second'
/ 'minute' / 'hour' removed (ADR-0049 — no backend bucketed them and
none could advertise them: `supports.queryDateGranularity` is a record
over `DateGranularity`, which declares day, week, month, quarter, year.
Offer the coarsest interval that still answers the question) |
| `cube-join-sql-and-relationship-removed` (:8244) | objectstack-ai#18612 | retire
`sql` and `relationship` from CubeJoin: the join is derived from the FK
relationship and no author-supplied ON clause executes; the addendum
adds the D2 strip for persisted artifacts; citation dropped, the
sentence already said it (ruling 5725370783 + addendum 5727426171) |
cube join keys 'sql' and 'relationship' removed (ADR-0049 — neither was
ever read: both strategies synthesise the ON clause as a foreign-key
equality, so an authored join condition was REPLACED under a 200 and a
declared cardinality changed no SQL. Keep `joins.ALIAS.name` alone; the
record KEY is the foreign-key field on the base object) |
| `record-highlights-field-icon-removed` (:8509) | objectstack-ai#10054 | option A:
measured dead (zero read points, not designer-publishable), so it
retires under the ADR-0087 flow; citation dropped (ruling 5364978909) |
record:highlights highlight-field key 'icon' removed (ADR-0049 — no
render path: the highlight chip has no icon slot, the register hook
carries field names only, and the Studio designer publishes the field
list as plain strings, so an authored icon was accepted and drawn by
nothing) |
| `page-component-responsive-removed` (:8766) | objectstack-ai#11027 | ruling B:
retire `page.components[].responsive` (ADR-0049, wired into no renderer)
and repair the texts that redirected authors to it (ruling 5380752244) |
page component key 'responsive' removed (ADR-0049 enforce-or-remove — no
renderer ever applied per-component breakpoint layout overrides, and the
shared ResponsiveConfig shape leaves with its last carrier; use
responsiveStyles (ADR-0065) for breakpoint behaviour that IS applied) |
| `object-grid-default-sort-removed` (:8868) | objectstack-ai#11805 | retire
object-grid `defaultSort` (the strict route per the playbook),
completing the objectui-side direction ruling at the producer (ruling
5404972152) | object-grid component prop 'defaultSort' removed (retired
under ADR-0049 enforce-or-remove as the legacy single-sort second
spelling of 'sort', read only when 'sort' was absent; the pair moves to
sort: [{ field, order }], the array shape every read path honours) |
| `object-grid-resizable-columns-removed` (:9055) | objectstack-ai#21445 | `resizable`
is canonical and `resizableColumns` retires now as a tombstone naming
it: zero writers, so no window (immediate retirement) (triage direction
5958164933) | object-grid component prop 'resizableColumns' removed (the
legacy second spelling of 'resizable', read only when 'resizable' was
absent, retires at once so 'resizable' is the one spelling; the value
moves to 'resizable' when that is absent, and is deleted when it is
present) |
| `object-kanban-quick-add-removed` (:9258) | objectstack-ai#17260 | option B:
`quickAdd` leaves `object-kanban` (accepted and dropped there); this
repo carries the tombstone half (card body (the objectui ruling it
executes, option B) + triage 5620331176) | object-kanban component prop
'quickAdd' removed (retired from the board under ADR-0049
enforce-or-remove — the affordance is gated on a host-supplied
'onQuickAdd' function no producer puts on an object-kanban node, so the
key was accepted and dropped; delete the key — object-kanban offers no
quick-add control) |
| `permission-allow-restore-purge-removed` (:9643) | objectstack-ai#12497, objectstack-ai#1883 |
option B: retire `allowRestore` / `allowPurge`, which gate operations
that do not exist; objectstack-ai#1883 stays open as the M2 anchor, where
undelete/purge ship as feature + RBAC in one batch and the keys return
with it (card body (objectstack-ai#12497); ruling 5421209848 (objectstack-ai#1883)) |
object-permission keys 'allowRestore' and 'allowPurge' removed (ADR-0049
— the `restore`/`purge` operations they claimed to gate have never
existed, so granting the bits delivered nothing; dispatched destructive
lifecycle verbs stay denied fail-closed. The keys return with the M2
lifecycle initiative, which builds undelete and purge together with the
permission bits that gate them) |
| `field-reference-to-alias` (:9962) | objectui#6837 | ruling C:
protocol normalisation belongs to the server and the frontend only
executes the protocol; half 1 (this repo) guarantees the serve path
carries only `reference`, half 2 deletes objectui's legacy fallback arms
(ruling 5475017957 + half-1 pointer 5475055291) | field key
'reference_to' → 'reference' (the legacy objectql runtime dialect for a
lookup/master_detail target; normalising to the protocol is the server's
job and the renderer only executes the protocol, so stored rows must
serve the canonical spelling before objectui deletes its `reference ??
reference_to` fallback arms) |
| `hook-timeout-to-timeout-ms` (:10383) | objectstack-ai#14478 | ruling B: a
duration-shaped number key carries its unit in its name (or a
unit-carrying value), every existing offender renamed under an ADR-0087
conversion, no grandfathered baseline (ruling 5518649320 + population
ruling 5548763981) | hook key 'timeout' → 'timeoutMs' (a duration key
carries its unit in its name, and this one's unit lived only in the
description; the value, milliseconds, is unchanged) |
| `job-timeout-to-timeout-ms` (:10424) | objectstack-ai#14478 | as above (as above) |
job key 'timeout' → 'timeoutMs' (a duration key carries its unit in its
name, and this one's unit lived only in the description; the value,
milliseconds, is unchanged) |
| `api-endpoint-cache-ttl-to-cache-ttl-seconds` (:11028) | objectstack-ai#14478 | as
above (as above) | api endpoint key 'cacheTtl' → 'cacheTtlSeconds' (a
duration key carries its unit in its name, and this one's unit lived
only in the description; the value, seconds, is unchanged, and the key
stays GET-only) |
| `dashboard-refresh-interval-to-refresh-interval-seconds` (:11097) |
objectstack-ai#14478 | as above (as above) | dashboard key 'refreshInterval' →
'refreshIntervalSeconds' (a duration key carries its unit in its name,
and this one's unit lived only in the description; the value, seconds,
is unchanged) |
| `memory-persistence-auto-save-interval-to-ms` (:11458) | objectstack-ai#14478 | as
above (as above) | memory datasource key
'config.persistence.autoSaveInterval' → 'autoSaveIntervalMs', on both
the file and auto arms (a duration key carries its unit in its name, and
this one's unit lived only in the description; the value, milliseconds,
is unchanged) |
| `turso-config-timeout-to-timeout-ms` (:11682) | objectstack-ai#14478 | as above (as
above) | turso datasource key 'config.timeout' → 'config.timeoutMs' (a
duration key carries its unit in its name, and this one's unit lived
only in the description and a .meta() title no parse reads; the value,
milliseconds, is unchanged) |
| `view-page-mount-removed` (:11772) | objectstack-ai#17063 | the maintainer chose to
retire (「撤」) over finishing the objectui render half or parking it: the
`page` member and its mount leave the spec under enforce-or-remove (card
body (the maintainer ruling it records)) | list-view type 'page' and its
`pageName` binding removed (retired rather than finished: the delegating
render half was never built, so a page view fell through to the grid
branch and drew an empty table; ADR-0049 enforce-or-remove) |
| `list-view-sort-string-clause-to-array` (:11877) | objectstack-ai#17053,
objectui#8221 | objectui#8221 option B: the legacy string `sort` is
retired, one spelling platform-wide, the array. objectstack-ai#17053: the spec slot
that produces those documents stops accepting the string objectui now
refuses (triage 5620223775 (objectstack-ai#17053); ruling 5567944420 (objectui#8221))
| the bare string list-view `sort` clause becomes the `{ field, order
}[]` array (one sort orthography platform-wide, the array: objectui
already refuses the string, so the schema stops minting documents its
own consumer refuses) |
| `object-tenancy-organization-field-removed` (:12377) | objectstack-ai#19054 | take
`organizationField` off the authorable surface; its one real use stays a
platform-internal fact; citation dropped, the sentence already said it
(card body (the maintainer ruling it records)) | object
`tenancy.organizationField` removed (ADR-0049 — the stamp-only column
declaration was authorable by every application and declared exactly
once in the whole protocol, on the platform's own credential table; the
divergence moves to a platform-internal table in
@objectstack/metadata-core and stops being a knob) |
| `view-item-owner-hidden-removed` (:12487) | objectstack-ai#20085 | retire both keys
(ADR-0049 enforce-or-remove, zero pull) via the retirement playbook;
citation dropped (triage direction 5826969296) | view item keys
'owner'/'hidden' removed (ADR-0049 — declared on the view item record
and stored verbatim, read by nothing: no view switcher ever filtered on
`hidden`, and no per-user scope ever read `owner`, so a view marked as
one user's was listed for everyone) |
| `view-overlay-owner-hidden-removed` (:12631) | objectstack-ai#20230 | follow
objectstack-ai#20085's disposition for the same key pair on the overlay door: the same
retirement (triage direction 5856621469) | flattened view overlay keys
'owner'/'hidden' removed (ADR-0049 — the view item's pair on the overlay
door, retired the same way: declared, accepted by the write door and
stored verbatim, read by nothing, so a `hidden: true` overlay hid no
view and an `owner` scoped none) |
| `page-component-filter-record-to-rule-array` (:13220) | objectui#6206,
objectstack-ai#17321 | objectui#6206 option B: one filter orthography platform-wide,
the rule array. objectstack-ai#17321 ruling B: a partial D2 conversion of what maps
losslessly; combinator-carrying rows pass through untouched and are
named as a TODO (flattening would silently change what a page selects)
(ruling 5406409590 (objectui#6206); ruling 5644018752 (objectstack-ai#17321)) | a
record-form or single-level AST filter at a converged rule-array door
becomes the `[{ field, operator, value }]` rule array wherever the
mapping is lossless (flat keys → `equals` rules, `{ $op: v }` → the
mapped operator, AST comparisons → one rule each); a filter carrying
`$and` / `$or` / `$not` or any part with no lossless rule spelling is
left exactly as stored — reported as a TODO, which `os migrate meta
--stored` lists — and is not the form its door declares (one filter
orthography platform-wide, the rule array; the migration converts only
what maps losslessly and names the rest, because flattening a combinator
would silently change what a page selects) |
| `report-joined-chart-removed` (:13477) | objectstack-ai#20161 | retire, not build
block charts: the joined arm refuses a container chart, the block key
goes, a non-joined report keeps its live chart; citation dropped (triage
direction 5852548444) | a joined report's 'chart' removed from its
blocks and refused on the container (ADR-0049 enforce-or-remove: the
joined renderer draws each block as a table and never read either, so
the chart parsed and nothing was plotted; a non-joined report keeps its
live 'chart') |
| `form-layout-inline-grid-to-vertical` (:13742) | objectstack-ai#20221 | retire the
`inline` / `grid` arms: multi-column already exists as `columns` and
`inline` is not a record-form layout; citation dropped, the sentence
already said it (triage direction 5855767378) | form 'layout' arms
'inline' and 'grid' rewritten to 'vertical' (ADR-0049 — no renderer ever
gave either a behaviour of its own: every form presentation folded both
to 'vertical'. Multi-column is 'columns', honoured under either layout,
and is left untouched) |
| `currency-config-precision-removed` (:13898) | objectstack-ai#19992 | remove
`currencyConfig.precision`: a currency's decimal places are the
currency's, not a setting; citation dropped, the sentence already said
it (triage 5817146460 (ruling 乙 on objectstack-ai#19910 it executes)) | currency field
key 'currencyConfig.precision' removed (ADR-0049 — no renderer or
runtime ever read it: an amount's decimal places are its currency's ISO
4217 minor unit, derived from the currency itself. Its ISO 4217
contradiction check and the default `2` baked into parse output went
with it; the field-level `precision` is a total digit count and is
untouched) |
| `permission-rls-tags-removed` (:14002) | objectstack-ai#20321 | RETIRE by the
maintainer's criterion (no mainstream platform has the capability);
citation dropped, the sentence already said it (triage verdict
5860425529) | RLS-policy key 'tags' removed (ADR-0049 — nothing ever
read a policy's tags and no mainstream platform tags a row-level policy;
dropping it changes no access decision) |
| `flow-decision-mode-inclusive-explicit` (:14141) | objectstack-ai#15429 | align with
mainstream engines: an edge-branched decision is exclusive (first
match), and taking every true edge must be declared (`mode:
'inclusive'`); the migration writes it explicitly for existing nodes so
authored behaviour is unchanged (ruling C narrows that promise to
sources and artifacts) (ruling 5793803317; ruling C 5863827385) |
edge-branched decision with two or more conditioned out-edges and no
`mode`: `mode: 'inclusive'` written explicitly (the traversal became
exclusive, first match in declaration order, as mainstream engines treat
a decision, and taking every true edge must now be declared; the key
keeps the every-true-edge behaviour those nodes had, and the author
deletes it where the branches partition) |

No site was left in place as unclear; `open_questions` is empty.

## Text only (A4)

Stage 3's AST-skeleton plus string-text tool (`skeleton.cjs`, TypeScript
6.0.3; stage 4's copy with only its TypeScript load path changed to this
worktree, md5 `3b10ec8a7e6284f19def54d35f001698` →
`32b4630d7d0a532ee26239319269fe91`). Leg 1 compares an AST skeleton with
every string's text masked (a `+` chain of string operands reads as one
string, so re-wrapping is invisible); leg 2 compares the text of every
string group, requiring each changed group to carry a tracker id before
and none after, and every other group byte-identical.

- `registry.ts`, base `e901c27449` vs the committed copy at
`9c1d040145`: **1 of 1 SAME**, exit 0 — 62656 tokens both sides, 4955
string groups, 35 changed, every changed group carried an id before and
carries none after; parse diagnostics 0/0.
- Controls on scratch copies of the head file, each mutation counted on
disk first (anchor hits 1, replacement present 1, anchor left 0):
`renameFlowConfigAliases` renamed → DIFF exit 1; `!==` flipped to `===`
→ DIFF exit 1; one summary re-split into two `+` operands → SAME, 0
groups changed, exit 0; the `hook.timeout` surface string (never carried
an id) changed → skeleton SAME, text leg VIOLATION, exit 1. No repo file
was mutated for the controls.
- The edits were applied by a script whose every anchor was asserted to
hit exactly once, inside its own conversion's summary line span, and
verified on disk after the write (36 anchors over 35 conversions). The
conversion-table extractor reads 35 summaries changed, `toMajor` /
`surface` / declaration unchanged on all 127, and 0 ids left in any
summary.

## Pins and quotes (A6)

No test asserts a summary, so no pin moves: nothing under `*.test.*`
reads `.summary` off a conversion (the only summary reads are
`spec-changes.ts` and `build-upgrade-guide.ts`), and every removed
id-bearing fragment was searched across the repo. The hits are other
files' own prose with their own citations (CHANGELOGs,
`migrations/registry.ts` rationale, docblocks, `liveness/*.json` notes,
test titles such as `permission.test.ts:278`, docs prose in
`content/docs/permissions/*.mdx`), not quotes of a summary.
`content/docs/**`: no quote of a changed summary. `skills/**`: none.

## Verification

All builds and tests through `scripts/pm/os-verify-lock.sh` (slot
`issue-20749-s5`), each `VERDICT command-exit 0`:

- `pnpm --filter @objectstack/spec build`, then `check:generated` on the
merged head `9a35e049e5`: "✓ All 15 generated artifacts are up to date".
- The package `test` script (`vitest run --project local
--maxWorkers=2`) on `9a35e049e5`: "Test Files 605 passed (605) / Tests
17904 passed | 1 todo (17905)".
- `test:repo`: the 15 repo-project files that read the conversion
registry, `spec-changes` or the guide, "Test Files 15 passed (15) /
Tests 221 passed (221)". NOT MEASURED:
`scripts/build-schemas-check-mode.test.ts` (88 cases at about 7 s each,
over the foreground cap; it reads only conversion surfaces, which the
proof shows unchanged) and the remaining repo-project files; reason:
wall clock on a shared box. CI runs them.
- `pnpm --filter @objectstack/spec run typecheck` on `9a35e049e5`: exit
0; "check:test-typecheck: OK — 52 file(s) / 246 error(s) / 135 pinned
signature(s) held".
- `pnpm turbo run build --concurrency=2 --filter=./packages/*
--filter=./packages/*/*`: "Tasks: 71 successful, 71 total", for the
gates that read built packages.
- Gates: `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` (no paths) at `9a35e049e5`
derives 79 commands; each ran with its exit code written to disk before
any pipe. Three first exited 3 (PREREQUISITE NOT MET:
`check:doc-formula-expressions`, `check:dual-build-cjs-loads`,
`check:lean-entry-closure` need built packages) and exit 0 after the
build; the dist-reading gates were re-run after it too. `--ran`: "✓
dispatch-gates --ran: 79 derived famil(ies) accounted for — 79 run, 0
NOT-MEASURED".
- `pnpm check:doc-authoring` (self-test and run): exit 0, "17323
customer-facing string(s) across 1250 spec sources clean"; the
sibling-package ledger holds its baseline (`packages/spec` sits outside
it, so no ledger change). `pnpm check:nul-bytes`: exit 0, "no raw ASCII
control bytes".
- Changeset gates with this body as the `--event` payload:
`check-changeset-no-major.mjs --base origin/main --event` exit 0 ("✓
LEVEL AXIS: this PR declares clause-② `no`", declaration line `Clause-②:
no`); `check-partof-closing-keyword.mjs` with `PR_BODY` exit 0 ("no
Part-of/closing-keyword contradiction");
`check-adr-0087-registration.mjs --base origin/main` exit 0 ("adds no
declared-breaking changeset (1 non-breaking changeset(s) seen)");
`check-empty-changeset.mjs --base origin/main` exit 0;
`check-changeset-fixed.mjs` exit 0.
- ESLint, a proven narrowing: `eslint --no-inline-config --format json`
over the one changed TS file reads 1 file, 0 errors, 0 warnings; the
population is read from ESLint's own config (`calculateConfigForFile`
resolves it, `isPathIgnored` false); invariance: `eslint.config.mjs`
enables no type-aware linting (`parserOptions.project` /
`projectService` null for this file, its header at :327-328 says so), so
a string-text edit cannot move an untouched file's verdict. Repo-wide
`pnpm lint` is CI's.

## Acceptance notes

- `origin/main` was merged once (`9a35e049e5`, five commits: objectstack-ai#21539,
objectstack-ai#21473, objectstack-ai#21554, objectstack-ai#21556, objectstack-ai#21557; spec moved only in
`contracts/approval-service.ts` TSDoc); spec rebuilt, `check:generated`,
the spec test project, typecheck and the gate union re-ran on the merged
head. No os-regen deferral was recorded.
- Hot file: no open PR touches `conversions/registry.ts`,
`spec-changes.json` or the upgrade guide (all nine open PRs' file lists
read just before opening this one).
- Census after this PR (stage 3's instrument at `9a35e049e5`): non-test
160 → 125 messages, 358 → 318 ids; class (b) is empty. Left for the
later stages: class (c) conformance-case notes 58 messages / 68 ids (the
`objectstack-ai#5322` selector and the `objectstack-ai#8934` name pin move with their tests), class
(f) internal registry rationale 17 / 33, the test strings 1804 / 1920 in
425 files; `migrations/registry.ts` 50 / 217 stays with objectstack-ai#20234's stage
11. Word-form hits (an id spelled after "PR", "issue" and the like) stay
6, all outside this diff.
- Excluded, untouched: `migrations/registry.ts`, comments anywhere,
classes (c) and (f), test strings, the `.mjs` gate scripts. No gate is
added or loosened.

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

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

Projects

None yet

1 participant