Repository navigation
feat(spec)!: page requires only on the compiled kinds — refused at parse on react, full and slotted pages (#21459) - #21547
Conversation
…, 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>
…ge-requires-compiled-kinds
…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>
…ge-requires-compiled-kinds
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 138 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
Contract reviewServed-tier: Inputs read: card #21459 (body; comments ① Derived judgmentsEach accept-set or public-surface change the diff implies, judged against ruling A and the refs.
② Semver level
③ Boundary flagsDev
Dev
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 Implemented-by: VERDICT: PASS Generated by Claude Code |
ACCEPT — PR #21547 @
|
…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>
…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>
…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>
Fixes #21459
Clause-②: yes (narrowing)
Summary
This PR executes ruling A (record
5964312254).PageSchemanow acceptsrequiresonly whenkindishtmlorjsx, the two kinds the metadata save door compiles. On areact,fullorslottedpage it is refused at parse, and so is a page that omitskind, which defaults tofull. The refusal names the key, the page's kind and the compiled kinds.checkPageRequiresKind(packages/spec/src/ui/page.zod.ts). It is an exported object-level check chained ontoPageSchemawith.superRefine(...), right aftercheckPageSourceCompleteness. That is the mechanism this file already uses for kind-conditional rules, so no second one is added. Its vocabulary is the new exportCOMPILED_PAGE_KINDS = ['html', 'jsx']. The issue iscode: 'custom'atpath: ['requires']. The message opens with "requiresis refused on akind: 'react'page" (or the page's kind), nameshtmland its deprecated aliasjsx, says "Delete the key.", and ends with the houseos migrate meta --from 17sentence. It carries no tracker number.page-requires-non-compiled-kind-removed(step 18,order: 58,retiredFromLoadPath: true,retiredAfter: '17.6.0'). It strips the key fromreact,full,slottedand kind-less pages.page-requires-non-compiled-kind-refused, which carriesconversionIds: [page-requires-non-compiled-kind-removed].STEP18_RATIONALEfragment (order: 66, placed afterui-ai-chat-window-retired, which landed first at 65). The registry regions were regenerated withgen:migration-registry.RETIRED_KEYS_BY_MAJORrow: the key stays live on html pages.liveness/page.json: therequiresrow stayslive. Its evidence gains the PARSE reader, its note's per-kind clause shrinks to the compiled kinds, andverifiedAtis now 2026-10-03. The state counts do not move.metadata-form-zod-reconciliation.test.ts: its per-kind clause now reads "on every other kind the parse refuses it".api-surface/ui.jsonandexport-origins/ui.jsongain the two exports, andcontent/docs/references/ui/page.mdxre-renders the describe. All three were produced bycheck:generated --fix, never edited by hand.@objectstack/specminorwith the BREAKING banner,Clause-②: yes (narrowing), a FROM → TO table, and the ADR-0087 markerregistered 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)requires:hit inexamples/**,packages/apps/**(none there),content/docs/**andskills/**is the stack-level capability list. In examples that isapp-crm,app-showcaseandapp-todo'sobjectstack.config.ts, plus prose comments naming capability tokens. The only page bodies that carryrequiresare the 11htmlPage(...)sites inpackages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts, allkind: 'html', so they are still accepted. The proximity scan (eachrequires: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-shapin89cad75d55shows pagerequiresonly 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.kindparses asfull, and the refinement runs on the parsed value. So{ requires: [...] }with nokindis refused as afullpage, and the message adds "(fullis also the kind of a page that omitskind)". A.shapemirror without the default reaches the check withkindabsent and gets the same issue, which the exports-parity fixture pins.[]:compileHtmlPagereturnsundefined, sofindHtmlPageSourceGapsanswersnullandstampHtmlPageRequiresreturns 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 ("acceptsrequiresonly whenkindishtmlorjsx"), 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:
metadata_spec_invalidwarning and a_diagnosticsbadge at every boot;os migrate meta --from 17lists the edit.This is pinned at the real seam:
loadMetaFromDbover a seeded stored react page carryingrequiresgivesloaded: 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 beforereportPageRequiresAbsentAtLoad(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, thereportPageRequiresAbsentAtLoadTSDoc "How a stored page gets here". It is still true, but now only of html rows.runtime-authoring-gate.ts:679and:728were already gated to html / jsx bycompileHtmlPage(:634). No change.builtinComponents.tsxpageSaveBody(at the pin) deletesrequireson 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 mergedorigin/maintwice throughscripts/pm/os-regen-merge.sh:dafb0f6d1eafter #21531, ande38149267cat49161683fb.ui/page.zod.tsandmigrations/registry.tsmerged textually clean. Both rationale fragments are kept (ui-ai-chat-window-retired65, this one 66), and the regenerated regions matchcheck:migration-registry.os-regenartifacts both sides touched were regenerated on the merged tree, and the delta against main is exactly the two exports and the describe row.'ui-ai-chat-window-retired'(3),'ui/AIChatWindowProps'(2) and'ai:chat_window'(17).ui/page.zod.ts,migrations/registry.ts,conversions/registry.ts,liveness/page.jsonor the reconciliation test.49161683fb, main gained1ac7308d7aand41b13331cd. Neither touches a file in this diff.Tests (all at head
e38149267cunless noted)@objectstack/spectest (vitest run --project local): 604 files, 17888 passed, 1 todo. Run atd7cd797549; the later merge moved nothing underpackages/spec.@objectstack/spectypecheck: exit 0. That coverstsc --noEmit,check:scripts-typecheckandcheck:test-typecheck(52 files / 246 errors held, unchanged).@objectstack/metadata-protocoltest: 205 files passed, 3 skipped (3157 tests passed). Typecheck exit 0, and--listFilesincludesprotocol.runtime-authoring-gate.test.ts.packages/spec/src/ui/page-requires-compiled-kinds.test.ts: the refusal onreact,fullandslotted, checked for code, path and named subjects; the omitted-kind default; the empty array; html / jsx controls; norequireson every kind; the stack door envelope (STACK_SCHEMA_INVALID, 422, atpages.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, withui/Page:assignedProfilesas 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 refusesrequireson areact,full,slottedor kind-less page with{ code: 'INVALID_METADATA', status: 422 }, onecustomissue atrequires, nojsx-*compile finding, and nothing persisted. The html control still saves and stamps. The load pin is described above.scripts/ablation-replace.mjs, run under the verify lock. The mutation replaces the kind conditionif ((COMPILED_PAGE_KINDS as readonly string[]).includes(kind)) return;with a barereturn;, so the key is accepted on every kind again. The source-resolved spec suites need no rebuild.6b13a7df8e44→1fd2575e28e3, marker on disk 1.6b13a7df8e44) andgit diff HEADis empty. Green leg: 187 passed.Gates
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 116 commands ate38149267c. All 116 were run and their exit codes recorded.--ranreports: "116 derived famil(ies) accounted for — 115 run, 1 NOT-MEASURED". The NOT-MEASURED one ispnpm check:dual-build-cjs-loads(exit 3, PREREQUISITE NOT MET: it needs every package'sdist/, a repo-wide build), and it is declared to CI.check:skill-examplesandcheck:lean-entry-closurefirst exited 3 for missingclient-react/objectqlbuilds. After building those closures they re-ran to exit 0. Highlights, each exit 0:check:generatedreports "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--eventpayload),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.tsfiles 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
spec-object-refinements-7715.test.tscensus (at the pin) countsPageSchema's object-level checks and listsattached: ['checkPageSourceCompleteness']forPageNodeSchema. It is built to go red when the spec adds a check. At objectui's next@objectstack/specbump, that row will ask forcheckPageRequiresKindto 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-showcaseand every other example author no page-levelrequires, so no example changed.Generated by Claude Code