diff --git a/.changeset/22130-protocol-18.md b/.changeset/22130-protocol-18.md new file mode 100644 index 00000000000..1d2542e7114 --- /dev/null +++ b/.changeset/22130-protocol-18.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': major +'@objectstack/mcp': patch +--- + +feat(spec)!: `PROTOCOL_VERSION` is `18.0.0`; the runtime handshake accepts `engines.protocol: '^18'` and refuses `'^17'` + +Clause-②: yes (narrowing: the handshake refuses a manifest pinned to protocol 17 (`'^17'`, `'^17.x.y'`, `'17.x'`), while the exported `PROTOCOL_VERSION` moves to `'18.0.0'` and a `'^18'` manifest is admitted) + + + +**BREAKING**, graded `major`: Changesets is in pre mode (`next`) for the v18 line, so this ships in an `18.0.0-next.N` prerelease. The protocol major moves in this reviewed change, not in the version pass (ruling record 6049734955, Q1 → B). Until that line's first version pass, `PROTOCOL_VERSION` is one major ahead of the package version; `check-changeset-no-major.mjs` admits that only in pre mode with a pending `major` for `@objectstack/spec` (ruling record 6056808625). + +- **`PROTOCOL_VERSION` reads `'18.0.0'`** and `PROTOCOL_MAJOR` reads `18`. The ADR-0087 D1 handshake on every load and install door (the app load seam, `POST /api/v1/packages`, `POST /api/v1/marketplace/install-local`) now admits a package whose `engines.protocol` admits major 18, and refuses one pinned to 17 (`'^17'`, `'^17.x.y'`, `'17.x'`) with `OS_PROTOCOL_INCOMPATIBLE` (422), naming `objectstack migrate meta --from 17`. +- **What you change.** Run `objectstack migrate meta --from 17` to list the edits for existing sources; `--write` applies the ones it can prove, including the declared `engines.protocol` range, rewritten in place from `'^17'` to `'^18'`, and you apply the rest by hand. +- **`spec-changes.json` and the protocol upgrade guide** now carry the protocol 17 → 18 record, generated from the registries: 64 mechanical conversions and 329 semantic entries. The aggregate record spans 16 → 18. +- **`os init`** stamps `'^18'` in the manifests it writes, and `os lint`'s `protocol/missing-engines-range` fix names `'^18'`. The `create-objectstack` template keeps `'^17'` until the version pass stamps it together with its `@objectstack/*` dependency ranges. +- **`@objectstack/mcp`**: the README's `defineStack` examples declare `'^18'`. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 4a424f51c45..f2cd2ff5225 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -2474,7 +2474,7 @@ export default defineStack({ name: 'My App', description: 'My ObjectStack application', // Protocol major this app is authored against (ADR-0087 load-time check). - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }, objects: Object.values(objects), diff --git a/content/docs/getting-started/your-first-project.mdx b/content/docs/getting-started/your-first-project.mdx index 8dc1367c172..c9d7e4ad1d3 100644 --- a/content/docs/getting-started/your-first-project.mdx +++ b/content/docs/getting-started/your-first-project.mdx @@ -135,7 +135,7 @@ export default defineStack({ type: 'app', name: 'My App', // Protocol major this app is authored against (ADR-0087 load-time check). - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }, // `automation` runs flows and, per ADR-0097, materializes declarative // `connectors:` entries at boot. The three generic executors below register diff --git a/content/docs/upgrading.mdx b/content/docs/upgrading.mdx index d7deb90cd6c..09e7a98d3b6 100644 --- a/content/docs/upgrading.mdx +++ b/content/docs/upgrading.mdx @@ -156,7 +156,7 @@ runtime checks that handshake **first**, before it loads anything: ```ts manifest: { // ... - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, } ``` diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 5c98d9147ba..b5a2c6cb727 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -3,7 +3,7 @@ # Metadata protocol upgrade guide -Current protocol: **17.0.0** · chain support floor: **protocol 16** · generated from the ADR-0087 registries (`@objectstack/spec` `conversions/` + `migrations/`). +Current protocol: **18.0.0** · chain support floor: **protocol 16** · generated from the ADR-0087 registries (`@objectstack/spec` `conversions/` + `migrations/`). ## How to upgrade — from protocol 16 onward @@ -15,7 +15,7 @@ objectstack validate && tsc --noEmit && # your own verify loop is Mechanical rewrites are applied for you and reported as a diff; **semantic TODOs** are printed with acceptance criteria and are yours to resolve — the chain never auto-applies a change that requires judgment. -The chain's support floor is protocol **16** — 1 major behind the current protocol **17**, and no earlier. A consumer further behind must reach protocol 16 by another path first (an older `@objectstack/cli` still carries the retired steps) before this command will run. From protocol 16 forward, replaying every remaining hop in one command **is** the designed-for case — that part of timeliness is never load-bearing (ADR-0087); arriving from *before* the floor is not supported at all. +The chain's support floor is protocol **16** — 2 majors behind the current protocol **18**, and no earlier. A consumer further behind must reach protocol 16 by another path first (an older `@objectstack/cli` still carries the retired steps) before this command will run. From protocol 16 forward, replaying every remaining hop in one command **is** the designed-for case — that part of timeliness is never load-bearing (ADR-0087); arriving from *before* the floor is not supported at all. ## Protocol 16 → 17 @@ -471,6 +471,1105 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-conte - Why not automatic: The workflow slot was declared end to end and implemented nowhere: no code in either repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches were plugin-dev's retired stub probe and the generic discovery walk), no implementation of any WorkflowProtocol method ever existed, and no host ever mounted `/api/v1/workflow` (DEFAULT_DISPATCHER_ROUTES, before it was retired as a stale list, named it among routes that never existed). Every part of it was ADR-0078's silently-inert declaration: a CoreServiceName nothing filled, a contract nothing implemented, a protocol nothing served, a discovery route field no builder could truthfully populate. These are TS/API surfaces and a discovery RESPONSE field — never stored in stack metadata, so there is no source for the chain to rewrite; consumers of the deleted types move their imports themselves. ADR-0049 / ADR-0078. - Done when: No import of IWorkflowService, WorkflowProtocol or the Get/WorkflowState/Config/Transition types resolves; no code calls getService('workflow') or reads discovery `routes.workflow` / `services.workflow`; record state machines, approvals and record-triggered automation go through the replacement mechanisms. Discovery output on a default boot is unchanged (the slot was always reported unavailable; now it is simply absent). +## Protocol 17 → 18 + +Protocol 18 extends the publish-time refusal of unresolved placeholders, which protocol 17 applied to datasource connection config, to the memory driver's config-material persistence keys: `persistence.path` (file persistence and the `auto` override) and `persistence.key` (localStorage and the `auto` override) refuse `${…}` placeholder syntax at publish. Nothing resolves a placeholder there — the driver would create a literal `./${DATA_DIR}/…` path or write under the literal localStorage key — the same authored-under-a-false-belief shape, one surface over. The memory driver's `initialData` stays deliberately unjudged: it carries arbitrary record values, where a literal `${…}` may be legitimate data. It also retires `MetadataPluginConfig.additionalTypes` (ADR-0049 enforce-or-remove): the key was documented as THE plugin kind-declaration channel and read by nothing — the manager's type registry is seeded once from `DEFAULT_METADATA_TYPE_REGISTRY` and never merged with it, so authoring it configured nothing. A kind enters the live set as a side effect of registering an item of that kind. It also refuses malformed field `scale`/`precision` declarations: both are digit counts, so a non-integer or negative value (`scale: 2.5`, `precision: -1`) has no defined meaning — the write-time `scale` check, which refuses an over-scale value rather than rounding it, deliberately left it unenforced rather than invent floor/round semantics, which made the declaration silently inert. The schema now refuses both at parse (`z.number().int().min(0)`); the mechanical conversion deletes a malformed value from old sources and stored rows (behaviour-preserving), and the semantic entry tells the author to re-declare the count they meant. Finally, it removes the `objects["*"].allowExport` grant from the shipped admin permission sets — `admin_full_access`, `organization_admin` and the derived `organization_admin_no_bypass`. Measured on 17.0.0 GA, that wildcard made the export axis undeniable for an org admin: an application could declare an object exportable by nobody and the platform exported it anyway, with no supported opt-out, because a code-package set cannot be edited (`403 [not_overridable]`) and the admin held no app-authored set in which to write the per-object `false` that would have won. It is the earlier removal of `member_default`'s CRUD wildcard applied to the export axis, which had kept its wildcard by omission rather than by decision. From 18 an admin exports exactly what an app-authored set grants — a posture the same run measured to be already precise. Unlike everything else in this step it changes no schema, so nothing refuses at publish: the upgrade signal is behavioural and belongs here. Finally, it converges `record:chatter` / `record:discussion` `position` on the renderer's vocabulary (maintainer ruling 2026-08-15): the schema declared `sidebar`/`inline`/`drawer` — values no renderer branch ever compared, so the schema's own `sidebar` default silently rendered in flow while the value that actually docks the panel (`right`) was refused at publish. The row now speaks `bottom`/`right`/`left`; the mechanical conversion rewrites the old spellings (`sidebar` → `right`, `inline` → `bottom`, `drawer` → `right`), and the three schema defaults (`position`, `collapsible`, `defaultCollapsed`) are dropped per the `maxVisible` principle — renderer fallbacks stay the renderer's facts. It also retires `targetVariable` on `element:text_input` and `element:record_picker` (ADR-0049 enforce-or-remove): a declarative hint with zero readers in any repo — the live binding runs the other direction, resolved from the page variable whose `source` names the component's `id` (PageVariableSchema) — so an author who wrote only `targetVariable` got an input that wrote nothing, with a success receipt. The mechanical conversion strips the key from old sources (pure lossless delete — it never had an effect to lose); the tombstone's prescription says how to declare the binding that works. Finally, it retires the whole `element:filter` element (ADR-0049 enforce-or-remove at ELEMENT grain — the wider finding that the `targetVariable` retirement recorded and left for its own card): no renderer for the element ever shipped in any repo — objectui registers none, Studio's designer palette lists it as a no-renderer exclusion, and the 2026-06 page-liveness audit recorded it rendering "Unknown component type" — so every one of its six authorable keys was a capability claim nothing kept. All six are retiredKey tombstones; the mechanical conversion strips them from old sources (pure lossless deletes) and leaves the bare node, which the parse then refuses by name — delete the component. List surfaces own their filtering: a view's `userFilters` quick-filter bar / the list toolbar's filter builder. It also retires the whole `element:form` element (ADR-0049 enforce-or-remove at ELEMENT grain — the `element:filter` shape one element over, recorded by that retirement's own verdict sweep): no renderer for the element ever shipped in any repo — objectui registers none, Studio's designer palette lists it as a no-renderer exclusion naming the live replacement, and the 2026-06 page-liveness audit recorded it rendering "Unknown component type" — so every one of its six authorable keys was a capability claim nothing kept. All six are retiredKey tombstones; the mechanical conversion strips them from old sources (pure lossless deletes) and leaves the bare node, which the parse then refuses by name — delete the component. Use the object-bound `object-form` block instead — rendered, designer-publishable, its props declared for the component-props gate, and carrying the same intent (`objectName`, `fields`, `mode`, `submitText`). It also closes the two explicit column lists on relationship fields: `field.inlineColumns` entries are now the strict, name-keyed InlineGridColumnSchema (mirroring the objectui grid renderer's measured reads — objectui aligned the widget to `name` and retired the `field` spelling with no tolerant alias), and `field.relatedListColumns` entries are child field-name strings (the only form the related-list renderer hydrates fully). Both were z.array(z.any()) — a mis-keyed column published clean and rendered as blank cells with the right row count. The mechanical conversion respells inline `{ field }` entries as `{ name }` and folds related-list column objects to their identity string; unknown keys are named rejections at publish from this major. It also retires `measures..filters` on analytics cubes (ADR-0049 enforce-or-remove): a declared per-metric raw-SQL filter with zero consumers — both SQL strategies aggregate the metric's `sql` and never read `filters`, so a hand-authored `filters: [{ sql: "stage = 'closed_won'" }]` parsed, registered, and silently returned the UNFILTERED aggregate under the author's metric name (the same defect the dataset path had, on a hand-authored cube; the dataset half was repaired through its own structured channel when the analytics strategy began compiling each dataset measure's `filter`). The raw-SQL fragment also ran against the platform's structured-FilterCondition direction — it cannot be parameterized, re-targeted per driver dialect, or walked by the lint filter rules. The mechanical conversion strips the key from old sources (pure lossless delete — it never had an effect to lose); 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, see `cube-member-sql-expression-retired`). Finally, it retires the stack `themes` carrier and `ThemeSchema` whole (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21, disposition B: 退役授权面): the pipeline was live from the authoring gate through artifact ingest and stopped there — zero non-test readers of stored `theme` items, `theme` never a registered metadata type, no first-party app mounting the spec-aware provider, nothing selecting an active theme — so an authored theme shipped through every green gate and changed nothing on screen. `app.branding` stays the one colour surface; objectui's ThemeEngine/ThemeContext and their unit tests are retained. Semantic rather than mechanical: an authored palette has no lossless target (N themes vs M apps is a judgment), so the entry prescribes the hand move instead of deleting authored content silently. It also retires the `record:highlights` highlight-field `icon` (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21, executing the 2026-08-20 census verdict): a declared key with zero read points in any direction — objectui's renderer normalized the authored object and carried `icon` into a highlight chip with no icon slot, `useRegisterHighlightFields` registers field NAMES only (structurally unable to carry it), and the Studio designer publishes the field list as plain strings — while six author-facing surfaces advertised the key (the shape that got the reference-rail `icon` refused, on the highlight chip). The mechanical conversion strips the key from the object entries of every `record:highlights` `fields[]` (pure lossless delete — the chip renders label and value only, so it never had an effect to lose); there is no replacement, and the live neighbour `readonly`, declared because the chip's read-only gate reads it, is untouched. It also retires the import mapping `lookup` transform's steering params (ADR-0049 enforce-or-remove — the sub-walk half of the 17.0.0 mapping cleanup that retired `extractQuery` / `errorPolicy` / `batchSize`): `fieldMapping[].params.object` / `.fromField` / `.toField` / `.autoCreate` declared a per-entry reference-resolution dialect the import path never implemented — `lookup` copies the cell through and resolution runs off the target field's own metadata — and `autoCreate` read as create-if-missing while an unresolved reference actually fails the row (`import_reference_not_found`), with or without the key. The eleven alias spellings convert to guidance so every spelling lands on the prescription; the mechanical conversion strips the four keys from stored sources (pure lossless deletes — none ever had an effect to lose). Finally, it retires the component-translation copy key `pages..components..submitLabel` and its `submit` alias (ADR-0049; maintainer ruling 2026-08-22): the face is measured, not mirrored — each copy key exists because some component in `ComponentPropsMap` declares it — and `submitLabel`'s only declarer was `element:form`, retired whole above, so the key had no declared component left to translate and the resolver overlay was its only reader. Retire won over re-anchor because the live form surface (`object-form`) speaks `submitText` (`I18nLabelSchema`), localizable at its own authoring site; re-anchoring would have widened the face for one word. The mechanical conversion strips the key from stored bundles and items (pure lossless delete — nothing read it once `element:form` was retired), at the acknowledged cost of dropping the bespoke-component route for that one word. Finally, it retires `page.components[].responsive` and the whole `ResponsiveConfig` layout vocabulary it carried (ADR-0049 D2; maintainer ruling 2026-08-22): the key was the destination the `dashboard.widgets[].responsive` tombstone prescribed as the live alternative, and a two-repo measurement (tsc-probe methodology with positive and negative controls) found the claim false — objectui's two implementations of the contract (`useResponsiveConfig`, `ResponsiveProtocol`) had zero callers and nothing read `.responsive` off a page component, so the prescribed migration moved an inert key to an inert key while the platform's own error message vouched for it. The same change repairs every shipped text that carried that redirect. `ResponsiveConfigSchema`, its two breakpoint maps and the `BreakpointName` enum had no other authorable carrier and leave with the key (RETIRED_DEFS_BY_MAJOR[18]); the live per-breakpoint channel on a page component is `responsiveStyles` (ADR-0065), which objectui really compiles. The mechanical conversion strips the key from stored pages (pure lossless delete — it never had an effect to lose). Finally, it retires nine of the eleven members of the plugin manifest's `contributes` block (ADR-0049 enforce-or-remove; triage graded 2026-08-21, cloud census leg discharged clean 2026-08-24): `events`, `menus`, `themes`, `translations`, `actions`, `drivers`, `fieldTypes`, `functions` and `commands`. A census of all three repos, with controls, measured that the whole monorepo contains exactly one non-test read of `manifest.contributes`, and it reads `kinds`; the other nine members parsed, entered the manifest, and changed nothing, while published docs and the schema's own JSDoc kept teaching them (`commands` documented Commander.js resolution the CLI dropped for oclif; `fieldTypes` advertised a registration seam that never existed). All nine are retiredKey tombstones mirroring `loading`; `kinds` survives (live reader), and `routes` was left to a ruling of its own, which retired it as well (the `plugin-manifest-contributes-routes-retired` entry). D3 semantic, no D2 conversion: a manifest is not a stack collection member, so a conversion would be a transform with no seam that ever runs. On the surviving `kinds` bucket it also retires the `globs` sub-field (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-24): the schema promised that declaring `globs` enables file-type discovery, but discovery globs `filePatterns` off the metadata type registry — which `contributes.kinds` does not extend, as `metadata-plugin.zod.ts` records outright — so an authored `globs` was accepted, stored, served back through `GET /metadata/kind`, and never consulted (zero value reads; the only non-test occurrences were the schema declaration and two type positions). The `kind` bucket itself and its `id` are untouched; file-type discovery stays single-channel on `filePatterns`. D3 semantic `plugin-manifest-kind-globs-retired`, same no-seam reasoning. Finally, it retires `object-grid`'s `defaultSort` (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-25, decision-inbox batch 4 — the producer half of objectui's `table.defaultSort` retirement, which the maintainer's 2026-08-22 「接受所有」 ruling on objectui's sort sink ordered): the legacy second spelling of `sort`, a single `{ field, order }` pair the renderer read only when `sort` was absent (measured at the `.objectui-sha` pin `190fbd01d`, `plugin-grid/src/ObjectGrid.tsx:1244-1246` and `:2847`, which wraps it `[schema.defaultSort]` — the exact array shape `sort` carries). One intent, two spellings; objectui's mirror schema is parity-test-only and parses nothing at runtime, so only the spec strictObject can refuse the key. The mechanical conversion carries the pair over — renamed to `sort` and wrapped in the array shape — when `sort` is absent, and strips it as a pure lossless delete when `sort` is present (the renderer's own precedence made it unread then). Finally, it retires the object-permission lifecycle bits `allowRestore` and `allowPurge` (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-26, decision-inbox batch 5, which chose retiring the two bits over gating operations that do not exist): the `restore` / `purge` ObjectQL operations the bits claimed to gate have never existed — no destructive lifecycle verb is in the engine's dispatch vocabulary, which a test pins — so granting the bits delivered nothing, and an author who declared `allowPurge: false` believed a lock on GDPR hard-deletion existed when the operation itself did not. Both keys are retiredKey tombstones; the evaluator's pre-mapping rows retired in the same batch (a dispatched `restore`/`purge` stays denied fail-closed via the DESTRUCTIVE_OPERATIONS backstop, so there is no ungated window), and the mechanical conversion strips the keys from every object grant in `permissions[].objects` (pure lossless delete — they never had an effect to lose). `allowTransfer` is ENFORCED — the server guards who may rewrite a record's owner — and stays. The keys return with the M2 lifecycle initiative (feature + RBAC in one batch), which stays open as their anchor. Finally, it narrows the per-option `default` key OUT of the form-view options vocabulary (ADR-0049 declared-but-unenforced; maintainer ruling 2026-08-28 on the console form renderer's analysis, disposition 甲): `SelectOptionSchema` serves two surfaces and only the OBJECT-field face reads `default` (enforced there by a maintainer ruling of 2026-08-10 — `applyFieldDefaults` falls back to the option marked `default: true`; that face, its alias rows and its precedence pin are untouched). On a form-view field's option list the key parsed clean and nothing read it — the insert-path fallback consults the object definition's options, never a form view's, and no form renderer seeds a value from it (measured against the console's form controls, none of which reads the key; the ruled census found ZERO authored occurrences across the tree, the example apps and the published *.form.ts corpus). The FormView vocabulary's own option shape (`FormSelectOptionSchema`, ui/view.zod.ts) now refuses the key with the prescription; the mechanical conversion strips it from stored sources (pure lossless delete — it never had an effect on this surface to lose). It also retires the paper metadata-customization protocol whole (ADR-0049 enforce-or-remove, maintainer ruling 2026-08-29): `kernel/metadata-customization.zod.ts` — the three-layer platform/user patch-overlay model with field-level change tracking and a 3-way-merge story — was exported, documented as the customization architecture, and implemented ONLY by an unreachable `packages/metadata` limb (no route served the paper `…/overlay`/`…/effective` endpoints; the four optional service members were called only by their own unit tests). ADR-0126 §6 wall 4 supersedes it on the record ("nothing may build against it"). The module's seven defs and the three section-5 API contracts leave via RETIRED_DEFS_BY_MAJOR; the authorable carriers `MetadataPluginConfig.customizationPolicies` / `.mergeStrategy` and `MetadataManagerConfig.persistence.overlayWritable` are retiredKey tombstones (no D2 conversion — plugin/manager configs are not stack collection members, the additionalTypes reasoning). The customization that actually ships: ADR-0005's org overlay and ADR-0126's packaged-metadata model. Finally, it canonicalizes the legacy objectql field-key dialect `reference_to` → `reference` on lookup/master_detail fields (the server half of the maintainer's 2026-08-31 ruling that the server normalizes the protocol and the renderer only executes it). `FieldSchema` has always refused `reference_to` by name, but stored `sys_metadata` rows written by seams that bypass the parse still carry it, held up today only by objectui's `reference ?? reference_to` fallback arms — which the ruling's objectui half deletes. The mechanical conversion renames the key (the house precedence for a shadowed alias: a canonical `reference` wins, a disagreeing pair is kept for the author), replays on every stored-row rehydration so the serve face only ever emits the canonical spelling, and `os migrate meta` rewrites old sources; the authoring-surface rejection with its rename prescription is unchanged. It also retires `connector.errorMapping` (ADR-0049 enforce-or-remove; triage ruling 2026-09-02): `ErrorMappingConfig` (4 keys) and its `ErrorMappingRule[]` (7 keys) were authorable through `ConnectorSchema` — and, via `DeclarativeConnectorEntrySchema`, through `stack.connectors[]` and the `/meta/connector` door — and read by nothing: no provider, dispatcher or materializer ever mapped an external error through the rules, so `unmappedBehavior` configured nothing and a rule's `userMessage` was never shown to anyone. That spelling is the live API-error channel's (`ApiError.userMessage`), so an author who wrote a rule here reasonably believed they were marking a refusal for an end user; the failure was silent in both directions. The carrier key is a retiredKey tombstone on the non-strict `ConnectorSchema` (a bare deletion would be a silent strip), the three defs — `integration/ErrorMappingConfig`, `integration/ErrorMappingRule` and the orphaned `integration/ConnectorErrorCategory` enum — leave via RETIRED_DEFS_BY_MAJOR, and the mechanical conversion strips the block from `connectors[]` (pure lossless delete; it never had an effect to lose). It also retires the fourteen hour/minute/day-shaped deadline keys of the incident-response, training and change-management families (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02): six on the incident-response schemas, five on the training schemas and three nested in the change-management schemas, every one on the published surface and read by nothing — the schemas are mounted by no stack key and registered as no metadata type — so a compliance author who wrote `triageDeadlineHours: 4` held a deadline the platform never kept. All fourteen are retiredKey tombstones (the schemas are not strict; a bare deletion would be a silent strip) with no D2 conversion, for the additionalTypes reason: none of these schemas is a stack collection member, so the chain has no seam. It then retires those three compliance-shaped families WHOLE (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05, ruled A, not roadmapped): the nineteen defs of `system/incident-response.zod.ts`, `system/training.zod.ts` and `system/change-management.zod.ts` — roughly a hundred declared keys, exported from `@objectstack/spec/system`, mounted by no stack key, registered as no metadata type, absent from the liveness ledgers, read by nothing repo-wide (examples, skills and objectui at the pinned sha included) — leave via RETIRED_DEFS_BY_MAJOR with one D3 semantic entry per family; the fourteen deadline-key tombstones leave with their defs' source and their RETIRED_KEYS_BY_MAJOR[18] entries stay as history. Boolean capability claims such as `notifyRegulators`, `requirePostIncidentReview`, `trackCompletion` and `approval.required` were the sharpest declared-≠-enforced shape left: an author writing `notifyRegulators: true` held a compliance promise the platform never kept. And it resolves the branch the deadline-key ruling held open — no roadmapped e-signature consumer — so `ESignatureConfig.expirationDays` / `reminderDays` (`data/document.zod.ts`, defaults 30 / 7 days, read by nothing) are retiredKey tombstones with no D2 conversion (`document` is no stack collection member), registered in RETIRED_KEYS_BY_MAJOR[18] with one D3 semantic entry. Finally, it moves the unit of every duration-shaped `z.number()` key whose unit lived only in its description into the key name (maintainer ruling 2026-09-02, no grandfathered baseline): `hook.timeout` and `job.timeout` become `timeoutMs` (mechanical rename, retired from the load path), and the five keys with no stack seam — `MetadataManagerConfig.cache.ttl` / `cache.databaseLoader.ttl` (seconds and milliseconds fourteen lines apart under one name), `DriverOptions.timeout`, and the tenant `connectionPool.idleTimeout` / `accessControl.sessionTimeout` whose unit the reference pages never published — are retiredKey tombstones with a semantic entry each, naming the suffixed key. The `data`, `ui`, `ai` and `integration` remainder closes the same sweep: `dashboard.refreshInterval` → `refreshIntervalSeconds`, the connector pair `health.circuitBreaker.monitoringWindow` → `monitoringWindowMs` and `triggers[].interval` → `intervalSeconds` (both halves later absorbed by the removal of the block each key lived in — see the connector retirements below), and the two datasource config keys `memory config.persistence.autoSaveInterval` → `autoSaveIntervalMs` (BOTH union arms — the `auto` arm forwards the same value to the same file adapter, so splitting them would have left one value with two spellings) and `turso config.timeout` → `timeoutMs` all convert, because a dashboard, a connector and a datasource are stack collection members stored as rows; the two with no seam — `ConversationAnalytics.duration`, computed at runtime and never authored, and `NoSQLQueryOptions.timeout`, a per-call driver argument — are retiredKey tombstones with a semantic entry each. That remainder is what takes `check:duration-unit-keys` to zero offenders over `packages/spec/src/**`; the gate goes red again by design when its declared population widens beyond that subtree. It also retires the three outer keys of `MetadataManagerConfig.cache` — `enabled`, `ttlSeconds` (the duration rename's respelling of `ttl`, never shipped) and `maxSize` — that the rename above surfaced (ADR-0049 enforce-or-remove): declared, defaulted and published, read by nothing — `MetadataManager` hands only `cache.databaseLoader` to the loader — so `cache: { enabled: false }` switched nothing off. All three are retiredKey tombstones registered in RETIRED_KEYS_BY_MAJOR[18] with one D3 semantic entry and no D2 conversion (a manager config is no stack collection member); the rename is folded into the removal, so `cache.ttl` now prescribes deletion rather than a hop to a retired key. It also retires the seven cron-typed positions nothing evaluated (ADR-0049; the 2026-09-06 ruling retired each family rather than marking it experimental): the two export-schedule crons, `ScheduleState.cronExpression`, `DataSyncConfig.schedule`, `CacheWarmup.schedule` and the two disaster-recovery crons were parsed into the cron envelope and read by nothing (the D7 ledger row `cron-declared-unwired`). All seven are DELETED OUTRIGHT — no retiredKey tombstone, no RETIRED_KEYS_BY_MAJOR[18] entry, no D2 conversion and no D3 semantic entry — so this step replays nothing for them and `migrate meta` lists no edit: the keys simply stop existing. That the chain is silent does NOT make the deletion silent to an author: the PARSE strips (no schema here is `.strict()`), but above it `lintUnknownAuthoringKeys` names the dropped key for the one position a stack manifest reaches — `os validate` and `os build` both print `connectors..syncConfig.schedule: 'schedule' is not a declared connector key, so its value is dropped at load.`, and `os validate --strict` EXITS 1 on that warning. The other six positions are unreachable from a manifest, so for those the parse-level strip is the whole of it. That is the maintainer ruling of 2026-09-10 on the retirement PR, taken over the seat recommendation to keep the connector D2, on the reading that customers do not upgrade major by major in order. It also retires the `type: 'page'` LIST-VIEW mount and its `pageName` binding (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09 「撤」). The member was added so a view could render nothing of its own and delegate to an already-published page, but only the spec half landed: no renderer ever routed it — objectui's list-view switch shares its default arm with `grid` — so a page view drew an empty table where the page belonged, and the three parse refusals policing the binding policed a mount that never mounted anything. The enum VALUE carries its prescription on the `type` enum's own error map (an enum-value narrowing has no tombstone to hang one on, the `exportOptions` 'pdf' precedent); `pageName` is a retiredKey tombstone on both list-view doors. The D2 conversion STRIPS both keys rather than rewriting `type` to `'grid'`: `type` defaults to `grid` in the schema, so deleting it lands the row on exactly what it already rendered without this registry guessing a view type. The surviving page mount is the app navigation item (`PageNavItem.pageName`), untouched. It also retires `object-kanban`'s `quickAdd` (ADR-0049 enforce-or-remove; the spec half of the director-seat ruling of 2026-09-08 that the board grows no inline record-creation path and retires the key). The board FORWARDED the key into the shared renderer but the affordance is gated on both `quickAdd` and `onQuickAdd`, and `onQuickAdd` is a host-supplied FUNCTION JSON cannot carry and no producer puts on an `object-kanban` node — so the gate was permanently false. The drop was NOT silent, and that is what made it worse than silence: objectui's html tier reported the published key as `unknown-prop`, the same diagnostic a typo gets, so an author following the contract met a tool contradicting it with no way to tell which side was wrong. A retiredKey tombstone on `ObjectKanbanPropsSchema` with one D2 conversion that is a pure lossless DELETE (the key never had an effect to preserve) scoped by component `type`. Delete the key; `object-kanban` offers no quick-add control. It also retires the bare STRING `sort` clause on the list-view doors (ruled 2026-09-07: the legacy string clause is retired, one spelling, the array). This is the PRODUCER half of the seam whose consumer half shipped in objectui first: `convertSortToQueryParams` now refuses a runtime string, so `ListViewSchema.sort` was minting documents its own consumer rejects — a document that validated upstream failed downstream, and the author was told off by the wrong layer. Like the `type` value above it is a VALUE narrowing with no tombstone to hang a prescription on, so the surviving array member's own error map carries it, keyed on `issue.input` being a string. The D2 conversion REWRITES rather than strips, because the clause is losslessly mechanical: `'created_at desc'` is the tuple `{ field, order }`, a bare field name meant ascending and is written out as `order: 'asc'`, and the comma-separated multi-key form becomes one entry per key in the same order. A string that does not parse as that grammar — the `'-field'` dialect above all — is left alone and meets the door instead: that dialect belongs to `RecordRelatedListProps.sort`, never reaches `convertSortToQueryParams`, and retiring it was NOT ruled. It also removes `page.assignedProfiles` (ADR-0090 D2 / ADR-0049 enforce-or-remove; maintainer ruling 2026-09-12 「同意」). The key was authorable on the published `PageSchema` and named for the Profile concept ADR-0090 D2 deleted, while the schema's own alias table CORRECTED an authored `profiles:` into it — two files from `security/permission.zod.ts` answering the same word with "no Profile concept". Measured across this repository and objectui it had zero readers, so a page that "assigned profiles" was open to every caller who could reach it. It is a retiredKey tombstone on `PageSchema` — the def is still parsed from the `page` root, so there is an author to teach — and the two alias entries became refusals naming the permission-set route. The D2 conversion STRIPS the key — there is no lossless target, because which permission set a given profile name corresponds to is a judgement no walker can make, which is what the paired D3 semantic entry is for. Finally, it removes `aria` from the chart config (ADR-0049 enforce-or-remove; maintainer decision of 2026-09-12 — judge the protocol wrong for this one key). It is the last member of the `aria` family retired for the same measured reason as `dashboard.aria` and `dashboard.widgets[].aria` before it: an ARIA block an author can declare and nothing lowers to the DOM. It survived those two sweeps by depth — it sits inside the widget’s `chartConfig` bag, which no drill had reached until the per-key pass recorded in `liveness/dashboard.json`. That pass found `aria` to be the one `ChartConfigSchema` key with no reader on EITHER face: the chart implementation declares no `aria` prop, the presentation lowering names it nowhere, and the react block omits it from ``’s `dataProps`. Remove rather than enforce, because the same chart config already carries a WORKING accessible-name channel in `description` (lowered as `role="img"` + `aria-label`), and giving `aria` a reader would put two accessible-name sources on one element behind a precedence rule nobody has written — one node, one accessibility vocabulary. The tombstone rides `ChartConfigSchema` and therefore copies into `ReportChartSchema`, so the key is registered twice; the D2 conversion STRIPS it from all three authored sites (`dashboards[].widgets[].chartConfig`, `reports[].chart`, `reports[].blocks[].chart`) as a pure lossless delete — it never had an effect to lose. The two alias spellings that pointed at it, `accessibility` and `ariaProps`, became refusals carrying the same prescription rather than renames onto a tombstone. It also states, and enforces, who owns a dataset-bound chart's STRUCTURE (ADR-0021; maintainer ruling 2026-09-12): the dataset decides which series exist and which column each one reads, `chartConfig` carries appearance, and `dashboard.widgets[].chartConfig`'s `type`, `xAxis`, `yAxis` and `series` are refused by name on that carrier — the widget's own `type` is the chart family and `dimensions`/`values` are the selection. An authored `yAxis[].field` was a live membership channel: the renderer synthesised a series from it when the chart declared none, so one authored axis could silently re-point a dataset-bound series at another column and the chart still drew. The D2 conversion strips the four keys from dashboard widgets only — `ReportChartSchema` and the inline-data react `` tier keep their own axes — and the paired semantic entry carries what the stripped keys were saying, because an authored axis field may name a column the widget never selected and no walker can move that intent into the dataset. Finally, it splits the translation bundle type in two (maintainer ruling 2026-09-13: settings copy belongs to the platform): the platform bundle keeps all eleven groups and the per-app bundle (`stack.translations`, `defineTranslationBundle`) no longer declares `settings`, which is keyed by `SettingsManifest.namespace` and only platform code declares a manifest. Both bundles load into ONE served tree, so an app-authored `settings` branch did not sit inert — but nor did it override the platform: the app’s bundles arrive in `AppPlugin`’s `start()` (Phase 2) and the platform’s at `kernel:ready` (Phase 3), and `deepMerge` gives the later source the leaf, so what an application had was a GAP FILLER on a namespace it does not own — rendering only where the platform bundle carried no string for that key and locale. The registered `translation` ITEM follows the file door (maintainer ruling 2026-09-22: one app metadata type, two authoring doors, one accepted shape) and no longer declares `settings` either; there the group had been STRONGER, because the runtime-authored layer is read over the shipped bundles, so a stored item overrode the platform’s own copy. The D2 conversion strips the group from per-app bundle entries and from bare items alike — the runtime translation sync replays it over every stored row before merging — and the paired semantic entry says what the strip means at each door, because a notice reading "(removed)" says neither that an item’s overrides give way to the platform’s string nor that a gap falls back to the manifest's own English literal. Finally it retires object `tenancy.organizationField` (ADR-0049 enforce-or-remove). The key named the column a PLATFORM ROW is stamped from, as opposed to the column the object is WALLED by (`tenantField`); on an ordinary object those are the same column, and the entire protocol declared it exactly once — on `sys_api_key`, a better-auth-managed credential table this platform ships and no application authors. Its three readers were all platform-row writers, scope-pinned by name, so an application declaration was inert by construction while still forcing every future piece of organization logic to ask "what if somebody set this?". The divergence is NOT retired, only its authorability: it moves to `PLATFORM_STAMP_ORGANIZATION_COLUMNS` in `@objectstack/metadata-core`, keyed by object name and read by the stamp face alone, so audit stamping, the approval-row writer and the automation-run recorder keep their behaviour with no authorable input. The conversion is a lossless delete, and a lossless delete still leaves the author a judgment, which the family's D3 entry `object-tenancy-organization-field-retired` carries — an application whose tenant column genuinely is not `organization_id` declares `tenancy.tenantField`, which both walls the object and stamps its platform rows. It also retires `connector.connectionTimeoutMs` (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-22, letter A — the narrower SECOND decision the key was owed after the ruling that made its nine ledger siblings live deliberately left this one dead). Bounded, defaulted, `.describe()`d and served back by `/meta/connector`, so an author had every signal it worked — and no site ever applied it as a deadline. This retirement is NOT the zero-mention shape: five sites outside `packages/spec` read the key (the materialization fingerprint and the provider-context build in the automation service, `ctx.connectionTimeoutMs` in the `rest` and `openapi` provider factories, and the `?? 30000` fallbacks that put it back on the reported def), but every one is a pass-through whose only termini are the def `GET /connectors` echoes and the fingerprint that decides whether to re-materialize. The one mapping from authored policy onto the platform's outbound `fetch` was handed `retryConfig` and `requestTimeoutMs` only, so the key was carried and never honoured — the same parsed-unmarked-unenforced state ADR-0049 forbids, wearing a longer route. Nor was the `实现` arm available: a WHATWG `fetch` exposes one `AbortSignal` over the whole operation and never the connect phase, so bounding time-to-response with it would kill a slow-but-connected upstream the author meant to allow with a large `requestTimeoutMs`. `requestTimeoutMs` is the replacement and the bound the platform can keep. The carrier key is a retiredKey tombstone on the non-strict `ConnectorSchema` (a bare deletion would be a silent strip), registered under both def keys because `DeclarativeConnectorEntrySchema` carries it too, both carriers wrapping the same private `ConnectorBaseSchema`; the D2 conversion strips it from `connectors[]` as a pure lossless delete — it never had an effect to lose — because a stored connector row CAN carry it (the `PUT /meta/connector/:name` door persists the authored value and the stored-row rehydration seam is live for this type, both measured); and the withdrawn `ConnectorProviderContext` member, which is code and has no authored source to rewrite, leaves via the paired semantic entry instead. Finally it gives the one-filter-orthography convergence (ruled 2026-08-25: one filter spelling platform-wide, the rule array) its mechanical half at rest (ruled 2026-09-12): the D2 conversion `page-component-filter-record-to-rule-array` rewrites a record-form or single-level AST `filter` at the converged rule-array doors — `dataSource.filter`, the `object-*` / `element:number` / `element:record_picker` `filter` props and `object-grid.defaultFilters` — to the rule array wherever the mapping is lossless, and leaves a filter carrying `$and` / `$or` / `$not` (or any part with no lossless rule spelling) exactly as stored, because flattening a combinator changes which rows a page selects. It is retired from the load path, so authors are still refused at the door and taught the array; the stored-row seams and this chain replay it. It also retires the view item's `owner` and `hidden` (ADR-0049 enforce-or-remove). Both sat on the view-item identity layer, were accepted by the strict authoring door and by the wire member the `view` write door validates, and were stored verbatim — and nothing read either: both switcher read paths filter on `viewKind` + `object` and sort on `order`, so `hidden: true` hid nothing, and no per-user scope ever read `owner`, so a view marked as one user's was listed for everyone who can read the object. Per-user view scoping is a parked direction (ADR-0017, amended 2026-09-04), not a shipped mechanism. Both keys are `retiredKey()` tombstones on the SHARED shape, because that shape also feeds the `.strip()` wire member, where a bare deletion would be a silent strip. The D2 conversion `view-item-owner-hidden-removed` strips them from the view item RECORD spelling only, as a lossless delete, in both collections a record travels in — `views` (stack sources and stored rows) and the assembled-manifest `viewItems` channel (package export, environment artifacts), whose registration parse would otherwise refuse an artifact assembled before this release. It also retires a `joined` report's `chart` at both coordinates (ADR-0049 enforce-or-remove): the joined renderer draws each block as a table and returns before the one container `chart` read, and no renderer reads a block's `chart` at all, so a chart on a joined report parsed, passed the chart-bindings lint, and plotted nothing. The key leaves `JoinedReportBlockSchema`'s closed shape (its `guidance` table carries the prescription) and the joined arm of `ReportSchema`'s refinement refuses a container `chart`; `chart` stays live on every non-joined report. The D2 conversion `report-joined-chart-removed` strips both as a pure lossless delete — neither ever had an effect to lose — because a stored report row CAN carry them (the Studio report form offered a block `chart` input until this change); it is retired from the load path, so authors are refused at parse rather than rewritten. It retires the view item's `owner` / `hidden` pair on the flattened overlay door too (ADR-0049; the view item's disposition for the same key pair, followed here as triage directed): the lean personalization PUT with no `config` declared its own `owner` / `hidden`, accepted and stored them, and nothing read either. Both are `retiredKey()` tombstones on the two overlay members with the view item's own prescription texts, and the D2 conversion `view-overlay-owner-hidden-removed` strips them from the flattened spelling (no `config`, no container slot) in `views` and `viewItems`, so a stored overlay row is served without them. A row that held other view keys is then valid again and re-saves; a row that held nothing but its identity and the two keys is left identity-only, which the door refuses, so it is badged invalid, refused on a whole-row re-save and reported `failed` by `os migrate meta --stored --apply` until it is deleted or given the setting its author meant. Its D3 record is the semantic entry `view-overlay-owner-hidden-retired`. It also narrows form `layout` to `vertical` | `horizontal` on both surfaces that declared the four-arm enum — the `object-form` page component and the form view (ADR-0049 enforce-or-remove). No renderer ever gave `inline` or `grid` a behaviour of its own: every form presentation folded both to `vertical`, multi-column is `columns` (honoured under either layout), and `inline` is a toolbar / filter-row pattern rather than a record-form layout — redundant vocabulary under the maintainer's family criterion (a capability mainstream platforms have is served once, here by `columns`), retired with no alias window. Both enums refuse the two values with a per-value prescription naming `columns`; the D2 conversion `form-layout-inline-grid-to-vertical` rewrites them to `vertical` (behaviour-preserving, `columns` untouched) on `object-form` page components, on every form payload a view carries, and on the assembled-manifest `viewItems` channel. It also removes `currencyConfig.precision` (ADR-0049 enforce-or-remove): declared and validated against ISO 4217, read by no renderer or runtime — a currency amount's decimal places are its currency's ISO 4217 minor unit, derived from the currency itself. The D2 conversion `currency-config-precision-removed` strips it from every field's `currencyConfig` as a pure lossless delete, which matters most at rest: the schema used to bake `precision: 2` into parse output, so stored object rows and built artifacts carry it without anyone having written it. Retired from the load path; an authored key is refused with the prescription. It also retires the RLS policy's `tags` (ADR-0049 enforce-or-remove; graded RETIRE by the maintainer's criterion — no mainstream platform tags a row-level policy): the key promised categorization and reporting for governance and compliance, and nothing ever read it — the RLS compiler never consulted it and no preview rendered it. It is a `retiredKey()` tombstone on `RowLevelSecurityPolicySchema` (the `priority` posture one key over), and the D2 conversion `permission-rls-tags-removed` strips it from every policy in `permissions[].rowLevelSecurity` as a lossless delete, so a stored permission row that still carries it replays clean. It is retired from the load path, so authors are refused at parse rather than rewritten. Its D3 record is the semantic entry `permission-rls-tags-retired`. Finally, it removes `aria` from the action (ADR-0049 enforce-or-remove), the fourth member of the `aria` family after `dashboard.aria`, `dashboard.widgets[].aria` and the chart config's, and retired for the same measured reason: an ARIA block an author can declare and nothing lowers to the DOM. The liveness ledger had graded it `live` on an uncited "partial" note with no reader behind it; at the pinned renderer, none of the surfaces that render an action — button, icon, menu, group and bar, the row and bulk action menus, the record quick-actions toolbar — reads it. Remove rather than enforce, because every one of them already takes the accessible name from the action's required `label` (visible text, or `aria-label` on an icon-only action), and the node that places the actions carries the node-level `aria` block — a per-action block would be a second spelling of both. The D2 conversion `action-aria-removed` STRIPS the key from stack actions and object-nested actions as a pure lossless delete, retired from the load path so authors are refused at parse; its D3 record is the semantic entry `action-aria-retired`. It also retires the connector resilience family (ADR-0049 enforce-or-remove, one batch): `connector.health` — the `healthCheck` probe (eight keys) and the `circuitBreaker` (six) — `connector.status` and the connector-nested `webhooks`, sixteen authorable keys with no reader outside the spec package. No loop ever polled a connector endpoint or tripped a breaker; nothing read an authored `status` (the runtime publishes a computed `state`, and participation is `enabled`); and a webhook nested in a connector was never registered as a `webhook` item, so it was never materialized or delivered — the top-level `webhooks:` collection is the delivered one. The three carrier keys are retiredKey tombstones on `ConnectorBaseSchema`, registered under both carrier defs; `status`, defaulted `'inactive'`, joins `connectionTimeoutMs` in the retired-default residue stage, because every 17.x parse emitted it into every connector. Seven defs leave whole — `ConnectorHealth`, `HealthCheckConfig`, `CircuitBreakerConfig`, `ConnectorStatus`, `WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm` — and the D2 conversion `connector-resilience-keys-removed` strips the three keys from `connectors[]` and stored rows as a pure lossless delete (the nested webhooks are stripped, never moved: moving them would start deliveries that never happened). It ABSORBS the breaker half of the duration rename above: `health.circuitBreaker.monitoringWindow` → `monitoringWindowMs` is no longer converted, because the whole block it lived in is now removed. Finally it makes edge-branched `decision` nodes EXCLUSIVE (maintainer ruling 2026-09-23, 「跟主流对齐」): the first conditioned out-edge that holds, in declaration order, is the branch, and taking every true branch is the declared `mode: 'inclusive'`. The D2 conversion `flow-decision-mode-inclusive-explicit` writes that key onto every decision with two or more conditioned out-edges and no `conditions` list, so a flow written while every true branch ran keeps its behaviour; it is a default flip, so it is retired from the load path AND refused by the flow rehydration seam and the artifact-ingestion door, and replays only here — the paired semantic entry carries the judgment the diff then asks for. BREAKING for flows stored in `sys_metadata`, by maintainer ruling: such a decision with no `mode` takes the first-match meaning on upgrade and nothing rewrites it; `os migrate meta --stored` lists each one for review, and `mode: 'inclusive'` is the one-line fix where a node meant every branch. It also retires the list view's own `tabs` (ADR-0049 enforce-or-remove). The key parsed and was stored at every list-view door and drew nothing: a list view's own `tabs` has no reader, the one component that would draw it has no production mount, and the tab strip above an object's records is the saved-view switcher, which renders one tab per `listViews` entry and reads no `tabs` key (`userFilters.tabs`, a different key of the same element type, is read and rendered, and stays). The key is a `retiredKey()` tombstone on the list-view shape (its prescription says how to move each tab to a named `listViews` entry); `ViewTabSchema` itself stays, because the page-only `userFilters.tabs` preset bar reuses it and renders. The D2 conversion `view-list-tabs-removed` strips the key from every list payload in `stack.views[]` as a lossless delete, and is retired from the load path, so authors are refused at parse rather than rewritten. It retires the inner `name` on cube members — `measures..name` and `dimensions..name` (ADR-0049 enforce-or-remove) — by the mainstream criterion: Cube.dev and LookML key a member by its declared name, with no second inner name that can disagree. Both member bags are records, and every consumer already resolved a member by its record KEY, publishing and querying it as `.`; the REQUIRED inner copy was read by nothing, and one that disagreed with its key was silently ignored. The keys are retiredKey tombstones on `MetricSchema` and `DimensionSchema`, and because the key was required, every stored or built cube carries it: the D2 conversion `cube-member-inner-name-removed` strips it from every member of every cube, retired from the load path, and its notice prints a disagreeing value beside the key that stays. Its D3 record is the semantic entry `cube-member-inner-name-retired`, which asks the author of a disagreeing name which spelling they meant. It also retires the connector `triggers` array (ADR-0049 enforce-or-remove; ADR-0041 keeps connector-event triggers in its third tier, as their own trigger package): the `ConnectorTrigger` shape — `key`, `label`, `description`, `type` (`polling` / `webhook`) and `intervalSeconds` — was read by nothing. The automation engine registered a connector's actions only, its trigger registry holds FLOW trigger kinds that no connector trigger ever entered, no polling loop read an interval and no receiver was driven by a `webhook` trigger, so a declared trigger never started a flow. `triggers` is a retiredKey tombstone on `ConnectorBaseSchema`, registered under both carrier defs; the provider-bound refusal of the key, whose reason (the provider derives triggers) was untrue, is gone with it, since the tombstone refuses every value on every carrier. `ConnectorTrigger` leaves whole, and the D2 conversion `connector-triggers-removed` strips the array from `connectors[]` and stored rows as a pure lossless delete — never turning a trigger into a flow, which is the author's decision (an `api` flow for an external event, a `schedule` flow for a scheduled pull, each calling the connector's action). It ABSORBS the trigger half of the connector duration rename (its breaker half went with `health` above), so `connector-health-and-trigger-durations-unit-in-key`, with neither half left, is no longer in this step. It also retires a cube's `refreshKey` whole — the refresh cadence `every` and the data-change probe `sql` (ADR-0049 enforce-or-remove). Nothing read either key, and no analytics result is cached, so a declared cadence refreshed nothing and every query was computed when it was asked, as it still is. The key is a retiredKey tombstone on `CubeSchema`, and the D2 conversion `cube-refresh-key-removed` strips the whole block from every cube as a pure lossless delete, retired from the load path. Its D3 record is the semantic entry `cube-refresh-key-retired`. A refresh cadence is declared again when a result cache exists. It also narrows the `time` stored form to the zone-less wall clock the record validator already enforces (ADR-0053 D-C1), so a field default or an action param default or value with a `Z` or a UTC offset is refused when it is authored or submitted rather than on every insert that falls back to it. The D2 conversion `time-default-utc-suffix-dropped` drops a `Z` or a zero offset, which names the same wall clock, and leaves a non-zero offset as stored for its author to rewrite; its D3 record is the semantic entry `time-default-zone-refused`. It also retires the page header's `breadcrumb` switch (ADR-0049 enforce-or-remove): no renderer ever drew a trail for it — objectui drew an empty slot that nothing filled — and the navigation trail is drawn once, by the app shell's header. The key is a retiredKey tombstone on `PageHeaderProps`, beside the `icon` that row lost at 17, and the D2 conversion `page-header-breadcrumb-removed` strips it from every `page:header`, `true` and `false` alike, retired from the load path. Its D3 record is the semantic entry `page-header-breadcrumb-retired`. The `nav:breadcrumb` component type is not part of it: the Studio page palette still offers it. It also retires connector-attached sync from the connector (ADR-0049, the ENFORCE route by ruling): `connector.syncConfig` — `strategy`, `direction`, `realtimeSync`, `timestampField`, `conflictResolution`, `batchSize`, `deleteMode`, `filters` — and `connector.fieldMappings` — `source`, `target`, `defaultValue`, `dataType`, `required`, `syncMode` — fourteen keys no engine ever executed, whose `latest_wins` and `soft_delete` defaults read as configured policy and did nothing. The capability is mainstream, so the definition moves rather than lapses: every mainstream platform binds a sync to its TARGET, so a `mapping` gains `connectorSource`, the `rest` / `openapi` connector it pulls from, the read action and an optional timestamp `watermark`, and a `job` sets the cadence (no schedule key returns to the connector). That binding is declared in this step and executed in a later one. Both connector keys are retiredKey tombstones on `ConnectorBaseSchema`, registered under both carrier defs; `DataSyncConfig`, `SyncStrategy`, `ConnectorConflictResolution` and `ConnectorFieldMapping` leave whole; and the D2 conversion `connector-sync-keys-removed` strips both keys from `connectors[]` and stored rows as a pure lossless delete — never writing a `mapping`, which would start writes that never happened. It also narrows an analytics cube member's `sql` — `measures..sql` and `dimensions..sql` — to a column reference: a field of the cube's object, a relationship path ending in one, or `'*'` (maintainer ruling D, ADR-0021 "zero raw SQL / zero raw expressions" carried from the dataset layer to the cube members it compiles to; ADR-0049 enforce-or-remove). A SQL expression there names no single field, so no platform check could judge which fields it reads, and the two analytics strategies never agreed on it: the raw-SQL path ran it verbatim, the ObjectQL path refused it. It is now refused at parse with a prescription naming the ADR-0021 dataset form — a measure with its own structured `filter` for a conditional count or sum, and `derived: { op, of: [...] }` over named measures for a ratio, sum, difference or product. No D2 conversion: an expression has no mechanical rewrite into a dataset, so the semantic entry `cube-member-sql-expression-retired` carries the move, including the scale change a ratio makes (a `derived` ratio is a 0–1 fraction). It also closes the form view's inline grid columns: `subforms[].columns`, on `view.form` and on `formViews` entries, was `z.array(z.any())` while a relationship field's `inlineColumns` was already the strict `InlineGridColumnSchema`, so a mis-keyed column published clean and drew a blank grid column, and `scale` on a currency column, which the other carrier refuses under the maintainer's rulings of 2026-09-23 (option B) and 2026-09-24 (option 乙), published green. The carrier now references that schema, so both carriers are judged by it, with its own prescriptions. The D2 conversion `form-view-subform-columns-canonicalized` respells a `{ field }` column as `{ name }`, the respelling `field-column-lists-canonicalized` makes on `inlineColumns`: it rewrites stored rows and assembled artifacts and lists the edit under `os migrate meta`, and it is retired from the load path, so an author writing `field` meets the refusal. A view saved with a failing column is refused with the column schema's prescription, and a stored row carrying one is diagnosed at rehydration; neither is stripped, because which column an unknown key or a mixed `field`/`name` entry meant is the author's call, and a conversion that dropped the key would accept at load what the parse now refuses. Its D3 record is the semantic entry `form-view-subform-columns-closed`. On both carriers, the reach of `inline-grid-column-currency-scale-refused` extends to a column that declares no `type`: such a column takes its type from the child field, which the column schema cannot see, when the console hydrates it, so `defineStack`'s cross-reference check re-parses a column whose `name` is a `currency` field of the child object as the type it renders as, and the refusal of its `scale` is the column schema's own. Reach: the child object must be declared in the same stack; a column naming no field of it, or a subform whose child object comes from another package, is not judged there. It has no D2 conversion, for the declared-type entry's reason: deleting the key is the migration, and a conversion that dropped it would accept it at load, the grace window ruling B refused. Its D3 record is the semantic entry `inline-grid-column-identity-only-currency-scale-refused`. It also gives the executor target of an action one spelling on the page blocks that run one. `ActionSchema` has always refused `endpoint` with the rename to `target`, while the `action:button` and `action:icon` component rows declared `endpoint` as a key of their own, and the console's `api` handler reads `target` only — so an `api` button authored with `endpoint` was accepted by the props gate and called nothing. The rows now refuse it with the same rename, read from the one alias table both share. The D2 conversion `action-block-endpoint-to-target` renames the key on an `api` action, where the rename is lossless, retired from the load path so authors are refused at the door while stored rows and `os migrate meta` replay it; an `endpoint` on a block with no `actionType` or another one is left as stored and reported as a TODO. Its D3 record is the semantic entry `action-block-endpoint-spelling-retired`. Finally, it retires the form field's `publicPicker` block (ADR-0087 D2, immediate — the maintainer's ruling E, which reverses the earlier ruling that had declared it): an anonymous public form no longer offers record search. The block opted a lookup, `master_detail` or `user` field on a public form into a picker served by an unauthenticated route; that route is deleted, and the public-form resolve route now leaves those three field types off the anonymous rendering unconditionally. The schema refuses the key with the prescription; the mechanical conversion `form-field-public-picker-removed` strips it from old sources and stored rows (lossless in effect — its only reader was the deleted route), and the semantic entry asks the author how a visitor should now choose: a `select` field with static `options`, or a form behind sign-in. It also closes the third carrier of the inline grid column: an `object-master-detail-form` page block's `details` was `z.array(z.unknown())`, so a key its renderer does not read and `scale` on a currency column, which the other two carriers refuse under the maintainer's rulings of 2026-09-23 (option B) and 2026-09-24 (option 乙), went through `objectstack validate` green. Each detail entry is now a strict shape of the twelve keys the renderer reads, and its `columns` references `InlineGridColumnSchema`. Page-component `properties` is read by the component-props gate, which reports a failing entry or column as an advisory finding, and is not parsed on the metadata save or load path, so a stored page still saves and loads and no conversion is registered; the authored census found nothing to respell. `defineStack`'s identity-only check reaches the block wherever a page carries it, with the reach `inline-grid-column-identity-only-currency-scale-refused` records for the other two carriers. Its D3 record is the semantic entry `ui-object-master-detail-form-details-closed`. It closes the fourth carrier the same way: `record:line_items` had no `ComponentPropsMap` row — it was the one entry on the string-arm registration ledger — so the component-props gate skipped its props, and the showcase project page's five `field`-keyed columns published green over a grid of empty cells. The row declares the fifteen keys the renderer reads, requires `relationshipField` and at least one column, and its `columns` references `InlineGridColumnSchema`; the showcase columns are respelled `name` in the same change. The panel draws its columns as authored, with no hydration from the child object's field, so `defineStack`'s identity-only check does not reach it. Its D3 record is the semantic entry `ui-record-line-items-props-closed`. It also holds an ADR-0021 dataset's `field` — `dimensions[].field` and `measures[].field` — to the accept set the cube members it compiles to already hold, from one shared declaration: a field of the dataset's object, a relationship path ending in one, and on a measure also `'*'` (ADR-0021 "zero raw SQL / zero raw expressions"; ADR-0049 enforce-or-remove). The slot was a bare string that parsed any expression, while the analytics dataset door already refused one on every query, so an expression could be saved and never answered. It is now refused at parse with a prescription naming the ADR-0021 form — a measure with its own structured `filter`, or `derived: { op, of: [...] }` over named measures — and so are an empty string (a count omits `field` instead) and `'*'` on a dimension, which names no axis. The one lossless repair is D2: `dataset-count-measure-empty-field-removed` drops a `count` measure's empty `field`, which still counts rows. An expression has no mechanical rewrite into a column, so the semantic entry `dataset-member-field-expression-refused` carries the rest. It also closes the export options of an `object-grid` page block. `exportOptions` was `z.unknown()`, so a bare format array — the list view's legacy spelling, which the list view lifts to `{ formats }` — was accepted on the grid, whose renderer reads `exportOptions.formats` and lifts nothing: the export menu offered its csv/json default and the author's list was dropped. The row now takes the list view's five-member export options object by identity, not the list view's union, and refuses a bare array with the object form named, a format outside the enum and an undeclared key. Page-component `properties` is read by the component-props gate, which reports these as advisory findings, and is not parsed on the metadata save or load path, so a stored page still saves and loads and no conversion is registered: the bare array never worked here, and lifting it would change the menu a deployed grid shows. The authored census found nothing to respell. Its D3 record is the semantic entry `ui-object-grid-export-options-closed`. It also makes an agent's structured output JSON-only (ADR-0049 enforce-or-remove). The cloud AI runtime, which executes agents, enforces `structuredOutput` on every final answer and refused four of its members before an agent's first turn: the `regex`, `grammar` and `xml` formats — no key ever carried a pattern or grammar to check against, and an answer is checked only as JSON — and the `coerce_types` step, for which no coercion engine exists. All four are refused at parse with a prescription, and the D2 conversion `agent-structured-output-refused-members-removed` deletes a block whose `format` was retired, deletes a retired `fallbackFormat` and drops `coerce_types` from the pipeline, retired from the load path. It also retires the metric sub-caption at both ends (maintainer ruling 2026-10-01, which reverses the 2026-08-06 ruling that gave it a translation key of its own; ADR-0049). The widget translation key `dashboards..widgets..subCaption` overlaid a widget's `options.description`, a key the dashboard schema never declared and no authored widget wrote, so the overlay in `translateDashboard` was its only writer. The overlay is removed, `subCaption` is a `retiredKey()` tombstone on the widget translation node, and its former `subtitle` alias now carries the retirement instead of a rename onto a key that accepts nothing. A widget keeps one authored description, `widget.description`, which renders as the card-header subtitle and is translated by the widget's `description` key. The D2 conversion `translation-widget-sub-caption-removed` strips the key from bundle entries and stored translation items as a lossless delete of what is served, retired from the load path so authors are refused at parse; its D3 record is the semantic entry `translation-widget-sub-caption-retired`. It also makes an agent's memory contract state exactly what the runtime honours (ADR-0049 enforce-or-remove). The cloud AI runtime, which executes agents, recalls the newest `maxEntries` long-term notes before the first round, writes one every `reflectionInterval` delivered interactions, and keeps them in its own database store; before an agent's first turn it refused the `vector` store (the old default) and `redis`, an enabled `longTerm` missing either number, and a `reflectionInterval` without one. So `longTerm.store` is retired as a whole key — the memory store is platform infrastructure, not agent metadata — and the D2 conversion `agent-memory-long-term-store-removed` deletes it, losslessly, retired from the load path; and with long-term memory enabled both numbers are required at authoring, with no default declared, so an upgrading author chooses them. It also retires an agent's conversation state machine, `agent.lifecycle` (ADR-0049 enforce-or-remove). It was parsed and never read: no runtime moved an agent through a declared state or refused an undeclared transition, and enforcing it would have meant a statechart interpreter beside Flow, the two-engine shape ADR-0020 rejected. What it reached for is served elsewhere — a conversation phase is a skill selected by its `triggerConditions`, a multi-step process is a Flow, a record's status transitions are the `state_machine` validation rule — so authoring refuses the key with that prescription, and the D2 conversion `agent-lifecycle-removed` deletes it, losslessly, retired from the load path. The XState `StateMachineSchema` family, kept by ADR-0020 only for this door, left the package with it. It also retires a cube measure's custom-SQL-expression types — `number`, `string` and `boolean` from `AggregationMetricType`, so from `measures..type` (ADR-0049 enforce-or-remove). They marked a measure whose `sql` was the whole computation, and with that `sql` now a column reference they had nothing left to compute: the raw-SQL path returned the column unaggregated and the ObjectQL path refused the measure. Each is refused at parse with a prescription naming the six aggregates. No D2 conversion: the column alone does not say which aggregate the author meant, so the semantic entry `cube-metric-expression-types-retired` carries the choice, and a stored cube that still carries one is refused rather than rewritten. It also retires `object-grid`'s `resizableColumns` (ADR-0049 enforce-or-remove; objectui's ruling that `resizable` is canonical, under the startup rule of immediate retirement): the legacy second spelling of `resizable`, read only as `schema.resizable ?? schema.resizableColumns` (measured at the `.objectui-sha` pin `89cad75d55`, `plugin-grid/src/ObjectGrid.tsx:5361`). One switch, two spellings, and zero writers in either repository, so there is no window. A retiredKey tombstone on `ObjectGridPropsSchema` with one D2 conversion that follows the renderer's precedence: the value moves to `resizable` when that is absent, and strips as a lossless delete when it is present (it was never read then). Its D3 record is the semantic entry `object-grid-resizable-columns-retired`. It also types seven members of an `object-grid` page block: `rowHeight`, `rowColor`, `navigation`, `conditionalFormatting`, `bulkActionDefs`, `aggregations` and `operations` were `z.unknown()` (an array of it for `bulkActionDefs`), although the grid reads each with one shape, so `rowHeight: 42` passed every door and rendered as `compact`. The five a list view also declares take the list view's own schemas by reference; `aggregations` takes the measured `[{ field, type }]` with the query AST's aggregation functions, and `operations` the four booleans a grid read point names (`create`, `update`, `delete`, `export`), refusing `read` and `import`, which nothing reads. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-grid-row-members-typed`. It also types `navigation` on the `object-map`, `object-gantt` and `object-tree` page blocks (the first stage of the `ComponentPropsMap` `z.unknown()` close-out): each renderer hands it to the shared navigation hook, which reads `navigation.mode` and falls back to `page`, so `navigation: 42` and a bare mode string passed every door and opened the record page. The three rows now take the list view's `NavigationConfigSchema` by reference, the carrier the grid, kanban, calendar and timeline blocks already take. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-map-gantt-tree-navigation-typed`. It also retires the `ai:chat_window` page element (ADR-0049 enforce-or-remove), the `user:profile` shape one namespace over: no renderer for it ever shipped, and none is wanted — the console leaves it unregistered on purpose, because the floating chat overlay it mounts on every page is the supported AI chat entry point — so a page that placed one validated clean and drew "Unknown component type", and its four props configured nothing. The name leaves `PageComponentType` and is refused by name at the node, its `ComponentPropsMap` row stays as a whole-bag refusal carrying the same prescription, and the props def `AIChatWindowProps` is unpublished. No conversion is registered: the only edit is deleting the node, a layout decision that is the author's. Its D3 record is the semantic entry `ui-ai-chat-window-retired`; `ai:suggestion` is unchanged. It also narrows page `requires` to the kinds whose source is compiled at save (ADR-0080 §5; maintainer ruling 2026-10-03, letter A): the plugin-namespace list is derived from an html page's source when the page is saved, while on `react`, `full` and `slotted` pages nothing derived it, the Studio page editor dropped it on every save, and a load-time warning was its one reader. `PageSchema` now accepts the key only when `kind` is `html` or its deprecated alias `jsx`, and refuses it at `requires` on every other kind, a page that omits `kind` included, naming the key, the page's kind and the compiled kinds. The key stays live on html pages, so there is no tombstone. The D2 conversion `page-requires-non-compiled-kind-removed` deletes the key from those pages, retired from the load path, so stored rows and artifacts replay clean while authored sources are refused until edited; the delete is lossless. Its D3 record is the semantic entry `page-requires-non-compiled-kind-refused`. It also types eight list members of the `object-grid`, `object-kanban` and `object-calendar` page blocks (the second stage of the `ComponentPropsMap` `z.unknown()` close-out): the grid's `fields`, `selection`, `selectable`, `rowActions`, `bulkActions` and `batchActions`, the kanban's `columns` and the calendar's `calendar` were `z.unknown()` (an array of it for the lists), although each renderer reads them with one shape, so a `{ name }` entry in `bulkActions` passed every door and was skipped. The members a list view declares take the list view's own by reference (`batchActions`, the spelling the grid reads first, takes `bulkActions`'s); the grid's `fields` and `selectable` and the kanban lane take the measured shape. The grid's `columns` stays open: its group headers draw an authored column's `options`, which the list view's column entry does not declare. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-grid-kanban-calendar-list-members-typed`. It also refuses, at parse, a hook whose `body` targets a table of stored metadata, `sys_metadata` or `sys_metadata_history` (maintainer ruling 2026-10-03, letter A: an app-authored body may not touch those tables, whose only writer for a body is the metadata protocol). The runtime already refused such a hook where a body becomes a handler, so it never ran, while the metadata save door answered 200 for it. `HookSchema` now refuses the same set at `object`, or at the list member, with the runtime's prescription to change metadata through the metadata API, judged by the one predicate the runtime uses: a hook with a `body` in any form whose target names either table. A code `handler` and the wildcard `'*'` stay outside it, as they are at registration. No key is removed, so there is no tombstone, and no D2 conversion exists: a refused hook carries no intent a rewrite could keep. Its D3 record is the semantic entry `hook-body-stored-metadata-target-refused`. It also types four members of the `object-form` page block (the third stage of the `ComponentPropsMap` `z.unknown()` close-out): `contentLayout`, `submitBehavior`, `navigateOnSuccess` and `mobile` were `z.unknown()`, although the form reads each with one shape, so a `submitBehavior` `kind` the form does not know passed every door and fell through to the thank-you panel. `submitBehavior` takes the form view's own block by reference; the other three take the measured shape. The form's `fields` and `sections` and the master-detail form's two stay open — the form draws a `{ name }` field entry and an inline runtime field inside a section, which the typed shapes would refuse — and `customFields` stays open until the spec declares the runtime form field its entries are. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-members-typed`. It also completes the `element:text` `variant` convergence (the second release of the ruled two-release split): the enum is the nine values `ui:text` publishes — `h1`-`h6`, `body`, `caption`, `overline` — and the pre-convergence spellings `heading` and `subheading`, which every release since the nine were added still accepted, are refused by name with a prescription naming the level to write. The D2 conversion `element-text-variant-heading-levels` rewrites `heading` to `h2` and `subheading` to `h3` on every `element:text` page component — the heading element each one always rendered, so the outline is unchanged and the heading takes that level's style. The `body` default for an absent `variant` is unchanged. It also types two members of the `object-metric` page block (the fourth stage of the `ComponentPropsMap` `z.unknown()` close-out): `aggregate` and `trend` were `z.unknown()`, although the tile reads each with one shape, so `aggregate: 'count'` and a trend with no `value` passed every door, and the tile asked the server for a measure it does not have, or painted a lone `%`. `aggregate` takes the query AST's aggregation functions and the chart aggregate's `groupBy` union by reference, with `groupBy` optional because a metric is one number; `trend` takes the badge's measured shape. `drillDown` and `compareTo` stay open: each by-reference candidate declares a key the tile never reads (the chart drill-down's `filter`, the dashboard comparison's `dimension`), and the chart drill-down refuses the `report` the tile draws, so each waits on a ruling. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-aggregate-trend-typed`. It also retires an `object-master-detail-form` detail entry's `sortField` (ADR-0049 enforce-or-remove; the spec half of objectui's own retirement of the override). The console stopped reading the authored override: the field its line grid stamps with each line's position on drag-reorder is derived from the child object — its first field named `position`, `sort_order`, `sequence`, `line_no`, `line_number` or `sort` — and the pinned console had crossed that change while the spec still declared the key, so an authored value published green and was dropped. A retiredKey tombstone on the strict detail entry with one D2 conversion that is a pure lossless DELETE scoped by component `type` and by position (`properties.details[]`); its D3 entry `object-master-detail-form-detail-sort-field-retired` carries the one judgment left, whether the child object declares the field the line order is kept in. It also types the `object-metric` page block's `compareTo` (the fifth stage of the `ComponentPropsMap` `z.unknown()` close-out) to the tile's read, per the ruling between the reference and the read: `{ kind }`, with `kind` the dashboard widget comparison's own vocabulary by reference, and `dimension` refused by name, because this inline tile shifts the date macros in its own `filter` and never reads a dataset time dimension. A bare kind string, a kind outside the two and a `dimension` passed every door and compared the wrong window. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-compare-to-typed`. It also types the `object-metric` page block's `drillDown` to the tile's read (the same stage and ruling): its five list members — `enabled`, `title`, `target`, `columns`, `maxRows` — are the chart drill-down's own by reference, and `filter` and `mode` are refused by name, because a metric tile has no click event for a drill filter to resolve against and no row for `mode` to open; both passed every door and were ignored. The drill `report` stays open: the tile draws a dataset-bound report, but the spec declares no drill report yet, and declares that contract first. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-drill-down-typed`. It also types the `object-grid` page block's `columns` (the fifth stage of the `ComponentPropsMap` `z.unknown()` close-out), the list member the second stage held: the grid's group headers drew a column's `options`, which the list view's column entry does not declare, and objectui has since retired that read and takes the labels from the object field only. So the member takes the list view's own `columns` by reference — all field names or all column entries — and a column keyed `accessorKey` / `header` / `name`, a mixed list or an undeclared column key (`editable`, `options`, `reference`), which passed every door and drew no column or was ignored, is refused. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-grid-columns-typed`. It also refuses, at parse, a flow `create_record`, `update_record` or `delete_record` node whose `objectName` is the string `sys_metadata` or `sys_metadata_history` (the maintainer ruling of 2026-10-03, letter A, applied to flows: app-authored work may not write those tables, whose only writer is the metadata protocol). The runtime already refused such a node before any write, at its first run, while every authoring door accepted the flow. `FlowSchema` now refuses the same set at `nodes.N.config.objectName`, through the one judge `registerFlow` and `objectstack validate` share, with the runtime's prescription to change metadata through the metadata API: one of those three write nodes whose `objectName` names either table by exact name. A `get_record` node and a dynamic target stay outside it: the run judges the name it hands the data engine. No key is removed, so there is no tombstone, and no D2 conversion exists: a refused node carries no intent a rewrite could keep. Its D3 record is the semantic entry `flow-write-node-stored-metadata-target-refused`. It also types the top-level `fields` of the `object-form` and `object-master-detail-form` page blocks (the last stage of the `ComponentPropsMap` `z.unknown()` close-out), the two members the third stage held: the form drew a `{ name }` field entry its own page-builder guide taught, with a `label`, `type` and `required` it silently dropped, and objectui has since retired that entry from every authoring face, drawing only a stored one by its name. So both rows take field names, objectui's own declaration of the member, and refuse an object entry with what to write instead — a `{ name }` entry is its bare name, and a `{ field }` entry belongs in a section. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-fields-names-typed`. It also types the `object-gantt` page block's `markers` (the same stage): its entries were `z.unknown()` because the marker contract lived only in objectui, so a marker with no `date`, a numeric `date` or a misspelled member passed every door and the chart drew no line, or drew it unlabelled. The spec now declares objectui's own authoring declaration of a marker, `{ date, label?, color? }` with `date` a string, and the row takes it. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-gantt-markers-typed`. It also types the `object-timeline` page block's `mapping` (the same stage): the binding record — four optional field names for an entry's title, date, description and marker colour — was `z.unknown()` because its contract lived only in objectui, so a bare field name or a misspelled member passed every door and the rail drew the default field. The spec now declares objectui's own declaration of it, and the row takes it. The stage's other members — the metric drill-down's `report`, the form's `customFields` and both forms' `sections`, the timeline's `items` and the action containers' members — stay open: each contract has more than one viable shape that no ruling decides yet. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-timeline-mapping-typed`. It also types the `object-kanban` page block's `conditionalFormatting`, the one member the `ComponentPropsMap` `z.unknown()` close-out held for a ruling: it was `z.unknown()` while objectui's kanban also authored a native rule dialect the list view refuses, so `42` or a rule with no `style` passed every door and the board painted no card for it. objectui has since made the list view's `{ condition, style }` rule the member's only authoring dialect, and the board evaluates it with the grid's evaluator, so the row takes the list view's own member by reference, as `object-grid` does. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-kanban-conditional-formatting-typed`. It also requires every block of a `joined` report to bind a `dataset` (ADR-0021 single-form, enforced under ADR-0049 enforce-or-remove): the schema comment and the reports guide both said each block is dataset-bound, but the joined arm of `ReportSchema`'s refinement required only a non-empty `blocks`, so a block with no `dataset` parsed, passed `objectstack validate` and every save door, and drew nothing: the joined renderer issues no query for it, and a report whose blocks all lack one falls through to the pre-9.0 presentation bridge, which issues none either. The arm now refuses each such block at `blocks[i].dataset`, naming the block, with the prescription to bind it to a dataset; `dataset` stays optional on the block shape, which is read only on a `joined` report. No key is removed, so there is no tombstone, and no D2 conversion exists: only the author knows which dataset a block was meant to show. Its D3 record is the semantic entry `ui-report-joined-block-dataset-required`. It also types the `object-form` page block's `customFields`, one of the two contracts the `ComponentPropsMap` `z.unknown()` close-out held as forks and the maintainer has since ruled: each member is the runtime form field the form draws, which the spec did not declare, so a member with no `name` or a misspelled member passed every door and the form drew the field without it. The spec now declares a closed runtime form field of the members the form draws, in camelCase, keyed by `name` — the `grid` widget's snake_case keys stay out until the widget reads a camelCase spelling — and the row takes a list of it. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-custom-fields-typed`. It also types the `sections` of the `object-form` and `object-master-detail-form` page blocks, the other ruled fork: a section's `fields` draws an inline runtime form field beside a name and the form view's `{ field }` entry, which the stored form view's section refuses, so the sections stayed `z.unknown()` and a misspelled key passed every door. Both rows now take one page-block section shape of their own — the form view's section keys plus those three entry arms, the inline arm the runtime form field — in canonical spellings only: a page block's `properties` is never parsed on the way to the form, so a deprecated section `visibleOn` or a string `columns`, which a form view folds at parse, was dropped, and is refused with the canonical spelling. The stored form view is unchanged. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-sections-typed`. It then types the three members the stages above held open, as the maintainer ruled them on the decision card for those forks. The `object-metric` drill-down's `report` is `ReportSchema`, by reference (fork 1, letter B): it waited until a joined report refused a block that binds no dataset, and since then every report the member admits is one the drill drawer draws — a report with no `dataset`, a bare report name or a `{ name }` reference, which the drawer answered by listing the records, is refused. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-drill-down-report-typed`. It types the `object-timeline` page block's `items` (fork 4, letter B): each entry is one of objectui's two ruled kinds, closed — a feed entry `{ time, title, description, variant, icon, content, className }` or a gantt row `{ label, items }` of bars `{ title, startDate, endDate, variant }`, each date a string or epoch milliseconds — and a row refinement pairs each entry with the kind the block's `variant` selects, so a feed entry with no `title`, or a gantt row on a feed timeline, is refused instead of drawn empty. A feed entry's `content` (child components) is held unjudged until a writer appears. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-timeline-items-typed`. And it types the members of the `action:group` and `action:menu` page blocks, the last of those forks (the same card, fork 5, letter A): each member was an open record the container draws and runs itself, so a misspelled key, a node-style `actionType` or an `endpoint` no `api` handler reads passed every door. A member now takes `action:button`'s keys with its executor spelled `type`, measured from the containers' reads — an `action:menu` item reads no `size` and declares none — with the rows' prescriptions; `outcomeMessages`, a member `className` and a member `properties.params` are refused, and `outcomeMessages` stays undeclared on all four action blocks as one decision. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-action-group-menu-members-typed`. It then closes the one static-values spelling those members still accepted and the containers drop: an `action:group` or `action:menu` member's `params` takes the input list, an `ActionParam[]` array, only, unless the member's `type` is `api`, whose object `params` keeps its request-payload window. `params` carries one shape and no second value-bag key is declared, so an object `params` on any other member, which parsed and then reached no action, is refused at `actions.N.params` with the prescription to author an action with static parameter values as its own `action:button` node. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-action-group-menu-member-params-array-only`. It also judges an `approval` flow node's `config` at parse against the contract the spec declares for it, `ApprovalNodeConfigSchema`, WHOLE. The approval executor fails the node on any issue of that contract, while `objectstack validate` and `objectstack compile` exited 0 on an undeclared `escalation.bogusKey` or a `timeoutHours: 0.5` and compile copied it into the artifact. The approval node now joins a declared contract map beside the builtin executor contracts, read by the one judge `registerFlow` and `objectstack validate` share, with no plugin loaded: an undeclared key or a refused value is refused at `nodes.N.config.` in the contract's own words, its did-you-mean included, and a key left out as before. The builtin arm stays presence-only. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know what the author meant. Its D3 record is the semantic entry `flow-approval-node-config-contract-refused`. Then the builtin arm stops being presence-only: a present value a builtin node's executor contract refuses is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code. Every builtin executor parses its config against that contract before it acts, so a `create_record` `outputVariable: 42` or a screen field `min: '1'` used to pass `objectstack validate` and `objectstack compile`, register, and fail every run that reached the node. The arm judges only what the build can know the run will parse: never a value carrying a `{token}`, whatever its slot's type (held back by ruling, not admitted: outside `http` such a token in a number or boolean slot still fails at its first run, so those slots take a literal); on `http`, which parses after interpolating, only token-free values and never the credential-held `signingSecret`; on a `loop`, only one with a `body`; on the region containers, never the region slots. Key membership is untouched. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know the value the author meant. Its D3 record is the semantic entry `flow-builtin-node-config-values-refused`. It also retires the flat-list form of a package manifest's `permissions` (ADR-0049 enforce-or-remove): `ManifestPermissionsSchema` was a union of a list of permission strings and the structured ADR-0025 block `{ services, hooks, network, fs }`, and nothing ever acted on the list — the loader registers the consented grant set, never the manifest's request — so the block is now the only form. A list is refused at parse with its prescription, and the D2 conversion `manifest-permissions-string-list-removed` strips it from the stack's manifest and every `packages[].manifest` as a lossless delete, retired from the load path; translating what each dropped string meant into the four lists is the author's judgement, not a rewrite. It also makes a declared index state its uniqueness scope (ADR-0120 D1, staged to this protocol by D7). On `indexes[].unique`, bare `true` was the one spelling whose scope was positional: it built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. The parse now refuses it with a prescription naming both words — `'global'` (installation-wide, the index bare `true` built) and `'organization'` (one holder per organization). Field-level `unique: true` is untouched. The D2 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'`, which is lossless and drift-free by construction, retired from the load path so authors are refused at the door while stored rows, built artifacts and `os migrate meta` replay it. Its D3 record is the semantic entry `declared-index-bare-unique-true-retired`: whether each respelled index was really meant installation-wide is the author's call. It also takes the injected organization column off seven deployment-level platform tables — `sys_job`, `sys_job_run`, `sys_job_queue`, `sys_flow_dispatch`, `sys_migration`, `sys_migration_journal` and `sys_presence` (ADR-0131 D7). A writer census found no writer that attributes a row of any of them to an organization, so the column only ever held NULL, and under a walled posture the tenant wall hid every row from every reader. Each now declares `systemFields: { tenant: false }` and the object-level capability gate `requiredPermissions: ['manage_platform_settings']`: with no column there is no wall, so reads are governed by object permission, and the gate keeps one organization's administrator off another organization's rows. Nothing in stack metadata is rewritten; an existing database keeps the column as an orphan the boot drift report names, and `os migrate apply --allow-destructive` drops it. The D3 records are the seven `sys-*-organization-column-retired` semantic entries. It also refuses, at parse, a flow edge that does not resolve in its own graph or that repeats an earlier one. An edge's `source` and `target` must name nodes of the graph that declares it — the flow's own nodes, or the region body's for an edge inside a region — because the engine resolves them there alone, and a dangling edge carried the run nowhere, silently; and an edge with the same `source`, `target`, `type`, `condition` and branch `label` as an earlier edge of that graph is refused, because the engine runs a target once per out-edge it selects and a copy ran it again. Both are judged in the region walk the node-id rule uses, so `objectstack validate`, `registerFlow` and the metadata save door agree. No key is removed, so there is no tombstone, and no D2 conversion exists: a dangling endpoint carries no intent a rewrite could recover, and dropping a copy changes how often its target runs. Its D3 record is the semantic entry `flow-edge-unresolved-or-repeated-refused`. And the builtin arm judges key membership where no other door does: a key a `script` or `subflow` node's executor contract does not declare is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code. Those two descriptors publish no `configSchema`, so `registerFlow`'s undeclared-key check skipped them, while their executors parse the strict contract and refuse the node on an undeclared key: a `script` `bogusKey` used to pass `objectstack validate`, `objectstack compile` and registration and fail every run that reached the node. Every other builtin keeps its undeclared keys at registration, against its descriptor; a retired `script` key keeps its tombstone. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know what an undeclared key was meant to be. Its D3 record is the semantic entry `flow-script-subflow-config-undeclared-keys-refused`. It also moves the settings cascade's global rung out of the tenant-scoped `sys_setting` into the new tenant-less `sys_platform_setting` (ADR-0131 D7): one row per namespace and key for the deployment, no organization column, reads governed by the `manage_platform_settings` capability. The settings service writes a global-scope key there and reads the rung from there alone, and the `global` option of `sys_setting.scope` retires because no write reaches it. The cascade order and the `global` resolution source are unchanged. Nothing moves automatically: the v18 upgrade ceremony moves existing global rows, `sys_secret` handles included, and they open unchanged because the ADR-0128 AAD binds no holder object and no organization. The D3 record is the `sys-setting-global-rung-moved` semantic entry. It also retires the document family WHOLE (ADR-0049 enforce-or-remove; the ruling of record on PDF and print documents, letter B′, 2026-10-08: "A document is a page with a print declaration; no new template type"): the four defs of `data/document.zod.ts` — `data/DocumentTemplate` (a docx template with placeholders), `data/Document`, `data/ESignatureConfig` and the orphaned `data/DocumentVersion` — exported from `@objectstack/spec/data`, mounted by no stack key, registered as no metadata type and read by nothing in this repository, objectui or hotcrm, leave via RETIRED_DEFS_BY_MAJOR with one D3 semantic entry, so that "template" means one thing: a printable document is a page that declares `print`. The `ESignatureConfig` deadline-key tombstones leave with their def's source and their RETIRED_KEYS_BY_MAJOR[18] entries stay as history. It retires the single-brace `{…}` template dialect from the flow VALUE slots (the C half of the maintainer's ruling D on the flow expression dialects): the `assignment` node's values, in all three shapes, and the `fields` map of `create_record` and `update_record`, where a CEL value envelope is already the expression form. A string there is now the literal text it spells, and one carrying a `{…}` token is refused — by `FlowValueSlotSchema`, `registerFlow`, `objectstack validate` and the executor alike — with the CEL spelling of each token. No D2 conversion exists: every authored spelling was measured lossy (an absent key writes nothing under the template and fails under CEL; CEL divides two integers as integers), so which value an absent key should write is the author's judgment. The date macros and the `$User` paths keep their meaning until CEL can spell them. Its D3 record is the semantic entry `flow-value-slot-template-dialect-refused`. It also takes the injected organization column off the compliance ledger, `sys_audit_log` (ADR-0131 D7): some of its rows are about deployment-level actions no organization owns, so the organization a row is about stays in the attribution field `tenant_id`, which every writer already stamps, and never becomes the tenancy anchor. With no column there is no wall, so a platform administrator now reads the rows about no organization too; an organization reader is scoped to the rows about its active organization by the platform row policy `sys_audit_log_org`, stripped when no wall is enforced, and `organization_admin` names the ledger without the superuser bits so its wildcard bypass cannot skip that policy. Per-tenant retention partitions on `tenant_id`. Nothing moves automatically: an existing database keeps the column as an orphan the boot drift report names, for the v18 ceremony to drop once its values are confirmed in `tenant_id`. The D3 record is the `sys-audit-log-organization-column-retired` semantic entry. Then the builtin key arm covers every builtin whose contract registration could judge: a key the executor contract of a `get_record`, `create_record`, `update_record`, `delete_record`, `notify`, `http`, `screen`, `map`, `loop` or `parallel` node does not declare is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code, closed with the rename-or-remove remedy. Registration's descriptor walk refused those keys already, after `objectstack validate` and `objectstack compile` had passed them, and it now stands aside for those types, so each has one judge; the declared key sets were measured equal first, so registration refuses what it refused before. `try_catch` waits for its contract's `retry` to close (below): it stripped an unknown key where its descriptor closes it. No key is removed, so there is no tombstone, and no D2 conversion exists. Its D3 record is the semantic entry `flow-builtin-node-config-undeclared-keys-refused`. It executes ADR-0032 Decision 3 in the flow TEXT slots — a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message`: they render through the formula template engine, so their placeholders are `{{ }}` holes, a variable path with an optional formatter (the engine's hole grammar now admits a `$`-named variable, so `{{ $error.message }}` is a hole). A single-brace `{…}` token there is refused — by the node contract, `registerFlow` and `objectstack validate` alike — with the hole spelling of each path token, or, for arithmetic, a function, a date macro or a run-user path, the `assignment` that computes it into a variable. No D2 conversion exists: the 17.x interpolator and the engine render a `Date` differently (JSON-quoted against ISO text), and a whole-slot object differently in a screen or `end` text, so the rewrite is the author's to check. Every other flow string keeps the single-brace dialect. Its D3 record is the semantic entry `flow-text-slot-single-brace-refused`. It also makes the deployment's platform-global declaration total (ADR-0131 D7): an object a deployment declares platform-global in its `org-scoping` service's `platformGlobalObjects` gets no organization column on that deployment, because the injected-columns plan reads the declaration, so the organization wall and the driver agree by having nothing to scope. The engine reads it at its plugin start, before the first schema sync, once every plugin init has run, and re-plans the objects registered before it; the security layer's stand-down for such an object retires with it. An absent declaration changes nothing, and a malformed one is refused and declares nothing. Nothing moves automatically: a declaring deployment's existing table keeps the column as an orphan the boot drift report names. The D3 record is the `platform-global-object-organization-column-retired` semantic entry. It also retires the `sys_view_definition` platform object as inert (ADR-0131 D13): no framework code wrote or read its rows, and runtime-authored views are `view` items in `sys_metadata`. The object, its two registrations, its `kernel:ready` active-row index migration and that migration's exports leave, and its name leaves the platform-object registry. Nothing in stack metadata is rewritten; an existing database keeps the table, which no platform path drops. The D3 record is the `sys-view-definition-retired` semantic entry. Then the retry policy closes, and `try_catch` joins the builtin key arm: `RetryPolicySchema`, the one declaration behind `job.retryPolicy` and a `try_catch` node's `retry`, refuses a key it does not declare, naming it with a did-you-mean, where it used to strip it — and with opt-in defaults a stripped `maxRetries` meant no retry at all. No writer relied on the strip. With `retry` closed to the five keys the descriptor declares, a key a `try_catch` node's contract does not declare is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code, and the descriptor walk keeps plugin node types only. A `retryDelayMs` the conversion leaves beside a different `backoffMs` meets its tombstone there, as it met the walk. No key is removed, so there is no new tombstone, and no D2 conversion exists. Its D3 record is the semantic entry `try-catch-and-retry-policy-undeclared-keys-refused`. + +### Mechanical (applied for you) + +| Conversion | Surface | Change | Load window | +|---|---|---|---| +| `field-malformed-scale-precision-removed` | `object.fields.*.scale / object.fields.*.precision` | malformed field 'scale'/'precision' declarations (non-integer or negative) are removed — they were silently unenforced; the schema now refuses them at authoring | retired — `migrate meta` only | +| `record-chatter-position-vocabulary` | `page.component.record:chatter.position / page.component.record:discussion.position` | 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) | retired — `migrate meta` only | +| `element-input-target-variable-removed` | `page.component.element:text_input.targetVariable / page.component.element:record_picker.targetVariable` | 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) | retired — `migrate meta` only | +| `element-filter-removed` | `page.component.element:filter.object / page.component.element:filter.fields / page.component.element:filter.targetVariable / page.component.element:filter.layout / page.component.element:filter.showSearch / page.component.element:filter.aria` | 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 | retired — `migrate meta` only | +| `element-form-removed` | `page.component.element:form.object / page.component.element:form.fields / page.component.element:form.mode / page.component.element:form.submitLabel / page.component.element:form.onSubmit / page.component.element:form.aria` | 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 | retired — `migrate meta` only | +| `field-column-lists-canonicalized` | `field.inlineColumns[].field / field.relatedListColumns[] object entries` | 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) | retired — `migrate meta` only | +| `metric-filters-removed` | `analyticsCubes[].measures..filters` | 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) | retired — `migrate meta` only | +| `cube-sub-day-granularities-removed` | `analyticsCubes[].dimensions..granularities` | 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) | retired — `migrate meta` only | +| `cube-join-sql-and-relationship-removed` | `analyticsCubes[].joins..sql / analyticsCubes[].joins..relationship` | 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..name` alone; the record KEY is the foreign-key field on the base object) | retired — `migrate meta` only | +| `record-highlights-field-icon-removed` | `page.component.record:highlights.fields[].icon` | 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) | retired — `migrate meta` only | +| `mapping-lookup-params-removed` | `mapping.fieldMapping[].params.object / .fromField / .toField / .autoCreate` | mapping lookup params 'object'/'fromField'/'toField'/'autoCreate' removed (ADR-0049 — the import path never read them: `lookup` copies the cell through and reference resolution runs off the target field's own metadata. `autoCreate` never created anything — an unresolved reference fails the row either way. Implementing them instead would have added a second reference-resolution dialect to the import path) | retired — `migrate meta` only | +| `translation-component-submit-label-removed` | `translation.pages.components.submitLabel` | 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) | retired — `migrate meta` only | +| `page-component-responsive-removed` | `page.components[].responsive` | 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) | retired — `migrate meta` only | +| `object-grid-default-sort-removed` | `page.component.object-grid.defaultSort` | 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) | retired — `migrate meta` only | +| `object-kanban-quick-add-removed` | `page.component.object-kanban.quickAdd` | 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) | retired — `migrate meta` only | +| `permission-allow-restore-purge-removed` | `permission.objects..allowRestore / permission.objects..allowPurge` | 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) | retired — `migrate meta` only | +| `form-view-option-default-removed` | `view.form.sections[].fields[].options[].default` | form-view per-option 'default' removed from the FormView vocabulary (ADR-0049 declared-but-unenforced — nothing on the form path read it: the insert-path default falls back to the OBJECT definition's option list, and no form renderer seeds a value from a form view's. The object field option's 'default' stays enforced; declare the pre-selected choice there — field-level 'defaultValue', or 'default: true' on that field's own options entry) | retired — `migrate meta` only | +| `field-reference-to-alias` | `field.reference_to` | 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) | retired — `migrate meta` only | +| `connector-error-mapping-removed` | `connector.errorMapping` | connector key 'errorMapping' removed (ADR-0049 — no engine ever mapped an external error through the rules, so the eleven nested keys configured nothing, and the rule-level `userMessage` shared its spelling with the live API-error channel while never being shown; deleting the block resolves that collision without a rename. The whole ErrorMappingConfig / ErrorMappingRule shape and the ConnectorErrorCategory enum went with it) | retired — `migrate meta` only | +| `connector-connection-timeout-ms-removed` | `connector.connectionTimeoutMs` | connector key 'connectionTimeoutMs' removed (ADR-0049 — the platform never applied it as a deadline and cannot at the site it names: a WHATWG `fetch` exposes one `AbortSignal` over the whole operation and never the connect phase. The value only travelled — onto the reported def and the materialization fingerprint. Use `requestTimeoutMs`, which `resilientFetch` applies as each attempt's deadline, and bound the connect phase at a provider or gateway that can separate the phases) | retired — `migrate meta` only | +| `hook-timeout-to-timeout-ms` | `hook.timeout` | 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) | retired — `migrate meta` only | +| `job-timeout-to-timeout-ms` | `job.timeout` | 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) | retired — `migrate meta` only | +| `api-endpoint-cache-ttl-to-cache-ttl-seconds` | `apis[].cacheTtl` | 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) | retired — `migrate meta` only | +| `dashboard-refresh-interval-to-refresh-interval-seconds` | `dashboard.refreshInterval` | 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) | retired — `migrate meta` only | +| `connector-resilience-keys-removed` | `connector.health / connector.status / connector.webhooks` | connector keys 'health', 'status' and 'webhooks' removed (ADR-0049 — no connector health probe or circuit breaker ever ran, nothing read an authored status (the runtime reports a computed `state`), and a webhook nested in a connector was never registered or delivered. The ConnectorHealth / HealthCheckConfig / CircuitBreakerConfig, ConnectorStatus and WebhookConfig / WebhookEvent / WebhookSignatureAlgorithm shapes went with them) | retired — `migrate meta` only | +| `connector-triggers-removed` | `connector.triggers` | connector key 'triggers' removed (ADR-0049 — a connector trigger never started anything: the automation engine registered a connector's actions only, no polling loop read an interval and no receiver was driven by a webhook trigger. The ConnectorTrigger shape went with it, including the `interval` spelling renamed to `intervalSeconds` earlier in this step. Start the work from a flow that calls the connector's action instead: an `api` flow for an external event, a `schedule` flow for a scheduled pull) | retired — `migrate meta` only | +| `memory-persistence-auto-save-interval-to-ms` | `datasource.config.persistence.autoSaveInterval` | 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) | retired — `migrate meta` only | +| `turso-config-timeout-to-timeout-ms` | `datasource.config.timeout (turso)` | 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) | retired — `migrate meta` only | +| `view-page-mount-removed` | `view.list / view.listViews.* — the list-view type 'page' and its pageName binding` | 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) | retired — `migrate meta` only | +| `list-view-sort-string-clause-to-array` | `view.list.sort / view.listViews.*.sort — the bare string sort clause` | 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) | retired — `migrate meta` only | +| `page-assigned-profiles-removed` | `page.assignedProfiles` | page key 'assignedProfiles' removed (ADR-0090 D2 deleted the Profile concept it was named for, and no renderer, route or read door ever enforced it — the page stayed open to everyone; ADR-0049 enforce-or-remove) | retired — `migrate meta` only | +| `chart-config-aria-removed` | `dashboard.widgets[].chartConfig.aria / report.chart.aria / report.blocks[].chart.aria` | chart config key 'aria' removed (ADR-0049 enforce-or-remove — no chart renderer ever applied it on either face, so declared ARIA attributes silently did not reach the DOM; the accessible name that IS applied is the sibling 'description') | retired — `migrate meta` only | +| `dashboard-widget-chart-config-structure-removed` | `dashboard.widgets[].chartConfig.type / dashboard.widgets[].chartConfig.xAxis / dashboard.widgets[].chartConfig.yAxis / dashboard.widgets[].chartConfig.series` | dataset-bound dashboard widget chart-config keys 'type'/'xAxis'/'yAxis'/'series' removed (ADR-0021 — the dataset decides which series exist and which column each one reads; the widget's own 'type' is the chart family, and 'dimensions'/'values' are the selection, so an authored axis could only agree with the dataset or silently re-point a series at another column) | retired — `migrate meta` only | +| `translation-per-app-settings-removed` | `stack.translations[]..settings / translation.settings` | 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 | retired — `migrate meta` only | +| `object-tenancy-organization-field-removed` | `object.tenancy.organizationField` | 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) | retired — `migrate meta` only | +| `page-component-filter-record-to-rule-array` | `page.component.dataSource.filter / page.component.properties.filter (the object-* blocks, element:number, element:record_picker) / page.component.properties.defaultFilters (object-grid) — the record and single-level AST filter forms` | 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) | retired — `migrate meta` only | +| `view-item-owner-hidden-removed` | `view.owner / view.hidden — on the view item record ({ name, object, viewKind, config })` | 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) | retired — `migrate meta` only | +| `report-joined-chart-removed` | `report.blocks[].chart / report.chart on a joined report` | 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') | retired — `migrate meta` only | +| `view-overlay-owner-hidden-removed` | `view.owner / view.hidden — on a flattened view overlay ({ name, object, viewKind, …, no config })` | 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) | retired — `migrate meta` only | +| `form-layout-inline-grid-to-vertical` | `page.component.object-form.layout / view.form.layout / view.formViews.*.layout` | 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) | retired — `migrate meta` only | +| `currency-config-precision-removed` | `object.fields.*.currencyConfig.precision` | 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) | retired — `migrate meta` only | +| `permission-rls-tags-removed` | `permission.rowLevelSecurity[].tags` | 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) | retired — `migrate meta` only | +| `action-aria-removed` | `action.aria / object.actions[].aria` | action key 'aria' removed (ADR-0049 enforce-or-remove — no action surface ever applied it; every renderer takes the accessible name from the action's required 'label', and the placing node's own 'aria' block names the region) | retired — `migrate meta` only | +| `cube-member-inner-name-removed` | `analyticsCubes[].measures..name / analyticsCubes[].dimensions..name` | cube member key 'name' removed from measures and dimensions (ADR-0049 enforce-or-remove — nothing read it: every consumer resolves a member by its record KEY, published and queried as `.`. The record key is the member's name; to rename a member, rename its key) | retired — `migrate meta` only | +| `flow-decision-mode-inclusive-explicit` | `flow.nodes[].config.mode (decision)` | 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) | retired — `migrate meta` only | +| `view-list-tabs-removed` | `view.list.tabs / view.listViews.*.tabs — the list view's own tab definitions` | list-view key 'tabs' removed (ADR-0049 enforce-or-remove — parsed and stored, drawn by nothing: no renderer ever mounted a tab bar for it, and the tab strip above an object's records is the saved-view switcher, which renders one tab per `listViews` entry; move each tab you want to a named list view) | retired — `migrate meta` only | +| `cube-refresh-key-removed` | `analyticsCubes[].refreshKey` | cube key 'refreshKey' removed, with its 'every' and 'sql' (ADR-0049 enforce-or-remove — nothing read it: no analytics result is cached, so a declared refresh cadence refreshed nothing. Delete the key; a refresh cadence is declared again when a result cache exists) | retired — `migrate meta` only | +| `time-default-utc-suffix-dropped` | `object.fields.*.defaultValue / action.params[].defaultValue / page.component.element:button.action.params[].defaultValue (type time)` | a `time` literal default's `Z` or zero-offset suffix is dropped, which names the same wall clock; a default with a non-zero offset is left as stored and reported as a TODO, because a `time` value carries no zone (ADR-0053 D-C1) and only its author knows which wall clock it meant | retired — `migrate meta` only | +| `page-header-breadcrumb-removed` | `page.component.page:header.breadcrumb` | page:header prop 'breadcrumb' removed, whether 'true' or 'false' (no renderer ever drew a trail for it: objectui drew an empty slot and nothing filled it, and the app shell's header draws the navigation trail) | retired — `migrate meta` only | +| `connector-sync-keys-removed` | `connector.syncConfig / connector.fieldMappings` | connector keys 'syncConfig' and 'fieldMappings' removed (ADR-0049 — no engine ever ran a connector-attached sync or moved a value through a connector field mapping, so the `latest_wins` and `soft_delete` defaults resolved and deleted nothing. The DataSyncConfig, SyncStrategy, ConnectorConflictResolution and ConnectorFieldMapping shapes went with them. A sync is defined on its target instead: a `mapping` whose `connectorSource` names the connector it pulls from, with a `job` for the cadence) | retired — `migrate meta` only | +| `form-view-subform-columns-canonicalized` | `view.form.subforms[].columns[].field / view.formViews..subforms[].columns[].field` | form-view subform grid column entries respelled 'field' → 'name', the grid's column identity (the carrier accepted any value until it took the inline grid column contract; a relationship field's inlineColumns get the same respelling from field-column-lists-canonicalized) | retired — `migrate meta` only | +| `action-block-endpoint-to-target` | `page.component.action:button.endpoint / page.component.action:icon.endpoint` | action:button / action:icon component prop 'endpoint' → 'target' on an `api` action — the rename `ActionSchema` already prescribes; the console's `api` handler reads `target` only. An `endpoint` on a block with no `actionType` or another one is left as stored and reported as a TODO | retired — `migrate meta` only | +| `form-field-public-picker-removed` | `view.form.sections[].fields[].publicPicker` | form field 'publicPicker' removed (ADR-0087 D2 — the anonymous public-form record-search picker is retired: an anonymous public form no longer takes lookup, master_detail or user fields, and the anonymous lookup route is gone. Use a select field with static options, or put the form behind sign-in) | retired — `migrate meta` only | +| `dataset-count-measure-empty-field-removed` | `dataset.measures[].field (aggregate count, empty string)` | a `count` dataset measure's empty `field` is removed: a count with no `field` counts rows, which is what the empty string compiled to, and a measure's `field` is now a column reference that refuses an empty string | retired — `migrate meta` only | +| `agent-structured-output-refused-members-removed` | `agent.structuredOutput.format / agent.structuredOutput.fallbackFormat / agent.structuredOutput.transformPipeline` | agent structured-output formats 'regex' / 'grammar' / 'xml' and the transform step 'coerce_types' removed: the AI runtime refused each before the first turn, because structured output is checked only as JSON and no coercion engine exists. A block whose format was retired is deleted, a retired fallback format is deleted, and the coerce step is dropped from the pipeline | retired — `migrate meta` only | +| `translation-widget-sub-caption-removed` | `translation.dashboards.widgets.subCaption` | translation widget key 'subCaption' removed: the metric sub-caption it overlaid onto the widget's 'options.description' is retired at both ends — the dashboard schema never declared that key and no authored widget wrote it, so the overlay was its only writer. A widget keeps one authored description, 'widget.description', translated by the widget node's 'description' key | retired — `migrate meta` only | +| `agent-memory-long-term-store-removed` | `agent.memory.longTerm.store` | agent memory key 'longTerm.store' removed: the memory store is platform infrastructure, not agent metadata — the AI runtime keeps long-term memory notes in its own database store and refused the 'vector' default and 'redis' before the first turn. The key is deleted; every other memory key stays | retired — `migrate meta` only | +| `agent-lifecycle-removed` | `agent.lifecycle` | agent key 'lifecycle' removed: the conversation state machine was parsed and never read — no runtime moved an agent through a declared state. The key is deleted; a conversation phase is a skill with triggerConditions, orchestration is a Flow, record transitions are a state_machine validation rule | retired — `migrate meta` only | +| `object-grid-resizable-columns-removed` | `page.component.object-grid.resizableColumns` | 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) | retired — `migrate meta` only | +| `page-requires-non-compiled-kind-removed` | `page.requires` | page key 'requires' removed from react, full and slotted pages (a page with no kind is full) — the plugin-namespace list is derived from the source at save only on html / jsx pages; on the other kinds nothing derived or enforced it, and the Studio page editor drops it | retired — `migrate meta` only | +| `element-text-variant-heading-levels` | `page.component.element:text.variant` | element:text 'variant' spellings 'heading' → 'h2' and 'subheading' → 'h3' (the vocabulary converged on the nine values ui:text publishes; each old spelling already rendered that heading element, so the outline is unchanged and the heading takes that level's style) | retired — `migrate meta` only | +| `object-master-detail-form-detail-sort-field-removed` | `page.component.object-master-detail-form.details[].sortField` | object-master-detail-form detail entry prop 'sortField' removed (the console reads no authored value: the line grid stamps the field it derives from the child object, so the key was accepted and dropped; delete the key — the child object's own position field keeps the line order) | retired — `migrate meta` only | +| `declared-index-unique-scope` | `object.indexes[].unique / objectExtensions[].indexes[].unique` | declared-index bare `unique: true` → `unique: 'global'` (ADR-0120 D2 — the scope is stated, never positional; `'global'` is exactly the index bare `true` built, so the physical index is byte-identical; field-level `unique: true` is not converted) | retired — `migrate meta` only | +| `manifest-permissions-string-list-removed` | `manifest.permissions` | manifest 'permissions' as a flat list of permission strings removed (ADR-0049 — no loader ever read the list, so dropping it changes no grant; the structured { services, hooks, network, fs } block is the only form, and a permission string has no mechanical mapping onto it) | retired — `migrate meta` only | + +### Semantic (delegated to you, with acceptance criteria) + +- **`action-aria-retired`** — `action.aria / object.actions[].aria — the ARIA block on an action` → The action's required `label`, which every action renderer uses as the accessible name (the visible button or menu-item text, and the `aria-label` of an icon-only action). To name the region that places the actions, the `aria` block of the placing node — `page.components[].aria` or the list view `aria`. + - Why not automatic: The D2 conversion `action-aria-removed` deletes `aria` from every stack action and every object-nested action, and the delete is lossless: no surface that renders an action ever read the block, so the ARIA attributes it declared never reached the DOM. The residue is accessibility work the author did that no user benefited from. An author who wrote `aria.ariaLabel` believed screen-reader users heard that name; they heard the `label`. The strip deletes the text along with the key, and only the author can say whether it should become the `label` — which sighted users read too — or whether it described the toolbar or list the action sits in, and belongs in that node's `aria` block instead. + - Done when: No action, top-level or nested under an object, carries `aria`; the parse refuses it. Every action that had carried an `aria.ariaLabel` has a `label` conveying what that name was meant to announce, or the author has moved the text to the placing component's or list view's `aria` block, or confirmed the existing label already says it. With a screen reader, focusing an icon-only action announces its label. +- **`action-block-endpoint-spelling-retired`** — `page.component.action:button.endpoint / page.component.action:icon.endpoint — the endpoint an `api` action button calls, on the two page blocks that run an action` → `target` — the one key the action runner dispatches an executor on, and the key `ActionSchema` already renames `endpoint` to. + - Why not automatic: The D2 conversion `action-block-endpoint-to-target` renames `endpoint` to `target` in author sources and on every stored-row rehydration, for a block whose `actionType` is `api` — the one meaning the key declared, and the rename is lossless there. Three things are left. A block that carries `endpoint` with no `actionType` was called through the action runner's legacy API fallback, which a `target` with no type does not reach, so the author has to add `actionType: 'api'` as well. A block with another `actionType` never read `endpoint`, so only the author can say whether its value should become the `target` or be deleted. And a block carrying both spellings with different values is left for the author to keep one. Each is left as stored and reported as a TODO. Code is out of reach: a custom action handler that read `endpoint` off the action it was handed reads nothing once the block carries `target`. + - Done when: No `action:button` or `action:icon` block carries `endpoint` in source or at rest; each block that called an endpoint names it as `target` with `actionType: 'api'`. Pressing such a button in the console sends one request to that endpoint, and `os validate` reports no `component-props-unknown-key` finding for `endpoint`. No custom action handler reads `endpoint` from an action dispatched by either block. +- **`action-bulk-dispatch-contract-undeclared`** — ``action.execution` — the bulk dispatch contract an action’s body is written for` → Declare `execution: 'perRecord' | 'aggregate'` on every action a list view wires into the selection bar, DERIVED from the wiring that action already has: a view naming it in `bulkActions: ['']` (the bare-string form) dispatches it once per selected row with that row's `recordId` ⇒ `execution: 'perRecord'`; a `bulkActionDefs` entry naming it with `execution: 'aggregate'` dispatches it once for the whole selection with every id in `params._selectedIds` ⇒ `execution: 'aggregate'`. The derivation is exact wherever an action is wired ONE way, because the wiring is what the body has been receiving all along — declaring it changes no behaviour, it writes down the behaviour. ⛔ There is no default: an action no view bulk-wires, and an action whose body genuinely serves both contracts (it reads `recordId` AND `_selectedIds` and copes with either), stays UNDECLARED rather than being given a value. + - Why not automatic: Not losslessly convertible, because the fact being written down does not live on the item being rewritten. The declaration belongs to the ACTION and the evidence for it belongs to the VIEWS — potentially several, in other files or other packages — so no per-item transform has both halves in hand, and `objectstack migrate meta` rewrites stored metadata by key. The residue is genuinely a judgement: an action wired BOTH ways has no correct value, because one call and N calls have different side effects and the platform will not silently unify them (the 2026-09-12 ruling that made an action declare its dispatch contract refused exactly that option). Such an action is TWO actions — split the body along the line the two wirings already draw and declare each half — or, if the body was deliberately written to serve both, it stays undeclared and the two wirings stand. The census that is this migration’s input was taken 2026-09-13 over objectstack@a9c64779046 (shipped app metadata, test fixtures excluded: 13 distinct bulk-wired actions — 11 unambiguously per-record, 1 unambiguously aggregate, 1 wired both ways) and hotcrm@c716a2ccb3d31574a1a238a590f3e331ddae0200 (3 distinct bulk-wired actions — 2 per-record, 1 aggregate, 0 wired both ways). So the both-ways residue is real but rare, which is why it is a structured TODO and not a blocking rewrite. + - Done when: `objectstack validate` (and `os lint` / `os build`) reports no `action-dispatch-contract-mismatch` finding on the stack; every action a list view wires into the selection bar either declares the `execution` its wiring implies, or is deliberately left undeclared with the reason recorded beside it; no action is wired both ways while declaring either contract. Prove the derivation rather than assuming it: for each action you declared `'aggregate'`, its body reads `params._selectedIds` and does NOT depend on `ctx.recordId`; for each you declared `'perRecord'`, the reverse. Run the bulk button once per declared action against a multi-row selection and confirm the number of dispatches matches the declaration (N for per-record, one for aggregate) — a mismatch that used to be silent is what this key exists to surface. +- **`action-engine-facade-find-query-envelope`** — `Action handler body — `ctx.engine.find(object, filter)` (`ActionEngineFacade.find`, `@objectstack/spec/ui`)` → `ctx.engine.find(object, { where: filter })` — the engine's own query envelope (`EngineQueryOptions`), the same options bag `IDataEngine.find` takes. The filter moves under `where` verbatim: `find('task', { status: 'open' })` → `find('task', { where: { status: 'open' } })`. An unfiltered `find(object, {})` is unchanged, and the rest of the envelope — `fields`, `orderBy`, `limit`, `offset`, `expand` — becomes reachable from a handler for the first time. A caller-supplied `context` is ignored: the facade is trusted and stamps its own elevated one. + - Why not automatic: The rewrite itself is lossless and mechanical, but it is not automatable here: an action handler is authored TypeScript, and the chain rewrites stored metadata by key, so no `os migrate meta` step can reach a call expression inside a function body. The change is a WITHDRAWAL of the parameter shape an earlier typing fix chose (the filter alone), ruled by the director seat on 2026-09-12, with the maintainer's agreement, on the long-term axis 「one platform, one query shape」. The facade had been given a shape different from the engine's — the `where` half alone — which made the most natural spelling the wrong one: an author who passed the engine's envelope got `{ where: { where: … } }`, matching no row and resolving to `[]` with no error, while an unfiltered `{}` kept working under either belief so a dead handler looked partially alive. The alternative — refusing `where` at the top level with an intersection — was rejected because it asserts a vocabulary fact the spec declares nowhere, reserving the field name `where` across every customer's data model to buy one parameter's compile-time check. + - Done when: Every `ctx.engine.find(...)` in the app's action handlers passes an envelope. Where the handler is annotated with the PUBLISHED `ActionHandlerContext`, `tsc --noEmit` finds every unmigrated call on its own — a bare filter is a compile error there, an object literal failing the excess-property check and a `FilterCondition` variable failing TS2559. ⚠️ Where it is NOT — a handler in an `objectstack.config.js` / `.mjs`, one annotated with a local copy of the context type, or a `(ctx: any)` handler — the type reaches nothing and a type-check alone proves nothing: those callers are refused at RUNTIME by the facade arm, with the same prescription, so the migration is complete for them only once each such handler has actually been RUN. Then confirm the reads that were already SILENTLY EMPTY: any handler that had been passing the envelope was resolving to `[]` on every call, so a suite written against the mistake passed and the row count is the only witness — re-run each migrated handler against seeded data and assert it now returns the rows its filter selects, rather than asserting it still resolves. +- **`address-location-value-unknown-keys-refused`** — `stored `address` and `location` field VALUES (`AddressSchema` / `AddressValueSchema`, `LocationValueSchema` — ADR-0104 D1), and the two authoring doors that parse the same contract: a `location` / `address` field's literal `defaultValue` and an action param of those types — undeclared keys` → the declared key the rejection names. An address value accepts exactly `street`, `city`, `state`, `postalCode`, `country`, `countryCode`, `formatted`; a location value exactly `lat`, `lng`, `altitude`, `accuracy`. Every rejection carries the surface, the offending key and a rename (`postal_code` / `zipCode` / `zip` / `postcode` → `postalCode`, `latitude` → `lat`, `longitude` → `lng`). A key that names no declared member is removed at the producer — never tolerated at a consumer: an alias for an off-spec key in a consumer stays forbidden (contract-first — fix the metadata, not the runtime) + - Why not automatic: Maintainer ruling 2026-09-01, option A: both value classes refuse undeclared keys. Both value classes were all-optional STRIPPING `z.object`s, so a value with a completely wrong key set parsed green and the wrong keys vanished from the parse output: the showcase seed wrote `postal_code`, the platform accepted it, dropped it, and rendered an empty ZIP box (found while counting stored address values for objectui's survey of which structured values its field validator checks; an earlier report had named the same stripping on the address widget's round-trip, whose ZIP input bound `zipCode` against a stored `postalCode`), while a stored-value scan over the class could only ever report a clean count it had no way to earn. Closing the two shapes restores declared = enforced and pulls "loose" back to the one deliberate exception (`FileValueSchema`, untouched). Where the refusal BITES is the ADR-0104 write path's own evidence-gated posture, deliberately unchanged: a record write carrying an undeclared key is refused only on a deployment that has attested `adr-0104-value-shapes` (or opted in with `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1`); everywhere else it stays warn-first and is reported to the admitted-violation sink, and `os migrate value-shapes` now COUNTS such keys, so a deployment holding them cannot attest until they are cleaned. No read path parses these shapes; a stored value reads back as written. + - Done when: `os migrate value-shapes` reports zero findings on `address` / `location` fields — every stored value carries only declared keys (`postalCode`, never `postal_code` / `zipCode`; `lat` / `lng`, never `latitude` / `longitude` / `heading` / `speed`) — and every `address` / `location` `defaultValue` literal and action-param value parses with only declared keys. Declared keys parse byte-identically to before; `FileValueSchema` still admits extra keys. +- **`admin-export-wildcard-removed`** — `the SHIPPED platform admin permission sets `admin_full_access`, `organization_admin` and the derived `organization_admin_no_bypass` — their `objects["*"].allowExport = true` wildcard grant (REMOVED; the rest of the wildcard is unchanged)` → an explicit `allowExport: true` on the object entries of an APP-authored permission set held by the principals meant to keep exporting. Nothing replaces the grant in the platform sets themselves + - Why not automatic: A capability NARROWING of a published set, and — like `export-axis-opt-in`, whose 17.0 story this completes — one no gate can announce: the metadata is unchanged and still parses, the shipped sets are re-seeded on upgrade, and the only observable is that an export which returned 200 now returns 403 `EXPORT_NOT_PERMITTED`. `export-axis-opt-in` told upgraders that "package-shipped sets are re-seeded on upgrade, so the built-ins are handled — `admin_full_access` and `organization_admin` now carry the grant explicitly"; from this major they deliberately do NOT, so a deployment that read that sentence and left its admins to the built-ins must now act. What the wildcard did, measured on 17.0.0 GA across 40 export probes: an org owner exported three objects on which NO app permission set granted export, 200 with full rows, and the app had no way to refuse — editing a code-package set answers `403 [not_overridable]`, and the org admin holds no app-authored set in which to write the per-object `false` that would have won. So an application could declare an object exportable by nobody, ship, and be silently wrong on an exfiltration boundary — declared ≠ enforced, on the axis where a silent gap costs the most. This is the 2026-08-07 ruling on the member baseline applied to export: that change removed `member_default`'s CRUD wildcard because a wildcard in a set every principal resolves is not a default but a floor nobody can get under; the export wildcard survived by omission rather than by decision, one tier up. It cannot be mechanically converted, in either direction: re-granting `allowExport` wherever an admin holds a set would restore today's behaviour and defeat the entire point, and leaving it withheld may revoke export an operator legitimately wants. WHICH principals may take a bulk copy is the segregation-of-duties judgement the axis exists to make explicit, and it belongs to the operator. Note the boundary this does NOT move: the export gate itself is unchanged and was never the defect (controls C1–C3 of the same run show it enforcing exactly), specific-over-wildcard precedence is unchanged, `allowExport` on a `"*"` entry remains a supported authoring shape in an app's OWN sets, and READ is untouched — an admin still sees every record they saw before. ADR-0087; maintainer ruling 2026-08-15, which removed `allowExport` from the wildcard entry of both shipped admin sets. + - Done when: For every principal whose ADMIN export you rely on, the grant is now authored where you control it: an app/environment permission set held by that principal names each object they must export and carries `allowExport: true` on it. Verify BEHAVIOURALLY — nothing fails at parse time, and a re-seed silently replaces the old built-ins: sign in as an org owner or platform admin and call `GET /api/v1/data//export`, confirming 200 where export is intended and 403 `EXPORT_NOT_PERMITTED` where it is not. ⚠️ Silence is not success: a deployment that upgrades without editing anything is VALID metadata whose administrators have quietly lost export on every object no app set grants, and the first sign will be a support report rather than an error. The reverse reading is worth one pass too — an object your app declares exportable by nobody is now genuinely exportable by nobody, which is the point of the change; confirm that is what you want before granting it back. +- **`admin-scope-business-unit-blank-refused`** — `security.permission.adminScope.businessUnit (AdminScopeSchema, ADR-0090 D12) authored or stored BLANK: the empty string, or a value that is nothing but whitespace (spaces, a tab, a newline). The key is the scope's only required one and names the root business unit of the delegated subtree; a blank value satisfied the requirement while naming no unit` → the sys_business_unit.name (machine name) of the business unit at the root of the subtree the delegate administers, written out: `businessUnit: 'north_america'`. If the permission set should not delegate administration at all, remove `adminScope` from it. ⛔ There is no replacement that can be DERIVED from what was written: a blank names no unit, so the root the author meant is not recoverable, and the platform must not pick one. + - Why not automatic: Maintainer ruling A, 2026-09-23: an empty or whitespace-only `businessUnit` is refused at parse, and stored scopes are not rewritten. `AdminScopeSchema` declared `businessUnit` as a bare string with no minimum, so `{ businessUnit: '' }` and `{ businessUnit: ' ' }` parsed green — measured against the published spec 17.4.0 and re-measured on `main` before the change. This narrows a published face: every other key of the scope is scoped TO this one, and ADR-0090 D12 declares the scope's WHERE as a business-unit subtree, which a blank does not name. The delegated-admin gate resolves the anchor by exact name, so a blank anchor resolves to an empty subtree and approves nothing on the subtree axes — no escalation was measured; the defect is a declaration that does not enforce what it declares, satisfied most readily by an author (an AI author above all) that knew the key was required and did not yet know the unit. The refusal is a NON-TRANSFORMING refinement at the key's own path, deliberately not a trim: the metadata save path persists the submitted body verbatim rather than the parsed value, so a trimming schema would validate one string and store another that the gate's exact lookup cannot resolve. A real name therefore parses byte-identical. Scope is blankness only: a real name with surrounding whitespace is not judged by this entry. ⚠️ STORED ROWS ARE NOT REWRITTEN and there is no D2 conversion (no lossless rewrite exists — the root cannot be inferred, and dropping the scope would silently change who is a delegate). The read path does not re-validate stored rows, so no stored permission set becomes unreadable. A stored blank-anchored scope is refused on its NEXT WRITE instead: a Setup or data-door edit of that permission set answers 422 INVALID_METADATA naming adminScope.businessUnit, and the boot reconciliation backfill of a legacy record with no metadata definition reports it through its existing durability ERROR (ADR-0094 D4), whose own prescription is to make the record body spec-valid; restoring a trashed blank-anchored set brings the record back and reports the missing definition at ERROR the same way. ⛔ No path skips the row. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). ADR-0049 / ADR-0087 / ADR-0090. + - Done when: Search every authored and stored permission set for an `adminScope` whose `businessUnit` is empty or whitespace-only — metadata files, `sys_metadata` permission rows, and the `admin_scope` column of `sys_permission_set` — and write the root unit's machine name, or remove `adminScope` where the set should not delegate. The sweep is mechanical for authored metadata: `AdminScopeSchema.safeParse` and `PermissionSetSchema.safeParse` answer exactly one `custom` issue at `businessUnit` (or `adminScope.businessUnit` when the set is parsed whole) naming `sys_business_unit.name` as the valid anchor. For STORED rows the search above is the catch-all, because reads never refuse: a definition already stored in `sys_metadata` with a blank anchor loads and resolves exactly as before and says nothing until it is written again. Two write paths then name it: a re-save of the set (refused 422 at adminScope.businessUnit), and — for a legacy `sys_permission_set` record with no metadata definition only — the boot log, where the ADR-0094 D4 backfill's first-failure ERROR names the record and carries the offending key and its summary ERROR counts and names the failed records. ⛔ A clean boot is therefore NOT a completed sweep. An absent `businessUnit` is refused exactly as before, with its own invalid_type issue. Nothing is normalised on the way through: an accepted anchor arrives byte-identical to what was written. The repo and example apps carried zero blank anchors at the time of the change, so no fixture had to be rewritten. +- **`advanced-plugin-lifecycle-config-retired`** — `kernel.advancedPluginLifecycle (the authorable config surface of `plugin-lifecycle-advanced.zod.ts` — 3 defs, 9 exported names: `AdvancedPluginLifecycleConfigSchema` / `AdvancedPluginLifecycleConfig` / `AdvancedPluginLifecycleConfigParsed`, `GracefulDegradationSchema` / `GracefulDegradation` / `GracefulDegradationParsed`, `PluginUpdateStrategySchema` / `PluginUpdateStrategy` / `PluginUpdateStrategyParsed`)` → (removed — there is no declarative replacement, because nothing ever read the declaration. The supported lifecycle surface is the HOST-DRIVEN library in `@objectstack/core`: construct `PluginHealthMonitor` and pass a `PluginHealthCheck` per plugin, construct `HotReloadManager` and pass a `HotReloadConfig` — the `content/docs/protocol/kernel/lifecycle.mdx` examples — rewritten to show the plugin exposing a method and the host registering it, never a declarative field — are the supported usage, and those input vocabularies (`PluginHealthStatus` / `PluginHealthCheck` / `PluginHealthReport`, `HotReloadConfig` with its embedded `DistributedStateConfig`, `PluginStateSnapshot`) SURVIVE in the same module as library parameter types. Degradation and update-strategy vocabularies return only via the ENFORCE route of ADR-0049 through a new ADR — the executor first, the vocabulary second) + - Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-08-25, route 2: retire the config container and keep the classes as a host-driven library. The container aggregated six config groups — `health`, `hotReload`, `degradation`, `updates`, `resources`, `observability` — and NO group had a runtime reader, re-measured per group at the retirement's base commit (8cdd696) with positive controls: the kernel never constructs `PluginHealthMonitor` or `HotReloadManager` (only their own unit tests and `core/examples/phase2-integration.ts` do, passing config DIRECTLY to the classes, never through this container); `degradation` / `updates` / `resources` / `observability` keys have no implementation body at all (controls: `checkMethod` resolves to `core/src/health-monitor.ts` and `debounceDelay` to `core/src/hot-reload.ts`, proving the scan sees real readers; the bare-name collisions — plugin-ordering's `optionalDependencies`, auth-manager's private `degradedFeatures`, plugin-security-advanced's `resourceLimits.maxCpu` read by `sandbox-runtime.ts` — are different surfaces, verified structurally). No manifest, stack collection or metadata-type binding ever embedded the container, so no authored document could carry it: an author declaring `health: {...}` or `rollback: { automatic: true }` got a clean parse and NOTHING — the shape of the plugin sandboxing / integrity / approval config that was never wired to anything, at container scale, sharpened by production-safety vocabulary (auto-restart, zero-downtime rolling updates, automatic rollback) an AI author (ADR-0033) reads as proof the capability exists. With no carrier key and no authored document there is nothing to tombstone and no seam for a D2 conversion: route 3, the shape of the dynamic plugin-loading family's removal and of the retired `ApiKeySchema` — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. + - Done when: No code imports any of the 9 retired names from `@objectstack/spec` or `@objectstack/spec/kernel` — every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in `kernel/plugin-lifecycle-advanced-retirement.test.ts`). No metadata document needs editing: the container was reachable from no metadata-type binding, stack collection or manifest embed, so no document could ever carry it. The host-driven library vocabularies survive unchanged on `./kernel` (`PluginHealthStatusSchema`, `PluginHealthCheckSchema`, `PluginHealthReportSchema`, `HotReloadConfigSchema`, `DistributedStateConfigSchema`, `PluginStateSnapshotSchema` — same pin), and `PluginHealthMonitor` / `HotReloadManager` stay exported from `@objectstack/core` with their tests green. ⚠️ Runtime behaviour is deliberately UNCHANGED: nothing ever read the container, so removing it removes no behaviour. +- **`agent-lifecycle-retired`** — `agent.lifecycle — the agent conversation state machine left the shape; with it the XState StateMachineSchema family left @objectstack/spec/automation (StateMachineSchema, StateNodeSchema, TransitionSchema, ActionRefSchema, GuardRefSchema and their types), and StateNodeConfig left the root and /ai entries` → no key: delete `lifecycle` from every agent. Put what the machine meant where the platform enforces it — a phase of a conversation is a skill with its own `instructions` and `tools`, selected by its `triggerConditions` and attached through the agent's `skills`; a multi-step process is a Flow; a record's status transitions are a `state_machine` validation rule on the object (a flat table of each state's allowed next states). Code that imported the state machine exports declares the shape it needs itself, or drops it + - Why not automatic: ADR-0049 enforce-or-remove: `agent.lifecycle` was parsed and never read. No runtime — not this repository, not the cloud AI runtime that executes agents — moved an agent through a declared state or refused an undeclared transition, so an authored machine changed nothing an agent did. Enforcing it would have meant a statechart interpreter beside Flow, the two-engine shape ADR-0020 rejected, and what it reached for is already served: conversation phases by skills (ADR-0064), orchestration by Flow (ADR-0019), record transitions by the `state_machine` validation rule (ADR-0020). Authoring now refuses the key with that prescription, and TypeScript rejects it. The D2 conversion `agent-lifecycle-removed` deletes it from existing sources and stored agent rows, losslessly. `StateMachineSchema` had kept its file only for this door (ADR-0020 implementation note 1), so the family left with it — which of the three destinations each deleted machine meant is the author's judgement, not a mechanical rewrite + - Done when: No agent declares `lifecycle`; it is refused at parse with its prescription, and TypeScript rejects it. Every conversation phase a deleted machine described is a skill the agent lists in `skills`, with its own `instructions`, `tools` and `triggerConditions`; every multi-step process it described is a Flow; every record status transition it described is a `state_machine` validation rule on that object. No source imports StateMachineSchema, StateNodeSchema, TransitionSchema, ActionRefSchema, GuardRefSchema or their types from @objectstack/spec. Every agent parses under the new schema. +- **`agent-memory-store-retired-and-limits-required`** — `agent.memory — longTerm.store left the shape (the memory store is the platform's); longTerm.maxEntries and reflectionInterval are required when longTerm.enabled is true, and reflectionInterval is refused without an enabled longTerm; longTerm.enabled is unchanged` → no storage key: delete `longTerm.store`, whatever it held — where long-term memory notes are kept is the platform's choice. An agent whose `longTerm.enabled` is true declares `longTerm.maxEntries` (how many distilled notes are kept for each user; the newest are recalled before the first round and older ones evicted) and `memory.reflectionInterval` (how many delivered interactions pass between the reflections that write a note). An agent without enabled long-term memory declares no `reflectionInterval` + - Why not automatic: ADR-0049 enforce-or-remove: the `agent.memory` contract states exactly what the runtime honours. The cloud AI runtime, the one runtime that executes agents, enforces long-term memory from `enabled`, `maxEntries` and `reflectionInterval`: it recalls the newest `maxEntries` notes before the first round, writes one note every `reflectionInterval` delivered interactions, and evicts notes beyond `maxEntries`. It keeps the notes in its own database store, and before an agent's first turn it refused the `vector` store (the old default, so what an omitted `store` parsed to), `redis`, an enabled `longTerm` missing either number, and a `reflectionInterval` without an enabled `longTerm`. Authoring now refuses the same declarations, each with a prescription. The D2 conversion `agent-memory-long-term-store-removed` deletes `store` from existing sources and stored rows, losslessly: no value of it ever chose a backend. No default is declared for either number, because none has a measured basis — so an agent with long-term memory enabled and either number missing no longer parses, and only its author can choose the numbers it needs + - Done when: No agent declares `memory.longTerm.store`, or a `backend`, `storage` or `provider` key under `longTerm`; each is refused at parse with its prescription, and TypeScript rejects `store`. Every agent whose `longTerm.enabled` is true declares both `longTerm.maxEntries` and `memory.reflectionInterval`, each an integer of at least 1 chosen for that agent, and no agent declares `reflectionInterval` without an enabled `longTerm`. Every agent parses under the new schema. +- **`agent-structured-output-refused-members-retired`** — `agent.structuredOutput — the values 'regex', 'grammar' and 'xml' left StructuredOutputFormat (at format and fallbackFormat) and 'coerce_types' left TransformPipelineStep (at transformPipeline); 'json_object', 'json_schema', 'trim', 'parse_json' and 'validate' are unchanged` → a JSON contract: `format: json_schema` with a JSON Schema in `schema` when the answer must have a shape, or `format: json_object` when any JSON value will do — or no `structuredOutput` block at all when the agent needs no output contract. A fallback format names one of the two JSON formats or is left out. In place of `coerce_types`, declare the exact types in `schema`, so the answer is validated as the model wrote it + - Why not automatic: ADR-0049 enforce-or-remove. The cloud AI runtime, the one runtime that executes agents, enforces `structuredOutput` on every final answer and refuses an agent that declares any of these four members before its first turn: the spec never had a key to carry the pattern or grammar a `regex` or `grammar` answer would be checked against, a final answer is checked only as JSON, and no coercion engine exists. Hosted model APIs constrain a final answer by JSON Schema only; regex and grammar constraints live in inference engines and in tool-input formats, not on an agent's answer. The D2 conversion `agent-structured-output-refused-members-removed` makes each stored or existing source parse: it DELETES a block whose `format` was retired, deletes a retired `fallbackFormat`, and drops `coerce_types` from the pipeline. The deletion of a block is the edit that needs judgement: it removes an output contract the runtime never kept, and only the author can say whether the agent should now carry a `json_schema` contract instead — the conversion cannot write the schema the author meant + - Done when: No agent declares `format` or `fallbackFormat` as `regex`, `grammar` or `xml`, and no `transformPipeline` lists `coerce_types`; each is refused at parse with its prescription, and TypeScript rejects each at a `StructuredOutputFormat` or `TransformPipelineStep` position. Every agent whose block the conversion deleted either carries a `json_schema` contract whose `schema` states the answer it must give, or the author has confirmed it needs no output contract. An agent that relied on coercion declares the exact types in `schema` and a test turn returns an answer that validates without conversion. +- **`ai-conversation-analytics-duration-unit-in-key`** — `ConversationAnalytics.duration, the emitted session length whose name carried no unit (ai/conversation.zod.ts)` → durationSeconds — rename the key; the value is unchanged + - Why not automatic: Maintainer ruling B (2026-09-02, extended on 2026-09-05 to runtime-emitted durations): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. It stands alone because it is the only offender in ai/ and the only one on its file. What makes the bare name worth a registry row rather than a quiet edit is the company it kept: every other number on ConversationAnalytics is a COUNT — totalMessages, totalTokens, peakTokenUsage, pruningEvents, tokensSavedByPruning — so the one field that carried a unit was the one field that did not say so, sitting in a block of twelve unitless integers. The two instants beside it, firstMessageAt and lastMessageAt, already spelled themselves; the measurement between them did not. Tombstoned with retiredKey(); the shape is not strict, so a bare deletion would strip in silence and an emitter writing the old spelling would lose the value with no error anywhere. Why a semantic entry and not a D2 conversion: conversation analytics are computed at runtime and handed to a consumer, never authored by hand and never stored as a sys_metadata row, so the conversion chain has no seam that would ever see one — the same disposition every runtime-emitted measurement in this stack has taken. ADR-0087. + - Done when: Every producer that BUILDS a ConversationAnalytics spells durationSeconds, and every consumer that reads a session length reads durationSeconds. Authoring duration fails to compile (input type `never`) and fails to parse with the rename prescription rather than a bare unrecognized-key error. Behaviour is unchanged: durationSeconds: 1800 is the same half hour duration: 1800 was, the key stays optional, and the non-negative bound rides along with the renamed key so a negative session length is still refused. The migration is proved correct when no source in the tree spells a bare duration on this shape AND the twelve sibling counts are untouched — a sweep that suffixed any of them has read a count as a duration and over-applied the rule. +- **`ai-json-schema-untyped-subschema-refused`** — `action.ai.outputSchema (stack actions and object-nested actions) and agent.structuredOutput.schema — a JSON Schema in which an object subschema with no type carries a type-scoped keyword` → the same schema with a `"type"` declared on every subschema that carries a type-scoped keyword: `"object"` beside `properties`, `required`, `additionalProperties`, `patternProperties`, `propertyNames`, `minProperties` or `maxProperties`; `"array"` beside `items`, `prefixItems`, `contains`, `minItems`, `maxItems` or `uniqueItems`; `"string"` beside `minLength`, `maxLength`, `pattern` or `format`; `"number"` or `"integer"` beside `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum` or `multipleOf`. A subschema meant to accept several types declares them as an array (`"type": ["string", "null"]`). + - Why not automatic: Both slots are compiled by the cloud AI runtime — `action.ai.outputSchema` before the action runs, to validate its result, and `agent.structuredOutput.schema` as the agent's structured-output contract — and both readers call one guard whose schema reader does not check a type-scoped keyword on a subschema that declares no `type`. That guard refuses the whole schema before anything runs. The spec declared both slots as open records, so such a schema passed `defineStack`, `objectstack validate` and the metadata save door, and the author learned of it only when the action or agent was invoked. Both slots are now one declaration that mirrors the guard exactly — the same 22 type-scoped keywords (`properties`, `required`, `additionalProperties`, `patternProperties`, `propertyNames`, `minProperties`, `maxProperties`, `items`, `prefixItems`, `contains`, `minItems`, `maxItems`, `uniqueItems`, `minLength`, `maxLength`, `pattern`, `format`, `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`), present with any value on an object node whose `type` is absent; the same descent into every value of `properties`, `patternProperties`, `$defs`, `definitions` and `dependentSchemas` and into the single subschema or each array entry of `items`, `additionalProperties`, `contains`, `propertyNames`, `not`, `if`, `then`, `else`, `unevaluatedProperties`, `unevaluatedItems`, `anyOf`, `oneOf`, `allOf` and `prefixItems`, under typed and untyped parents alike, without following `$ref` — and refuses each offending subschema at its own path with the `type` to declare named. Boolean subschemas, `{}`, a node with any `type` value, and an untyped node carrying only keywords outside the list (`enum`, `const`, `$ref`, `anyOf`, `title`, …) are accepted, as the runtime accepts them. Measured on the built package: the per-type schema the metadata save door validates with refuses an action, an object-nested action or an agent carrying such a schema at the subschema path, and `defineStack` throws with the same path; the same schema with `type` declared is accepted at both. Read from source and not run: a row already stored still loads, because the database loader replays the conversion chain and parses nothing, and its next save is refused until the `type` is declared. No conversion is registered: every refused schema was already refused by the runtime, so nothing that worked stops working; and supplying a `type` is a judgment about what the author meant, not a lossless rewrite, because declaring one also narrows what the schema accepts. Population measured at the change, on origin/main 135daaa06b: zero untyped subschemas in the three authorings of either slot across the package fixtures (one action `ai.outputSchema`, two `structuredOutput.schema`), and zero authorings of either slot in the examples, the documentation and the published skills; the one `outputSchema` the examples carry is a connector action's, a different key. Deployed metadata NOT MEASURED. + - Done when: Every `ai.outputSchema` on an action (stack-level and object-nested) and every `structuredOutput.schema` on an agent parses: `objectstack validate` reports no issue whose message reads `uses "…" without a "type"` at either slot, and every subschema in either schema that carries a type-scoped keyword declares its `type`. The action or agent then runs past the AI runtime's schema compilation instead of being refused before it runs. +- **`analytics-authorable-unknown-keys-refused`** — `analytics cube definitions (`defineCube` / `defineStack({ analyticsCubes })`: the cube, each metric, each dimension, each join) and the `/analytics/query` body's nested `timeDimensions[]` items — undeclared keys` → the declared key the rejection names. Every rejection carries the surface, the offending key and a rename suggestion (`title` → `label` on a metric/dimension, `label` → `title` on the cube, `granuarity` → `granularity`, `orderBy` → `order`; `filters` on a query gets the `where` prescription). A key that names no supported capability is simply removed + - Why not automatic: The unknown-key strictness campaign (the sweep that ended silent stripping of undeclared keys as the default, one schema family at a time), its data/ batch. These shapes parsed `.strip` — an undeclared key on an authored cube was silently dropped, so a join authored with a typo'd `relationship` registered with the `many_to_one` default (a different join than the author declared) and a metric's misspelled key vanished under a successful parse. The subtle half: `/analytics/query`'s top level has been strict since the degraded shim's envelope dialect was retired (one URL, one request body), but top-level strictness does not recurse — `timeDimensions: [{ dimension, granuarity: 'day' }]` rode through the strict wrapper with the typo stripped, bucketing the whole range as one group under an ordinary 200. Undeclared keys on all eight sites are now refused at parse time with a prescriptive message. (Two of the eight were themselves removed later in this major, because nothing ever read them: the nested metric `filters[]` item, by `metric-filters-removed`, and the cube's `refreshKey` block, by `cube-refresh-key-removed`.) + - Done when: Every cube in `defineStack({ analyticsCubes })` / `defineCube` parses with only declared keys at every level (cube, measures, dimensions, joins); every `/analytics/query` body's `timeDimensions[]` items carry only `dimension`/`granularity`/`dateRange`. Declared keys parse byte-identically to before. +- **`analytics-cube-public-default-visible-enforced`** — `data.Cube.public — an analytics cube that declares public: false, and every cube in an artifact built by os compile before this release (the compiler writes the parsed stack, so it carries a materialized public: false on each cube that omitted the key)` → nothing, to keep a cube queryable: cubes are visible by default. Delete an authored `public: false` that only restated the old default, and write it only on a cube that must stay out of the analytics API. Recompile every `os compile` artifact built before this release + - Why not automatic: A DECLARED-DEFAULT CORRECTION plus the enforcement that makes the key real. The analytics cube schema declared `public` with a default of `false` under an access-control comment, and nothing read it: `/analytics/meta` listed every cube and every query door answered it. The analytics service now reads it. A cube declared `public: false` is left out of `/analytics/meta`, and `/analytics/query` and `/analytics/sql` refuse it with 404 `CUBE_NOT_FOUND` — the same refusal, byte for byte, that an unknown cube name gets, so the refusal does not confirm a hidden cube exists. Enforcing the old default as declared would have hidden every cube that omits the key, so the default moves to `true` in the same change, and a cube that omits the key stays visible exactly as it was. Two holdings change behaviour on upgrade. An authored `public: false` — including one copied from the example app, which carried it — now hides the cube and refuses its queries. And an artifact built by `os compile` before this release carries a materialized `public: false` on every cube that omitted the key, because the compiler writes the parsed stack with its defaults applied; a host that registers cubes from such an artifact hides all of them until the artifact is recompiled. The key is visibility, not row security: records stay governed by object permissions and row-level security on every door, and the metadata door keeps serving cube definitions. + - Done when: `GET /analytics/meta` lists every cube your dashboards and reports query, and a query naming each one answers 200. For each authored `public: false`, either delete it (the cube is meant to be queried) or keep it and confirm that `/analytics/meta` omits the cube and that a query naming it answers 404 `CUBE_NOT_FOUND`. Every compiled artifact in use was built by `os compile` from this release or later. +- **`analytics-cube-single-granularity-default-enforced`** — `data.Cube.dimensions.granularities — an analytics cube time dimension whose granularities list holds exactly one interval, whether an author wrote it that way or the protocol 18 conversion cube-sub-day-granularities-removed reduced a longer list to it` → nothing, when that one interval is the bucket the dimension should be grouped at by default. When it is not, list every interval the dimension serves (two or more state no default) or omit the key. A dashboard or report whose query the engine aggregate path cannot evaluate (a custom-SQL measure, or a member of a cube with `joins` that resolves through one) either stops grouping by such a dimension or groups by one that declares no single interval + - Why not automatic: An inert key made real. A cube time dimension's `granularities` was read only for a cube the dataset compiler minted, where a one-interval list is the dataset's default bucket. A cube authored with `defineCube()` or `defineStack({ analyticsCubes })` never reached that reader, so grouping by its time dimension grouped raw timestamps, one group per distinct instant, whatever the list said. The analytics service now reads every cube by the compiled-dataset rule: on `/analytics/query` and on the `/analytics/sql` dry run, a time dimension the query groups by without stating a granularity is bucketed at the one interval its list declares. A granularity the query states still wins, one the list does not name is not refused, and a list of two or more states no default. Two holdings change on upgrade. A query grouping by such a dimension answers one row per bucket where it answered one row per timestamp. And a bucketed query leaves the raw-SQL path, which declines every bucketed query, for the engine aggregate path, which answers 400 `INVALID_FIELD` for every member it cannot evaluate — the same refusal, byte for byte, that the same query already got with that granularity stated by hand. Those members are: a custom-SQL measure (a `number`, `string` or `boolean` measure whose `sql` is an expression); and, on a cube whose members resolve through its `joins`, a measure or a `where` field over a joined object, a `timeDimensions` entry over a joined object (bucketed or a window, so grouping by a one-interval time dimension over a joined object is refused too), a dimension that traverses more than one relationship, and an `avg` or `count_distinct` measure beside any dimension over a joined object. The raw-SQL path serves every one of these, so each such query grouped by such a dimension goes from answered to refused. On a host whose `queryCapabilities` offers raw SQL with no engine aggregate bridge (a hand override: the analytics plugin wires both), no strategy remains for a bucketed query, so every newly bucketed query, a plain count included, goes from answered to "No strategy can handle query". The protocol-18 conversion `cube-sub-day-granularities-removed` strips the retired sub-day intervals from every authored and stored cube, so a dimension that offered one sub-day interval and one coarser interval now holds a one-interval list: a default bucket its author never wrote. + - Done when: Every cube time dimension whose `granularities` lists exactly one interval is one you mean to bucket at that interval by default: a query that groups by it on `/analytics/query` answers one row per bucket, and `/analytics/sql` shows the bucketed statement. Every time dimension that should have no default lists two or more intervals or omits the key. No dashboard or report groups by a one-interval dimension a query the engine aggregate path refuses — a custom-SQL measure; a measure, `where` field or `timeDimensions` entry over a joined object; a dimension that traverses more than one relationship; an `avg` or `count_distinct` measure beside a dimension over a joined object — or each one that did now groups by a dimension without a single interval. A host that overrides `queryCapabilities` to raw SQL only either adds an engine aggregate bridge or groups by no one-interval dimension. +- **`analytics-date-range-array-two-bounds-required`** — `the ARRAY arm of timeDimensions[].dateRange on an analytics query — AnalyticsQuerySchema / the POST /analytics/query and /analytics/sql bodies, a dataset selection's timeDimensions, and any AnalyticsQuery a host passes to AnalyticsService.query in-process — authored with anything other than EXACTLY two string bounds: a one-element window such as ["2026-01-01"], the empty array [], and three or more bounds such as ["2026-01-01", "2026-01-31", "2026-02-28"]` → exactly two string bounds — `[start, end]`. A ONE-ELEMENT window is that day written as BOTH bounds: `['2026-01-01']` becomes `['2026-01-01', '2026-01-01']`, the shape the shipped migration table for the closed preset vocabulary already prescribes for a single day, and the shape all four analytics faces have selected that one day with since the fix that made them read the array arm one way. ⛔ The EMPTY array and THREE-OR-MORE bounds have NO replacement that can be derived from what was written: an empty array names no window at all, and a 3+ array names no pair — decide the window the widget was meant to show and write its two bounds, or drop the dateRange entirely (the field is optional, and absent means the query is not time-bounded). A relative window is a preset name from the closed vocabulary (`'last_7_days'`) or a date-macro pair (`['{7_days_ago}', '{today}']`). + - Why not automatic: Maintainer ruling A of 2026-09-12, re-affirmed 2026-09-13, which tightened the array arm to exactly two string bounds: the arm was a bare `z.array(z.string())` with NO length constraint, while the refusal sentence in the same source file said verbatim that "an explicit window is the two-element array [start, end]" and the shipped migration table for the closed preset vocabulary told an author to write a single day as `['2026-01-20', '2026-01-20']`. So only the TYPE was weaker than the prose beside it, and a measurement of one authored document on each face found what that bought: one authored `['2026-01-01']` meant a point window on ObjectQLStrategy, NO time clause at all on NativeSQLStrategy (the whole of history), an unbounded-above window in the draft-preview evaluator, and a shifted point window in DatasetExecutor.runCompare — the same document, four backends, four different numbers, no error on any of them. The fix that followed made all four faces refuse it with the ADR-0112 envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`, which left the contract door LOOSER than every reader behind it; this narrowing closes that gap at the door. ⚠️ No D2 conversion and no stored-metadata rewrite, deliberately: rewriting `['2026-01-01']` to the same day twice at load would be the platform deciding, silently, that the author meant one day rather than a window whose end they forgot — and for the empty array and 3+ bounds there is nothing to decide FROM. The blast radius is the WIDGET, not the page: a stored dashboard carrying a now-refused range loses that widget with the accurate refusal shown, and the dashboard still loads. Since that fix every such stored range already failed at QUERY time with the same code and status, so this adds no new class of breakage — it moves the refusal to authoring time and states it accurately. ADR-0049 / ADR-0087 / ADR-0112. + - Done when: Grep every authored `timeDimensions[].dateRange` ARRAY — dashboard widget datasets, saved analytics queries, SDK / MCP callers, in-process `AnalyticsService.query` calls — and count its bounds. Two string bounds parse byte-identically to before, as do every preset name and an absent `dateRange`; anything else now answers one prescriptive issue at `timeDimensions.N.dateRange` naming the arity it received, so `AnalyticsQuerySchema.safeParse` and `POST /analytics/query` both make the sweep mechanical. ⚠️ Do not trust the numbers a one-element window used to produce: the four analytics faces disagreed about what it meant, so a widget that showed a plausible figure may have been reading all of history on one backend and a single day on another. Re-check what each converted widget was supposed to show against its two explicit bounds. +- **`analytics-query-window-non-negative-integer`** — `the row window of an analytics query — limit and offset on AnalyticsQuerySchema, the POST /analytics/query and /analytics/sql bodies, and a dataset selection (POST /analytics/dataset/query) — authored as a negative number (limit: -1, offset: -1), a fraction (limit: 1.5), or an integer above Number.MAX_SAFE_INTEGER` → a non-negative integer, or no key at all: delete `limit` to return every row (a `limit: -1` written to mean "no limit" is exactly that), write `limit: 0` only for no rows, and delete `offset` (or write `offset: 0`) to skip nothing; a fraction becomes the integer page size that was meant. An `offset` with no `limit` stays valid and returns every row after the offset, on SQLite and PostgreSQL alike + - Why not automatic: Both members were a bare `z.number()`, and every value outside the non-negative integers answered differently per driver and per face. Measured at POST /api/v1/analytics/query on SQLite and PostgreSQL 16, through the real dispatcher route: `limit: -1` returned every row on the native SQLite face, a 500 on native PostgreSQL and all but the last row on the ObjectQL face; `limit: 1.5` answered a 500, two rows and one row; `offset: -1` a 500 on both native drivers and every row on the ObjectQL face. No value had one answer, so the contract refuses them instead of any engine guessing (contract first, ADR-0049): the two members are `z.number().int().nonnegative()` on `AnalyticsQuerySchema`, the dataset selection reads the same two declarations off its shape, and the runtime doors answer the ADR-0112 envelope `400 VALIDATION_FAILED` naming `limit` or `offset` before any engine runs. ⚠️ No D2 conversion and no stored-metadata rewrite: the window is a QUERY-time request field, not a `sys_metadata` shape, and the refused values meant different windows on different backends, so coercing one would be the platform guessing which the author meant. The one stored producer that lowers into a selection, a dashboard widget's `limit`, is already declared a positive integer. Measured in this repository at the change: no example, fixture, document or published skill authors a negative or fractional analytics window. ADR-0049 / ADR-0112. + - Done when: Grep every authored analytics `limit` and `offset` — saved analytics queries, SDK and MCP callers, dataset selections, and queries a host builds in-process — and rewrite each negative or fractional value as described. POST /analytics/query and /analytics/sql answer `400 VALIDATION_FAILED` with `details.fields[].field` naming `limit` or `offset`, and POST /analytics/dataset/query names `selection.limit` or `selection.offset`, so a sweep is mechanical; `AnalyticsQuerySchema.safeParse` reports the same issue at the member. Non-negative integer windows parse byte-identically to before, `limit: 0` still answers no rows, and absence stays absence. The service does not parse a query passed to it in-process, so a host that builds one parses it with `AnalyticsQuerySchema` first. A query that carried a refused value was never returning one window, so re-check what the widget was meant to show rather than trusting the old result set. +- **`analytics-row-wildcard-outside-count-refused`** — `analyticsCubes[].measures..sql, analyticsCubes[].dimensions..sql and datasets[].measures[].field (data.MetricSchema.sql / data.DimensionSchema.sql / ui.DatasetMeasureSchema.field) authored as the row wildcard * where no count consumes it — a cube measure whose type is anything but count, any cube dimension, and a dataset measure whose aggregate is anything but count or that declares none (a derived measure)` → what the member meant. A row count: `type: 'count'` on a cube measure or `aggregate: 'count'` on a dataset measure, keeping `'*'` (a dataset count may also omit `field`). An aggregate of values: the column it aggregates — a field of the object (`amount`) or a relationship path ending in one (`account.amount`). A cube dimension: the column it groups by; to count rows, declare a `count` measure instead. A `derived` measure: delete the `field` key, which nothing read — a derived measure combines other measures by name + - Why not automatic: `'*'` is the row wildcard a `count` aggregates (`COUNT(*)`): it reads no field value, so no other aggregate has a column to read over it, and a dimension has no aggregate at all. The contract nevertheless admitted it in a cube member's `sql` on any measure and on a dimension, and in a dataset measure's `field` under any aggregate, and the analytics strategies passed it to the database as written. Measured at POST /api/v1/analytics/dataset/query over a real SQLite driver, on the native-SQL and the ObjectQL strategy alike: a dataset measure aggregating `'*'` under `sum`, `avg`, `min`, `max` or `count_distinct` answered 500 DATABASE_ERROR — a server fault for an authoring mistake the contract had admitted. A dataset measure compiles to the cube measure it names verbatim, so the same reading covers an authored cube measure; a dimension over `'*'` (GROUP BY *) was measured the same way when the dataset dimension was narrowed. Such a member never produced an answer, so no working document changes meaning: the failure moves from the query to the authoring parse, which names the slot and the aggregate and prescribes a `count` or a column. There is no D2 conversion: rewriting to `count` would change the figure the author asked for, and only the author knows which column a sum over `'*'` was meant to read. A STORED document is not rewritten: a metadata read still serves it as stored, with the refusal on its read diagnostics, and a re-save through the metadata write door is refused at the slot. The dataset query door parses every dataset it is handed, inline or saved, so a stored dataset carrying such a measure is refused 400 VALIDATION_FAILED on EVERY query — including a query that selects only its other measures, which used to answer: it fails closed until the member is fixed. An authored cube reaches the analytics runtime through the stack definition, whose parse refuses it when the stack is built. In-repo census before the change: no example, platform object, doc, skill or fixture authored one, and neither did objectui at the pinned commit; deployed metadata was NOT measured. ADR-0021 / ADR-0049 / ADR-0087 + - Done when: Every analytics cube and dataset parses: `CubeSchema`, `DatasetSchema`, the analytics_cube and dataset write doors, defineCube and defineStack refuse `'*'` on a cube measure whose `type` is not `count` and on a dataset measure whose `aggregate` is not `count` (at its `sql` / `field`, code custom), and on a cube dimension (at its `sql`, code invalid_format), each naming the slot and prescribing a `count` or a column, so the sweep is mechanical — parse each document, and each refusal is one member to change. For each changed member, a query that selects it returns a figure instead of a 500, and a dashboard bound to a stored dataset that carried one answers again on every widget. A `count` over `'*'`, a dataset count with no `field`, and every member that names a column parse byte-identically to before and run on both strategies. +- **`analytics-time-dimension-date-range-vocabulary-closed`** — `the bare-STRING arm of timeDimensions[].dateRange on an analytics query — AnalyticsQuerySchema / the POST /analytics/query and /analytics/sql bodies, a dataset selection's timeDimensions, and any AnalyticsQuery a host passes to AnalyticsService.query in-process — authored as anything other than one of the thirteen declared date-range preset names (today, yesterday, this_week, last_week, this_month, last_month, this_quarter, last_quarter, this_year, last_year, last_7_days, last_30_days, last_90_days): the display spelling "Last 7 days" the schema comment used to show, the driver-memory dialect "last N days" / "last 3 months", or a bare ISO date such as "2026-01-20" (the SQL strategies' single-day dialect)` → a preset name from the closed vocabulary — `'last_7_days'` for "Last 7 days" / "last 7 days", `'last_30_days'`, `'this_month'`, and so on (`DATE_RANGE_PRESETS` in `@objectstack/spec/data` is the list; the rejection prints it) — or, for an explicit window, the two-element array the array arm always accepted: `['2026-01-20', '2026-01-20']` for the single day a bare ISO string used to mean on SQL, `['2026-01-01', '2026-01-31']`, or `['{7_days_ago}', '{today}']` in date-macro tokens + - Why not automatic: Maintainer ruling of 2026-09-06 on the analytics date-range string (option A — contract first): the protocol is the baseline, so the vocabulary is declared once in the schema and the drivers align to it, in a driver change of their own, instead of each guessing. The arm was a bare `z.string()` whose only documented example, `"Last 7 days"`, no driver could parse: driver-memory recognised exactly `today` and a case-sensitive `last N ` and fell every other string through to a `[range, range]` pseudo-window that — measured through mingo on 2026-09-05 — matched EVERY `Date`-typed row, 2099 included, because a `Date` compares above a `String` under BSON cross-type ordering; the SQL strategies read the same string as a single ISO day. A dashboard asking for one week silently got all of history on one backend and one day on the other, with no error on either. The string arm is now `z.enum(DATE_RANGE_PRESETS)` — derived from `data/date-range-presets.ts`, the vocabulary's single source of truth since the dashboard date filter's three copies of the list were folded into it, so the two cannot drift — and any other string is refused at parse time with one prescriptive issue at the field's own path; the runtime door answers the ADR-0112 envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` (`api/error-code-ledger.zod.ts`). ⚠️ No D2 conversion and no stored-metadata rewrite: this value is a QUERY-time request field, not a `sys_metadata` shape, and the two retired dialects meant different windows on different backends, so coercing one would be the platform guessing which the author meant. Measured in this repository at the ruling: three authored `'Last 7 days'`, all in spec tests, and no published dashboard authors the string arm at all (the shipped console lowers presets to the array arm). ADR-0049 / ADR-0112. + - Done when: Grep every authored `timeDimensions[].dateRange` string — dashboard datasets, saved analytics queries, SDK / MCP callers, in-process `AnalyticsService.query` calls — and rewrite each bare string that is not one of the thirteen preset names: a relative phrase to its preset (`'last_7_days'`), an ISO day to the two-element array `[day, day]`. `POST /analytics/query` now answers `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` naming the value and the vocabulary, so a sweep is mechanical; `AnalyticsQuerySchema.safeParse` reports the same issue at `timeDimensions.N.dateRange`. Preset names and `[start, end]` arrays parse byte-identically to before. A query that carried one of the retired spellings was never returning the window it named (all rows on driver-memory, one day on SQL), so re-check what the widget was supposed to show rather than trusting the old result set. +- **`api-assembled-entry-split`** — `api.AssembledInstalledPackageSchema / api.InstalledPackageAtEitherStageSchema / api.ListInstalledPackagesResponseSchema / api.GetInstalledPackageResponseSchema / api.PackageApiContracts, with the types AssembledInstalledPackage, InstalledPackageAtEitherStage, ListInstalledPackagesResponse, GetInstalledPackageResponse and their Parsed twins — imported from @objectstack/spec/api` → the same names, unchanged, imported from `@objectstack/spec/api-assembled` — change the import path and nothing else. Every schema parses and refuses exactly what it did, the route map has the same four entries, and the JSON Schema ids are unchanged (`json-schema/api/AssembledInstalledPackage.json` and its three siblings are still published under `api/`). Every OTHER Package API declaration — the request schemas of both read doors, the install / uninstall / upgrade / rollback shapes, `PackageApiErrorCode` — stays on `@objectstack/spec/api`. + - Why not automatic: Maintainer ruling of 2026-09-17, option B (narrow the entry, rather than add a bundle-weight rule to the browser-reachability ledger or accept the weight as it stood): split the API entry so its browser-facing half no longer carries the assembled-package declarations. Those five embed the ASSEMBLED package body, which reaches the whole metadata vocabulary and, behind it, the datasource declaration and the driver-config validators; declared inside `@objectstack/spec/api`, that tree was part of every bundle of the entry, and a browser module importing two string constants from it paid for all of it. Measured on the splitting PR: that module (objectui `@object-ui/core` column-sortability) bundles to 166,529 bytes gzipped instead of 311,124. The split moves an import path, which is TypeScript source rather than metadata — nothing authors, stores or parses it — so there is no source a D2 conversion could rewrite, and the move is recorded here. + - Done when: No code imports any of the five names, their types or their Parsed twins from `@objectstack/spec/api` — each such import is a TS2305 "has no exported member" error after upgrade (TS2724 with a did-you-mean when a similarly named export exists; the suggested name is a different schema, not the replacement), and at runtime the binding is undefined. The same names import cleanly from `@objectstack/spec/api-assembled`. No metadata document, stored row or JSON Schema reference needs editing: the schemas and their published ids did not change. +- **`api-endpoint-cache-ttl-unit-in-key`** — `apis[].cacheTtl — the response-cache lifetime of a declared API endpoint` → `cacheTtlSeconds` — the same lifetime, in seconds, with the unit in the key name. It still applies to GET endpoints only. + - Why not automatic: The D2 conversion `api-endpoint-cache-ttl-to-cache-ttl-seconds` renames `cacheTtl` to `cacheTtlSeconds` in the `apis` collection and on stored endpoint rows, keeping the value, and the rename is lossless: the key always meant seconds. The judgment is whether the author knew that. The unit lived only in the description, on the same endpoint surface where `rateLimit.windowMs` spells its unit in milliseconds, so a value written in milliseconds — `cacheTtl: 60000` meant as one minute — cached responses for almost seventeen hours, and the rename carries 60000 over unchanged. A cache that lives a thousand times longer than intended serves stale data long after the underlying records change, with no error anywhere. Only the author can say which unit each value was written in. + - Done when: No endpoint carries `cacheTtl`; the parse refuses it with the rename. Every `cacheTtlSeconds` value is the cache lifetime the author intends in seconds — an endpoint meant to cache for one minute reads `cacheTtlSeconds: 60`. A GET to the endpoint repeated inside that window is answered from the cache, and one repeated after it reflects a record changed in between. +- **`api-error-retry-after-unit-in-key`** — `EnhancedApiError.retryAfter (api/errors.zod.ts) — the ADR-0112 error envelope on the wire` → retryAfterSeconds — rename the key; the value (seconds) is unchanged + - Why not automatic: Maintainer ruling B of 2026-09-02 on duration-shaped keys: the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. BREAKING ON THE WIRE, and ruled in deliberately: its 2026-09-05 population ruling puts the ~16 runtime-emitted measurements in scope because they are read by humans and agents even if nobody authors them, and names ApiError.retryAfter explicitly, with its own BREAKING note. The ambiguity here is sharper than the usual bare duration. A consumer meets TWO retry-after values on the same 429 response: this envelope field, which has always been delta-seconds, and the HTTP Retry-After header, which per RFC 9110 section 10.2.3 may carry EITHER delta-seconds OR an HTTP-date. Spelled identically, they read as one value in two places; spelled retryAfterSeconds, the envelope states its own unit and the header keeps its own rules. THE HTTP HEADER IS A SEPARATE, UNCHANGED SURFACE — its name is fixed outside this repo and nothing in this rename touches it. Do not "fix" the header to match, and do not read a green grep for `retry-after` in transport code as leftover work. A SEMANTIC entry rather than a D2 conversion because an error envelope is emitted, never stored: it is not a stack collection member and never a sys_metadata row, so the conversion chain has no seam that would see one. ADR-0087, ADR-0112. + - Done when: No producer emits `retryAfter` on an ApiError envelope and no consumer reads it; the old spelling is a retiredKey() tombstone that fails tsc at the construction site and fails the parse with the rename prescription. Concretely, check three things. (1) Code building a rate-limit error body: rename the key to `retryAfterSeconds`, value unchanged. (2) Client code backing off on a 429: read `error.retryAfterSeconds`. (3) The transport layer is NOT part of this change — a handler setting the `Retry-After` response header, and a client reading it (including the HTTP-date branch), keep the RFC 9110 spelling and are correct as they stand. A local rate-limiter decision object of the shape { allowed, retryAfter } is not this key either: it is not an ApiError envelope and is untouched. +- **`api-runtime-config-durations-unit-in-key`** — `two api-layer runtime configuration durations whose name carried no unit: DataLoaderConfig.cacheTtl (api/contract.zod.ts) and RouteDefinition.timeout (api/router.zod.ts)` → cacheTtlSeconds (seconds) and timeoutMs (milliseconds) — rename each key; both values are unchanged + - Why not automatic: Maintainer ruling B of 2026-09-02 on duration-shaped keys: the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. Two keys on two shapes, in one entry because they share a disposition and an audience: both are api-layer runtime configuration a host or plugin builds in code, and neither is part of a published metadata document. DataLoaderConfig.cacheTtl named seconds only in its describe on a batching config whose other numbers are counts (maxBatchSize, maxConcurrency); RouteDefinition.timeout said "Execution timeout in ms" in prose and nothing else. Both are retiredKey() tombstones — neither shape is strict, so a bare deletion would strip the old key in silence and an unknown-key error could not carry the rename. Why a semantic entry and not a D2 conversion: a DataLoaderConfig is a per-request batch-loader construction argument and a RouteDefinition is a router registration built by a plugin at start — neither is a stack collection member and neither is ever stored, so the chain has no seam that would run on them (the kernel/Manifest:loading precedent). Worth knowing while grepping: packages/runtime declares its OWN local RouteDefinition interface for the ai:routes hook payload — a different type with no duration key at all, untouched by this rename. ADR-0087. + - Done when: Every DataLoaderConfigSchema.parse(…) and RouteDefinitionSchema.parse(…) site spells `cacheTtlSeconds` / `timeoutMs`; authoring either old spelling fails to compile (input type `never`) and fails to parse with the rename prescription naming the suffixed key. A DataLoader configured with `cacheTtlSeconds: 60` expires per-request cache entries after 60 seconds exactly as `cacheTtl: 60` did, and its `min(0)` bound rides along, so a negative TTL is still refused. A route declared with `timeoutMs: 30000` aborts after 30 seconds exactly as `timeout: 30000` did. +- **`approval-position-address-role-retired`** — `approvals position address role: — the approverId filter of the approvals request list, the actorId of every decision, and a stored pending_approvers slot` → `position:`, the one spelling of a position address; a flow approver authored as `{ type: 'role', value: }` becomes `{ type: 'position', value: }` + - Why not automatic: The fourth face of the ADR-0090 D3 `role` retirement, beside `actor-user-roles-to-positions` and `action-session-roles-to-positions`, and like them a runtime face with no spec schema. The approvals service read `role:` as a second spelling of `position:` wherever it compares a slot with the caller (the "My Pending" filter, the participant gate, `viewer.can_act`, and the slot test of every decision), because 15.x-era slots and the stock console's identity list carried it. ADR-0090 D3 retires the word with no alias window, so once the pinned console sent `position:` the arm came out in one edit (maintainer ruling, 2026-10-04). `position:` is now the only position address: a `role:` ask matches only a slot stored under that exact spelling, and a `role:` actor is refused with 403 `FORBIDDEN`. The same ruling closed the one WRITER of the spelling. The deprecated `role` approver TYPE already resolved as `org_membership_level` (the org-membership tier: owner, admin, member), but when that lookup found no one the fallback slot kept the AUTHORED spelling, `role:`, and a holder of a same-named position decided it through the arm, so the runtime silently honoured a membership-tier declaration as a position. The fallback now writes the canonical `org_membership_level:`, and no path writes a `role:` slot. Two classes of pending request are therefore decided only by the privileged override, or by a reassign to a real approver: a request a 15.x-era release stored as `role:`, and a new request from a flow that still authors `{ type: 'role', value: }` and whose tier lookup finds no one. No stored slot is rewritten: the ruling refused a one-time rewrite as the permanent migration debt ADR-0090's first forcing fact names. Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds. FIRST, no metadata key moves: the address is runtime DATA (a request slot, a query parameter, a decision body's actor), never a `sys_metadata` row, so there is no source for a declarative transform to rewrite. SECOND, the one authored shape that leads here, `{ type: 'role', value: }`, is ambiguous by construction: the deprecated alias means the membership TIER, and whether its author meant a position instead is a judgment only that author can make, so a mechanical rewrite to either type would guess. The `ApproverType` `role` alias itself is a separate retirement and is unchanged here. ADR-0090 D3, ADR-0087. + - Done when: No client sends `role:` as an `approverId` or an `actorId`: every such value is `position:`, and a request routed to the position is listed for its holder, served with `viewer.can_act: true`, and decided by that holder with no `actorId` named. Every flow approver authored as `{ type: 'role', value: }` is reviewed by its author: `` a position name becomes `{ type: 'position', value: }`, and `` a membership tier (owner, admin, member) becomes `{ type: 'org_membership_level', value: }`. `os lint` reports the first as `approval-approver-not-membership-tier` and the second as `approval-approver-type-deprecated`. Every pending request whose slot reads `role:`, or `org_membership_level:` from such a flow, is either decided by a platform admin or a tenant admin of its organization (the approve or reject is recorded with `via_override: true` and resumes the run) or reassigned to the position's holder, who then decides it. Verify on a running app, as an admin: the pending list filtered by `approverId` set to each such slot address answers no rows. +- **`assembled-package-body-plugins-envelope`** — `artifact `packages[].manifest.plugins` and `packages[].manifest.devPlugins` — the two keys inside an ASSEMBLED package body (`AssembledPackageBodySchema`, ADR-0130 D4)` → Declare `plugins` / `devPlugins` at the stack TOP LEVEL only — the artifact envelope, where `os serve` / `os migrate` / `os dev` read them and where `composeStacks` still concatenates them (`concat` is unchanged for in-memory composition). Delete both keys from every `packages[i].manifest` body: a multi-package artifact that carried them is rebuilt from source (`os build` / `composeStacks(…, { manifest: 'preserve' })` no longer folds them into a body), and a hand-written `packages[]` entry drops them. + - Why not automatic: A classification error, not a new special case (maintainer ruling A, 2026-09-04: both keys are artifact envelope keys, top level only, never inside `packages[]` — decided while one artifact was being taught to carry several co-owning packages). `plugins` and `devPlugins` were the only members of the assembled-body key set whose values are runtime ASSEMBLY instructions rather than serialisable metadata: `plugins` holds what a host hands to `kernel.use()` — live plugin instances, manifests or package names — and `devPlugins` is the `os dev` load list. Inside an artifact a package body is inert JSON, so a plugin written under `packages[i].manifest` could never be constructed by any loader; every reader (`serve.ts`, `schema-migration-plugins.ts`) reads the top level, and the "resolve `packages[]` when the top level is absent" repair every other reader took would have turned a silent skip into a boot that registers garbage. Options B (readers resolve JSON descriptions into live plugins) and C (the emitter special-cases the two keys) were refused. After the ruling, "an artifact carries metadata, a host assembles plugins" is one sentence every reader inherits. Not losslessly convertible: hoisting a body-level plugin to the envelope changes who loads it, and a live instance has no JSON form to move. + - Done when: No `packages[i].manifest` in any artifact carries `plugins` or `devPlugins`; the body schema refuses either with `unrecognized_keys` naming the key (pinned in `assembled-package-body.test.ts`), and `artifact-packages.ts` refuses the entry at load with that path. A stack declaring `plugins` / `devPlugins` at the top level parses byte-identically to before, `composeStacks` still concatenates both in stack order, and `manifest: 'preserve'` emits package bodies without them. An existing multi-package artifact that carried a body-level `plugins` is rebuilt from source. +- **`audience-posture-default-invite-only`** — `system.AuthConfig.audience` → explicit `auth: { audience: { posture: 'open' | 'email_domain', selfRegistrationPermissionSet: '' } }` (deployments that intend open self-registration only) + - Why not automatic: The default audience posture flipped when one declared posture replaced the emergent self-registration default: an UNDECLARED `audience` now means `invite_only` — email/password self-registration (and social-provider JIT sign-up) is refused with 403 SELF_REGISTRATION_CLOSED unless the address holds a pending invitation. Previously the emergent default was open self-registration with no email verification. Whether a deployment truly means to admit strangers (public portal) or was open only by accident is a security judgment no transform can make — and a posture that opens self-registration must also DECLARE the permission set a self-registrant receives and accepts forced email verification, neither of which can be invented mechanically. + - Done when: A deployment that relies on open self-registration declares `audience.posture` ('open', or 'email_domain' with `allowedEmailDomains`) plus `selfRegistrationPermissionSet`, and its sign-up flow still works end to end (verification email delivered, registrant holds the declared permission set). Every other deployment verifies operators can still add users (invitation, admin create-user / import, SCIM, or an operator-registered identity provider) and that anonymous sign-up now answers 403 SELF_REGISTRATION_CLOSED. +- **`automation-flow-list-route-retired`** — `GET /api/v1/automation — the flow-list route of the automation door, together with its request and response schemas ListFlowsRequestSchema and ListFlowsResponseSchema (and their ListFlowsRequest, ListFlowsRequestParsed, ListFlowsResponse and ListFlowsResponseParsed types), FlowSummarySchema and its FlowSummary type, the listFlows entry of AutomationApiContracts, and the automation.list method of @objectstack/client. Every other automation route is unchanged, including POST /api/v1/automation (create a flow) at the same path` → GET /api/v1/meta/flow — flows are metadata (ADR-0106), and this is the governed read of them; from the SDK it is `client.meta.getItems` with the type `flow`. It answers the full flow definitions rather than bare names, so a caller that only needs the names maps each item to its `name`. The runtime enablement and trigger binding of every flow — the one piece of engine state a definition does not carry — is `GET /api/v1/automation/_status` (`client.automation.getRuntimeStatus`), which is unchanged + - Why not automatic: Maintainer ruling of 2026-09-25 on the list doors found declaring `limit` / `cursor` and never reading them (this route is door ④; verbatim 「退役,统一走 /meta/flow」, given when asked why the flow list does not use the standard API), under ADR-0049 enforce-or-remove. The route's contract described a capability nobody built: ListFlowsRequestSchema declared `status`, `type`, `limit` (default 50) and `cursor`, and the handler read none of them — it asked the automation service for its flow names with no arguments at all. ListFlowsResponseSchema declared a page of FlowSummary rows with `total`, `nextCursor` and `hasMore`, and the handler answered a bare array of names beside a literal `hasMore: false`. So a caller filtering by status received every flow, a caller paging with a cursor re-read the only page forever, and a caller reading FlowSummary fields read undefined — each with a 200 and no error. Measured before removal, on the main branch of this repository and cloud and on objectui at both its pinned commit and main: zero callers of the route or of the SDK method outside their own tests, while both real flow lists in the product — the Console flow-runs page and the Setup packaged-automation page — already read GET /api/v1/meta/flow. Implementing the declared contract instead would have built a second, weaker metadata list beside the governed one; retiring it leaves one read. There is no alias and no transition window: GET simply stops being mounted there. There is no D2 conversion and no tombstone, because the shape is HTTP-only — nobody authors a ListFlowsRequest and nothing persists one — so the three schemas are whole-def removals in RETIRED_DEFS_BY_MAJOR and this entry carries the record. ADR-0049 / ADR-0087 / ADR-0106. + - Done when: On the composition `objectstack serve` builds, GET is no longer mounted at /api/v1/automation (nor at its environment-scoped twin), so the host gives its standard unmatched answer with no residual refusal text of its own. Because POST still lives at that path, on the Hono host that answer is 405 METHOD_NOT_ALLOWED with an Allow header naming POST — the same answer any path where only another verb is registered gets, for anonymous and signed-in callers alike. A transport that forwards every automation path to the dispatcher is told the domain does not handle it and answers its own not-found 404 (the @objectstack/hono catch-all does), and there the domain's anonymous floor still answers an unidentified caller 401 first, as it does for every automation path. The automation service's flow-name enumeration is never called by any HTTP request. The route-ledger row for the route is gone, AutomationApiContracts has eight entries and none of them is a GET at the bare path, and a TypeScript import of any of the removed schemas or types is a compile error (TS2305). @objectstack/client no longer declares automation.list, so a call to it is a compile error rather than a request to a path that no longer answers. POST /api/v1/automation still creates a flow, and every other automation route — the single-flow reads and writes, trigger, toggle, clone, runs, resume, cancel, restore-suspension, screen, _status and the actions and connectors catalogs — answers exactly as before. +- **`automation-runs-cursor-retired`** — `api.listRuns cursor — the pagination query parameter of GET /api/v1/automation/:name/runs declared by ListRunsRequestSchema, its slot on IAutomationService.listRuns, and its option on all three @objectstack/client run-list surfaces (automation.runs.list, automation.listRuns, environment().automation.listRuns). The limit parameter of the same door is NOT part of this retirement and is unchanged, default(20) included` → a wider `limit` — this door does read it, bounded to 1..100, and it is spent as the run store's history window. There is no replacement for `cursor` itself, deliberately: nothing ever minted one, so no caller holds a value to carry over, and the response `nextCursor` it would have paired with has never been emitted. Read the response `hasMore` to learn whether the window was short — it is now computed from the engine rather than the constant `false` it used to be, so for the first time it answers the question a caller reaching for a cursor was actually asking + - Why not automatic: ADR-0049 enforce-or-remove (maintainer ruling 2026-09-21 on the list doors found declaring `limit` / `cursor` and never reading them — this door is door ①, and the ruling took letter C of three for it; letter A — build a cursor protocol for a 100-row window — and letter B — retire the key and leave the `hasMore` lie standing — were both considered and refused). `cursor` was declared on the request, VALIDATED at the boundary, forwarded into a `cursor?: string` slot on the service contract, and read by no implementation: the engine never looked at the option, and no emit site has ever written the response half `nextCursor`, so a caller looping until the cursor ran out re-read the first and only window forever with no error. ⭐ The `limit` half of this door was NOT retired, and the distinction is the ruling, not an oversight. The sibling `/packages` door retired its `limit` with its `cursor` (the 2026-09-13 ruling aligning that door's declaration with its reads: pagination is no part of a small bounded list) because nothing read it; the parent ruling explicitly does not transfer here. On this door `limit` is read end to end — the boundary enforces the declared 1..100 range off the schema itself, the service takes it as an option, and the engine spends it as `RunStore.listHistory`'s window — and the Console's flow-runs page sends it today. Retiring it would have been a regression, and its `.default(20)` stays with it. The same card computes `hasMore`, which is the half a bare retirement would have left lying. `GET /api/v1/automation/:name/runs` shipped a literal `hasMore: false` beside a list the engine had already truncated with `.slice(0, limit)`, so a caller asking for one row of a thousand was handed one row and told that was all of them. The engine now reports truncation to the door through a new optional contract member, `IAutomationService.listRunsPage`, which returns `{ runs, hasMore }`: it over-reads its history source by exactly one row and compares the merged, filtered, ordered set to the caller's window. The over-read is what makes the answer sound — `runs.length === limit` cannot tell a flow with exactly `limit` runs from one with ten thousand, and `RunStore.listHistory`'s signature is deliberately unchanged because over-reading is expressible in the `limit` it already takes. There IS a tombstone: the request schema is non-strict, so a bare deletion would have made Zod SILENTLY STRIP whatever a generated client kept sending — a clean parse and a parameter that never takes effect, which is this card's own defect re-created one layer down (ADR-0104). `cursor` is therefore a `retiredKey()`, typed `never` for tsc and raising the prescription at any parse, and is registered in RETIRED_KEYS_BY_MAJOR[18]. There is NO D2 conversion: a conversion rewrites an authored source or a stored `sys_metadata` row, and this shape is HTTP-only — nobody authors a `ListRunsRequest` and nothing persists one. The `os migrate meta` house sentence is therefore correctly absent from the prescription. There is no `acceptRetiredDefaultResidue` stage either: `cursor` carried no default, so it materialized into no artifact and there is no residue to accept. The SDK half is part of the retirement rather than a follow-up: `@objectstack/client` declared `cursor` and appended it on all three run-list surfaces, so retiring the key in the schema alone would have left the one generated client this repo ships typing it `string` and sending it into a route that silently drops it — the ADR-0104 shape the tombstone exists to prevent, re-created one layer down. The same call was made when the notifications `cursor` was retired: the client dropped the option and recorded the removal in its docblock. ADR-0049 / ADR-0087. + - Done when: No caller sends `cursor` to `GET /api/v1/automation/:name/runs`, and that is true of every channel this repo ships rather than of the schema alone. Writing it on a `ListRunsRequest` is a `tsc` error (the input type is `never`), and any value reaching a parse raises the prescription rather than a generic unrecognized-key issue. The option is gone from `IAutomationService.listRuns`, so an implementation can no longer declare a slot for it. ⭐ It is also gone from the SDK, which is the channel most callers actually reach this door through: `@objectstack/client` no longer declares `cursor` on `automation.runs.list`, `automation.listRuns` or `client.environment(id).automation.listRuns`, and no longer appends `?cursor=` on any of the three — so the key cannot be smuggled past the retired schema by an untyped caller. Without that half the retirement would have re-created its own defect one layer down: the schema typing the key `never` while the shipped client typed it `string` and sent it, silently dropped by a route that no longer reads it (ADR-0104). ⚠️ ONE wire behaviour CHANGES and must be verified as such, because it reverses a decision recorded when this door's query parameters were first validated where they are read (the fix for `?limit=abc` reaching the engine as `NaN`): a repeated `?cursor=a&cursor=b` used to answer `400 VALIDATION_FAILED` with a `details.fields[]` entry naming `cursor`, and now answers `200` with the key ignored like any other unrecognised query name. That fix validated the key rather than deciding it, so that a future cursor implementation would not be the one to discover the type was unenforced; this ruling decides it instead — there will be no cursor implementation on this door — so the refusal would be validating a key the contract no longer has. This route declares no closed query set (AGENTS.md route-ownership rule 5), so an unrecognised name has never been refused here on its own account. ⚠️ `hasMore` also changes, from a constant to an answer: a request whose window is shorter than the matching run set now receives `hasMore: true` where it previously received `false`. A caller that treated `false` as "this is the whole history" was always wrong and is now told so. ⚠️ Read the new `false` with one qualification: unfiltered it is exact, but under `?status=` it means "no further match inside the window that was scanned" rather than "none exists", because the durable history source has no status slot and the window is taken before the filter is applied. Pushing the filter down is a store-contract change this card did not scope. `nextCursor` stays absent — nothing mints one — and `limit` behaves exactly as it did, including its `.default(20)`. A deployment whose automation service does not implement `listRunsPage` answers `501` naming the member, and ⛔ never a `200` carrying an invented `hasMore`. +- **`autonumber-default-unique-organization`** — ``fields..unique` on a `type: 'autonumber'` field when the author OMITS the key — the contract default moves from `false` (no index) to `'organization'` (one holder per organization; the NULL-safe tenant-composite unique index `(COALESCE(organization_id, '__global__'), )` on an organization-scoped object, a plain unique index where the object has no organization key)` → keep the omission to take the default — an auto-number is a business identifier and is unique per organization from now on with zero application-side declaration; write `unique: false` EXPLICITLY on the one autonumber field that is a display-only sequence and is never used to identify the record. Every other field type keeps `unique: false` as its default, and every authored spelling (`true` / `'organization'` / `'global'` / `false`) parses exactly as before + - Why not automatic: Not losslessly convertible because the change is data-dependent, not textual: a table that already holds duplicate auto-numbers (a counter that re-issued a burned number, or the seed/API tenancy split running two counters for one object) cannot take the index the default now declares. The SQL driver refuses to silently degrade — it logs at `error` naming the index, the columns and the remedy, the same boot's drift pass names the conflicting key groups with row counts, and `os migrate plan` reports the blocked `create_index` with the same groups (ADR-0120 D4) — but which of the duplicate rows keeps the number is a business decision no migration entry can make. Maintainer ruling 2026-08-31, on a downstream CRM's measurement that eight of its nine auto-numbered business identifiers could be issued twice: an auto-number that may repeat is not an identifier, so unique is the platform default and opting out is the declaration, not the other way round. + - Done when: Every `autonumber` field without an authored `unique` parses to `unique: 'organization'` (`FieldSchema.parse({ type: 'autonumber' }).unique === 'organization'`, and through `ObjectSchema` the same); an authored `unique: false` on an autonumber field parses to `false`; every non-autonumber field type without an authored `unique` still parses to `false` at the same key position. On a serving boot, each organization-scoped object with such a field carries `uniq__organization_id_`; a table whose data blocks it shows the blocked `create_index` with its conflicting groups in `os migrate plan` until the rows are deduplicated and the plan is re-run, and `os migrate duplicates` lists the holder rows of any value minted across organization partitions. +- **`branded-identifier-schemas-retired`** — `the six branded identifier schemas of `@objectstack/spec/shared` (`shared/branded-types.zod.ts`, removed whole): `ObjectNameSchema`, `FieldNameSchema`, `ViewNameSchema`, `AppNameSchema`, `FlowNameSchema`, `RoleNameSchema`, and their type exports (`ObjectName`/`ObjectNameParsed` through `RoleName`/`RoleNameParsed`).` → (removed — no replacement brand layer. Parse an identifier through the schema of the surface that stores it: object and field names through `ObjectSchema`/`FieldSchema` (inline snake_case regex), flow names through `FlowSchema`, app names through `AppSchema` (`SnakeCaseIdentifierSchema`), position/role names through `PositionSchema`. A caller that wants a standalone identifier check uses `SnakeCaseIdentifierSchema` or `SystemIdentifierSchema` from `@objectstack/spec/shared` directly — both stay published.) + - Why not automatic: Maintainer ruling 2026-09-01 (director decision batch C, verbatim 「同意」: retire) — ADR-0049 enforce-or-remove. The brands promised compile-time safety ("you cannot pass an ObjectName where a FieldName is expected") that no consumer could obtain: no schema in either repository ever composed a brand, so nothing produced or accepted a branded value, while the surfaces the brands were named for are validated by inline regexes or bare `SnakeCaseIdentifierSchema` three files away. Binding was weighed and not adopted: zero consumers exist, binding would silently change five surfaces' accept sets (the inline regexes admit a leading underscore the brand base does not), and a future real need for centralized identifier grammar re-opens freely against actual pull. + - Done when: No code imports any of the six schemas or their types from `@objectstack/spec/shared` (TS2305 after upgrade — the module is removed, not stubbed); the five surfaces' validators are byte-for-byte untouched (inline regexes at `data/object.zod.ts`, `data/field.zod.ts`, `automation/flow.zod.ts`; bare `SnakeCaseIdentifierSchema` at `ui/app.zod.ts`, `identity/position.zod.ts`); `SnakeCaseIdentifierSchema` and `SystemIdentifierSchema` themselves remain published and unchanged; the six def keys (`shared/ObjectName`, `shared/FieldName`, `shared/ViewName`, `shared/AppName`, `shared/FlowName`, `shared/RoleName`) leave `json-schema.manifest/shared.json` in the same change that registers this entry. No authored metadata document ever embedded a branded value, so no source rewrite ships and `objectstack migrate meta` has nothing to visit. +- **`by-id-write-unreadable-row-not-found`** — `the data write doors — a by-id update or delete of a row the caller cannot read, on every object and for every principal` → read 404 `RECORD_NOT_FOUND` on a by-id update or delete as "no row you can see has this id" — the read door's meaning — and keep a 403 for a row the caller can read but may not write + - Why not automatic: A WRITE-DOOR ANSWER, made one with the read door's. A by-id update or delete of a row the caller cannot read used to answer a 403 — `PERMISSION_DENIED` where a write-class row filter binds the caller, otherwise a later gate's own 403, such as `FORBIDDEN` from record sharing or a parent-derived gate's code on attachments and comments — while an id that names no row answered 404, so the write door told a hidden row apart from a missing one. The by-id write pre-image check now asks every principal whether it can read the row it addressed, through a by-id read in its own context that every data middleware's visibility applies to, and answers a row that read does not return with the read door's not-found: the same code, status and body a nonexistent id gets. It also refuses a by-id write a principal no row filter binds could previously land on a row hidden from it, such as an attachment's uploader or a comment's author whose parent record they can no longer read. A caller who can read the row but may not write it keeps its 403. Writes the platform issues under the caller's context — the engine's cascade delete, a hook's write, the referential clear of a lookup — keep their previous answer, and writes not routed by id are unchanged. + - Done when: Every client that handles a by-id update or delete treats 404 `RECORD_NOT_FOUND` as "not found or not visible" and no longer reads a 403 there as proof the row exists; an operator who needs a user to write a row grants that user read access to it first. +- **`cache-warmup-scheduled-strategy-retired`** — `CacheWarmup.strategy — the value 'scheduled' left the warmup-strategy enum (packages/spec/src/system/cache.zod.ts), and the enum describe stopped promising "scheduled (cron)". The key itself, DistributedCacheConfig.warmup.strategy, is unchanged and still authorable` → 'eager' to warm at startup or 'lazy' to warm on first access — the two strategies the vocabulary ever described without pointing outside itself. There is no replacement for the cadence: a warmup on a schedule is a job. Declare a `job` with schedule.expression (system/job.zod.ts) whose handler does the warming — that is the one cron slot this platform evaluates, and it is the slot deliberately kept when the seven cron-typed positions nothing read were deleted + - Why not automatic: ADR-0049 enforce-or-remove, closing the residue the retirement of the seven cron-typed positions nothing reads left inside the schema it had just edited. That retirement deleted CacheWarmup.schedule — the cron key this enum member selected — and declined the member itself on the reading that it is "a value, not a position this ruling names". That is a statement about the ruling's SCOPE, not a finding that the value was sound: after the deletion the member declared a warmup cadence with no key left to configure it, no engine that has ever run one, and a .describe() still promising "(cron)" — ADR-0049 declared-not-enforced in the form Prime Directive 10 names outright, a capability advertised that the runtime does not deliver. Re-measured on main at 690f083f83 with a lit control rather than inherited from the card: CacheWarmupSchema has zero runtime consumers outside its declaring file (six files reference it — the generated reference page import, the declaration-map and export-origins catalogues, the ADR-0058 D7 ledger comment and two spec test files — while the control, ConnectorSchema, resolves to 46 files), and no cache-warmup engine exists anywhere on the platform. Bookkeeping follows the hot-reload-inert-state-strategies-retired and crypto.hash precedents: an enum-VALUE narrowing puts nothing in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) and leaves the four surface ratchets byte-identical (no def changed, and they key on positions and names, never on a def's value set), so the prescription hangs on the enum's own error map dispatched by issue.input — telling the author of a TYPO that their value "was removed" would misinform. It is a SEMANTIC entry rather than a D2 conversion because there is no source to rewrite: CacheWarmup is bound to no metadata type and embedded in no stack collection, so no authored document and no stored row has ever carried this value, and os migrate meta has nothing to list. Route 3 of the retirement playbook, the shape of the dynamic plugin-loading family's removal and the advanced plugin-lifecycle config's retirement: this entry IS the declaration. ADR-0049, ADR-0087. + - Done when: No configuration passes strategy: 'scheduled' to CacheWarmupSchema or to DistributedCacheConfigSchema.warmup. TypeScript callers cannot: CacheWarmup['strategy'] is now 'eager' | 'lazy', so the literal is a compile error at the authoring site. Callers that arrive as JSON get a parse REFUSAL — not the silent strip the cron-position retirement left for the schedule key beside it, because a narrowed enum rejects rather than drops — carrying the prescription, which names the job route. Concretely, check two places. (1) Any host or deployment config embedding a DistributedCacheConfig: a warmup block selecting the retired strategy now fails to parse where it previously parsed green; change it to eager or lazy. (2) Anything that was waiting on the cadence to take effect: it never did. No warmup has ever run on a schedule on this platform, so migrating the value changes no runtime behaviour whatsoever — what changes is that the contract stops promising it. If a scheduled warmup is genuinely wanted, it comes back through the ENFORCE leg of ADR-0049: the engine first, the declaration with it, never as a bare enum row again. +- **`cbp-master-detail-required-forced`** — `object.fields..required on a `master_detail` reference under `sharingModel: 'controlled_by_parent'` — authored via `ObjectSchema.create()`` → `required: true` on the master reference (or nothing at all — the builder now forces `required: true` when the key is omitted). An explicit `required: false` on that shape is refused at `ObjectSchema.create()` with a located error carrying this same prescription. Metadata at rest is untouched: raw `.parse()`/`.safeParse()` still accept the old shape, the security gate's derived enforcement stays, and the lint rule `relationship/master-detail-required` stays `warning` until its own v18 promotion (Direction 1 of the 2026-08-16 maintainer ruling whose Direction 2 this is) + - Why not automatic: A `controlled_by_parent` detail derives ALL of its record access from the master that its `master_detail` reference names (ADR-0055). With the reference not `required`, an insert may omit the master FK: the row lands with a null FK that the derived read filter `masterFK IN (accessible master ids)` can never match — unreadable by everyone — and every later by-id write answers `422 MISSING_REQUIRED_FIELD`. The finding behind the ruling measured that only the security gate closed this shape while the declaration surface still accepted it. The maintainer ruling (2026-08-16, Direction 2) makes the unsafe shape impossible to NEWLY declare at the builder; whether to keep `required: false` was never a real choice (the value contradicts the sharing model), so the flip is forced rather than convertible — and an explicitly authored `false` is refused rather than silently rewritten (ADR-0032 "no silent failure"). + - Done when: Every `ObjectSchema.create()` call declaring `sharingModel: 'controlled_by_parent'` either omits `required` on its `master_detail` reference(s) (now emitted as `required: true`) or declares `required: true` explicitly; the authored app builds and boots. An explicit `required: false` there fails the build with `ObjectSchema.create(...): field ... declares required: false on a master_detail reference under sharingModel: controlled_by_parent`. Stored metadata keeps loading byte-identically (`safeParse` green, `required` unrewritten). +- **`cbp-master-detail-required-lint-error`** — `object.fields.MASTER.required / .readonly / .system, where MASTER is a `master_detail` reference on an object declaring `sharingModel: 'controlled_by_parent'` — as judged by `os lint` under `relationship/master-detail-required`` → `required: true` on every `master_detail` reference of a `controlled_by_parent` object, with neither `readonly: true` nor `system: true` on it: declare the master reference as an ordinary required field. `os lint` now reports each of the three unsafe shapes there — `required` absent or `false`; `required: true` + `readonly: true`; `required: true` + `system: true` — at `error` under `relationship/master-detail-required`, so `os lint` exits non-zero and the metadata-generation rubric marks the stack invalid. On every other object the rule is unchanged: a `warning` for a `master_detail` without `required: true`, and no finding for the two flagged shapes. + - Why not automatic: A `controlled_by_parent` detail derives ALL of its record access from the master that its `master_detail` reference names (ADR-0055). Record validation never checks a field that is not `required`, and skips `readonly` and `system` fields before its required check is reached, so on these three shapes nothing but the security gate refuses an insert that omits the master FK. A record that lands without it anyway is readable by nobody — the derived read filter `masterFK IN (accessible master ids)` never matches null — and every later by-id write is refused. Before this step the lint predicate was `required !== true` at `warning` on every object: the two flagged shapes drew no finding at any severity, and the third drew a warning that an author or a generator could ignore. The maintainer ruling of 2026-08-16 (Direction 1) scheduled the promotion for the v18 boundary as a deliberate narrowing of the authoring contract. Its builder half (the `cbp-master-detail-required-forced` entry) forces `required: true` at `ObjectSchema.create` but never inspects `readonly` or `system`, so two of the three shapes still pass the builder and meet their first authoring-time refusal here, and the third still reaches it from any object not authored through the builder. Runtime tolerance is unchanged on purpose: the security gate keeps refusing these inserts, and keeps resolving the master for metadata already at rest. + - Done when: `os lint` reports no `relationship/master-detail-required` finding at `error`: on every object declaring `sharingModel: 'controlled_by_parent'`, each `master_detail` reference declares `required: true` (or omits it and is authored through `ObjectSchema.create`, which emits `required: true`) and carries neither `readonly: true` nor `system: true`. ⚠️ WHICH DOOR: the refusal is `os lint`'s and the metadata-generation rubric's only. The rule is not in the authoring-rule registry, so `os build`, `os validate` and the metadata save door do not run it, and a stack carrying the shape still builds and publishes — read `os lint`'s exit code, not a green build. Stored metadata is not rewritten and keeps loading, and the security gate still refuses an insert that omits the master FK on these shapes. Repo census at the time of the change: 129 authored objects across the example apps, the platform and plugin objects and the CLI's golden eval corpus, 7 of them `controlled_by_parent`, 0 findings at `error`. +- **`cel-predicate-list-comparand-refused`** — `security.PermissionSet rowLevelSecurity[].using and .check, and sharingRules[].condition — a CEL predicate comparing a field with != or == against a list, either a list literal or a current_user membership set the runtime resolves to an array (org_user_ids, positions, accessible_org_ids, or a key staged into rlsMembership), and the negation of such a comparison. On driver-mongodb, also a query filter carrying $ne with an array comparand, at any depth under $and / $or / $not` → the list operator the comparison was standing in for. "One of these values" is in: record.status in ["open", "pending"], or record.reviewer_id in current_user.org_user_ids. "None of these values" is the negated in: !(record.status in ["closed", "archived"]). In a query filter, $in and $nin. Scalar != and ==, null, in, and field-to-field comparisons lower exactly as before + - Why not automatic: The @objectstack/formula pushdown compiler lowered such a comparison to a $ne carrying the array, to a bare-array equality, or to a $not around one. A row-level using clause is composed into the query after the engine's comparand-shape check, and driver-mongodb passed the shape to the server: measured through mingo, the named proxy for MongoDB query semantics, $ne against an array and the $nor that a negated equality becomes selected every row storing a scalar, so the read returned the rows the policy was written to hide. A check written != against a membership set admitted and stored every write, on driver-sql as on driver-mongodb. The compiler now refuses the comparison with reason unsupported, so the RLS compiler drops the policy and fails closed when no other policy applies: reads under it return no rows and check writes are refused 403. A declared sharing rule with such a condition is skipped at bootstrap and never seeded. The authoring lint reports a list literal as rls-predicate-unenforceable; a membership set holds its value only per request, so that form is refused at request time. driver-mongodb refuses $ne with an array comparand with INVALID_FILTER / 400, as driver-sql and driver-memory already do. Metadata AT REST is not rewritten and this entry adds no D2 conversion: the platform cannot tell which list operator a list comparison was standing in for, and a policy rewritten on the author's behalf would change which rows it admits, which is the policy author's decision. ADR-0058 D4 / ADR-0087. + - Done when: Grep the rowLevelSecurity using and check predicates of your permission sets, and the condition of your sharing rules, for != or == whose other side is a list literal or a current_user membership set, and for the negation of such an ==, then rewrite each with in or its negation. On driver-mongodb, grep stored query filters for $ne with an array value and rewrite each with $nin. +- **`cel-predicate-one-value-comparand-refused`** — `security.PermissionSet rowLevelSecurity[].using and .check, and sharingRules[].condition — a CEL predicate whose comparison is handed something other than one value: an ordering operator (>, >=, <, <=) against a list literal or a current_user membership set; an in list with a member that is itself a list; a comparison with no field at all against a membership set (current_user.org_user_ids != "x"); an ordering operator against the current_user root or a key that resolves to an object; and a field compared with another field (==, !=, or an ordering operator) where either column holds a list or an object on the record, as a json column or a multiple lookup does. In a filter passed to matchesFilterCondition, also $gt / $gte / $lt / $lte with an array, and $in / $nin with an array member` → the comparison the predicate was standing in for. "One of these values" is in: record.status in ["open", "pending"], or record.reviewer_id in current_user.org_user_ids; "none of these values" is !(record.status in ["closed", "archived"]), with the list flat. An ordering takes one bound: record.status > "m", and a range is two comparisons joined by &&. A comparison against the caller names one key: record.reviewer_id > current_user.id. A field compared with a json or multiple field has no pushdown form: compare with a single-valued column, or move the condition into a validation rule or hook. One-value comparisons, flat in lists, and field-to-field comparisons between single-valued columns lower and evaluate exactly as before + - Why not automatic: Ruling A of 2026-09-24 refused a list under != and in the equality slot, holding both to the declared comparand — a literal or a `{ $field }` reference; stage 2d closes the same fault one position over, measured through the real plugin-security on driver-sql and driver-memory. !(record.status in [["closed", "archived"]]) lowered to a negated $in whose only member was a list, which the strictly comparing write-check evaluator matched on no record, so the negation admitted and stored every write, and driver-memory returned every row on a read. record.status > ["m"] compared the list as the string "m". current_user.org_user_ids != "x" and current_user.org_user_ids > "a" folded to "no restriction": every write admitted and every row read. record.reviewer_id > current_user compared the whole caller object as a string. record.status != record.tags, with tags a json or multiple field, matched every post-image, so the check admitted and stored every write. The CEL compiler now refuses the first four with reason unsupported, so the RLS compiler drops the policy and fails closed when no other policy applies (reads return no rows, check writes are refused 403, the analytics read scope is the deny scope) and a declared sharing rule is skipped at bootstrap; the authoring lint reports what the source shows (a list literal, the current_user root). The compiler cannot see a column's type, so the last is refused by the write-check evaluator on the record whose compared column holds a list or an object: INVALID_FILTER / 400, nothing stored. Metadata AT REST is not rewritten and this entry adds no D2 conversion: the platform cannot tell which comparison a predicate was standing in for, and rewriting it on the author's behalf would change which writes and rows it admits, which is the policy author's decision. ADR-0058 D4 / ADR-0087 / ADR-0112. + - Done when: Grep the rowLevelSecurity using and check predicates of your permission sets, and the condition of your sharing rules, for an ordering operator next to a list or to current_user alone, for an in list that nests a list, for a comparison with no record field against a current_user membership key, and for a field compared with a json or multiple field; rewrite each as the replacement says. Then re-check what each policy is supposed to admit rather than assuming what it admitted before was right: several of these admitted every write, and two folded to no restriction at all. +- **`cel-predicate-variable-root-comparand-refused`** — `security.PermissionSet rowLevelSecurity[].using and .check, and sharingRules[].condition — a CEL predicate comparing with != or == against the bare current_user root, the variable with no key named after it, whether the other side is a field or a literal, and the negation of such a comparison. For a caller of the published compiler that binds its own variables, also a variable that resolves to an object` → the key of current_user the comparison means: record.owner_id == current_user.id, or current_user.organization_id, or current_user.email. A membership test is in: record.owner_id in current_user.org_user_ids. Scalar keys, membership sets under in, literals, null and field-to-field comparisons lower exactly as before + - Why not automatic: The @objectstack/formula pushdown compiler resolved the bare root to the whole caller context object, every kernel-resolved key at once with the membership arrays included, and lowered the comparison to a $ne carrying that object, to a bare-object equality, or to a $not around one; a constant comparison such as current_user != "guest" folded to no restriction. A strict compare never equals an object, so through the real SecurityPlugin on driver-sql a check written != against the root, or its negated ==, admitted and stored every insert and by-id update it was written to refuse, a USING-only such policy admitted every insert on the write pass, and explain reported the read as narrowed with the caller membership sets echoed in its readFilter. ADR-0058 D2 declares the operand opposite a field as a literal, a current_user scalar or a pre-resolved current_user set, and the published $eq / $ne contract declares a literal or a { $field } reference; the root is none of them. The compiler now refuses it with reason unsupported in both of its modes, so the authoring lint reports it (rls-predicate-unenforceable on either clause, sharing-rule-unlowerable-condition on a sharing condition), and the RLS compiler drops the policy and fails closed when no other policy applies: reads under it return no rows, check writes are refused 403, and explain answers denies. A declared sharing rule with such a condition is skipped at bootstrap as it already was, now with reason unsupported instead of unresolved-variable. Metadata AT REST is not rewritten and this entry adds no D2 conversion: the platform cannot tell which key the author meant, and a policy rewritten on the author's behalf would change which rows it admits. ADR-0058 D2 / ADR-0087. + - Done when: Grep the rowLevelSecurity using and check predicates of your permission sets, and the condition of your sharing rules, for != or == whose other side is current_user with no key after it, then rewrite each against the key it means (current_user.id, current_user.organization_id or current_user.email), or with in against a membership set. +- **`change-management-duration-keys-retired`** — `change-management duration keys: `ChangeImpact.downtime.durationMinutes`, `RollbackPlan.steps[].estimatedMinutes`, `ChangeRequest.implementation.steps[].estimatedMinutes`` → nothing to re-declare — delete the keys. No change-management engine exists on the platform: nothing schedules a maintenance window, executes or times an implementation or rollback step, or compares an estimate with what happened, so there is no live mechanism to declare a duration to + - Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on the unread deadline keys (ruled A: retire per family). Three minute-shaped keys, at three nested sites, sat in the exported change-management schemas and in the generated reference docs — an author could write `estimatedMinutes: 15` on a rollback step and reasonably expect it to feed a schedule — and read by NOTHING: the schemas are exported from `@objectstack/spec/system`, mounted by no stack key, registered as no metadata type, absent from the 2026-06 liveness ledgers, and the reader census over every package outside `packages/spec` (tests and changelogs excluded) and over objectui at the pinned sha returned zero hits for every key. All three sites are NESTED (`downtime.durationMinutes`, `steps[].estimatedMinutes` twice), so the authorable-surface ratchet — which walks top-level def properties — never listed them; their `RETIRED_KEYS_BY_MAJOR[18]` entries carry the nested spelling for the spec-changes / upgrade-guide projection. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections; none of these schemas is either, so a conversion would be a transform with no seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent). + - Done when: No `ChangeImpact.downtime` block carries `durationMinutes`, and no implementation or rollback step — in a `RollbackPlan` or inside a `ChangeRequest` — carries `estimatedMinutes`. TypeScript authors get the refusal at compile time (each key is typed `never`); a value reaching the parse is refused with the prescription (`invalid_type` at the nested path of the key, e.g. `rollbackPlan.steps.0.estimatedMinutes`). ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ever read the keys, so removing them removes no behaviour. +- **`change-management-family-retired`** — `the change-management family, retired whole: the six defs system/ChangeImpact, system/ChangePriority, system/ChangeRequest, system/ChangeStatus, system/ChangeType and system/RollbackPlan, and every name system/change-management.zod.ts exported from @objectstack/spec/system (the six *Schema consts, their z.input aliases and the ChangeRequestParsed alias)` → nothing to re-declare — no change-management engine exists on the platform, so there is no working configuration to migrate to. Nothing routed a change request for approval, walked its implementation steps, honoured a rollback plan or gated on `securityImpact.requiresSecurityApproval` / `approval.required`; a change record the organisation keeps is ordinary object data, declared as an object with its own fields, and an approval that must actually gate something is a flow (ADR-0018) with an approval node. Metadata change tracking on the platform is `sys_metadata` history and the package model (ADR-0126), unrelated to this vocabulary. If ITIL change management becomes a product capability it re-declares fresh, through the enforce route of ADR-0049 — the engine first, the vocabulary second + - Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 on the families' remaining keys and defs (ruled A: retire the three compliance-shaped families whole via RETIRED_DEFS_BY_MAJOR, the integration/ErrorMappingConfig precedent; not roadmapped). Six defs and roughly fifty declared keys sat on the exported surface and in the generated reference docs, and were read by NOTHING: the schemas were exported from `@objectstack/spec/system`, mounted by no `stack.zod.ts` key, registered as no metadata type, absent from the 2026-06 liveness ledgers, and the reader census over every package outside `packages/spec` (tests and changelogs excluded), over `examples/**` and `skills/**`, and over objectui at the pinned sha returned zero hits for every exported name, with a lit control. `ChangeRequest.approval.required` and `ChangeRequest.securityImpact.requiresSecurityApproval` read as gates the platform enforced, and neither ever did — the worst form of the declared-but-unenforced shape, on a security-adjacent surface. Tagging the family `[EXPERIMENTAL — not enforced]` was the fallback the ruling did not take (a human-only signal). The duration-key tombstones of the 2026-09-02 per-family ruling (three nested sites, `RETIRED_KEYS_BY_MAJOR[18]`, D3 `change-management-duration-keys-retired`) leave with their defs' source; their registry entries stay as history. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections; none of these schemas is either, so a conversion would be a transform with no seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent), and with no carrier key there is no shape on which a tombstone could sit. + - Done when: No code imports ChangeImpactSchema, ChangePrioritySchema, ChangeRequestSchema, ChangeStatusSchema, ChangeTypeSchema or RollbackPlanSchema — or any of their type aliases — from @objectstack/spec or @objectstack/spec/system: every such import is TS2305 after upgrade, and no working replacement exists to point at because the vocabulary described nothing real. `kernel/MetadataChangeType` (M92 of the type-alias pin, a different declaration with a live consumer) is unaffected. The six defs are absent from `json-schema.manifest/system.json`, the api-surface / declaration-map / export-origins shards and the generated reference docs. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ever parsed or read these shapes, so removing them removes no behaviour. +- **`chart-config-aria-retired`** — `dashboard.widgets[].chartConfig.aria / report.chart.aria / report.blocks[].chart.aria — the ARIA block on a chart config` → The sibling `description`, which the chart renderer lowers onto the chart graphic as its accessible name (`role="img"` with an aria-label). One accessibility vocabulary per chart node. + - Why not automatic: The D2 conversion `chart-config-aria-removed` deletes `aria` from every dashboard widget chart config, report chart and report block chart, and the delete is lossless: no chart renderer on either face ever applied the block, so the ARIA attributes it declared never reached the DOM. The residue is accessibility work the author did that no user benefited from. An author who wrote `aria.label` for a chart believed screen-reader users heard that name; they heard the `description` if one was set, and nothing specific if not. The strip deletes the label text along with the key, and only the author can say whether that text should become the chart's `description` — a field that other readers of the chart may also show — or whether the existing description already says it. + - Done when: No chart config on a dashboard widget, a report or a report block carries `aria`; the parse refuses it. Every chart that had carried an `aria.label` has a `description` conveying what that label was meant to announce, or the author has confirmed the existing description does. With a screen reader, focusing the chart graphic announces the description as its name. +- **`cli-command-contribution-retired`** — `kernel.cliCommandContribution (the orphan exported schema of `cli-extension.zod.ts` — 1 def, 2 exported names: `CLICommandContributionSchema` / `CLICommandContribution`)` → (removed — there is no declarative replacement, because no declarative surface ever carried it. CLI commands are registered through oclif's native plugin discovery: the plugin package declares an `oclif` section in its own `package.json` — `OclifPluginConfigSchema` in the same module describes that live surface and SURVIVES, as does the module docblock's Commander.js migration record, which the `manifest.contributes.commands` tombstone cites) + - Why not automatic: ADR-0049 enforce-or-remove, applied to the exported orphan-value-schema class: an exported schema with no consumer reads as a capability, the lesson of the plugin sandboxing / integrity / approval config that was never wired to anything. The schema described a "CLI Command Contribution declaration in the manifest" and claimed retention "for describing command metadata in plugin manifests" — but after the retirement of the plugin manifest's nine dead `contributes` members tombstoned `manifest.contributes.commands` (protocol 18), no manifest surface could legally carry these entries: the export advertised a shape whose only declared carrier rejects it. The manifest never referenced this schema even before the tombstone — its inline `commands` item schema was an independent duplicate. Zero consumers outside spec's own test and generated artifacts, measured at the retirement's base commit (146f448a5) with positive controls in objectstack, objectui (pinned sha) and cloud. With no carrier key and no authored document there is nothing to tombstone and no seam for a D2 conversion: route 3, the shape of the advanced plugin-lifecycle config's retirement and of the retired `ApiKeySchema` — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. + - Done when: No code imports `CLICommandContributionSchema` or `CLICommandContribution` from `@objectstack/spec` or `@objectstack/spec/kernel` — both are TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in `kernel/cli-command-contribution-retirement.test.ts`). No metadata document needs editing: the def was reachable from no metadata-type binding, stack collection or manifest embed — the only surface that ever claimed to carry command contributions (`manifest.contributes.commands`) already rejects the key with its own tombstone prescription, which is unchanged by this retirement. `OclifPluginConfigSchema` / `OclifPluginConfig` survive on `./kernel` (same pin). ⚠️ Runtime behaviour is deliberately UNCHANGED: the CLI never resolved commands from this declaration — commands are oclif-auto-discovered, before and after. +- **`client-envelope-convergence-analytics-automation`** — `client.analytics.query / client.analytics.meta / client.analytics.explain / client.automation.trigger — the resolved value of four published `@objectstack/client` methods: the runtime dispatcher's `{ success, data }` envelope before, its `data` member after` → the payload — `r.data.X` → `r.X` on all four. `client.analytics.query`, called with a query, now resolves to `AnalyticsResult`: `r.data.rows` → `r.rows`. `client.analytics.meta`, with or without a cube name, resolves to `AnalyticsMetadataResponse['data']`, the bare cube list: `r.data[0].name` → `r[0].name`. `client.analytics.explain` resolves to `AnalyticsSqlResponse['data']`, `{ sql, params }`: `r.data.sql` → `r.sql`. `client.automation.trigger`, given a trigger name and a payload, resolves to `AutomationResult`: `r.data.status` → `r.status` and `r.data.runId` → `r.runId` — the same value `client.automation.execute` already answered for the same handler. Same call, same wire body, one SDK calling convention + - Why not automatic: `ObjectStackClient` had two response readers. `unwrapResponse` strips the runtime dispatcher's `{ success, data }` envelope and hands back `data`; every other dispatcher-served method already used it, and these four alone ended `return res.json()`, so their callers alone had to read `.data`. All four now end `return this.unwrapResponse(res)` and their return declarations are the payload types. THE WIRE IS BYTE-IDENTICAL: every route answers exactly the body it answered before, no Zod schema moves, no `packages/spec` declaration moves, no authorable key and no stored representation is involved — the landing diff touches no `packages/spec` path at all — so a raw-HTTP caller is unaffected and `objectstack migrate meta` has nothing to rewrite. This is registered rather than exempted because the change is NOT wholly compiler-delivered, and the gap is exact rather than theoretical. For the three analytics methods it is: every old read is `error TS2339: Property 'data' does not exist on type …`, so tsc names each site. `client.automation.trigger` is the exception — `AutomationResult` itself declares `success: boolean` and `error?: string` (`AutomationResult` in `packages/spec/src/contracts/automation-service.ts`, byte-identical at the merge base and at this landing), so `r.success` and `r.error` COMPILE ON BOTH SIDES while their meaning moves: before, `r.success` was the envelope's flag — always `true` on a resolved call — and `r.error` was never set on a 2xx; now they are the run's own, and a refusal the door does not classify as 400 / 409 / 422 is answered 200 carrying `success: false` with `error` set. A consumer branching on either reads a DIFFERENT QUESTION at the same spelling, with no diagnostic anywhere. And there is no authored source for the conversion chain to rewrite: this is a published TypeScript surface whose enforced channel is tsc at the call site, and for an untyped JS caller there is no constrained channel at all — `.data` simply reads `undefined` — which is why the ledger entry is the only notification that reaches them. That is the same argument the two sibling entries on this package make (`client-delete-result-success`, `client-meta-reset-result-reset`), and this one is the stronger case of the three: those corrected declarations that were UNINHABITED, revealing a defect rather than breaking working code, whereas this moves reads that work today. ⛔ Do not write `r.rows ?? r.data.rows`: there is one producer shape, and a consumer accepting two spellings is what contract-first exists to prevent. The failure path is unchanged and deliberately so — `ObjectStackClient.fetch` rejects on every non-2xx BEFORE either reader runs, carrying the ADR-0112 error envelope, and `unwrapResponse` itself never throws. ADR-0087 D3. + - Done when: No code reads `.data` off a `client.analytics.query`, `client.analytics.meta`, `client.analytics.explain` or `client.automation.trigger` result. For the three analytics methods `tsc` names every site for a typed caller (TS2339); an untyped JS caller must be swept by hand for the four spellings, because nothing will report it. ⚠️ `client.automation.trigger` needs the hand sweep even WITH a type-checker: every branch on `r.success` or `r.error` off `trigger` has to be re-read one by one, because both compile before and after while their subject moved from the envelope to the run. A branch that treated `r.success` as "the call was accepted" now asks "the run succeeded" — the two differ on exactly the 200-answered refusals — and a `catch`-only error path now has a resolved `success: false` sibling it never had to consider. Nothing about the request, the route, the status codes or the thrown error shapes changes, and no server needs upgrading: the value you may now read is the one that was already arriving, one level in. `client.analytics.queryDataset` is NOT part of this move — it is served with no envelope at all and resolved to the bare payload before and after. Populations: in the ObjectStack repo, zero production call sites and the loud test pins that ship with this change; in objectui, one production row-extraction chain that tolerates both spellings today and is tightened to the post-unwrap spelling once this lands; `objectstack-ai/cloud` is NOT MEASURED — a `.data` read on any of the four there is a runtime break after this change, and this entry is the only notice it gets. +- **`client-meta-reset-result-reset`** — `client.meta.deleteItem(...).deleted / .type / .name (the return of `client.meta.deleteItem()` and the environment-scoped `client.environment(id).meta.deleteItem()`)` → `reset` — `r.deleted` → `r.reset`. Same call, same wire body, declared shape. Both twins now declare `DeleteMetaItemResponse` (`@objectstack/spec/api`); `type` and `name` have no replacement because the reset door never echoed them — the caller already holds both, it passed them in + - Why not automatic: Both `deleteItem` declarations on `@objectstack/client` declared `Promise<{ type: string; name: string; deleted: boolean }>` while `DeleteMetaItemResponseSchema` declares `{ success, reset?, message? }`. The declaration was not merely imprecise, it was UNINHABITED: `DELETE /meta/:type/:name` ends in `res.json(result)` with `deleteMetaItem`'s return, and not one of that method's four return branches carries `type`, `name` or `deleted`. Both surfaces are pure `unwrapResponse` / `_unwrap` passthroughs — and the reset body carries no `data` key, so nothing is stripped — which makes the declaration a CLAIM about the wire, never a rewrite of it, and the claim was false in the one direction that matters: the compiler endorsed a spelling no server has ever sent. `if (r.deleted)` compiled and read `undefined` on EVERY reset, including the ones that really removed an overlay row; `if (r.reset)` was rejected by the compiler and correct on the wire. So this REVEALS a defect rather than breaking working code — every reader of the old key was already reading `undefined`, on every deployment and not just some. The truthful flag also carries the distinction the phantom one could not express at all: `reset: true` means an overlay row was deleted, `reset: false` means none existed and the item was already at its artifact default. Registered as a semantic entry rather than a mechanical conversion for the reason the rewrite does not capture: a call site that branched on `r.deleted` has been taking the FALSE branch unconditionally since it was written, and whatever that branch did — or skipped — is what has to be re-read. There is no authored source for the chain to rewrite either; this is a published TypeScript surface whose enforced channel is tsc at the call site, and for an untyped JS caller there is no constrained channel at all, which is why this entry is the only notification that reaches them. ⛔ Do not write `r.reset ?? r.deleted`: there is one producer shape, and a consumer accepting two spellings is what contract-first exists to prevent. No deprecated `deleted?: boolean` transition key ships, for the same reason — a transition period is for keys that WORKED, and this one never did. The identical correction one door over is `client-delete-result-success`; the wire is deliberately untouched here, per the 2026-08-29 ruling that reality is the contract. ADR-0087. + - Done when: No code reads `.deleted`, `.type` or `.name` off a `client.meta.deleteItem()` / `client.environment(id).meta.deleteItem()` result; `tsc` names every site for a typed caller, and an untyped JS caller must be swept by hand because nothing will report it. Nothing about the request, the route, the status codes or the error shapes changes, and no server needs upgrading — the value you may now read is the one that was already arriving. ⚠️ The real work is behavioural: every `if (r.deleted)` has been false since it was written, so re-read what each of those branches was supposed to do. Cache invalidation, registry refreshes and UI reloads guarded that way have never run, and switching to `r.reset` turns them ON for the first time — verify that is what you want rather than assuming it restores prior behaviour. Note `reset` is OPTIONAL in the contract and distinguishes two successful outcomes, so `if (r.reset)` and `if (r.success)` are different questions: the former asks whether a row went away, the latter whether the call was accepted. Any test that passed while asserting on `deleted` was asserting on `undefined` and needs rewriting, not renaming. +- **`client-oauth-applications-delete-void`** — `client.oauth.applications.delete(clientId) — both halves of what a caller of this published `@objectstack/client` method observes: the DECLARED return, `Promise` before and `Promise` after, and the SETTLE BEHAVIOUR, which rejected with `SyntaxError: Unexpected end of JSON input` on every successful delete before and resolves after` → no value — `void`. There is nothing to move a read TO, because the promise never resolved for a caller to read anything off it. The migration is on the settle path instead: `try { await client.oauth.applications.delete(id); } catch { /* it probably worked */ }` → drop the workaround, the `catch` was executing on EVERY successful delete and now executes only on a real failure. A read off the resolved value — `(await client.oauth.applications.delete(id)).deleted` — was unreachable code that has never executed and now stops compiling (TS2339). Same call, same request, same wire body + - Why not automatic: The route answers HTTP 200 with a ZERO-BYTE body: `POST {auth}/oauth2/delete-client` returns nothing from its handler, the vendor declares the endpoint `void`, and the response carries `content-type: application/json` with NO `content-length` header at all. The method ended `return res.json()`, so it rejected `SyntaxError: Unexpected end of JSON input` on every successful delete — after the row had already been removed server-side. There was no success path a caller could observe, and the obvious recovery made it worse: the retry failed DIFFERENTLY, with the route's 404 `not_found`, because the client was already gone. The method now reads the body as text, returns on the empty case, and still parses (and still throws on) a non-empty one — so the ONLY behaviour that moved is the zero-byte case, which is the defect itself. THE WIRE IS BYTE-IDENTICAL: same route, same request body, same status codes, same error bodies — which on this route are better-auth's FLAT `{ error, error_description }`, NOT ObjectStack's nested ADR-0112 envelope; no Zod schema and no `packages/spec` declaration moves, no authorable key and no stored representation is involved, so a raw-HTTP caller is unaffected and `objectstack migrate meta` has nothing to rewrite. This is registered rather than exempted because the change is NOT compiler-delivered where it matters, and the gap is exact rather than theoretical. The change has two halves and only one of them has a diagnostic. (1) The declared return moves from a ledgered `any` to `void`, so a typed caller that read a property off the resolved value now gets `error TS2339` — but that read was UNREACHABLE, since the promise never resolved, so the compiler names only code that has never run. (2) The half that DID run on every call — a `try`/`catch` wrapped around the delete — compiles identically before and after, with no diagnostic anywhere, while its `catch` block stops executing. So for the only behaviour that was ever observable, `tsc` names ZERO sites; and for an untyped JS caller there is no constrained channel at all. That is why the ledger entry is the only notification that reaches an upgrader — the same argument the three sibling entries on this package make (`client-delete-result-success`, `client-meta-reset-result-reset`, `client-envelope-convergence-analytics-automation`). ⚠️ Note the DIRECTION, which is the inverse of the usual break: this does not stop working code from working, it makes a method that could never succeed succeed. The hazard is therefore inverted too — code written to survive a permanent failure is now inert, and any alerting or error budget fed by this method's rejections goes quiet. ⛔ Do not keep the old behaviour behind a flag or a wrapper that re-throws: there is one producer shape, and the rejection was never a contract, it was a parse of an empty string. ⛔ Do not synthesise `{ deleted: true }` either: the 200 carries zero bytes and therefore zero information, and "it was already gone" is distinguished on the ERROR channel — a client that is not there answers 404 `{ error: 'not_found' }`, which `ObjectStackClient.fetch` raises as a throw before the body reader runs — so a synthesised success value would be a shape the wire never sends and strictly less informative than the 404 the caller already receives. ADR-0087 D3. + - Done when: ⚠️ The real work is behavioural and NOTHING will report it: every `try`/`catch` wrapped around `client.oauth.applications.delete()` has to be re-read one by one, because it compiles identically before and after while its `catch` block goes from running on every successful delete to running only on a real failure. Anything that block did — treating the delete as failed, retrying it (the retry answered 404 `not_found`, which may itself have been swallowed), skipping post-delete cleanup, cache invalidation, audit writes or a UI refresh, or reporting the delete to a user as failed — is now on the other branch, and the cleanup paths that were skipped run for the first time. Verify that is what you want rather than assuming it restores prior behaviour. Alerting, error budgets and dashboards fed by `SyntaxError` rejections from this method drop to zero: that is the fix landing, not an outage. Any test that passed while asserting this call rejects on a successful delete was asserting on the defect and needs rewriting, not renaming. On the type side, no code reads a property off the resolved value; `tsc` names those sites for a typed caller (TS2339), but every one of them was unreachable, so a clean type-check is NOT evidence that the sweep above was done. An untyped JS caller gets no report at all. Nothing about the request, the route, the status codes or the thrown error shapes changes and no server needs upgrading — the server has always answered this way; only the client stopped mis-reading it. Populations, measured at this landing: in the ObjectStack repo, ZERO production call sites — the only references are the pins that ship with this change (`oauth-applications-delete.test.ts`, `return-type-precision.test.ts`); in objectui at the pinned `.objectui-sha`, ZERO — neither `oauth.applications` nor `delete-client` appears anywhere in that tree; `objectstack-ai/cloud` is NOT MEASURED, and a `catch` there that swallowed this method's rejection is now dead code that this entry is the only notice of. +- **`cloud-subpath-retired`** — ``@objectstack/spec/cloud` — the whole published subpath (`packages/spec/src/cloud/`, 11 modules, 94 JSON-Schema defs): the cloud control plane's own contracts (`environment.zod`, `environment-package.zod`, `tenant.zod`, `developer-portal.zod`, `marketplace-admin.zod`, `app-store.zod` — 62 defs) and the package & marketplace format (`package.zod`, `package-version.zod`, `marketplace.zod`, `package-l10n`, `template-manifest.zod` — 30 defs)` → Two answers, by owner. (1) The package & marketplace FORMAT moved unchanged to `@objectstack/spec/marketplace` (`packages/spec/src/marketplace/`): rewrite the import path — `import { PackageSchema } from '@objectstack/spec/cloud'` becomes `from '@objectstack/spec/marketplace'` — and nothing else; every def, key and JSON Schema is byte-identical under its new `$id` category (`RENAMED_DEFS`, 32 entries). `EnvironmentType(Schema)` — the 7-member taxonomy the discovery fold table is total over — is re-declared in `@objectstack/spec/api` (`api/discovery.zod.ts`); the environment-artifact envelope was only ever a re-export and is imported from `@objectstack/spec/system`. (2) The cloud control plane's contracts have NO open-source replacement: `environment.zod` and `tenant.zod` are re-declared in the cloud repo beside their producer, and `developer-portal.zod`, `marketplace-admin.zod`, `app-store.zod`, `environment-package.zod` are deleted outright — zero consumers in any repo (maintainer ruling 2026-09-07, option A: cloud does not host the four consumer-less files, the move deletes them). Recoverable from git history at `d5d8d50db` if a declaration is ever wanted again; that is a new card in the cloud repo, not a re-import. + - Why not automatic: Maintainer direction (2026-09-06, verbatim, untranslated): 「我一直觉得 cloud 的协议应该放在云端,没必要开源」; ruled option B "cut by owner" on 2026-09-07: the control-plane half leaves the open-source spec, the package & marketplace format half stays. The control-plane schemas' producer and every consumer live in the closed cloud repo — the open-source tree read exactly one type from them (`EnvironmentType`, for the discovery fold table). Leaving them published made the obvious-looking binding of `client.environments.*` to a camelCase `Environment` row compile and read `undefined` at runtime against a snake_case wire (the client SDK's cloud methods carried no return annotation and were typed from `any`, and `@objectstack/spec/cloud` declared camelCase rows for a control plane that speaks snake_case); with the declarations gone the mis-binding is structurally impossible rather than warned about in a docblock. No alias and no deprecation window, per the standing 2026-08-27 ruling 「项目在创业阶段,用户也很少,短期不考虑渐进。」. Not losslessly convertible: an import path is TypeScript source, not a metadata document `objectstack migrate meta` can rewrite. + - Done when: No code imports anything from `@objectstack/spec/cloud` — the specifier is not an `exports` key and every such import fails to resolve (TS2307) after upgrade. Package-format consumers resolve the same symbols from `@objectstack/spec/marketplace` (pinned by resolved symbol identity in `kernel/package-dependency-dual-source.test.ts` and `system/environment-artifact.test.ts`). `api/discovery-environment-subset.pin.test.ts` still proves DiscoveryEnvironment ⊂ EnvironmentType against the re-declared enum. No metadata document needs editing: the 509 `cloud/*` authorable-surface baseline keys are discharged by the deletion gate's own proofs — 30 defs carried by declared rename, 62 by whole-def retirement (`RETIRED_DEFS_BY_MAJOR[18]`) — not by a tombstone an author could hit. ⚠️ Runtime behaviour is deliberately UNCHANGED: `os package publish`, the marketplace routes and the metadata plugin's artifact ingest parse byte-identically before and after. +- **`cluster-driver-dangling-values-removed`** — `kernel.cluster.driver (ClusterDriverSchema, kernel/cluster.zod.ts) - the `postgres` and `nats` enum values` → the drivers that actually ship - `memory` (single-process default), `redis` (@objectstack/service-cluster-redis, the production recommendation), or `custom` + registerClusterDriver(name, factory) for a self-provided transport. A config naming `postgres` or `nats` never worked: pick `redis`, or register the transport yourself under `custom` + - Why not automatic: Maintainer ruling of 2026-08-24 on the cluster driver line-up (option B adopted): single-node is the ObjectOS EE boundary, multi-node is Cloud differentiation, and a DB-first postgres cluster driver is not built absent concrete customer pull. The ruling's principle rider decides this entry: a schema-valid value must not be an unconditional runtime throw. Both removed values were dangling by the same measurement - the only non-test registerClusterDriver() caller is service-cluster-redis, so `driver: 'postgres'` or `driver: 'nats'` passed schema validation and then reached defineCluster()'s unconditional `Cluster driver "" is not registered` throw. It is a SEMANTIC entry rather than a mechanical conversion because the right replacement is a deployment decision (which transport actually backs this cluster), not a rename a codemod could apply; nothing at rest breaks, because a stored config naming either value never survived boot in the first place. The ruling records its own reversal condition: a value returns to the enum only in the release that ships an implementation behind it. No authorable KEY was retired (the `useExistingPool` field stays, reworded), so nothing lands in RETIRED_KEYS_BY_MAJOR. + - Done when: No `cluster.driver` config names `postgres` or `nats`; `ClusterDriverSchema.parse` on the chosen driver value succeeds; a deployment that needed a distributed transport boots on `redis` (or its `custom` registration) and `defineCluster()` no longer throws `Cluster driver "" is not registered` at startup. TypeScript call sites that typed the removed spellings against `ClusterDriver` fail tsc on upgrade; the fix is choosing a shipped driver, never widening a local mirror of the enum. +- **`connector-action-config-required`** — `The connectorConfig block of every type: 'connector_action' flow node — the BLOCK, and its connectorId and actionId once it is written. The block was optional on the node and both ids were any string inside it, so a node with no block, or with connectorId or actionId empty or only whitespace, parsed. That is the state of a node authored without its configuration, and of a new connector node from the Studio flow designer, which seeds both ids empty. At any depth, including an ADR-0031 region body. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate, a flow saved from the Studio flow designer (a connector node added and saved before it is configured), and a flow row already sitting in sys_metadata. Also reached: connectorId, actionId or input written under the node's config instead of the block, where the load-time conversion cannot complete the pair and leaves them there` → Declare what the node dispatches, on the node: `connectorConfig: { connectorId: 'slack', actionId: 'chat.postMessage', input: { channel: 'C0WINS000', text: 'Done' } }` — `connectorId` the registered connector's `name`, `actionId` one of the action keys that connector declares, `input` optional. Keys written under the node's `config` move into the block. A node you cannot configure yet is deleted until you can: there is no placeholder connector, and a block with empty ids names nothing to dispatch to + - Why not automatic: The block is the node's whole contract: the connector_action executor reads nothing else and refuses the node when `connectorId` or `actionId` is empty. The build doors checked only the block's shape once it was written, so `FlowSchema.parse`, `AutomationEngine.registerFlow` and `objectstack validate` all admitted a node with no block, or with an empty id, and every run that reached the node then failed at the executor's guard — a guard refusal, never routed to a `fault` edge, and no rerun could succeed because the config is metadata. The flow parse now refuses what that read refuses, in the walk that reaches every region body, so all three doors answer alike. A whitespace-only id is refused with the empty one: a connector `name` is a snake_case identifier, so whitespace names nothing a dispatch can reach. ⚠️ No D2 conversion: the platform cannot know the connector or the action the author left out, and no value it could write would dispatch anything. ⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0031. + - Done when: Run `objectstack validate` over every stack authored in config files, and boot every deployed stack. Each refusal names the node and the key: `FlowSchema.parse` anchors a `custom` issue at `nodes.N.connectorConfig` for an absent block, at `nodes.N.connectorConfig.connectorId` or `nodes.N.connectorConfig.actionId` for an empty or whitespace-only id, or at the region path `nodes.N.config.body.nodes.M.connectorConfig…`, and `objectstack validate` prints the same path under `flows.K.`. For each hit write the block, per the replacement. Two proofs. (1) For a stack authored in config files, `objectstack validate` is clean. (2) Boot the stack and confirm each flow REGISTERS: no `failed to register flow` warn for it — that warn line is the locator for a row that exists only in `sys_metadata`. A connector node carrying a complete block parses, registers and dispatches as before, and `input` stays optional. +- **`connector-error-mapping-retired`** — `connector.errorMapping — the rules / defaultCategory / unmappedBehavior / logUnmapped block and its per-rule keys, on a connector and on a stack connectors[] entry` → (removed — no connector engine maps an external error through authored rules.) Retry behaviour is `retryConfig`, which the outbound fetch applies. No connector-level channel shows an end user a message: an error users must read is surfaced by whatever handles the connector call's failure. + - Why not automatic: The D2 conversion `connector-error-mapping-removed` deletes the whole block from every connector, stack entry and stored connector row, with one notice per connector, and the delete is lossless: no provider, dispatcher or materializer ever mapped an external error through the rules, so the eleven nested keys configured nothing. The judgment is about what the rules were written to achieve. A rule marking an upstream code `retryable` never changed a retry — if that retry matters, it belongs in `retryConfig`. A rule with a `userMessage` never showed that message to anyone, although the spelling matches the live API-error channel and read as a user-facing refusal; if users need that text, whatever handles the failed call has to surface it. `unmappedBehavior` and `logUnmapped` suppressed or logged nothing. Which of these intents still matters is known only to the connector's author. + - Done when: No connector and no stack connector entry carries `errorMapping`; the parse refuses it, and no code imports ErrorMappingConfig, ErrorMappingRule or ConnectorErrorCategory. Calls through each connector fail and retry exactly as they did before the upgrade. For every rule whose intent still matters: a retry the author wanted is expressed in `retryConfig` and observed on a failing upstream, and a message the author wanted users to read is shown to them, by the caller that handles the failure, when the upstream fails. +- **`connector-provider-context-connection-timeout-ms-retired`** — `ConnectorProviderContext.connectionTimeoutMs, the declared connect deadline handed to every ConnectorProviderFactory (integration/connector-provider.ts)` → requestTimeoutMs for the deadline the platform keeps; for a connect-only bound, the provider's own providerConfig, where the provider owns the vocabulary + - Why not automatic: ADR-0049 enforce-or-remove, maintainer ruling 2026-09-22 letter A: retire connector.connectionTimeoutMs. The spec key is tombstoned and its authored sources are rewritten by the D2 conversion connector-connection-timeout-ms-removed; this entry carries the half a conversion cannot reach. The key was placed on this context by the round that made the connector resilience policy live, explicitly as a CARRY — handed over so that a custom provider on a transport able to separate the phases could honour it. Measured before removal, none did, and the carry itself was the last thing keeping the key alive in argument: the built-in rest and openapi factories read ctx.connectionTimeoutMs only to deposit it back onto the def that GET /connectors echoes, and connectorFetchOptions — the one mapping from authored policy onto the platform's outbound fetch — was never handed it. Being handed a value is not honouring it, so the carry is the same parsed-unmarked-unenforced state on one more surface, and it leaves with the key rather than outliving it as an orphan a factory could still read. Why a semantic entry and not a D2 conversion: a provider factory is CODE. There is no authored source and no sys_metadata row holding a read of ctx.connectionTimeoutMs, so the chain has no seam to rewrite — the removal reaches a factory author as a tsc error and as this entry, never as a mechanical edit. The declaration cannot be made honest by implementing it either: a WHATWG fetch exposes one AbortSignal over the whole operation and never the connect phase, so bounding time-to-response with this key would kill a slow-but-connected upstream the author meant to allow with a large requestTimeoutMs. ADR-0087, ADR-0097. + - Done when: No ConnectorProviderFactory reads ctx.connectionTimeoutMs; the member does not exist on ConnectorProviderContext and reading it fails to compile. A factory that genuinely needs a connect-phase bound declares it in its own providerConfig and applies it itself, on a transport that can observe the connect phase — it does not receive one from the host. Behaviour is unchanged for every shipped provider, because none applied the value: a connector that authored connectionTimeoutMs made exactly the same calls with exactly the same deadlines before and after. What does change is observable and intended: the def served by GET /connectors no longer echoes a connect deadline nobody keeps, and requestTimeoutMs — which resilientFetch applies as each attempt deadline — is the only timeout on the surface. The sibling members retryConfig and requestTimeoutMs deliberately do NOT move, and a sweep that removed either has over-applied this entry: both resolve to real reads at the fetch site. +- **`connector-resilience-keys-retired`** — `connector.health (healthCheck / circuitBreaker), connector.status and connector.webhooks — on a connector and on a stack connectors[] entry` → (removed — nothing replaces the probe, the breaker or an authored status.) Participation is `enabled` (and `provider` on a declarative instance); whether a registered connector can be dispatched is the computed `state` (`ready` / `degraded`) on `GET /api/v1/automation/connectors`; a webhook that is actually delivered is declared in the top-level `webhooks:` collection; probes and circuit breaking belong in the connector provider or an upstream gateway. + - Why not automatic: The D2 conversion `connector-resilience-keys-removed` deletes `health`, `status` and `webhooks` from every connector, stack entry and stored connector row, one notice per key, and the delete is lossless: no loop ever polled a connector endpoint, counted failures or tripped a breaker, no code read an authored status, and a webhook nested in a connector was never registered, materialized or delivered. Three judgements remain. First, a probe or breaker the author believed was protecting a flaky upstream never was — if that protection matters, it has to be built where calls are made (the connector provider) or in front of the upstream (a gateway). Second, `status` values like `active` or `error` gated nothing; an author who used `status` to switch a connector off needs `enabled: false` on the declarative entry instead. Third, the nested webhooks are STRIPPED, not moved: redeclaring one in the top-level `webhooks:` collection STARTS deliveries that never happened before, so which of them should exist is the author's call — and their `events` (`sync.completed`, `auth.expired` and the rest) and `signatureAlgorithm` have no counterpart there. The chain: in this same protocol step, `connector-health-and-trigger-durations-unit-in-key` no longer renames `health.circuitBreaker.monitoringWindow` to `monitoringWindowMs` — the whole block that key lived in is removed, so an author holding either spelling ends with no key at all. That conversion's other half, `triggers[].interval` to `intervalSeconds`, was absorbed the same way by the removal of the whole `triggers` array (`connector-triggers-removed`), so the rename itself is no longer in the step. + - Done when: No connector and no stack connector entry carries `health`, `status` or `webhooks`; the parse refuses each with its prescription (a stored `status: 'inactive'` default is accepted and stripped as inert residue), and no code imports ConnectorHealth, HealthCheckConfig, CircuitBreakerConfig, ConnectorStatus, WebhookConfig, WebhookEvent or WebhookSignatureAlgorithm. Every connector dispatches exactly as it did before the upgrade. Each declarative connector instance the author meant to be switched off carries `enabled: false` and is observed absent from `GET /api/v1/automation/connectors`; each nested webhook that is still wanted is declared in the top-level `webhooks:` collection and observed delivering; and each probe or breaker the author relied on is provided by the connector provider or a gateway and observed tripping against a failing upstream. +- **`connector-sync-keys-retired`** — `connector.syncConfig (strategy / direction / realtimeSync / timestampField / conflictResolution / batchSize / deleteMode / filters) and connector.fieldMappings[] (source / target / defaultValue / dataType / required / syncMode), on a connector and on a stack connectors[] entry` → A sync is defined on its TARGET: a `mapping` (`targetObject`, `fieldMapping`, `mode`, `upsertKey`) whose `connectorSource` names the `rest` or `openapi` connector instance it pulls from (`connector`), the action that reads the records (`action`, with a fixed `input` and a `recordsPath`) and, for a timestamp-incremental pull, a `watermark` (`field` on the record, `param` on the request); a `job` sets the cadence. The pull executor reads the binding when a `job` drives it — a `job` whose `pull: { mapping }` names the mapping, on the job's schedule; the binding alone moves no rows. + - Why not automatic: The D2 conversion `connector-sync-keys-removed` deletes `syncConfig` and `fieldMappings` from every connector, stack entry and stored connector row, one notice per key, and the delete is lossless: no engine ever ran a connector-attached sync or moved a value through a connector field mapping, so nothing the upgrade removes was ever happening. Three judgements remain. First, any part of the deployment designed around a connector sync running has never been running, so the author decides which syncs should now exist as target-side mappings; the conversion STRIPS the keys and never writes a `mapping`, because a mapping that is pulled STARTS writes into a table that never received them — its target object, match key and cadence are the author's. Second, the retired block named a direction, a conflict policy and a delete policy that no runtime applied — every delete is a hard delete, and `latest_wins` resolved nothing — and the pull that replaces it is one-way (external to local) and writes only through the mapping's `mode` and `upsertKey`: an author who relied on `export`, `bidirectional`, `soft_delete` or a conflict policy decides what to do without them. Third, a connector field map moved values nowhere; carrying its `source` → `target` pairs into `mapping.fieldMapping` makes them real for the first time, including any `defaultValue`, which the import mapping spells as a `constant` transform, and `required`, which the target field declares. + - Done when: No connector and no stack connector entry carries `syncConfig` or `fieldMappings`; the parse refuses either key with its prescription, and no code imports DataSyncConfig, SyncStrategy, ConnectorConflictResolution or ConnectorFieldMapping or their schemas. Every connector registers and dispatches its actions exactly as it did before the upgrade. Each sync the author still wants is a `mapping` whose `connectorSource` names a `rest` or `openapi` connector instance, with a `job` whose `pull` names that mapping chosen for its cadence. +- **`connector-triggers-retired`** — `connector.triggers — the ConnectorTrigger array (key / label / description / type / intervalSeconds, and the interval spelling it was renamed from), on a connector and on a stack connectors[] entry` → (removed — nothing replaces a connector trigger.) Start the work from a flow that calls the connector's action in a `connector_action` node: an external event starts an `api` flow that the event's sender calls, and a scheduled pull is a `schedule` flow. + - Why not automatic: The D2 conversion `connector-triggers-removed` deletes `triggers` from every connector, stack entry and stored connector row, one notice per connector, and the delete is lossless: the automation engine registered a connector's actions only, no polling loop read an interval, no receiver was driven by a `webhook` trigger, and no provider derived one — so a declared trigger never started a flow, before or after the upgrade. Three judgements remain. First, any part of the deployment designed around a connector trigger firing has never been running, so the author decides which of those triggers should now exist as flows: a `polling` trigger becomes a `schedule` flow whose `connector_action` node calls the connector's read action, and a `webhook` trigger becomes an `api` flow that the external sender calls. The conversion STRIPS the array and never writes a flow, because a flow that runs STARTS work that never happened before — its cadence, its action and what it does with the result are the author's. Second, a polling cadence is in SECONDS: the key was renamed from `interval` to `intervalSeconds` earlier in this same protocol step because the bare `interval` means milliseconds elsewhere in this spec, so a trigger written `interval: 60000` for one minute asked for once every sixteen hours or so — carry the intended cadence, not the stored number, into the schedule. Third, turning a `webhook` trigger into an `api` flow opens an inbound endpoint that never existed before (the trigger declared no receiver and no verification), and the platform refuses an `api` flow with no per-flow secret and verifies a signature on every call — so whether the external sender can sign its calls decides whether that flow can receive them directly. The chain: in this same protocol step, `connector-health-and-trigger-durations-unit-in-key` no longer renames `triggers[].interval` — the whole array that key lived in is removed, so an author holding either spelling ends with no key at all. + - Done when: No connector and no stack connector entry carries `triggers` in any spelling; the parse refuses the key with its prescription, and no code imports ConnectorTrigger or ConnectorTriggerSchema. Every connector registers and dispatches its actions exactly as it did before the upgrade. Each connector trigger the author still wants is a flow that is observed running: a scheduled pull as a `schedule` flow whose `connector_action` node calls the connector's action at the intended cadence in seconds, and an external event as an `api` flow observed starting when the sender calls it. +- **`cube-join-sql-and-relationship-retired`** — `analyticsCubes[].joins..sql / analyticsCubes[].joins..relationship — the authored ON clause and the declared cardinality on a cube join` → analyticsCubes[].joins..name alone. The ON clause is DERIVED from the declared relationship between the two cubes' objects, as a foreign-key equality: NativeSQLStrategy emits the LEFT JOIN and its ON from the dotted member path, and ObjectQLStrategy lowers the same alias to a relationship traversal with no ON clause at all. The record KEY is the foreign-key FIELD on the base object, never a second spelling of the object the join reaches. + - Why not automatic: The KEYS convert mechanically and do: the paired D2 conversion `cube-join-sql-and-relationship-removed` deletes both from every join, which is lossless because neither ever had an effect to lose, and names the cube in each notice. What does NOT convert is the INTENT. `sql` was REQUIRED and documented as the ON clause, and no reader ever consulted it: an authored condition was REPLACED by the synthesised foreign-key equality and the aggregate came back under a 200, joined on something the author had not asked for. `relationship` carried a `.default('many_to_one')` that nothing dispatched on, so `one_to_many` parsed, changed no SQL, and kept the many-to-one arithmetic. Deleting the keys restores honesty but does not give an author who wanted a non-FK join the thing they wanted, and it does not re-check the numbers the replaced join already produced. That is why this entry is a TODO addressed to them rather than a claim that the strip finished the job. A custom join condition is a capability card with its injection / allow-list boundary decided first, which the ruling deferred deliberately. + - Done when: Delete `sql` and `relationship` from every entry of every cube `joins` map; keep `name`. The paired D2 conversion `cube-join-sql-and-relationship-removed` performs that same strip mechanically wherever the chain is replayed — including over metadata already at rest, so a deployed artifact keeps booting while you do. Then check three things. (1) Did any deleted `sql` express something OTHER than the foreign-key equality between the two objects — a filtered join, a non-key column, a literal predicate? If so, the query you were getting was already the FK-equality answer and not the one you wrote, so re-read the numbers that join produced before assuming this change moved them; the fix is to model the relationship on the object, or to open a capability request for an authorable join condition. (2) Did any deleted `relationship` say anything but `many_to_one`? If so, the aggregate was already computed as many-to-one and still is — this change alters no result, it only stops the declaration from claiming otherwise. (3) Is each join KEYED by a foreign-key field of the cube's own base object? The key is the column the derived ON clause reads, so a join keyed after the object it REACHES never resolved at all. Nothing else regresses: `joins..name` is unchanged, and it is what both the joined table and the per-object RLS/tenant read scope are resolved from. +- **`cube-member-inner-name-retired`** — `analyticsCubes[].measures..name / analyticsCubes[].dimensions..name — the inner name a cube member used to require` → The record key. `measures` and `dimensions` are records, and the key a member is declared under IS its name: the analytics API publishes it as `.` and a query names it that way. To rename a member, rename its key. + - Why not automatic: The D2 conversion `cube-member-inner-name-removed` deletes the inner `name` from every metric and dimension of every cube, and the delete is lossless in behaviour: every consumer — discovery, both query strategies, the in-memory driver — resolves a member by its record key, so the inner value was never read. Where it EQUALED its key there is nothing left to decide. Where it DISAGREED, the key was already the name every query, dashboard and report used, and the inner value was a spelling nothing read; the conversion notice prints both. Only the author can say whether the disagreeing spelling was the one they meant — in which case the member must be re-keyed, and every consumer that names `.` changes with it — or a stale copy to drop. + - Done when: No metric or dimension of any cube carries `name`; the parse refuses it with the prescription. For every conversion notice whose `from` shows a name that differed from its record key, the author has either kept the key (nothing else changes) or re-keyed the member to the intended name and updated every query, dashboard and report that names `.`. `GET /api/v1/analytics/meta` lists each member as `.` exactly as before the upgrade. +- **`cube-member-sql-expression-retired`** — `analyticsCubes[].measures..sql and analyticsCubes[].dimensions..sql (data.MetricSchema.sql / data.DimensionSchema.sql) authored as a SQL expression — a CASE expression, an aggregate or a ratio of aggregates, a quoted or $-prefixed spelling, or any other value that is not a column reference` → a column reference: a field of the cube's object (`amount`), a relationship path ending in one (`account.amount`), or `'*'` for a count. A derived value moves to an ADR-0021 dataset over the same object: a conditional count or sum is a dataset measure with its own structured `filter` (`{ name: 'done_count', aggregate: 'count', filter: { status: 'done' } }`), and a ratio, sum, difference or product of measures is `derived: { op, of: [...] }` over measures named in the same dataset (`{ name: 'done_rate', derived: { op: 'ratio', of: ['done_count', 'task_count'] }, format: '0.0%' }`). A dimension that bucketed a column with a CASE expression has no expression form in either layer: group by the column itself, or keep the bucket as a field of the object and name that field + - Why not automatic: Maintainer ruling D (2026-09-30), from the analytics field-level read gate: a member whose `sql` is an expression names no single field, so no platform check can judge which fields it reads, and the analytics strategies never agreed on it — the raw-SQL path emitted it verbatim and the ObjectQL path refused it. ADR-0021 already set the direction for the author surface ("zero raw SQL / zero raw expressions"); it governed the dataset layer and left the cube members it compiles to open, which is the gap this closes. The dataset form is the declared home of a derived value because every field it reads is named: a measure filter names its fields, and a derived measure references other measures by name only. There is no D2 conversion: the rewrite moves a member to a different metadata type and cannot be derived from the expression text in general, so only the author can say which dataset measures express what the expression meant. A ratio also changes SCALE on the way: a `derived` ratio is a 0–1 fraction, while an expression that multiplied by 100 returned percentage points — pair the ratio with a `%` numeral pattern (the server marks a ratio column's percent scale as a fraction) and re-check any consumer that read the old number raw. ADR-0021 / ADR-0049 / ADR-0087 + - Done when: Every analytics cube parses: `CubeSchema`, the analytics_cube write door and defineStack refuse an expression member at its `sql` with a prescription that names the dataset form, so the sweep is mechanical — parse each cube, and each refusal is one member to move. For each moved measure, a dataset over the same object declares it, and a query over a fixture where the condition excludes rows returns the same figure the expression returned (a ratio: the same value divided by 100 when the expression returned percentage points). Every dashboard, report or saved query that named the cube member now names the dataset measure. A cube member that aggregates a column parses byte-identically to before. +- **`cube-metric-expression-types-retired`** — `analyticsCubes[].measures..type (data.AggregationMetricType) authored as number, string or boolean — the custom-SQL-expression metric types` → the aggregate the measure means: `sum`, `avg`, `min` or `max` over the column, `count` (over `'*'` for a row count, or over a column for its non-null values), or `count_distinct`. A value computed per row becomes a field of the object (a stored or formula field) that the measure aggregates; a ratio or other value derived from measures is `derived: { op, of: [...] }` on an ADR-0021 dataset + - Why not automatic: The three types existed to mark a measure whose `sql` was the whole computation — a ratio, a CASE, a window function — and named only what it returned. Since `cube-member-sql-expression-retired` a member's `sql` is a column reference, so the types had nothing left to declare: measured before this retirement, the raw-SQL strategy emitted the referenced column unaggregated (a bare column in a grouped statement, by SQL's own rules an error on PostgreSQL and an arbitrary row's value on SQLite) and the ObjectQL strategy refused the measure. There is no D2 conversion: the column alone does not say which aggregate the author wanted — a `number` over `amount` may have meant its sum, its average or its largest value — so only the author can choose, and a measure whose old expression computed something per row needs that value stored on the object before any aggregate can read it. Nothing is rewritten or dropped at rest: a stored or built cube that still carries one of the three is refused, with the prescription, at the boot and write doors, and a cube that reaches the analytics service without meeting the parse is refused at query time with the same text. ADR-0049 / ADR-0087 + - Done when: Every analytics cube parses: `CubeSchema`, the analytics_cube write door and defineStack refuse a measure typed number, string or boolean at its `type` with a prescription naming the six aggregates, so the sweep is mechanical — parse each cube, and each refusal is one measure to retype. For each retyped measure, a query over a fixture with more than one row per group returns the aggregate the author chose, and every dashboard, report or saved query that read the measure is checked against the number it now returns. A measure typed with one of the six aggregates parses byte-identically to before. +- **`cube-metric-filters-retired`** — `analyticsCubes[].measures..filters — the per-metric raw-SQL filter list` → One of the two filters that ARE applied: a `where` condition at query time, or an ADR-0021 dataset measure with a structured `filter`. (A third channel — folding the condition into the metric's own `sql` expression — left with `cube-member-sql-expression-retired`: a member's `sql` is a column reference.) + - Why not automatic: The D2 conversion `metric-filters-removed` deletes `filters` from every cube metric, and the delete is lossless in the narrow sense: neither SQL strategy ever read the key, so a metric authored with `filters: [{ sql: "stage = 'closed_won'" }]` already returned the UNFILTERED aggregate under the author's metric name, and still does. That is exactly why the strip does not finish the job. The author wrote a condition because they wanted a filtered number; every dashboard, report and export reading that metric has been showing a larger one. Only the author can say which of the two live mechanisms expresses the condition they meant — a query-time `where` changes every query, a dataset measure moves the metric to the governed layer — and whether numbers already published from the unfiltered metric need to be revisited. + - Done when: No cube metric carries `filters`; the parse refuses the key by name. For each metric that carried one, the author has either re-expressed the condition through one of the two live mechanisms or decided the unfiltered aggregate is what they want — and renamed the metric if its name promised the filter. With the condition re-expressed, a query over a fixture where the condition excludes rows returns the filtered aggregate (strictly smaller for a positive sum over excluded rows), not the unfiltered one. +- **`cube-refresh-key-retired`** — `analyticsCubes[].refreshKey (every, sql) — a cube's declared refresh cadence and data-change probe` → Nothing: delete the key. No analytics result is cached, so every query against a cube is computed when it is asked. A refresh cadence is declared again when a result cache exists. + - Why not automatic: The D2 conversion `cube-refresh-key-removed` deletes `refreshKey` from every cube, and the delete is lossless: nothing read `every` or `sql`, and no analytics result was ever cached for them to refresh, so no query answers differently. What the conversion cannot check is whether anything the author built assumed that cube results were cached or refreshed on a schedule. They never were. + - Done when: No cube carries `refreshKey`, and the parse refuses one with the prescription. Every analytics query answers as it did before the upgrade. Nothing the author maintains relies on cube results being cached or refreshed on a schedule. +- **`currency-config-precision-retired`** — `object.fields.*.currencyConfig.precision — the decimal-places key of a currency field's configuration, and its never-accepted `decimals` / `scale` spellings` → (removed — nothing replaces it.) A currency amount's decimal places are its currency's ISO 4217 minor unit (2 for USD, 0 for JPY, 3 for KWD), derived from the currency itself and declared nowhere. Delete the key. Do not move the number to the field-level `precision`: that key is the amount's total digit count, not its decimal places, and it is unchanged. + - Why not automatic: The D2 conversion `currency-config-precision-removed` deletes the key from every field's `currencyConfig` on objects and object extensions — in author sources, in stored object rows and in built artifacts, which can carry a `2` the old schema wrote into parse output without anyone authoring it — and the delete is lossless: no renderer or runtime ever read the key. Every display face derives the width from the currency. Two judgments remain, and neither is a rewrite. First, a width that never applied: the old contradiction check judged an authored value only on a `fixed` field whose code has a known ISO 4217 minor unit, so on a `dynamic` field, and on a `fixed` field whose code has none (a crypto or custom code), an author could declare a width other than the one the field displays — and read amounts as if it applied. Whether the displayed width is acceptable for that field is the author's call. Second, code the chain cannot reach: a plugin, integration or export of your own that read `currencyConfig.precision` from served object metadata now finds no key, and must derive the width from the field's currency the way the platform's renderers always did. + - Done when: No field's `currencyConfig` carries `precision`, `decimals` or `scale` — in sources, in stored object rows or in built artifacts; the parse refuses each by name with the prescription, and a stored row or artifact written before the upgrade loads without a refusal over it. No code of your own reads `currencyConfig.precision`; where it needed a width, it derives one from the field's currency. Every currency field renders its amounts exactly as before the upgrade, because the key never changed a rendered amount. `os migrate meta --stored --apply` rewrites stored rows so the per-row notice stops. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. +- **`dashboard-header-modal-target-page-only`** — `dashboard `header.actions[]` entries with `actionType: 'modal'` — an `actionUrl` naming a defined action, a bare object name, or the `_` prefix form (`create_`/`new_`/`add_`/`edit_`/`update_` + object). The keys themselves are unchanged and still parse; what changed is what the string RESOLVES to` → a declared page name (`stack.pages`, by `name`) — a modal target names a PAGE, only. To open an object's create/edit form from a dashboard header, use `actionType: 'form'` with an `.` form-view target (`actionType` accepts the full action-type enum, so that shape reaches this surface too) + - Why not automatic: Maintainer ruling A on modal targets (2026-08-09): a `type: 'modal'` string target names a PAGE, only — the spec TSDoc, the published docs and `defineStack`'s cross-reference walk already agreed, and the renderer's page-then-object leniency (self-labelled Back-compat) was retired rather than codified. One objectui change deleted the object fallback in the shared `useActionModal`; a second deleted `DashboardView`'s own second copy of the prefix convention (which had no page resolution at all), after enumerating both repos' corpora and finding zero producers of the prefix form. The `os validate` lint rule (`validateDashboardActionRefs`) then still pointed the other way: it accepted the retired shapes — blessing buttons that dispatch to a named refusal at runtime — and ERRORED on a page-named target, the one shape the runtime serves. The rule now resolves a modal target against declared pages, only. The ruling explicitly declined the middle shape (keep the prefix, reject bare object names): `create_opportunity` names the page `create_opportunity`, or it names nothing. + - Done when: Every dashboard `header.actions[]` entry with `actionType: 'modal'` has an `actionUrl` naming a declared page (`os validate` passes the dashboard-action-refs rule); header buttons meant to open an object's form declare `actionType: 'form'` with an `.` target instead. Clicking each converted button opens the intended page or form rather than a refusal dialog. +- **`dashboard-refresh-interval-unit-in-key`** — `dashboard.refreshInterval — the auto-refresh cadence of a dashboard` → `refreshIntervalSeconds` — the same cadence, in seconds, with the unit in the key name. The old rename hints (`refresh`, `autoRefresh`, `pollInterval`) now point at it. + - Why not automatic: The D2 conversion `dashboard-refresh-interval-to-refresh-interval-seconds` renames `refreshInterval` to `refreshIntervalSeconds` in the `dashboards` collection and on stored dashboard rows, keeping the value, and the rename is lossless: the key always meant seconds. Two judgments remain. First, the unit: nothing in the old name said seconds, and three other spellings authors reached for named no unit either, so a value written in milliseconds — `refreshInterval: 30000` meant as thirty seconds — asked for a refresh about every eight hours, and the rename keeps 30000. Second, the reader: the dashboard renderer ships in the separately released console, and when this rename landed it still read the old key, so a console that has not yet moved sees no cadence and starts no timer. A dashboard that stops refreshing after the upgrade is that lag, not a wrong value — which only a look at the running console can tell apart. + - Done when: No dashboard carries `refreshInterval`; the parse refuses it with the rename. Every `refreshIntervalSeconds` value is the cadence the author intends in seconds — a dashboard meant to refresh every thirty seconds reads `refreshIntervalSeconds: 30`. In the console the deployment runs, an open dashboard re-queries its widgets at that cadence; where it does not, the console build predates the renderer's move to the new key, and the author has recorded that until the console is upgraded. +- **`dashboard-widget-chart-config-structure-refused`** — ``dashboard.widgets[].chartConfig.type` / `.xAxis` / `.yAxis` / `.series` — the four keys that said which chart family to draw, which series exist and which column each one reads on a DATASET-BOUND widget (REMOVED)` → the widget’s own `type` and its ADR-0021 dataset selection. `chartConfig.type` becomes the widget’s `type` (the chart family has always been the widget’s — the dashboard renderer maps the widget type to the chart family and never read the chart config’s). `chartConfig.xAxis.field` becomes an entry in the widget’s `dimensions`: the dataset dimension the category axis plots. Each `chartConfig.yAxis[].field` becomes an entry in the widget’s `values`: the dataset measure that axis plots, one entry per mark, and a second axis is a second measure rather than a second axis declaration. Each `chartConfig.series[].name` is the same measure name, so a series list that matched `values` needs nothing and one that did not was already being ignored. What has NO replacement, and is the reason this is a TODO rather than a rewrite: the PRESENTATION those objects carried alongside the binding — `ChartAxis.title` / `format` / `min` / `max` / `stepSize` / `showGridLines` / `position` / `logarithmic`, and `ChartSeries.label` / `color` / `type` / `yAxis` / `stack` / `dashArray` / `opacity`. The dataset’s own dimension and measure declarations are what label and format a dataset-bound chart now; `colors` on the same chart config remains the palette channel, and a per-series mark type (the combo chart a widget could author through `series[].type`) has no authoring channel on this face at all. + - Why not automatic: Maintainer ruling of 2026-09-12 on the dataset-bound chart config, taking options C+D together: the protocol states the ownership split AND refuses the structural keys by name, because stating it without refusing them leaves the declared-but-inert shape ADR-0049 exists to end, and refusing them without stating it leaves an author with no reason. The defect being closed is not cosmetic: an authored `yAxis[].field` was a LIVE MEMBERSHIP CHANNEL — the renderer synthesised a series from the authored axes when the chart declared none — so one authored axis could silently re-point a dataset-bound series at a different column while the chart still drew, which reads as a true statement about the data. ⛔ Not mechanically convertible: the D2 conversion can delete the keys from a stored widget, but moving what they MEANT into the dataset selection needs facts the item does not carry — whether the dataset declares a dimension by that name, whether the measure is in the dataset at all, and whether the author wanted the axis they wrote or the one the selection derives. An authored field naming a column outside the selection is exactly the case where a walker guessing would produce a different chart rather than a refused one. The keys are NOT retired from the chart config itself: `ReportChartSchema` keeps its own `xAxis`/`yAxis` (narrowed to its bound dataset’s dimension and measure names), and the react `` tier keeps all four, because an inline-data chart has no dataset to derive structure from and the author’s axes are the only ones there are. + - Done when: Measured against the shipped schema, not restated from the card. (1) No dashboard widget carries `chartConfig.type`, `.xAxis`, `.yAxis` or `.series`: the D2 conversion `dashboard-widget-chart-config-structure-removed` strips them from authored sources on a chain replay and `os migrate meta --stored --apply` covers rows already at rest, and a value that reaches a parse is refused at that key’s own path with the prescription naming the dataset selection. (2) For every widget that carried one, the chart it draws after the migration is the chart the author meant: the family is the widget’s `type`, the category axis plots the dimension named in `dimensions`, and there is one mark per measure named in `values` — verified by rendering the dashboard, not by reading the metadata, because the pre-migration chart may have been plotting a column the selection never named. (3) A widget whose authored axes AGREED with its selection renders identically before and after, and that is the expected case; a widget that renders differently was relying on the membership channel this removes and is the case the ruling was made about. (4) Axis titles, number formats, axis bounds, grid lines and per-series labels/colours/mark types are gone from the widget and are NOT expected back: a dataset-bound chart takes them from the dataset’s dimension and measure declarations. A combo chart that was authored through `series[].type` on a dataset-bound widget has no authoring channel on this face after the change — that capability loss is ruled, not incidental, and an inline-data react `` is where a per-series mark type is still authored. +- **`dashboard-widget-dimensionless-multi-measure-refused`** — `dashboard widget measure arity WITHOUT a dimension — `dashboard.widgets[].values` (`DashboardWidgetSchema.values`) on a widget whose `dimensions` is absent or empty and whose `type` is one of the seven chart types that declare no rendering for several measures: `pie` / `donut` / `funnel` / `scatter` / `radar` / `treemap` / `sankey`` → Pick a visual that renders several measures, or split the widget. With no dimension, `type: 'table'` renders a row of measures and a bar-family type (`bar` / `column` / `horizontal-bar`) renders one bar per measure; both keep the unbounded `values` they have always had. The full set of types that render several measures on a dimensionless widget is the exported constant `DASHBOARD_WIDGET_MULTI_MEASURE_TYPES` — at this release `table`, `pivot`, `bar`, `column`, `horizontal-bar`, `line`, `area` and `combo` — and the refusal prints it from that constant. Or keep the type and give each measure its OWN widget: a new `id`, the same `dataset`, that one measure in `values`, and its own `layout` if the dashboard pins grid positions. ⛔ The migration does not do this for you and no conversion could: whether a dimensionless two-measure pie meant a table, a bar chart or two pies is an authoring choice, and N widgets need N ids and N boxes on a 12-column grid, which is a LAYOUT decision about a dashboard the registry has never seen. + - Why not automatic: Maintainer ruling D, on objectui's finding that a widget silently drops every measure after the first, applying the maintainer's standing rule 「协议不正确的应该先修改协议」 — the protocol is fixed where it admits measures a widget type cannot render. Its first application bounded the metric FAMILY to one measure (`dashboard-widget-metric-family-multi-measure-refused`); this entry applies the same principle to the chart types. Measured in objectui by the dev who delivered the multi-measure renderings for `table` / `pivot` and the bar, line, area and combo families: the other seven `ChartTypeSchema` members, given no dimension and two or more measures, render `values[0]` and drop the rest — the dataset query selects and computes every measure, and all but the first are thrown away. Every door accepted the document, because `values` is `z.array(z.string()).min(1)` with no upper bound outside the metric family. That is the declared≠delivered shape ADR-0049 exists to end. The census before the change found zero authored dimensionless multi-measure widgets of any type in the platform's examples or in objectui's example apps, so this ships at once with no deprecation window: there is no window in which a queried-and-discarded measure does anything. Relaxing later is free and needs no second migration — if one of the seven gains a declared multi-measure rendering (a radar of measures, a funnel of measure stages) it joins the constant, while leaving the key unbounded costs an author a widget that silently drops what they declared. ⛔ This change does not invent those renderings. + - Done when: ⚠️ WHICH DOOR: the refusal is the spec's, and it reaches every door that parses the spec schema — measured on `defineStack`, which throws naming the widget; on `os validate`, which loads the configuration through `defineStack` and fails there with that same issue; on the stack schema and the `dashboard` metadata-type schema; and on the metadata save path, where an ACTIVE save and a DRAFT save of such a dashboard both answer `422 INVALID_METADATA` at `widgets[N].values` and persist nothing. It is NOT refused by objectui's client-side authoring door until that door chains the new export: `@object-ui/types` builds its `DashboardWidgetSchema` from a `.shape` spread of the spec's, which carries the FIELDS and drops every object-level check, so its editor keeps accepting a dimensionless two-measure `pie` and the author meets the refusal at publish. ⇒ Do not read a green editor as a clean dashboard; re-parse through the spec. ⚠️ AND THE TODO CANNOT NAME YOUR MEASURES: a `SemanticMigration` is static prose emitted once per hop, with no per-document interpolation and no filtering by whether the stack carries the shape, so `os migrate meta` prints THIS paragraph, not a list of your widgets. The refusal is what names them, per widget, on the re-parse — drive the fix off `os validate`, not off the migrate output. WHAT IS REFUSED, exactly: ONE `custom` issue at `widgets[N].values`, naming the widget's `id`, the number of measures and the authored `type`, when `dimensions` is absent or an empty array, `values` carries two or more measures, and `type` is `pie`, `donut`, `funnel`, `scatter`, `radar`, `treemap` or `sankey`. The check is the exported `checkDashboardWidgetChartMeasureArity` (exported as `checkDashboardWidgetDimensionlessMeasureArity` until `dashboard-widget-single-series-multi-measure-refused` gave it a second arm and renamed it), and the set it reads is the exported `DASHBOARD_WIDGET_MULTI_MEASURE_TYPES` — one list, which the check, the refusal text and the `values` doc string all read. WHAT IS NOT, so this is not read as complete: the same seven types WITH a dimension are outside THIS entry — `scatter` and `radar` keep accepting several measures with a dimension, and `pie` / `donut` / `funnel` / `treemap` / `sankey` are refused with a dimension too, by `dashboard-widget-single-series-multi-measure-refused`; one measure parses on every type; every type in `DASHBOARD_WIDGET_MULTI_MEASURE_TYPES` keeps accepting any number of measures with no dimension; the metric family (`metric` / `kpi` / `gauge` / `solid-gauge` / `bullet`, and a widget that declares no `type`, which resolves to `metric`) keeps its OWN refusal, unchanged and still ONE issue — that check refuses a second measure at any dimensionality, and this one steps aside for the family rather than adding a second issue on the same `values`; an EMPTY `values` keeps the field's own `too_small`; a `type` outside `ChartTypeSchema` reports the TYPE refusal alone (zod treats that `invalid_value` as aborting and skips object-level checks), and called directly on a wider type enum the export judges only the types the spec declares; and whether each measure EXISTS in the bound dataset is still unreachable from this schema. VERIFY by re-parsing each dashboard: a dashboard that had one dimensionless two-measure `pie` should end with a `table` or bar-family widget carrying both measures, or with two widgets of one measure each — check the rendered grid afterwards, because the second measure is a number the dashboard was ALREADY paying to compute and had never shown. +- **`dashboard-widget-metric-family-multi-measure-refused`** — `dashboard widget measure arity — `dashboard.widgets[].values` (`DashboardWidgetSchema.values`) on a widget whose `type` is one of the metric FAMILY (`metric` / `kpi` / `gauge` / `solid-gauge` / `bullet`), INCLUDING a widget that declares no `type` at all and so resolves to the `metric` default` → ONE measure per tile. Keep the measure the tile is actually for — in practice `values[0]`, which is the only one that has ever rendered — and give each of the others its OWN widget: a new `id`, the same `dataset`, that one measure in `values`, and its own `layout` if the dashboard pins grid positions. ⛔ The migration does not do this for you and no conversion could: N tiles need N ids and N boxes on a 12-column grid, which is a LAYOUT decision about a dashboard the registry has never seen. If several numbers in ONE widget is what was meant, that is a different visual and the arity rule is not in its way: `type: 'table'` renders a row of measures, and the chart families (`bar` / `line` / `area` / `combo`) render one mark per measure — all of them keep the unbounded `values` they have always had. + - Why not automatic: Maintainer ruling D of 2026-09-12, on objectui's finding that a metric tile silently drops every measure after the first, applying the maintainer's standing rule 「协议不正确的应该先修改协议。」 — judge the protocol wrong rather than invent display semantics for `values[1..]`. Measured in objectui's first report of the defect: `values` was `z.array(z.string()).min(1)` with NO upper bound on every widget type, so a `metric` tile could declare three measures; the dataset query selected and computed all three, and the tile rendered `values[0]`. The other two were queried and dropped on the floor — the declared≠delivered shape ADR-0049 exists to end, kept alive by a runtime warning rather than closed. An objectui fix (merged) added the declared sub-caption, and objectui's interim half made the tile SAY that the extra measures are not rendered: that makes the tile honest about dropping them, it does not make the document legal. A metric tile answers ONE number — that is what the family means on every mainstream dashboard product, and `ChartTypeSchema` groups these five under "Performance (single value)" in its own words. Several numbers is a DIFFERENT visual, not a variant of this one, so the repair is an accept-set narrowing and not a renderer feature. ⛔ NOT the other arm (the wish, from a downstream application's manager dashboard, for several numbers on one tile): under this ruling that is a request for a different widget type, and it stays reachable through `table` / the chart families, which this narrowing does not touch. Ships at once, no deprecation window: there is no window in which a queried-and-discarded measure does anything. Widening later (a real gauge renderer that draws a target band, say) costs an author nothing and needs no second migration — a narrowing that is later relaxed is free, while leaving the key unbounded costs them a tile that silently drops what they declared. + - Done when: ⚠️ WHICH DOOR: this refusal is the PUBLISH door's, not the editor's. Every stored dashboard carrying more than one measure on a metric-family widget is refused the next time it is parsed THROUGH `@objectstack/spec` — `os build` / `os lint`, the metadata publish path, and any server-side door that parses the spec schema — with ONE `custom` issue at `widgets[N].values` naming the widget's `id`, the number of measures it declared, and the authored `type`. It is NOT refused by objectui's client-side authoring door: `@object-ui/types` builds its own `DashboardWidgetSchema` from `specFieldsExcept(SpecDashboardWidgetSchema.shape, …).extend({…}).strict()`, and a `.shape` spread carries the FIELDS while dropping every object-level check, so until that package imports and chains `checkDashboardWidgetMetricMeasureArity` the dashboard EDITOR keeps accepting three measures on a `metric` and the author meets the refusal later, at publish. ⇒ Do not read a green editor as a clean dashboard; re-parse through the spec. ⚠️ AND THE TODO CANNOT NAME YOUR MEASURES: a `SemanticMigration` is static prose emitted once per hop — `applyMetaMigrations` maps `step.semantic` straight onto the result with no per-document interpolation and no filtering by whether the stack even carries the shape — so `os migrate meta` prints THIS paragraph, not a list of your dropped measures. The refusal is what names them, per widget, on the re-parse. Drive the fix off `os build`, not off the migrate output. WHAT IS REFUSED, exactly: two or more `values` members on `metric`, `kpi`, `gauge`, `solid-gauge` or `bullet`, and on a widget that declares no `type` (it resolves to `metric`, and the message says so rather than claiming you wrote it). WHAT IS NOT, so this is not read as complete: a single-measure tile of any of those five types parses byte-identically to before; all fifteen OTHER members of `ChartTypeSchema` — `bar`, `horizontal-bar`, `column`, `line`, `area`, `pie`, `donut`, `funnel`, `scatter`, `treemap`, `sankey`, `combo`, `radar`, `table`, `pivot` — keep accepting three measures, unmoved; an EMPTY `values` keeps the field's own `too_small` from `.min(1)` and gains no second issue ("exactly one" is the conjunction of that lower bound and this upper one, so a mirror re-attaching this export onto a shape without `.min(1)` gets the upper bound only); a widget whose `type` is outside `ChartTypeSchema` reports the TYPE refusal ALONE (zod treats that `invalid_value` as aborting and skips object-level checks), so the arity refusal arrives on the next parse and the two are never seen together; and whether the surviving measure EXISTS in the bound dataset is still unreachable from this schema — a tile naming one measure nobody declared parses exactly as it did before. Nothing new is broken for consumers that DERIVE this schema: `.omit()`/`.pick()`/`.partial()` already threw on it before this change, because it already carried `checkDashboardWidgetStageOrder`; `.extend()` is unaffected — except that zod 4.4.3 refuses an `.extend()` which OVERWRITES a key on a refined object ("Cannot overwrite keys on object schemas containing refinements. Use `.safeExtend()` instead"), which was already true here and is why a per-`type` union arm was not the spelling chosen. VERIFY by re-parsing each dashboard and reading the widget count: a dashboard that had one three-measure `metric` tile should end with three single-measure tiles and the same three numbers on screen — check the rendered grid afterwards, because the two new tiles are numbers the dashboard was ALREADY paying to compute and had never shown. +- **`dashboard-widget-single-series-multi-measure-refused`** — `dashboard widget measure arity WITH a dimension on a single-series chart type — `dashboard.widgets[].values` (`DashboardWidgetSchema.values`) on a widget whose `dimensions` declares one or more dimensions and whose `type` is `pie`, `donut`, `funnel`, `treemap` or `sankey`; and the check export `checkDashboardWidgetDimensionlessMeasureArity` (from `@objectstack/spec/ui`), renamed `checkDashboardWidgetChartMeasureArity`` → Keep ONE measure on the widget, or pick a visual that renders several. With a dimension, `type: 'table'` renders a column per measure and a bar-family type (`bar` / `column` / `horizontal-bar`) renders one bar per measure in each category; both keep the unbounded `values` they have always had. Or keep the type and give each measure its OWN widget: a new `id`, the same `dataset` and `dimensions`, that one measure in `values`, and its own `layout` if the dashboard pins grid positions. ⛔ The migration does not do this for you and no conversion could: whether a two-measure pie by stage meant a table, a grouped bar chart or two pies is an authoring choice, and N widgets need N ids and N boxes on a 12-column grid, which is a LAYOUT decision about a dashboard the registry has never seen. A mirror that chained the old check export by name imports `checkDashboardWidgetChartMeasureArity` instead: same signature, same attachment point, and it refuses everything the old name refused. + - Why not automatic: Triage's ruling on objectui's finding that a dimensioned pie draws only its first measure: the spec refuses, the renderer does not invent. It extends `dashboard-widget-dimensionless-multi-measure-refused`, which applied maintainer ruling D (「协议不正确的应该先修改协议」) to a widget with NO dimension, to the dimensioned arm for the five types that draw one series whatever the dimension. Measured in objectui's shared chart renderer: the `pie` / `donut`, `funnel`, `treemap` and `sankey` arms each bind the first series and read no other, so `{ type: 'pie', dimensions: ['stage'], values: ['revenue', 'cost'] }` drew one slice per stage for `revenue` and no trace of `cost` — the dataset query selects and computes every measure, and all but the first are thrown away. Every door accepted the document, because the dimensionless rule stepped aside for any widget that declared a dimension. That is the declared≠delivered shape ADR-0049 exists to end. A pie of several measures has zero measured pull, so no rendering is invented for it. The census before the change found zero authored dimensioned multi-measure widgets of the five types in the platform's examples, its first-party dashboards or objectui's example apps, so this ships at once with no deprecation window. Relaxing later is free and needs no second migration — a type whose renderer gains a declared rendering for several measures with a dimension leaves the single-series set — while leaving the shape accepted costs an author a widget that silently drops what they declared. The check export is renamed in the same change because its old name said a widget with a dimension was outside it, which stopped being true; no first-party consumer chained it under that name. + - Done when: ⚠️ WHICH DOOR: the refusal is the spec's, attached at the same point as the dimensionless rule, so it reaches every door that parses the spec schema — measured on `defineStack`, which throws naming the widget, and on the stack schema, the dashboard schema and the `dashboard` metadata-type schema, each refusing at `widgets[N].values`. It is NOT refused by objectui's client-side authoring door until that door chains the export: `@object-ui/types` builds its `DashboardWidgetSchema` from a `.shape` spread of the spec's, which carries the FIELDS and drops every object-level check, so its editor keeps accepting a dimensioned two-measure `pie` and the author meets the refusal at publish. ⇒ Do not read a green editor as a clean dashboard; re-parse through the spec. ⚠️ AND THE TODO CANNOT NAME YOUR MEASURES: a `SemanticMigration` is static prose emitted once per hop, with no per-document interpolation and no filtering by whether the stack carries the shape, so `os migrate meta` prints THIS paragraph, not a list of your widgets. The refusal is what names them, per widget, on the re-parse — drive the fix off `os validate`, not off the migrate output. WHAT IS REFUSED, exactly: ONE `custom` issue at `widgets[N].values`, naming the widget's `id`, the number of measures and the authored `type`, and saying the type draws one series whatever its `dimensions`, when `dimensions` declares at least one dimension, `values` carries two or more measures, and `type` is `pie`, `donut`, `funnel`, `treemap` or `sankey`. The check is the exported `checkDashboardWidgetChartMeasureArity` — the dimensionless rule's own check, with a second arm; the single-series set it reads is not exported, and the refusal prints it. WHAT IS NOT, so this is not read as complete: `scatter` and `radar` WITH a dimension keep accepting several measures exactly as before (the ruling named the five: `radar` draws every series, and `scatter` says on the chart that it draws one); every type in `DASHBOARD_WIDGET_MULTI_MEASURE_TYPES` keeps accepting any number of measures with a dimension; one measure parses on every type; a DIMENSIONLESS widget of the five keeps the dimensionless refusal, word for word and still ONE issue; the metric family (`metric` / `kpi` / `gauge` / `solid-gauge` / `bullet`, and a widget that declares no `type`, which resolves to `metric`) keeps its OWN refusal, unchanged; an EMPTY `values` keeps the field's own `too_small`; a `type` outside `ChartTypeSchema` reports the TYPE refusal alone, and called directly on a wider type enum the export judges only the types the spec declares; and whether each measure EXISTS in the bound dataset is still unreachable from this schema. VERIFY by re-parsing each dashboard: a dashboard that had one two-measure `pie` by stage should end with a `table` or bar-family widget carrying both measures, or with two widgets of one measure each — check the rendered grid afterwards, because the second measure is a number the dashboard was ALREADY paying to compute and had never shown. +- **`dashboard-widget-stage-order-non-funnel-refused`** — `dashboard widget stage order — `dashboard.widgets[].options.stageOrder` (`DashboardWidgetOptionsSchema.stageOrder`) on a widget whose `type` is anything other than `funnel`, INCLUDING a widget that declares no `type` at all and so resolves to the `metric` default` → either `type: 'funnel'` on the widget that meant to declare a stage order, or — for every other widget type — DELETE `stageOrder` and order the widget with `options.sortBy` + `options.sortOrder`, which lower into the dataset query as `order: { : 'asc' | 'desc' }` instead of re-sorting what it returned. There is no third spelling: no other widget type has ever read the key, so nothing is lost by removing it that was not already absent from what rendered. The refusal lands at `options.stageOrder` and names the type the widget carries, the one type that reads the key, and the two keys to reach for instead. + - Why not automatic: The first finding of the report that `options.stageOrder` is honoured by the funnel branch only, ADR-0049 enforce-or-remove, and the enforce arm of a defect whose whole content was SILENCE. `options` is the open renderer-extras bag, so `stageOrder` was an ungated member of it: a `horizontal-bar` (or `line`, `pie`, `table`, `metric`) widget carrying an authored lifecycle order PARSED, booted, and forwarded the array to the renderer, which never consulted it. Measured at this repo's `.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694`: the forwarded `categoryOrder` prop has exactly one read in the charts plugin (`buildCategoryRank(categoryOrder)`, `AdvancedChartImpl.tsx:1514`) and it sits inside the `chartType === 'funnel'` guard opened at line 1473; the prop's other two occurrences in that file are its declaration and its destructure. The producer side has no gate either — `DatasetWidget.tsx:1468` builds the explicit order for ANY widget and forwards it whenever non-empty. So the authored order was accepted by the metadata layer, carried all the way to the chart, and dropped there, with nothing anywhere to say so: the widget rendered in whatever order the analytics query returned and looked deliberate. The reporter measured exactly that in a live app — a `horizontal-bar` carrying a seven-stage contract lifecycle rendered alphabetically by display label. The four SIBLING members of the same bag are not in this narrowing and were measured not to share the defect: `dateGranularity`, `sortBy`, `sortOrder` and `limit` are read unconditionally at the top of `DatasetWidget` (lines 443-455, outside every type branch) and lower into the `DatasetSelection` the server compiles, so they act on every widget type. `stageOrder` was the only member whose effect was confined to one branch. ⛔ NOT the other arm of the card ("or ordered marks honour it"): teaching `bar` / `line` / `area` to sort by a category order is a renderer change in the objectui repo, and widening the set of types that read the key can be done later WITHOUT a second migration — a narrowing that is later relaxed costs an author nothing, while leaving the key accepted-and-inert costs them a chart that silently lies. Ships at once, no deprecation window: there is no window in which an inert key does anything. + - Done when: ⚠️ WHICH DOOR: this refusal is the PUBLISH door's, not the editor's. Every stored dashboard whose widgets carry `options.stageOrder` on a non-`funnel` type is refused the next time it is parsed THROUGH `@objectstack/spec` — `os build` / `os lint`, the metadata publish path, and any server-side door that parses the spec schema — with one `custom` issue at `widgets[N].options.stageOrder` naming the authored type. It is NOT refused by objectui's client-side authoring door: `@object-ui/types` builds its own `DashboardWidgetSchema` from `specFieldsExcept(SpecDashboardWidgetSchema.shape, …).extend({…}).strict()`, and a `.shape` spread carries the FIELDS while dropping every object-level check (measured: `z.strictObject(DashboardWidgetSchema.shape)` accepts the widget and reports zero checks, while `.extend({})` keeps the refusal). At the `.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694` that package re-attaches NONE of the spec's exported checks, so until it imports and chains `checkDashboardWidgetStageOrder` the dashboard EDITOR still accepts the key on a `bar` and the author meets the refusal later, at publish. ⇒ Do not read a green editor as a clean dashboard; re-parse through the spec. Fix each by writing `type: 'funnel'` where a funnel was meant, and by deleting the key elsewhere — check the rendered order afterwards, because a widget that was silently ignoring the key renders EXACTLY as it did before once the key is gone, and `sortBy` / `sortOrder` is what changes it. A `funnel` widget carrying `stageOrder` parses byte-identically to before, a non-`funnel` widget carrying the other four `options` members is untouched, and a widget with no `options` at all is untouched. ⚠️ Three more shapes this does NOT reach, so do not read it as complete (the objectui door above is the first): a widget whose `type` is outside `ChartTypeSchema` reports the TYPE refusal alone (zod treats that as aborting and skips object-level checks), so the stage-order refusal arrives only on the next parse; and the array's CONTENTS are still unconstrained, so a `funnel` carrying a stage value the dimension never declares still parses and still renders that stage in the sentinel position; and a consumer that derives this schema with `.omit()` / `.pick()` / `.partial()` now gets a THROW from zod rather than a schema, because zod 4 refuses all three on an object carrying a refinement — latent rather than live (no consumer in either repo derives the widget schema that way today), and `.extend()` is unaffected. Repo census at the time of the change: zero authored widgets carry the key anywhere in the monorepo — 59 occurrences outside changelogs, all of them schema, tests, generated reference pages, the sdui-parser census and the gate that derives it. +- **`data-file-value-duration-unit-in-key`** — `FileValue.duration, the media length on the expanded file/image/avatar/video/audio read shape, whose name carried no unit (data/field-value.zod.ts)` → durationSeconds — rename the key; the value is unchanged, and a fractional second is still legal + - Why not automatic: Maintainer ruling A of 2026-09-17 on the last two duration keys no closed duration type could express: rename the key and record an ADR-0087 conversion-layer entry, with no new closed type and no narrowing of anything already stored. This key declared its unit in NO channel at all — no `.describe()`, no JSDoc, no unit token in the name — so the published reference page printed a bare number and the authoring site printed nothing. What makes the bare name worth a registry row is the company it kept: the only other number on FileValue is `size`, a BYTE count, so the one member that measured time was indistinguishable from a count at the very site an author (very often a model, ADR-0033) writes it. `durationSeconds` rather than a mechanical `durationSec` or `lengthSeconds`: this spec already spells a length of time `durationSeconds` in SIX places, and they are ENUMERATED rather than counted because a bare number in shipped prose cannot be re-checked against the tree — ai/conversation.zod.ts ConversationAnalytics.durationSeconds; and on system/metrics.zod.ts MetricAggregationConfig.window.durationSeconds, ServiceLevelIndicator.window.durationSeconds, ServiceLevelObjective.period.durationSeconds, ServiceLevelObjective.errorBudget.burnRateWindows[].durationSeconds and MetricsConfig.retention.durationSeconds. The media length is therefore the SEVENTH spelling of one vocabulary, not the first of a second one. The retired-key tombstone entry data/FileValue:duration carries the same six keys in the same order, so the two surfaces that state one fact cannot drift apart. The value type is deliberately UNCHANGED at `z.number().optional()`: a fractional second is the ordinary shape of a media length, so the closed `DurationSeconds` type (`.int().nonnegative()`, published beside `EpochMs` as a closed duration type) was considered and REFUSED by the ruling, and so was an `.int()` floor. That refusal is the load-bearing half — this row is one of the six genuine durations the closed types' unit set was derived from, and it is the one that takes a NAME instead of a TYPE. Tombstoned with retiredKey(); FileValueSchema is the one deliberate z.looseObject in this file, so a bare deletion would wave the old spelling through as an unrecognised extra key and the prescription would never be spoken. Why a semantic entry and not a D2 conversion: FileValueSchema is the ADR-0104 D3 wave-2 EXPANDED READ form, derived at read time from a sys_file id — the STORED form is FileReferenceIdValueSchema, an opaque string — so a file value is never authored as this shape and never persisted as a sys_metadata row, and the conversion chain has no seam that would ever see one. ADR-0104, ADR-0087. + - Done when: Every producer that BUILDS an expanded file value spells durationSeconds, and every consumer that reads a media length reads durationSeconds. Authoring duration fails to compile (input type `never`) and fails to parse with the rename prescription rather than riding through the loose shape as an unrecognised extra. Behaviour is unchanged: durationSeconds: 12 is the same twelve seconds duration: 12 was, the key stays optional, and durationSeconds: 12.34 still PARSES — a sweep that added .int() or adopted DurationSeconds has narrowed a value the ruling refused to narrow, and is the one over-application to look for. The five sibling members — url, name, size, mimeType, alt — are untouched; `size` in particular is a BYTE count, not a duration, so a sweep that suffixed it has read a count as a length of time. +- **`data-nosql-query-options-timeout-unit-in-key`** — `NoSQLQueryOptions.timeout, the per-query driver deadline whose name carried no unit (data/driver-nosql.zod.ts)` → timeoutMs — rename the key; the value is unchanged + - Why not automatic: Maintainer ruling B on duration units (2026-09-02): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. It stands alone because it is the only offender on its file. The neighbour is what makes it a real hazard rather than a naming preference: batchSize sits directly beside it, a plain row COUNT with the same z.number().int().positive() shape and the same order of magnitude, so two adjacent bare integers meant milliseconds and documents respectively with nothing at the call site to separate them. Tombstoned with retiredKey(); the shape is not strict, so a bare deletion would strip in silence and the query would run with no deadline at all while its author believed one was set — the failure a driver timeout exists to prevent. Why a semantic entry and not a D2 conversion: these options are a per-call driver argument, reached only through AggregationPipeline.options, which no stack.zod.ts collection declares and no sys_metadata row stores, so the chain has no seam. ADR-0087. + - Done when: Every caller that passes NoSQL query options spells timeoutMs. Authoring timeout fails to compile (input type `never`) and fails to parse with the rename prescription. Behaviour is unchanged: timeoutMs: 5000 is the same five seconds timeout: 5000 was, and the positive-integer bound rides along with the renamed key so a zero or negative deadline is still refused. Two neighbours on this same shape deliberately do NOT move, and a sweep that renamed either has over-applied the rule: batchSize is a COUNT of documents, not a duration, and consistency / projection / hint are not numbers at all. +- **`dataset-filter-nested-relation-equality-array-refused-at-save`** — `ui.Dataset filter and ui.DatasetMeasure filter — an ARRAY as an EQUALITY comparand on a field INSIDE a nested-relation condition, now refused when the dataset is PARSED: the implicit form { account: { region: [...] } } and the explicit form { account: { region: { $eq: [...] } } }, the empty array included, at any relation depth and under $and / $or / $not. Every other schema that carries a FilterCondition keeps the shared schema's reach` → the operator the list was standing in for, on the same field inside the same relation, exactly as in filter-equality-array-comparand-refused-at-save. "One of these values" is $in: { account: { region: { $in: ["a", "b"] } } } (authoring spelling "in"). "The stored multi-value field holds this value" is $contains with ONE member (authoring spelling "contains"), and an $or of those for any-of. A filter that meant a single value writes that value: { account: { region: "a" } }. The list operators keep their arrays, empty lists included; every scalar, null above all, is untouched; and $ne is NOT judged by this entry + - Why not automatic: Measured on origin/main 9e7824a445 before the change: DatasetSchema parsed a dataset whose filter was { account: { region: ["a"] } }, and one whose measure filter was { account: { region: { $eq: ["a"] } } }, GREEN — while the analytics where door, which charts both carriers on every path (the native-SQL and ObjectQL strategies and the draft preview), flattens the relation to the dotted member account.region and hands the list to the shared comparand-shape face, which refuses it with INVALID_FILTER / 400. So such a dataset saved clean and every chart built on it failed, for a different person, later. The shared FilterConditionSchema does not descend a field spec with no $ key, because the engine reads one as a deep-equality comparand; ruling A of 2026-09-24, which made the schema door refuse what the compile face refuses, drew the line there and it stays there. Triage on 2026-09-25 routed the fix to the two analytics carriers instead, rather than stop the analytics door descending, which would change what a nested list means: they refine their filter with the analytics door's own walk ($and / $or arrays and $not descended, other $ keys skipped, a plain object with no $ key descended as a nested relation at any depth) and refuse, inside a nested relation only, exactly what that door refuses there, in the face's words from the one builder both doors import. The one difference is that the door appends the location (at where.account.region) and the carrier does not, because its issue carries the location as its path (filter.account.region, measures.0.filter.account.region.$eq). A list outside a nested relation is the shared schema's refusal and is reported once. No filter changes meaning: the refusal moves from chart time to save. Metadata AT REST is not rewritten and this entry adds no D2 conversion, for the reason the runtime entry gives: an array on equality has no single honest value. The read path does not re-validate stored rows, so a stored dataset keeps loading; what changes is that re-saving it through the metadata protocol (422 INVALID_METADATA), defineStack or os validate is refused at the filter's path. Such a filter has failed every chart since the analytics door began refusing it, so the refusal is a repair and not a loss. In-repo census at 9e7824a445: no dataset or measure filter in examples, platform objects, docs or skills carries the shape; deployed datasets were NOT measured. ADR-0021 / ADR-0087. + - Done when: Validate every stack and re-save every stored dataset: os validate or defineStack, and a save through the metadata protocol, report each list in an equality slot inside a nested relation by path, with the field, the received list and both remedies, so the sweep of the two carriers is mechanical. Decide per filter what it meant — one of these values ($in), the stored list holds a value ($contains, an $or of them for several), or one value — and re-check what each chart is supposed to show: the filter had been failing every chart. A filter that reaches the analytics door by any other route, such as a caller where or a dataset selection runtimeFilter, is still refused only when it is charted, with INVALID_FILTER / 400 naming the field and the path. +- **`dataset-measure-aggregate-field-type-refused`** — `dataset measure `aggregate` × `field` pairs (`DatasetMeasureSchema`, the rows inside `Dataset.measures[]`) over a TEMPORAL field — `date`, `datetime`, `time` — whose aggregate that declared `FieldType` cannot carry: `avg` and `sum` over any of the three. ⚠️ This entry is ONE OF TWO on this leg, and its scope sentence is kept as written: it covered the temporal class and nothing else when it was registered. The non-temporal `sum` / `avg` rows followed in a later change, which registered NO entry of its own — it declared `not-required (already-registered dataset-measure-aggregate-field-type-refused)` against THIS id — so its widening rides this entry's prescription rather than a separate one. The `count_distinct` row's JSON-stored types (`json`, `composite`, `repeater`, `record`, `location`, `address`, `vector`, `multiselect`, `checkboxes`, `tags`) left the table in a third change, which likewise registered no entry and rides this one: no two backends compare those values for equality alike, so `count` is the aggregate that stays. The `min` / `max` rows over every class the table refuses are the second entry, `dataset-measure-selecting-aggregate-field-type-refused`. ⇒ Read BOTH when migrating; there is no third` → an aggregate the field's type accepts, per `AGGREGATE_FIELD_TYPE_COMPATIBILITY` (`@objectstack/spec/data`): `min` / `max` for a temporal field — both return a real instant of the field's own type — or `count` / `count_distinct`, which read no arithmetic off the value. A DURATION is not recoverable from an aggregate over instants: store it as a number (a computed "days open" field) and aggregate that. A `derived` measure whose `of` names a refused measure is fixed by fixing that measure, not the `derived` one + - Why not automatic: Nothing between the author and the driver correlated a measure's aggregate with its field type, so `avg` over a `Field.datetime` compiled to `AVG(col)` and reached the backend — where the ANSWER was decided by the dialect rather than by the data. Measured on both halves: SQLite coerces the column's canonical UTC text to a number by reading its leading digits, so `avg(submitted_at)` over 2026-05 and 2025-01 returns `2025.5` — the average YEAR, no error, no log; PostgreSQL 16 answers `function avg(timestamp with time zone) does not exist` (SQLSTATE 42883). ⚠️ The two halves are not evidenced alike: the SQLite half is PINNED by a live `sql.js` suite in `__tests__/aggregate-datetime-measure-refusal.test.ts`, while the Postgres half was MEASURED IN-SESSION on PostgreSQL 16.13 and is not pinned by any test — the live PG conformance job carries no cell for it. Nothing depends on it: the refusal is decided from declared metadata before a driver is reached. ⭐ The silent half is the dangerous one, and it is the DEV default: `derived: { op: 'difference', of: [avg_a, avg_b] }` over two such averages rendered `-0.85` on a tile labelled "average cycle time delta" — indistinguishable from a correct answer, which is the shape Prime Directive #12 exists to remove. Which pairs are accepted is therefore a contract, declared once in `@objectstack/spec` under the director's ruling of 2026-09-06 ("both legs, table in spec") and executed by the consumer legs; the compile-time leg (`dataset-compiler`, `service-analytics`) refuses the pair with `DATASET_INVALID` / 400 before any query is built, using the declared type the host already supplies through `AnalyticsServiceConfig.sourceFieldMeta`. ⚠️ A `date` / `datetime` used as a DIMENSION — grouping, bucketing, date-range filtering — is untouched: this is about aggregation only. + - Done when: Every dataset measure over a `date` / `datetime` / `time` field pairs that field with an `aggregate` the temporal class accepts — `min`, `max`, `count`, `count_distinct` — and none pairs it with `avg` or `sum`. ⚠️ The criterion as WRITTEN reaches no further: a measure over a field of any other class was not judged by the leg this entry was registered for. It is covered all the same — by this entry's own prescription, widened by a later change (which registered `not-required` against this id rather than an entry of its own) to `sum` / `avg` over every field class, and by a third, the same way, to `count_distinct` over the JSON-stored types; and by `dataset-measure-selecting-aggregate-field-type-refused` for `min` / `max`. ⛔ There is no third entry to look for. At protocol major 18 as a whole, every refused pair in `AGGREGATE_FIELD_TYPE_COMPATIBILITY` is refused at the compile door. Accepted pairs compile and execute byte-identically to before (`avg` over `number` / `currency`, `min` / `max` over `datetime`, `count` over anything); a refused pair answers `400 DATASET_INVALID` naming the measure, the field, its declared type and the accepted set, with no SQL emitted. The refusal stands down rather than guessing wherever the type cannot be resolved: no `sourceFieldMeta` wired, an unknown field, or a `relationship.field` path whose column lives on a joined object. +- **`dataset-measure-selecting-aggregate-field-type-refused`** — `dataset measure `aggregate` × `field` pairs (`DatasetMeasureSchema`, the rows inside `Dataset.measures[]`) pairing `min` or `max` with a field whose declared `FieldType` that aggregate cannot carry — every type outside the numeric, temporal and boolean classes. Named in full so an author can grep their own metadata: the string family (`text`, `textarea`, `email`, `url`, `phone`, `password`, `secret`, `markdown`, `html`, `richtext`, `code`, `color`, `signature`, `qrcode`), the option types (`select`, `radio`), the references (`lookup`, `master_detail`, `tree`, `user`), `autonumber`, the multi-option types (`multiselect`, `checkboxes`, `tags`), the file family (`image`, `file`, `avatar`, `video`, `audio`), the structured-JSON types (`json`, `composite`, `repeater`, `record`, `location`, `address`, `vector`) and `formula` — 37 field types × 2 aggregates = 74 pairs` → an aggregate the field's type accepts, per `AGGREGATE_FIELD_TYPE_COMPATIBILITY` (`@objectstack/spec/data`), or a different way of asking the question. ⚠️ There is no lossless rewrite, which is why this is a semantic TODO and not a D2 conversion: nothing can compute "the smallest text value" in a way every backend agrees on, so no transform can preserve the answer. The three routes an author actually has, per intent: ① the measure was COUNTING in disguise ("how many distinct owners") ⇒ `count`, which accepts every type because it reads no value, or `count_distinct`, which accepts every type but the JSON-stored ones (the structured-JSON types and `multiselect` / `checkboxes` / `tags`, whose values no two backends compare for equality alike); ② the measure wanted a FIRST or LAST RECORD ("the earliest-titled task") ⇒ that is a SORT on a list or report, which orders once in a declared direction, not an aggregate that asks each backend for its own smallest value; ③ the measure wanted a QUANTITY that happens to be stored as text or JSON ⇒ store it as a numeric or temporal field (a computed column) and aggregate that. A `derived` measure whose `of` names a refused measure is fixed by fixing that measure, not the `derived` one + - Why not automatic: Director ruling B of 2026-09-13: the compile door enforces the table for every aggregate. The table refused these 74 pairs from the day it was declared and NOTHING executed the refusal: the compile leg (`dataset-compiler`, `service-analytics`) carried an explicit scope condition — `if (!DERIVING_AGGREGATES.has(aggregate)) return;` — so `min` / `max` were never judged whatever the field type, and `service-analytics`' `measureResultType` went further and typed `min` / `max` over the string classes as a supported `'string'` result and over a `formula` field from its declared `returnType`. Four declarations, three answers, one pair — the worst shape of declared≠enforced, because nobody could tell which sentence was the contract. ⭐ The divergence is real and it is the ORDER rather than the arithmetic: string order is collation-dependent, so two backends answer two different "smallest" values for one metadata document, and `min(jsonb)` does not exist on PostgreSQL at all — the same shape Prime Directive #12 exists to remove. The ruling settled all three sub-questions together rather than per field class, because one shared fixture drove members of both halves: the string classes stay REFUSED as the director's 2026-09-06 ruling put them (「`min`/`max` numeric plus `date`/`datetime`; everything else refused」) and the table is NOT amended; the non-string classes are refused AND enforced; and `formula` is refused on the table's own storage ground — it is VIRTUAL in SQL storage, no column is emitted, so no aggregate can be lowered to it whatever `returnType` says. ⚠️ The "ruled C — the table is to be AMENDED to accept the string rows" note the tree carried in two test files had no ruling behind it: the card it cited is closed as a duplicate with zero rulings on it, and the earlier recorded ruling on this table says the opposite. Business pull was measured and is zero — the shipped `min` / `max` cases were in-tree fixtures pinning a result TYPE, not customer datasets reading one. ⚠️ Confidence gap, recorded rather than hidden: customer datasets in the `cloud` repository were not readable when this was decided. + - Done when: Every dataset measure declaring `aggregate: 'min'` or `'max'` pairs it with a field the class accepts — the numeric class (`number`, `currency`, `percent`, `rating`, `slider`, `progress`, `summary`), the temporal class (`date`, `datetime`, `time`) or the boolean class (`boolean`, `toggle`) — and none pairs it with a field of any other declared type. Accepted pairs compile and execute byte-identically to before, including `min` / `max` over a temporal field, which still carries `fields[].type: 'time'`; a refused pair answers `400 DATASET_INVALID` naming the measure, the field, its declared type and the accepted set, with no SQL emitted. ⚠️ A `text` / `select` / `lookup` / `formula` field used as a DIMENSION — grouping, labelling, bucketing, filtering — is untouched, and so is `count` / `count_distinct` over one: this is about the two SELECTING aggregates only. The refusal stands down rather than guessing wherever the type cannot be resolved: no `sourceFieldMeta` wired, an unknown field, or a `relationship.field` path whose column lives on a joined object. A measure column over such a pair also stops carrying a corrected `fields[].type`, because the pair no longer produces a column at all. +- **`dataset-member-field-expression-refused`** — `datasets[].dimensions[].field and datasets[].measures[].field (ui.DatasetDimensionSchema.field / ui.DatasetMeasureSchema.field) authored as anything but a column reference — a SQL expression (an arithmetic, an aggregate, a CASE, a subquery, a function call), a quoted or $-prefixed spelling, a padded or empty string, a broken path, or * on a dimension` → a column reference: a field of the dataset's object (`amount`), or a relationship path ending in one (`account.amount`) whose relationships are declared in `include`; on a measure also `'*'` for a count, and a count may omit `field` altogether (never `field: ''`). A derived value takes its ADR-0021 form: a conditional count or sum is a measure with its own structured `filter` (`{ name: 'done_count', aggregate: 'count', filter: { status: 'done' } }`), and a ratio, sum, difference or product of measures is `derived: { op, of: [...] }` over measures named in the same dataset (`{ name: 'done_rate', derived: { op: 'ratio', of: ['done_count', 'task_count'] }, format: '0.0%' }`). A dimension that bucketed a column with an expression has no expression form: group by the column itself, or keep the bucket as a field of the object and name that field + - Why not automatic: The dataset layer was declared to take no raw SQL (ADR-0021 "zero raw SQL / zero raw expressions"), and its `field` was documented as a field or a relationship path, but the slot was a bare string and parsed anything — declared, never enforced (ADR-0049). The runtime had already closed the other end for an expression: the analytics dataset door refuses one with a 403 refusal, inline or saved, because an expression names no single field and no platform check can judge which fields it reads. So an expression could be saved and never answered. That door never judged an empty `field` — it skips one. The cube members a dataset compiles to were narrowed to the same accept set earlier (`cube-member-sql-expression-retired`); the dataset compiler copies `field` into the member's `sql` verbatim, so the two slots now share one declaration. A dimension additionally refuses `'*'`: grouping by every column is no axis, and both analytics strategies answered such a dimension with a 500 database fault. An empty string is refused on both: a dimension groups by nothing, and a count spells "no field" by omitting the key. One empty string had a working row and has a lossless repair: a `count` measure with `field: ''` (the shape a blank Field box in Studio's dataset inspector stores) compiled to the row count on SQLite's native-SQL path, and without the key it compiles to `COUNT(*)` — the D2 conversion `dataset-count-measure-empty-field-removed` drops it from stored rows and sources. Everything else has no mechanical rewrite into a column: an expression becomes a measure filter, a derived measure or a field of the object, and a ratio changes scale on the way (a `derived` ratio is a 0–1 fraction, so an expression that multiplied by 100 returned percentage points). ADR-0021 / ADR-0049 / ADR-0087 + - Done when: Every dataset parses: `DatasetSchema`, the dataset write door and defineStack refuse a non-column `field` at `dimensions.N.field` / `measures.N.field` with a prescription that names the column-reference contract and the ADR-0021 form, so the sweep is mechanical — parse each dataset, and each refusal is one member to change. A count measure that carried `field: ''` loses the key by the D2 conversion, parses, and still counts rows; a non-count measure or a dimension with an empty `field` is left as stored and refused until it names a column. For each moved measure, a query over a fixture where the condition excludes rows returns the figure the expression meant (a ratio: the same value divided by 100 when the expression returned percentage points). A dimension or measure whose `field` is a column or a relationship path parses byte-identically to before. +- **`datasource-config-mongo-options-credential-refused`** — `datasource.config.options.auth.password (mongodb) — a login credential written into the MongoClient options passthrough` → remove the `auth` block from `options` (its other keys — `replicaSet`, `tls`, timeouts — stay legal) and bind the secret: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` secrets-store reference, with the username kept in the URL (`mongodb://user@host/db`) + - Why not automatic: The FOURTH spelling of the same inline secret: earlier publish refusals closed the top-level `password` key, the URL userinfo password and the credential-bearing URL query parameters — and the `options` passthrough stayed open one syntax over. `options: { auth: { username, password } }` parsed green, persisted the password cleartext into `sys_metadata` (served back by the ordinary data API), and genuinely authenticated: mongodb@7.5.0 transforms the block into `MongoCredentials` (measured), so the workaround was live, not inert. A non-empty string `auth.password` is now refused at publish with the binder prescription; `auth.username` alone stays writable (the asymmetry the URL grammar keeps between its two userinfo halves — a username is not credential material), as do all non-credential passthrough options. The bound secret wins over a passthrough `auth` block at connect (measured when the bound secret was made to reach the mongo client on its URL branch), so the replacement changes which store holds the secret, never which credential connects. There is no mechanical rewrite, for the same reason as the sibling entries `datasource-config-inline-credential-refused`, `datasource-config-url-userinfo-refused` and `datasource-config-url-query-credential-refused`: moving the value requires ENCRYPTING it into a `sys_secret` row through a running secret binder, which a source-file transform cannot do — and auto-dropping only the nested password would leave an `auth` block the client refuses at construction (measured: `credentials must be an object with 'username' and 'password' properties`). Runtime-environment DSNs (`OS_DATABASE_URL` and friends) never pass through the publish door and are unaffected by construction. The read path now also redacts the stored passthrough secrets (`options.auth.password`, `options.proxyPassword`, TLS key material, `AWS_SESSION_TOKEN`) instead of serving them back in cleartext. + - Done when: Every mongodb datasource parses with no `auth.password` inside `config.options`; each affected datasource carries `external.credentialsRef` (or has its secret bound through the connection form) with the username in its URL, and still connects; no passthrough credential remains in any stored `sys_metadata` row or authored source. +- **`datasource-config-options-nested-credential-spelling-refused`** — `datasource.config.options.**: any credential-SPELLED key (`password`, `authToken`, or a former alias — `passwd`/`pwd`/`token`/`jwt`/`auth_token`/`authtoken`) holding a non-empty string at any object depth of the mongodb options passthrough` → remove the nested key (no measured client behaviour reads any such position other than `auth.password`, which has its own refusal); if a real secret must reach the connection, bind it — the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`) or a direct `external.credentialsRef` reference + - Why not automatic: The nested-position closure of the `datasource-config-mongo-options-credential-refused` family: that entry refused the one MEASURED login position (`options.auth.password`) and left every other nested spelling of the same secret accepted — `options.auth.token`, `options.pool.password`, any credential-spelled key one object level down parsed green, persisted cleartext into `sys_metadata` (served back by the ordinary data API), and was served on the datasource read doors with `redactedConfigKeys: []` because the read-side nested judgment was a hand-enumerated path table. Publish now refuses a non-empty string under any credential SPELLING at any object depth of the passthrough — the same one spelling list the top level refuses and the read path redacts, so a nested position is treated identically to the top-level key it mirrors. Arrays are off the walk (row-shaped data is not config). The read path now also redacts these spellings at every depth, for every driver, and carries them forward on an untouched Save. There is no mechanical rewrite, for the same reason as the sibling credential entries: moving a value into `sys_secret` requires a running secret binder, which a source transform cannot do — and unlike `auth.password`, a nested spelling at an unmeasured position buys nothing at connect, so the usual outcome is deletion, which only the author can confirm. + - Done when: Every mongodb datasource parses with no non-empty credential-spelled string at any object depth of `config.options`; any real secret found there is re-bound through `external.credentialsRef` (or the connection form) and the datasource still connects; no nested credential remains in any stored `sys_metadata` row or authored source. +- **`datasource-config-postgres-url-unparseable-refused`** — `datasource.config.url (postgres) — connection URLs the `pg` client cannot parse (libpq's multi-host `h1:5432,h2:5433` form, a non-numeric port, a scheme-less non-URL, a malformed percent-escape), plus the filesystem-reading query parameters `?sslcert=` / `?sslkey=` / `?sslrootcert=`` → a single-host URL `pg` itself parses — `postgresql://[user@][host][:port][/dbname][?params]` (unix-socket forms stay accepted: a leading-`/` path, `socket:`, or a percent-encoded socket host). For a multi-host cluster, point the URL at one node or at a proxy/pooler in front of the cluster — `pg` does not implement libpq's multi-host DSN, so no spelling of it can connect. For certificate material, use the datasource-level `ssl` block (`ssl: { ca: …, cert: …, key: … }` next to `driver`) instead of file-path query parameters + - Why not automatic: `PostgresConfigSchema.url`'s own describe text documents the postgres URL grammar, but until protocol 18 the value was only string-scanned for credentials (the URL userinfo password and credential query parameters) and `${…}` placeholders — deliberately so at the SHARED helper, whose refusal to parse is load-bearing for mongo's multi-host/`+srv` forms (`new URL()` rejects the multi-host form outright, and the mongo arm hands the authored URL to its client untouched). For postgres that leniency was no check at all: `pg@8.22.0` does not implement libpq's multi-host DSN — both `pg-connection-string`'s `parse` and `pg`'s `ConnectionParameters` throw `TypeError [ERR_INVALID_URL]` on `postgresql://app@h1:5432,h2:5433/app` (measured) — so an operator could publish exactly that URL, see it saved, and discover only at connect time that it can never open a connection, via a bare `Invalid URL` whose `input` field `pg` redacts. The refusal now asks the same grammar one door up: `parse` from `pg-connection-string` (the parser `pg` itself uses) runs at publish, per-driver, and what it throws on is refused with the value's path named. Two adjacent shapes are refused as structurally unusable rather than parse-refused, both measured: a scheme-less value "parses" only by resolving against the parser's placeholder base (`postgres://base`), i.e. `pg` would connect to the literal host `base` with the authored text as the database name; and `?sslcert=`/`?sslkey=`/`?sslrootcert=` make `parse` itself call `fs.readFileSync`, so the verdict would depend on the validating host's filesystem — certificate material already has its declared home in the datasource-level `ssl` block. There is no mechanical rewrite: a URL `pg` cannot parse does not carry enough structure to say which single host the author meant (a multi-host DSN names several on purpose), so the choice of target is the author's. Runtime-environment DSNs (`OS_DATABASE_URL` and friends) never pass through the publish door and are unaffected by construction. + - Done when: Every postgres datasource parses with a `config.url` that `pg-connection-string` parses without throwing, that carries a scheme (or is a unix-socket path), and that carries no `?sslcert=`/`?sslkey=`/`?sslrootcert=` query parameter; each affected datasource still connects to the intended single host; certificate material, where needed, lives in the datasource-level `ssl` block; mongo/mysql/turso datasources are byte-identical before and after (their URL checks are unchanged). +- **`datasource-config-url-query-credential-refused`** — `datasource.config.url / datasource.config.syncUrl (turso) and datasource.config.url (postgres) — credential-bearing URL query parameters (`?authToken=` on turso, `?password=` on postgres)` → the same URL with the credential query parameter removed (non-credential parameters such as `?tls=` / `?sslmode=` stay legal), plus the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` secrets-store reference + - Why not automatic: The inline-credential closure refused the credential KEYS and the next refusal the URL userinfo spelling; the query string was the third spelling of the identical secret, one syntax over. `libsql://x.turso.io?authToken=eyJ…` landed in `sys_metadata` cleartext exactly as `config.authToken` did — and at connect `@libsql/core` assigns the URL token OVER the binder-injected one (measured), so the workaround also silently defeated the bound secret; `pg-connection-string` likewise honours `?password=` over userinfo (measured). Only parameters a measured client actually reads are refused: mysql and mongo ignore `?password=` (measured), so their URLs are unaffected. Runtime-environment DSNs (`OS_DATABASE_URL`, `OS_DATABASE_AUTH_TOKEN` and friends) never pass through the publish door and are unaffected by construction. There is no mechanical rewrite, for the same reason as the sibling entries `datasource-config-inline-credential-refused` and `datasource-config-url-userinfo-refused`: moving the value requires ENCRYPTING it into a `sys_secret` row through a running secret binder and stripping the cleartext, which a source-file transform cannot do — auto-stripping the parameter alone would silently drop a live credential instead. Do not substitute a `${…}` placeholder into the URL: placeholders in authored metadata are resolved by nothing and reach the database client verbatim (measured when the inline-credential refusal was built). + - Done when: Every datasource parses with no credential-bearing query parameter in `config.url` / `config.syncUrl` (no `?authToken=` on turso, no `?password=` on postgres); each affected datasource carries `external.credentialsRef` (or has its secret bound through the connection form) and still connects; no URL-embedded credential remains in any stored `sys_metadata` row or authored source. +- **`datasource-credentialsref-mongo-composed-no-username-refused`** — `datasource (mongodb) — `external.credentialsRef` bound while `config` authors no `url` and names no `username`` → decide what the datasource is meant to do, then make the two halves agree: add `username` to `config` so the bound secret is interpolated beside it into the composed connection URI at connect — or, for a datasource genuinely meant to connect unauthenticated, remove the `external.credentialsRef` binding (and unbind the orphaned `sys_secret` row via the Setup → Datasources form). Authoring a `config.url` that names a user is a third valid shape, judged by the prescription of the sibling URL-branch entry, `datasource-credentialsref-mongo-url-no-user-refused`. + - Why not automatic: The pair cannot work as written, and until protocol 18 it was accepted in silence at every door it passed. With no `config.url` the driver factory COMPOSES the connection URI from the discrete fields, and the bound secret has exactly one route into it — the userinfo written beside a username (`buildMongoUrl`: `const auth = user ? … : ''`). A falsy `username` closes that route, and the branch has no second one: `buildMongoAuth` returns early when there is no `url`, because the composed branch injects THROUGH the URI it builds rather than beside it. So `credentialsRef` bound with no `url` and no `username` composed `mongodb://host:port/db`, connected ANONYMOUSLY, and told the operator nothing. Nothing can be fabricated to rescue it: a MongoDB handshake cannot authenticate from a password alone — the same measured asymmetry behind the sibling URL-branch refusal. Both branches had always agreed on this input, so this inherits that ruling rather than re-opening it, and lands at the same authoring/publish door — the one place both halves are visible at once — as the "absence must be loud" half of the family of driver-factory arms, closed one driver at a time, that each dropped something declared without a word: the optional-driver arms that answered a missing package with no remedy, the turso arm that never read its bound secret, and the mysql and mongo DSN branches that discarded one. Deliberately NOT refused, each measured: a discrete `username` that is present and non-empty (the secret is live there — the composed branch has always interpolated it), an empty-string `credentialsRef` (not a binding — the connect path resolves under a truthy check), a non-string `username` (the driver config gate already reports the type error), and every other driver arm (the postgres equivalent is judged on its own client's measurement, never inherited — `pg` receives the bound password regardless of the DSN naming a user). An EMPTY-STRING `username` IS refused, unlike the sibling entry's present-but-empty userinfo carve-out: there MongoClient itself throws (`URI contained empty userinfo section`) so the shape is already loud, while here `username: ''` composes the same userinfo-free URI and connects — silently. There is no mechanical rewrite because the valid fixes are CONTRADICTORY intents — authenticate (name the user) versus anonymous (drop the binding) — and choosing between them requires knowing what the datasource is for. + - Done when: Every mongodb datasource that binds `external.credentialsRef` and authors no `config.url` names a non-empty `config.username` and connects authenticated as that user; every datasource meant to connect anonymously carries no `credentialsRef`; no datasource parse reports this composed-branch refusal. +- **`datasource-credentialsref-mongo-url-no-user-refused`** — `datasource (mongodb) — `external.credentialsRef` bound while `config.url` names no user in its userinfo` → decide what the datasource is meant to do, then make the two halves agree: add the username to the URL's userinfo (`mongodb://user@host/db`) so the bound secret is injected at connect — or, for a datasource genuinely meant to connect unauthenticated, remove the `external.credentialsRef` binding (and unbind the orphaned `sys_secret` row via the Setup → Datasources form) + - Why not automatic: The pair cannot work as written, and until protocol 18 it was accepted in silence at every door it passed. MongoClient credentials need a username as well as a password, and with `url` present the discrete `username` field is superseded — the only place the username can come from is the URL's own userinfo. So the connect-time injection of the bound secret on the URL branch is conditional on the URL naming a user: `mongodb://app@host/db` + bound secret authenticates, while `mongodb://host/db` + bound secret connects ANONYMOUSLY with the secret unused and the operator told nothing. Injecting anyway was measured worse (mongodb@7.5.0): fabricating an empty username turns a connection that works anonymously today into a guaranteed handshake failure, and refusing at connect would contradict `MongoConfigSchema.url`'s published contract ("bind the secret … and it is injected at connect time") while planting a per-branch asymmetry inside the driver factory — the defect class closed when each DSN branch was made to inject the bound secret its composed branch already used. The refusal therefore lands at the authoring/publish door, the one place both halves are visible at once, as the "absence must be loud" half of the family of driver-factory arms, closed one driver at a time, that each dropped something declared without a word: the optional-driver arms that answered a missing package with no remedy, the turso arm that never read its bound secret, and the mysql and mongo DSN branches that discarded one. Deliberately NOT refused, each measured: the present-but-empty userinfo forms (`mongodb://@h/db`, `mongodb://:p@h/db` — MongoClient itself throws `MongoParseError: URI contained empty userinfo section`), an empty-string `credentialsRef` (not a binding — the connect path resolves under a truthy check), the composed branch (no `url`, where the discrete `username` is live), and every other driver arm (the postgres equivalent is judged on its own client's measurement, never inherited — `pg` injects on a user-less DSN by its own measured mechanism). There is no mechanical rewrite because the two valid fixes are CONTRADICTORY intents — authenticate (add the username) versus anonymous (drop the binding) — and choosing between them requires knowing what the datasource is for. + - Done when: Every mongodb datasource that binds `external.credentialsRef` and authors `config.url` has a username in that URL's userinfo and connects authenticated as that user; every datasource meant to connect anonymously carries no `credentialsRef`; no datasource parse reports this URL-branch refusal. +- **`declared-index-bare-unique-true-retired`** — ``indexes[].unique: true` on a declared index (`objects[]` and `objectExtensions[]`) — the bare boolean, the one `unique` spelling whose scope was positional` → a stated scope: `unique: 'global'` (one holder across the whole installation — exactly the index bare `true` built, which is what the chain writes) or `unique: 'organization'` (one holder per organization — the driver prepends the NULL-safe organization key part `COALESCE(organization_id, '__global__')` to `fields` at registration). `unique: false` / omitted is unchanged, and field-level `unique: true` is unchanged and stays valid (it means per organization there) + - Why not automatic: The mechanical rewrite keeps every index exactly as it was built — `'global'` IS the verbatim column list bare `true` materialized, so nothing on disk changes. What the chain cannot know is what the author MEANT. On a declared index bare `true` read like "unique per organization" to anyone who knew the field-level meaning, and silently built an installation-wide constraint instead: an index meant per organization has been refusing a second organization's value all along, and its refusal told that organization somebody else holds it. Each respelled index is therefore a decision the owner makes once: keep `'global'` for a genuinely installation-wide key (a hostname, an external provider id, an engine dedup key), or move it to `'organization'` so each organization may hold the value once — a change to the physical index that `os migrate plan` shows before anything is applied. + - Done when: No declared index in the sources carries `unique: true`: `os validate` passes, and every stored `object` row reads back with `'global'` where it held bare `true`. `os migrate plan` against the existing database shows no index operation for an index kept at `'global'`. Each index moved to `'organization'` appears in that plan as a planned index change. +- **`device-request-response-interval-unit-in-key`** — `DeviceRequestResponse.interval (api/auth-endpoints.zod.ts) — the polling cadence in the device-flow response body` → intervalSeconds — rename the key; the value (seconds, default 2) is unchanged + - Why not automatic: Maintainer ruling B (2026-09-02, extended on 2026-09-05 to runtime-emitted durations): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. This key was ATTRIBUTED to RFC 8628 by the campaign card and reached this card only after the attribution failed verification, so the evidence is recorded here rather than left in a PR body. Ruling B exempts a key that mirrors a name fixed outside this repo, declared on the schema as .meta({ externalVocabulary }) — and DeviceRequestResponseSchema does not mirror RFC 8628 as a SET: `code` is not `device_code`, `verificationUrl` is not `verification_uri`, and `expiresAt` is not `expires_in` — a different name AND a different type, an ISO-8601 instant where the RFC carries a relative lifetime. A schema that has already renamed every RFC field it carries into house style cannot claim the standard fixes the one name it left bare. So it is a rename, and deliberately NOT a marker: a wrongly marked key is exempted permanently and silently, while a wrongly renamed one is visible. A SEMANTIC entry rather than a D2 conversion because the shape is RUNTIME-EMITTED — the body of POST /api/v1/auth/device/request, never a stack collection member and never a sys_metadata row, so the conversion chain has no seam that would see one. ADR-0087. + - Done when: No producer emits `interval` and no consumer reads it. The old spelling is a retiredKey() tombstone, so authoring it fails tsc (the key types never) and fails the parse with the rename prescription. Concretely: a CLI or client polling the device-token endpoint reads `intervalSeconds` off the request response and waits that many seconds between polls, exactly as `interval` did — the value and its unit are unchanged, only the key name moves. +- **`document-schemas-retired`** — `the document family, retired whole: the four defs data/DocumentTemplate, data/Document, data/ESignatureConfig and data/DocumentVersion, and every name data/document.zod.ts exported from @objectstack/spec/data (DocumentTemplateSchema, DocumentSchema, ESignatureConfigSchema, DocumentVersionSchema, their z.input aliases and their Parsed aliases)` → a printable document is a PAGE that declares `print` — no separate template type. Author the document (an invoice, a delivery order, a letter, a report) as an ordinary `page` with `kind: 'full'`, its blocks in `regions` drawn from the printable block subset (`record:details`, `record:highlights`, `record:line_items`, `element:text`, `element:image` and the rest of PRINTABLE_PAGE_COMPONENT_TYPES), and a `print` block for the paper, margins, running header and footer, page numbers and page-break hints. A docx-with-placeholders template, a stored document with versions, and an e-signature workflow have no replacement, because nothing on the platform ever merged, stored or sent any of them; a document record the organisation keeps is ordinary object data, and its files are `sys_file` attachments + - Why not automatic: ADR-0049 enforce-or-remove, by the ruling of record on the PDF / print document card (letter B′, 2026-10-08): "A document is a page with a print declaration; no new template type", and "The zero-reader DocumentTemplateSchema, DocumentSchema and ESignatureConfigSchema retire in v18 under ADR-0049 with ADR-0087 entries, so that 'template' means one thing." Four defs sat on the exported surface and in the generated reference docs — a docx template with typed placeholders, a document with versioning, access control and an e-signature block, and the signer workflow — and were read by NOTHING: they were exported from `@objectstack/spec/data`, mounted by no `stack.zod.ts` key, registered as no metadata type and absent from every liveness ledger, and the reader census over every package, app and example outside `packages/spec` (generated reference docs, release notes and changelogs aside), over objectui at its pin and its main, and over hotcrm returned zero hits for every exported name, against lit controls. Keeping them would have given an author two meanings of "template" — the dead docx one and the print page — and an AI that imports DocumentTemplateSchema a schema no runtime reads. DocumentVersionSchema had one carrier, DocumentSchema.versioning, and leaves with it. The ESignatureConfig deadline-key tombstones (RETIRED_KEYS_BY_MAJOR[18], D3 `esignature-config-deadline-keys-retired`) leave with their def's source; their registry entries stay as history. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections; none of these schemas is either, so a conversion would be a transform with no seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent), and with no carrier key there is no shape on which a tombstone could sit. `cloud` and real customer code are UNMEASURED. + - Done when: No code imports DocumentTemplateSchema, DocumentSchema, ESignatureConfigSchema or DocumentVersionSchema — or any of their type aliases — from @objectstack/spec or @objectstack/spec/data: every such import is TS2305 after upgrade. A printable document is authored as a page with a `print` block, which `os validate` checks: the parse refuses `print` on a page that does not print its own authored blocks, and the printable block subset refuses any other block inside it. `data/DocumentSchemaValidation` (the NoSQL driver's schema-validation block, a different declaration) is unaffected. The four defs are absent from `json-schema.manifest/data.json`, the api-surface / declaration-map / export-origins shards and the generated reference docs. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ever parsed or read these shapes, so removing them removes no behaviour. +- **`driver-options-timeout-to-timeout-ms`** — ``DriverOptions.timeout` (data/driver.zod.ts) — the per-call options argument of every `IDataDriver` method` → `DriverOptions.timeoutMs` (milliseconds) — rename the key; the value is unchanged + - Why not automatic: Maintainer ruling 2026-09-02 on duration units (ruled B — no grandfathered baseline): the unit of a duration-shaped `z.number()` key lives in the key NAME, never only in the description. `timeout` said "Timeout in ms" in prose and nothing else. Tombstoned with retiredKey (`DriverOptionsSchema` is not strict, so a bare deletion would strip the old key in silence) and registered as `data/DriverOptions:timeout`. Why a semantic entry and not a D2 conversion: a `DriverOptions` object is built at a call site and handed to a driver method — it is not a stack collection member and is never stored, so the chain has no seam that runs on it. Measured on ca46f8f12: no in-repo driver reads the key (the engine's own per-call budget is a separate `timeoutMs` on its options), so callers move their spelling with no behaviour change. + - Done when: No caller passes `{ timeout }` in a `DriverOptions` argument; a call spelling it fails to compile (input type `never`) and `DriverOptionsSchema.parse({ timeout: 5000 })` fails with the rename prescription naming `timeoutMs`; `{ timeoutMs: 5000 }` parses to the same number. +- **`driver-remote-doors-tenant-scoped`** — `IDataDriver find, findOne, count, aggregate, update, delete, bulkUpdate, bulkDelete, updateMany, deleteMany, create and bulkCreate on TursoDriver's remote (libSQL) face, called with a tenant context` → a tenant-scoped call on the remote face reaches the rows the local face reaches for the same options: the caller's organization, rows with no organization, and under the group posture the caller's membership set. A by-id `update` outside that scope answers `null`, a by-id `delete` answers `false`, and a predicate write counts only the rows in scope. `create` stamps the caller's organization on a row that names none. To reach rows of every organization, call without `tenantId`, as on the local face + - Why not automatic: The engine hands every driver the caller's organization as `DriverOptions.tenantId`, and the group posture's membership set as `tenantIds` (ADR-0131 D8, ADR-0105 D2). TursoDriver's local face applies them through `SqlDriver.applyTenantScope` on every read and on every update and delete predicate, and stamps the organization on insert. Its remote face compiles its own statements, and its doors received no driver options: their statements carried the caller's filter and nothing else, and a remote `create` wrote no organization. Where the engine's Layer 0 wall composes a predicate above the driver, that wall held other organizations' rows back. Where it composes none (the posture in which Layer 0 is inert, or an elevated caller that carries its organization), the driver scope is the only fence, and on the remote face there was none. The remote doors now compile the local face's own predicate, by asking the same chokepoint, and AND it onto each statement, so the two faces answer the same rows by construction. The remote `create` stamps the organization as the local `create` does. `distinct` still refuses a tenant-scoped call on the remote face. The call signatures are unchanged, so nothing reaches the compiler. Code that relied on a tenant-scoped remote call reaching another organization's rows now gets the miss answer each door already declares, and a remote `create` that relied on landing a row with no organization now finds it under the caller's. ADR-0131 D8 / ADR-0087. + - Done when: No caller of a remote-mode TursoDriver passes `tenantId` and expects to read, count, aggregate, update or delete a row of another organization; a caller that means to reach every organization calls without a tenant context, as on the local face. No caller relies on a tenant-scoped remote `create` landing a row with no organization. Proven when a tenant-scoped call on each door answers the same rows on the remote face as on the local face for the same options: another organization's row excluded, `null`, `false` or untouched, and the caller's own rows and rows with no organization answered as before. +- **`driver-sql-calendar-day-methods-removed`** — `SqlDriver protected methods calendarDayExclusiveUpperBound, calendarDayUpperBoundRewrite and calendarDayBetweenRewrite (inherited by SqliteWasmDriver and TursoDriver)` → lower the filter before the driver compiles it — `lowerFilterCondition(where, { isDatetimeColumn })` from `@objectstack/spec/data` — instead of calling or overriding a driver method; leave `isDatetimeColumn` out and the whole-day rule applies to every column + - Why not automatic: The exported `SqlDriver` class of `@objectstack/driver-sql` declared three `protected` methods that were its own copy of the whole-day rule of ADR-0053 D-D1: on a `datetime` column, a bare-day inclusive upper bound (`$lte '2026-01-05'`, or the maximum of a `$between`) compiled as `$lt` the next day, and the last supported day (`9999-12-31`) compiled as no upper bound. `calendarDayExclusiveUpperBound` computed that bound, `calendarDayUpperBoundRewrite` rewrote a `$lte` with it, and `calendarDayBetweenRewrite` rewrote a `$between` with it. The shared filter lowering in `@objectstack/spec/data` (`lowerFilterCondition`) now applies the rule once, at the engine's `where` seam and at the RLS compile seam, before any driver sees the filter, so the driver's copy was deleted, and the three methods with it (ADR-0053 D-D1 items 5 and 9, as amended). Two consequences reach a subclass, and only one of them reaches the compiler. A subclass that CALLS one of the three, or declares one with `override`, stops compiling: TS2339 and TS4113, measured with tsc 6.0.3 against the published declaration. A subclass that re-declares one WITHOUT `override` compiles cleanly, with `noImplicitOverride` off and also with it on, because the base class no longer has a member to override. That declaration is never called: the driver calls none of the three any more, so the override goes silently dead and the rule it carried stops applying. An untyped JS subclass gets a `TypeError` at a call and the same silent death for an override. A driver subclass is CODE, never stack metadata, so there is no authored source for the chain to rewrite and no schema tombstone. For the silent half, this entry is the only notice there is: the same disposition as `driver-sql-distinct-bare-filter-typed` and `runtime-httpserver-wrapper-retired`. In this repo the one caller was `TursoDriver`'s remote face, changed in the same PR. A read through the engine or the RLS compile seam answers as before, because the seam lowers first; a filter handed to the driver directly is now compared as written. ADR-0053 / ADR-0087. + - Done when: No subclass of `SqlDriver`, `SqliteWasmDriver` or `TursoDriver` names `calendarDayExclusiveUpperBound`, `calendarDayUpperBoundRewrite` or `calendarDayBetweenRewrite`. Search the source for the three names rather than relying on tsc, because a re-declaration without `override` compiles and is never called. A subclass that called one to widen a bound hands the driver a lowered filter instead: `lowerFilterCondition(where, { isDatetimeColumn })`. One that overrode one to change which columns take the whole-day bound passes its own `isDatetimeColumn`. Proven when, on a `datetime` column, `where: { signed_on: { $lte: '2026-01-05' } }` reaches the driver through that path and returns a row stamped `2026-01-05T15:00:00.000Z`. Handed to the driver unlowered, the same filter compares against that day's midnight and drops the row. A host that reads only through the engine (`find`, `count`, `aggregate`) or through RLS policies needs no change: those seams lower the filter before the driver sees it. +- **`driver-sql-unresolvable-where-column-refused`** — `a `where` naming a column the table does not have, on `driver-sql` (and its `TursoDriver` / `SqliteWasmDriver` subclasses) — `find()` / `findOne()` answered `[]` and `count()` threw the dialect's own error; both now refuse with `INVALID_FILTER` / 400. The `aggregate()` door of `TursoDriver`'s remote face answered `[]` for a missing column or a missing table, and now refuses as the local face does: `INVALID_FILTER` / 400 for a `where` column the table lacks, `INVALID_FIELD` / 400 for a `groupBy` or aggregation column the table lacks, and `DATABASE_ERROR` / 500 for an object whose table is absent` → name a column the object actually has, or run schema sync so a recently declared field exists as a column before filtering, grouping or aggregating on it, and so the object's table exists. A caller that legitimately wants "no rows unless this matches" gets that from a predicate over a real column; there is no spelling of an unresolvable column that means "match nothing", which is exactly what the old empty list was mistaken for + - Why not automatic: One predicate had two answers. `SqlDriver.findRows()` carries the unknown-column recovery ladder (an unsortable query loses its ORDER BY, not its rows), whose rungs are all built from `buildBase()` — and `buildBase()` always re-applies `query.where`. So the ladder can drop a projection and can drop an ORDER BY, but it can never drop the clause that failed when the unresolvable column is in the WHERE: both rungs raise the same error and the method fell to `return []`. `SqlDriver.count()` runs a separate statement with no ladder at all, so the identical predicate threw. Measured on better-sqlite3, one seeded row: `where { 'title.x': 'y' }` gave `find()` 0 rows and NO error, while `count()` threw `code: 'SQLITE_ERROR'`, `status: undefined`, message `select count(*) as \`count\` from \`task\` where \`title\`.\`x\` = 'y' - no such column: title.x`. + +A list view calls both halves, so one query produced an empty page from the rows half and a 500-shaped failure from the total half — and a caller reading only the rows got a silent empty page saying "no records exist" for what was really "your predicate never ran". That is the single most AI-legible failure to get wrong: an agent reads "no matching records" and writes its next query on that belief. The thrown half was no better — the dialect's own `code`, no `status` (an unclassified 5xx at the REST boundary rather than a caller mistake), and the statement's bound literals inlined in the message, the same predicate-text disclosure shape the driver's field-reference filter refusals had already been made to stop echoing (the full diagnostic goes to the server log, never the response). + +Ruled by the maintainer on 2026-08-15: refuse BOTH halves with `INVALID_FILTER` / 400, naming the column. The envelope is not minted here — it is what every sibling refusal on this path already answers, required on both SQL drivers by `cross-field-conformance-cases.ts` and pinned by `sql-driver-boolean-identity.test.ts` and `sql-driver-cross-field-conformance.test.ts` — so what closes is a declared-vs-enforced gap, not a new posture. Recover-both was excluded by the ruling's own argument: dropping a WHERE returns rows the caller explicitly excluded, and the ladder's own premise — rows matter more than their order — is an argument about how rows are PRESENTED, which does not transfer to a predicate. The ladder KEEPS both of its recoveries — only the WHERE-failure terminal became a refusal. + +Reach, stated rather than assumed: the refusal fires on the wordings the ladder has always recognised — SQLite (`no such column: x`) and Postgres (`column "x" does not exist`). MySQL spells it `Unknown column 'x' in 'where clause'`, which neither arm matches, so on MySQL this condition still travels out as the raw dialect error; widening that predicate would also hand MySQL the ladder's recoveries it has never had, which is an accept-set change in the opposite direction and is filed separately. + +Addendum 2026-08-16. The paragraph above is kept as the state at registration; this amends it. MySQL joined the one shared predicate, so the reach is now all three dialects this driver speaks, and a MySQL reader must NOT conclude the migration does not apply — it applies exactly as it does on SQLite and Postgres. Because that predicate serves both consumers at once, the accept set moved in BOTH directions on MySQL in one line, and both halves were ruled together (option A, maintainer, 2026-08-16; a split predicate — the envelope while withholding the recoveries — was considered and refused). (1) THE ENVELOPE: an unresolvable WHERE column now refuses with the same `INVALID_FILTER` / 400 naming the column, instead of travelling out as the raw `ER_BAD_FIELD_ERROR` with the statement's bound literals inlined — that disclosure shape closed on the last dialect that still had it. (2) THE RECOVERIES: MySQL also gained the ladder's projection and ORDER-BY recoveries it had never had, so an unresolvable column in a projection or an ORDER BY now returns recovered rows where it used to throw. The two halves arrive together because `ER_BAD_FIELD_ERROR` spells every clause position with one sentence — `Unknown column 'x' in 'where clause'` / `'field list'` / `'order clause'` — so all three ride one arm of the predicate; that is pinned as the ruled direction by the widened predicate sweep in `sql-driver-unresolvable-where-column-refusal.test.ts`. The widening can never drop a predicate: every ladder rung is rebuilt from `buildBase()`, which unconditionally re-applies `query.where`. Unchanged by the ruling: a DOTTED filter key is still classified per dialect (Postgres raises undefined_table, which neither arm matches), the axis owned by the dotted-filter verdict, which refuses a dotted key whose head is a relation, a formula or a plain column at the protocol and engine doors. The entry id, surface and prescription are unchanged — this is a text amendment, not a new migration. + +This is a CODE-path API, not stored metadata, so — like `engine-dotted-projection-refused` and `engine-find-formula-filter-refused` — there is no `sys_metadata` row for the D2 chain to rewrite and this entry is the notification channel. No mechanical rewrite exists: the platform cannot know which real column a mistyped filter key meant, and guessing one would answer with rows the caller never asked for. ADR-0112. + - Done when: No saved report `query.filter`, flow condition, sharing/permission rule or hook filters on a name the queried object has no column for, and no report or dashboard groups by, or aggregates over, such a name. Reads, counts and aggregates complete with no `INVALID_FILTER` whose message says "names a column that object" or "names a column the database could not resolve", no `INVALID_FIELD` whose message says "has no column for, so the aggregate never ran", and no aggregate refused with `DATABASE_ERROR` / 500 because the object's table is absent. Where a filter key was a relationship traversal spelled as a dotted path, rewrite it against a column on the queried object — the driver never resolved such a path and answered `[]`, so any list that looked correct under one was already showing nothing. +- **`driver-sql-upsert-cross-row-identity-merge-refused`** — `an `upsert` with no `conflictKeys` — or naming the primary key — on a MySQL table that carries a non-primary UNIQUE key, in `driver-sql` (and its `TursoDriver` / `SqliteWasmDriver` subclasses). It merged onto whichever UNIQUE key the row collided with, silently rewriting a DIFFERENT row; when that happens the write is now rolled back and the call refuses with `VALIDATION_ERROR` / 400` → name the business key you meant to merge on (`conflictKeys`), so the intent is checkable and the pre-flight can answer for it; or drop/rename the extra UNIQUE key so the primary key is the only thing a row can collide on; or run the object on SQLite / PostgreSQL, which compile `ON CONFLICT (...)` and honour the named arbiter. There is no spelling of "merge onto whatever key happens to collide" that was ever correct — the old behaviour rewrote a row the caller never identified + - Why not automatic: MySQL's only merge statement is `ON DUPLICATE KEY UPDATE`, which carries NO conflict target: knex drops the named keys before the statement leaves the process, so the merge lands on whichever UNIQUE index the row collides with first. Two earlier pre-flight refusals closed the half where no unique index backed a caller-named target and the half where a rival unique key could absorb a caller-named one. This entry closes the residue those two left by construction: the `conflictKeys`-less call and the `['id']` call, which compile byte-identically and which no pre-flight can judge, because neither names anything. + +Measured on live MySQL 8.0.46 through the same knex + `mysql2` path `upsert` takes, `email` and `tax_id` both `unique: true`, NO `conflictKeys` at all: seeding `{email:'d@b.com', tax_id:'T-9', title:'first'}` inserted id `iVvD35rMk4BIayYc`, and `{email:'e@b.com', tax_id:'T-9', title:'second'}` then RESOLVED with no error — one row, the SEEDED one, its `email` rewritten `d@b.com` -> `e@b.com`. The id the caller was handed back was in no row at all. The identical pair on SQLite raises `UNIQUE constraint failed: ….tax_id` and leaves the seeded row untouched. + +Ruled by the maintainer on 2026-08-15, as a contract principle rather than a MySQL detail: *an `upsert` must never modify a row whose identity the caller did not supply and whose conflict key it did not name.* Enforcement was delegated to the drivers lane with blanket refusal excluded by name — refusing every `conflictKeys`-less upsert on any table with a business unique key would refuse the platform's own lifecycle archiver. Measured before choosing: on this path the merge target is always the primary key, so EVERY non-primary UNIQUE key is a rival and "narrowed to tables carrying a rival key" and "every table with a business unique key" are the same set — the narrowing that made a pre-flight refusal proportionate for a caller-named target does not exist here. + +So the enforcement is a post-hoc identity check instead, and it is exact rather than heuristic: `id` is insert-only on the merge path (made so once a merge on a non-primary conflict key was measured rewriting the existing row's primary key), so a row merged on the primary key always still carries the id the call supplied, and a row merged on any other key never does. Absence of that row after the statement is therefore a biconditional for "this landed on a row the caller never identified", which is why the refusal has no false positives. It runs inside a transaction with the statement — "never modify" is not satisfied by noticing afterwards — and only on MySQL tables that carry a rival UNIQUE key, so a table whose only key is its primary key keeps its single autocommitted round trip unchanged. + +This is a CODE-path API, not stored metadata, so — like `driver-sql-unresolvable-where-column-refused` — there is no `sys_metadata` row for the D2 chain to rewrite and this entry is the notification channel. No mechanical rewrite exists: the platform cannot know which business key an unnamed merge meant, and guessing one would merge onto a row the caller never named, which is the defect. ADR-0112. + - Done when: On MySQL deployments only. For every object whose rows are written with `upsert` and whose table carries a UNIQUE key besides the primary key, confirm the writer either supplies the `id` of the row it means to update or passes that business key as `conflictKeys`. Sweeps, imports and archival copies complete with no `VALIDATION_ERROR` whose message says "the merge landed on a row this call never identified". Where such a refusal appears, the old behaviour was silently overwriting an unrelated row on that table — audit the object for rows whose business key is correct but whose other columns belong to a different record, since no error was ever raised for those writes. +- **`driver-turso-config-local-path-wasm-retired`** — ``@objectstack/driver-turso`'s published `TursoConfigSchema` — the Spec / Studio mirror of the turso connection config a host may render configuration UI from — keys `localPath` and `wasm`` → delete both keys. The embedded replica's local file is named by `url` (`file:./replica.db`) with `syncUrl` pointing at the remote primary, which is what the driver has always read; nothing selects a WASM build of libSQL, and a runtime that cannot load native bindings uses the remote arm (`libsql://` / `https://`), which needs none + - Why not automatic: ADR-0049 enforce-or-remove, ruled per key by the maintainer on 2026-09-06, once all three of this package's unread config keys had been measured: both keys were declared on the package schema with a describe promising behaviour ("Local file path for embedded replica", "Use WASM build for edge/browser environments") and were read by no code — the driver names the replica file via `url`, and no mechanism picks a WASM build. Forwarding `localPath` would have created a second way to say what `url` says; forwarding `wasm` would have meant building a WASM selection that does not exist. Why a semantic entry and not a D2 conversion: `@objectstack/spec`'s own turso contract (`data/TursoConfig`, strict) never declared either key, so no stack source or stored datasource row that passed the spec door can carry them, and a value that never did anything has no lossless rewrite — the key is deleted by hand. Both stay declared on the package schema as `z.never()` tombstones (the shape is a plain z.object, so a bare deletion would strip in silence) carrying this prescription. The third key the same measurement found, `TursoDriverConfig.timeout`, was forwarded rather than removed and needs no entry. ADR-0049, ADR-0087. + - Done when: No `TursoConfigSchema.parse(…)` input spells `localPath` or `wasm`; authoring either fails to compile (input type `never`) and fails to parse with the prescription naming the key. A replica config that named its file only through `url` + `syncUrl` parses byte-identically to before, and every other declared key — `url`, `authToken`, `encryptionKey`, `concurrency`, `syncUrl`, `sync`, `timeoutMs` — keeps its bound, default and optionality. +- **`driver-upsert-cross-organization-conflict-refused`** — `IDataDriver upsert on SqlDriver, SqliteWasmDriver and TursoDriver (both faces): a tenant-scoped call whose conflict lands on a row of another organization, and the tenant column on the merge leg` → a tenant-scoped upsert merges only into a row of the organization it writes under; a conflict anywhere else answers `UNIQUE_VIOLATION` / 409 and writes nothing, so handle it as the colliding insert it is from the caller's organization. To move a row between organizations, call the driver's `update` door on that row: an upsert keeps the stored row's organization on merge + - Why not automatic: `upsert` resolves its conflict against the whole table, and the primary key and a `unique: 'global'` column are installation-wide (ADR-0120 D1). So the row a tenant-scoped call (`options.tenantId` on an object with a tenant column) collided with could belong to an organization the caller cannot read. The merge leg wrote every payload column except the insert-only ones onto that row, and the tenant column was not insert-only: the other organization's columns were overwritten and the row was re-parented to the caller's organization, with no error. That was the one driver door the tenant predicate did not reach (ADR-0131 D8). The merge leg is now fenced to rows whose stored tenant column equals the written one, for any conflict target, the primary key included. A conflict anywhere else, including a row with no organization, is refused with `UNIQUE_VIOLATION` / 409, the registered code a colliding insert gets, and the refusal names no organization. The fence is a predicate inside the merge statement on SQLite, PostgreSQL and the remote libSQL face. MySQL's merge statement takes no predicate, so there the statement and a read of the landed row run in one transaction (a savepoint inside a caller's transaction) and the read's failure rolls the write back. The tenant column also joined `insertOnlyUpsertColumns`, so an upsert with no tenant context keeps the organization of the row it merges into. Two things can break, and neither reaches the compiler, since the call signature is unchanged. Code that let a tenant-scoped upsert land on another organization's row now gets a refusal where it got a silent merge. Code that relied on a payload's tenant value to move a row on merge now finds the row where it was. ADR-0131 D8 / ADR-0087. + - Done when: Every caller that upserts with a tenant context handles `UNIQUE_VIOLATION` / 409 as a colliding insert, and none expects a merge into a row its organization cannot read. No caller relies on an upsert payload's tenant value to change which organization owns a row; that move goes through the `update` door. Proven when a tenant-scoped upsert on a key another organization's row holds answers `UNIQUE_VIOLATION` and that row reads back unchanged, while the same call on a row of the caller's own organization merges as before. An upsert with no tenant context that merges into a row leaves the row's organization as it was. +- **`element-data-source-and-object-block-filter-rule-array`** — `Page-component `dataSource.filter` (`ElementDataSourceSchema`, the binding every data-bound element carries) and the `filter` prop of the four `object-*` blocks in `ComponentPropsMap` — `object-grid`, `object-metric`, `object-kanban`, `object-calendar` (the FORM: the MongoDB-style `FilterConditionSchema` record at the binding, and the accept-anything `z.unknown()` at the four block doors, vs the `ViewFilterRule` array)` → `z.array(ViewFilterRuleSchema)` at all five doors — the rule array `[{ field, operator, value }, ...]` every other `filter` door in the map already carries (`record:related_list`, its Add-affordance picker, `element:number`, `element:record_picker`). A record-form filter `{ status: 'active' }` becomes `[{ field: 'status', operator: 'equals', value: 'active' }]`; an operator object `{ status: { $ne: 'done' } }` becomes `[{ field: 'status', operator: 'not_equals', value: 'done' }]`; several keys become several rules (they AND). An ObjectQL AST tuple array `[['owner_id', '=', '{current_user_id}']]` — which the `z.unknown()` block doors also took — becomes `[{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }]`; the value placeholders and date macros are unchanged. Legacy operator shorthands (`eq`, `ne`, `gt`, `notIn`, …) are accepted and normalized on parse. The dashboard widget `filter` (`dashboard.zod.ts`) is a different family, judged on its own, and is not moved by this entry; `object-grid.defaultFilters` is a different key and is not named by the ruling this entry records. + - Why not automatic: One filter orthography platform-wide (the maintainer's 2026-08-25 ruling, option B: a filter door takes the `ViewFilterRule` array rather than keeping a record-shaped exception every author and AI would have to remember) reached two more locations the ComponentPropsMap census could not see (ruled 2026-09-06, option A: converge the binding and the four block doors family-wide, under one entry, rather than record an exception). The binding-level `dataSource.filter` alone still said `FilterConditionSchema`: it refused the array the consumer's own pins author at that key, and `element:record_picker` carried two orthographies at two keys (`properties.filter` the rule array, `dataSource.filter` the record) resolved through one `??` in the renderer — the shape in which a dropped or misread filter returns the wrong rows without an error. The four `object-*` doors said `z.unknown()`: a read-point record derived from the renderers on 2026-08-13, when the `object-*` blocks first got props schemas in the map, twelve days before the ruling, not an exception to it — so an author following the showcase wrote the record and an author following the manifest wrote an array, and each got a silent success receipt while the html tier already declared `array` for the grid and the metric. The record's `$and` / `$or` / `$not` keys were misread by every gate block anyway (the console's filter converter had no branch for them), so the exception would have preserved a capability the consumer does not honour. Sequenced measurement-first, as the family had to be: at the objectui pin `a472b07` the `object-metric` aggregate path posted an array `where` that `POST /analytics/query` refused with 400 on every array form, so the converge was parked behind a bump of that pin; at the pin this repo builds against (`53ded82b`) the adapter lowers an authored array through `translateFilterArray` and the spec's own `parseFilterAST` sink before the wire, `ObjectGrid.tsx` lowers a rule array through `toFilterNode`, `ObjectKanban.tsx` / `ObjectCalendar.tsx` hand it verbatim to `$filter` where `convertQueryParams` lowers it, and the binding's composition seam AND-combines it with the named view's rules through `mergeFilterNodes`. The ruled migration check ran with the change: the in-repo sweep found four spec test fixtures at the binding (`page.test.ts`, all record form), five showcase authors at the block doors (`my-work.page.ts`, `index.ts`: four records on `object-metric`, one AST tuple array on `object-grid`) and three lint fixtures — every one rewritten to the rule array in the same change, and zero outside those files; this entry carries the prescription for authors outside the repo. Metadata AT REST: the mappable part of the table above is a D2 conversion, `page-component-filter-record-to-rule-array` (ruled 2026-09-12, option B: convert what maps losslessly and name what does not, rather than leave every stored row to its next save or flatten combinators), so `os migrate meta --stored` (the pass over a deployment's `sys_metadata` rows) rewrites a stored page whose `filter` is a flat record, an operator object whose operators the rule vocabulary spells, several such keys, or a single-level AST tuple array, and every stored-row read replays the same rewrite until it does. It is retired from the load path: an author writing the record form is still refused at the `filter` door. ⚠️ A filter carrying `$and` / `$or` / `$not` is left exactly as stored — the rule array only ANDs, and flattening a combinator changes which rows the page selects — and so is any filter with a part that has no lossless rule spelling: a `null` value (where a block queries an object the renderer skips that key, so it constrains nothing, and where its rows are inline it selects the rows whose value is null — no one rule keeps both, so the TODO names the `is_null` rule for the rows with no value and leaves which rows to select to the author), an operator such as `$null` / `$exists` or an AST `like`, an array or object comparand in equality position, or an AST `and` / `or` group. None of this depends on where a block's rows come from: a filter on a component whose rows are inline (`data: { provider: 'value' }` or `staticData`) — the binding's included — is rewritten or left exactly as it would be on a block that queries an object, because the `object-map`, `object-tree`, `object-calendar` and `object-gantt` blocks of the objectui version this release pins match a rule array against those rows and select the rows the stored form selected. A row left as stored keeps loading unchanged (`applyConversionsToStoredItem` replays the chain without validating, by its own contract), and its `filter` door refuses the form: at `dataSource.filter` on the page's next save; at a block's `properties.filter` — like `properties.defaultFilters`, a key of the open `properties` bag — only as the component-props gate's advisory finding (os validate, os build, os lint), since a re-save through the metadata API is not refused there. For a combinator record that refusal names the combinator and says why no rule spells it. `os migrate meta --stored` lists each such filter as a TODO under its row, naming the block and what blocks the rewrite (a value that is not a record or AST form at all — a bare string or number one of the former `z.unknown()` doors took — is neither converted nor reported, and a row carrying nothing else reads as already on protocol); a row whose only finding is such a TODO is reported `skipped`, and the run's exit code does not change for it. + - Done when: `ElementDataSourceSchema.safeParse({ object, filter: [{ field: 'status', operator: 'equals', value: 'active' }] })` succeeds and the parsed `filter` is the same rule array; `ComponentPropsMap['object-grid' | 'object-metric' | 'object-kanban' | 'object-calendar'].safeParse({ filter: })` raises no issue at `filter`; a record-form `filter: { status: 'active' }` is refused at the `filter` path of all five doors (`invalid_type`, expected array), and an AST tuple array is refused at `filter.0` (expected object). No `filter` door in `ComponentPropsMap` accepts the record any more (the twin of the census pin that asks whether any `filter` door still refuses the array). At runtime each block and the binding select exactly the rows the array selects — the same filter a list view renders — including the `object-metric` aggregate tile, whose analytics `where` is the lowered condition. Downstream (objectui, after a released spec version reaches the pin): the seventeen `dataSource.filter` test authors at the pin (fifteen tuple arrays, two records) become off-spec fixtures and `ElementDataSourceConfig.filter`'s "three shapes" note narrows — objectui cards filed by the seat, not blocked on here. +- **`element-filter-and-form-node-refused`** — `page.component.element:filter / page.component.element:form — the bare component node itself, left standing by the `element-filter-removed` and `element-form-removed` conversions after they strip its properties` → Delete the component node. `element:filter` → a list surface owns its own filtering: use a view's `userFilters` quick-filter bar or the list toolbar's filter builder. `element:form` → the object-bound `object-form` block, which is rendered, designer-publishable and carries the same intent (`objectName`, `fields`, `mode`, `submitText`). Nothing is placed where the node was unless the page needs it — which region keeps its layout is the judgment this step delegates + - Why not automatic: Both elements were retired whole at element grain (ADR-0049 enforce-or-remove): no renderer for either ever shipped in objectui, framework or cloud, so every authorable key was a capability claim nothing kept. The conversions are mechanical where they can be — they strip all twelve keys losslessly — and stop at the node, because removing an authored page node changes the LAYOUT of a page the author composed, and a conversion cannot know whether the region should close up, hold a replacement, or keep its slot. That residue is no longer inert: both names are members of `RETIRED_PAGE_COMPONENT_TYPES`, so `PageComponentSchema.type` refuses them by name, and a stack that replays the chain and stops there is schema-INVALID. Mechanical where it can be, delegated where it cannot — this entry is the delegation, in writing + - Done when: No `element:filter` and no `element:form` component remains in any page — regions, named slots and nested containers alike (the conversions walk all three, so every place they stripped properties is a place a bare node can be sitting). `os validate` is clean: the refusal is reported at the node's `type` path with `params.retiredComponentType` naming the element, so a remaining node is named individually rather than as one page-level failure. Replaying the same 17 → 18 chain over the edited source then reports the migrated stack schema-valid — `schemaValid: true` in `--json`, and the run closes with the schema-valid line rather than the manual-changes warning +- **`element-input-target-variable-retired`** — `page.component.element:text_input.targetVariable / page.component.element:record_picker.targetVariable — the declarative binding hint on the two input elements` → Declare the binding on the page variable instead: a `variables[]` entry whose `source` is the input component `id`. That reverse lookup is the one binding the renderer has ever honoured; the variable name is the author's choice, and `targetVariable` named it from the wrong end. + - Why not automatic: The D2 conversion `element-input-target-variable-removed` deletes `targetVariable` from every text-input and record-picker component, and the delete is lossless: no renderer, hook or runtime ever read the key, so an input authored with it and without a matching `variables[].source` wrote nothing, with a success receipt and no diagnostic. What the delete cannot do is restore the intent. An author who wrote `targetVariable: 'contact_email'` meant that input to feed that variable, and after the strip the page is exactly as unbound as it always was — now without even the hint that says so. Whether the variable exists, whether its `source` already names this component, and whether anything downstream (a flow input, a filter, a visibility predicate) reads it are facts about the author's page that no conversion can see, so the binding is delegated rather than invented. + - Done when: For every `element:text_input` and `element:record_picker` component that carried `targetVariable`: either the page declares a variable whose `source` equals the component `id`, or the author has decided the input needs no binding. With the binding declared, typing into the input (or picking a record) and then reading the variable — from whatever consumes it on the page — returns the value entered. No component authors `targetVariable`; the parse refuses it by name. +- **`element-number-filter-rule-array`** — ``element:number` component props — `filter` (the FORM: the MongoDB-style `FilterConditionSchema` record vs the `ViewFilterRule` array)` → `z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` every other `filter` input in `ComponentPropsMap` already declares (`record:related_list` and its Add-affordance picker). A record-form filter `{ status: 'won' }` becomes `[{ field: 'status', operator: 'equals', value: 'won' }]`; an operator object `{ amount: { $gt: 100 } }` becomes `[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys become several rules (they AND). Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are accepted and normalized on parse + - Why not automatic: One filter orthography platform-wide (the maintainer's 2026-08-25 ruling, option B: align the element to the `ViewFilterRule` array rather than keep it the record-shaped exception). `ComponentPropsMap['element:number'].filter` was the one `filter` input in the map declared as the MongoDB-style record (`FilterConditionSchema`) while its siblings declared the `ViewFilterRule` array, so the filter a list view stores and renders was refused by the KPI element beside it, and the objectui parity gate had to carry a reasoned exemption to look away. The convergence was sequenced consumer-first (ruling recorded 2026-08-25, Option A): the console adapter was changed so `ObjectStackAdapter.aggregate()` runs the same `translateFilterArray` its `find()` path runs, and the objectui pin carrying it was re-measured before this entry moved — but that measurement named the wrong hop, and the runtime route's refusal of the array corrects it here. `translateFilterArray` yields AST tuples, which are still a `FilterArray` — input-only sugar — so the real path is: authored array → `translateFilterArray` → lowered by `parseFilterAST` (`@objectstack/spec/data`, the single sink the `FilterArray` docblock names, since the maintainer's 2026-08-04 ruling C declared the array input-only sugar with one lowering seam) in the adapter, BEFORE the wire → a `FilterCondition` on the body. The hop that decides it is the runtime route `POST /analytics/query`, which parses `where` with `AnalyticsQueryRequestSchema` — a `FilterCondition` and nothing else — so an un-lowered array is refused there before any service code runs. `lowerAnalyticsWhere` (`service-analytics`), where that earlier measurement stopped, is the IN-PROCESS door (added when an array `where` was found silently dropped on the analytics path) for callers reaching `analyticsService.query` directly, not the wire's; it too still refuses a RAW rule-object array by design. The adapter-side lowering lands in the console's own repository. The ruled migration check ran with the change: the sweep of first-party corpora (examples/, skills/, create-objectstack, content/docs/, packages/apps/, spec fixtures) found ONE `element:number` author writing a record-form `filter` — a spec test fixture, rewritten to the array form in the same change — and zero outside the spec package; this entry carries the prescription for authors outside the repo. + - Done when: `ComponentPropsMap['element:number'].safeParse({ object, aggregate, filter: [{ field: 'status', operator: 'equals', value: 'won' }] })` succeeds and the parsed `filter` is the same rule array; a record-form `filter: { status: 'won' }` is refused at the `filter` path (`invalid_type`, expected array). At runtime the element renders its aggregate on an analytics-capable deployment with the array filter applied — the same filter a list view renders. Downstream (objectui, after a released spec version reaches the pin): the `element:number.filter:array` entry in `OFF_SPEC_ARM_EXEMPTIONS` (`registry-inputs-spec-parity.test.ts`) becomes deletable, which is what closes the console-side half of this convergence. +- **`element-record-picker-filter-rule-array`** — ``element:record_picker` component props — `filter` (the FORM: the MongoDB-style `FilterConditionSchema` record vs the `ViewFilterRule` array)` → `z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` the map's array-declared `filter` doors already carry (`record:related_list`, its nested Add-affordance picker, `element:number`; the four `object-*` blocks declare `filter` as `z.unknown()`, a gap measured on its own). A record-form filter `{ status: 'active' }` becomes `[{ field: 'status', operator: 'equals', value: 'active' }]`; an operator object `{ amount: { $gt: 100 } }` becomes `[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys become several rules (they AND). Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are accepted and normalized on parse. The binding-level `dataSource.filter` on the same node is a different key (`ElementDataSourceSchema`) and is not moved by this entry + - Why not automatic: One filter orthography platform-wide (the maintainer's 2026-08-25 ruling, option B: every `filter` door takes the `ViewFilterRule` array rather than keeping record-shaped exceptions). `ComponentPropsMap['element:record_picker'].filter` was the LAST `filter` input in the map still declared as the MongoDB-style record (`FilterConditionSchema`) after `element:number` converged: the three array-declared doors (`record:related_list`, its nested Add-affordance picker, `element:number`) carried the `ViewFilterRule` array and the four `object-*` doors declare `z.unknown()`, so the filter a list view stores and renders was refused by the picker beside them, and a lone holdout is the state where the next author copies the wrong form. Sequenced measurement-first, as that convergence had to be (the 2026-08-25 Option-A ordering ruling: measure the consumer's read path before the contract moves): at the objectui pin `00d3f09c` the renderer hands `filter` to `query.$filter` and calls `adapter.find()` (`components/src/renderers/basic/record-picker.tsx`); `ObjectStackAdapter.convertQueryParams` lowers an ARRAY `$filter` through `translateFilterArray` into filter AST tuples (`data-objectstack/src/index.ts`), the same door every list view's stored rule array already takes, and the engine lowers the tuples before the driver (`engine-filter-array-lowering.test.ts`); nothing on that path parses `properties` against the installed spec. The pin and objectui `main` (`f7cf7e8`) are byte-identical on every read-path file. The ruled migration check ran with the change: the sweep of first-party corpora (examples/, skills/, content/docs/, docs/, packages/**, .changeset/) found ONE `element:record_picker` author writing a record-form `filter` — a spec test fixture, rewritten to the array form in the same change — and zero outside the spec package; this entry carries the prescription for authors outside the repo. + - Done when: `ComponentPropsMap['element:record_picker'].safeParse({ object, filter: [{ field: 'status', operator: 'equals', value: 'active' }] })` succeeds and the parsed `filter` is the same rule array; a record-form `filter: { status: 'active' }` is refused at the `filter` path (`invalid_type`, expected array). At runtime the picker offers exactly the rows the array selects — the same filter a list view renders. Downstream (objectui, after a released spec version reaches the pin): the registry's `inputs.filter` entry for `element:record_picker` (`type: 'object'`, `record-picker.tsx`) flips to the array arm and the `record-picker-inputs-spec-parity.test.ts` pins that assert the record form follow — a console-side change filed in the objectui repository, blocked on that release. +- **`element-text-variant-heading-subheading-retired`** — `page components of type element:text — properties.variant authored as heading or subheading (ElementTextPropsSchema.variant)` → one of the nine values `ui:text` publishes: `h1`-`h6`, `body`, `caption` or `overline`. 'heading' → 'h2' and 'subheading' → 'h3' (the heading element each one always rendered), or the level the page outline means + - Why not automatic: The ruling converged `element:text` on the vocabulary `ui:text` already publishes, because a heading is a document level, not a text style: `heading` and `subheading` named a style and left the renderer to pick a level. It landed in two releases so authors outside this repository could move first — 17.5.0 added the nine and refused nothing, and 17.6.0 was a full release in which both vocabularies parsed. The D2 conversion `element-text-variant-heading-levels` makes the ruled edit: `heading` → `h2`, `subheading` → `h3`. That keeps the heading element (the renderer drew `heading` as an h2 element and `subheading` as an h3 element), so the document outline a screen reader walks is unchanged, but not the size: `heading` drew in the `h3` style and `subheading` in a medium-weight small heading style, and `h2` / `h3` draw their own, larger styles. Whether the page wanted that level is the author's call — a heading placed for its size rather than its place in the outline may want a deeper level. Nothing is dropped at rest: a stored page replays the rewrite at rehydration; a page component's `properties` is not parsed on the save path, and the component-props gate reports an old spelling as an advisory `component-props-invalid` finding, carrying the prescription, on `os validate`, `os build` and `os lint`. ADR-0087 + - Done when: No `element:text` page component carries `variant` `heading` or `subheading`; `os validate` reports no `component-props-invalid` finding under `properties.variant` for these blocks. For each rewritten block, open the page and check the heading: it renders the same heading element as before, in its level's style. Where the old, smaller look mattered more than the level, pick the level whose style you want and confirm the outline still reads in order. A block that omits `variant` still renders as `body`. +- **`engine-dotted-filter-refused`** — `a `where` / filter whose KEY is a dotted path with a relation, virtual-`formula` or plain-scalar head (`{"project_id.name": …}`, `{"is_open.x": …}`, `{"title.x": …}`) — at BOTH doors: the REST ingress (`assertFilterFieldsExist`, covering everything that reaches `findData`) and the engine seam itself (`engine.find` / `findOne` / `count` / `aggregate` / `update` / `delete`), which saved reports, flows and dashboard widgets reach directly` → denormalise the value onto the queried object (a stored field, written when the source changes) and filter that — the same remedy, in the same words, the SORT axis prescribes when it refuses the dotted spelling. To read a related column, `$expand` is unchanged; to CONDITION on one, the stored denormalised field is the supported shape. A dotted path into a structured/JSON field (`{"address.city": …}`) is NOT refused and keeps its current per-driver behaviour + - Why not automatic: FILTER was the last of the four query axes with no verdict for a dotted name: SORT refuses it, PROJECTION refuses it at both doors, and the FILTER gates judged a key on its HEAD SEGMENT only — so `where {"project_id.name": "Apollo"}` cleared the unknown-name check (which refuses a key naming no field of the object) because `project_id` is a real field, reached a driver that cannot serve the path, and answered 200 with zero rows. The formula verdict deliberately skipped dotted keys, so the axis answered one unserviceable intent two ways by spelling: `{is_open: true}` was refused while `{"is_open.x": true}` rode through. + +Measured across all THREE drivers before ruling: relation-head, formula-head, system-column-head and plain-scalar-head dotted filters return ZERO rows on `driver-memory`, `driver-sql` AND `driver-mongodb`, each under an ordinary 200 indistinguishable from an empty table. There is no working capability for this refusal to remove: an ObjectStack lookup stores the related record's SCALAR id (SQL: a string column plus FK; Mongo: a single-key index on a scalar), while Mongo's dotted paths traverse EMBEDDED DOCUMENTS — so the spelling is well-formed Mongo that matches nothing. On driver-sql, knex reads the dot as a table qualifier and emits a column no dialect can resolve; `find()` falls into the unknown-column recovery ladder and returns `[]` silently (the same measurement caught the list and count halves answering that query two different ways, a divergence with its own entry, `driver-sql-unresolvable-where-column-refused`, that now refuses it on both). + +Both doors now refuse the three measured-dead head classes with `400 INVALID_FIELD`, naming the whole offending key exactly as the caller wrote it and carrying the remedy sentence — no new mechanism, no new error class, per the maintainer's ruling. Both judge the head by the SAME `@objectstack/spec/data` classification (`classifyDottedFilterHead`), the one-source move the formula verdict made with `isVirtualSearchField`, so the doors cannot drift into answering one spelling two ways. Precedence mirrors the sort axis, verdict for verdict: `unknown` > `dotted` > unmaterializable. + +DELIBERATELY UNJUDGED, per the same ruling: a dotted path whose head is a structured/JSON field (`address.city`) — the one spelling the drivers genuinely disagree on (live on memory and mongodb, 2 rows in the measurement; silently empty on sql). Refusing it for symmetry would delete a working capability on two of three backends; declaring JSON-path filtering a capability (`supports`) waits for a real consumer. Array-valued heads (`multiple: true`, tag types) and file heads are unjudged for the same measured reason. The nested-relation OBJECT form `{ owner: { region: "NA" } }` is untouched: the refusal targets the dotted-STRING spelling alone. + +This is a CODE-path API, not stored metadata, so — like `engine-find-formula-filter-refused` and `engine-dotted-projection-refused` one step down — there is no `sys_metadata` row for the D2 chain to rewrite and this ledger entry is the notification channel. No mechanical rewrite exists: the platform cannot invent the stored column the remedy prescribes, and it must not join or post-filter instead — the drivers have already applied `limit`/`offset`, so any post-hoc predicate would filter an arbitrary page. + +AUTHOR-REACHABLE SURFACES: a saved report's `query.filter` (`sys_saved_report`) is forwarded VERBATIM into `engine.find` by `plugin-reports`, bypassing the ingress; flow node `config.filter` and dashboard widget filters are author-written the same way. A dotted filter path is exactly what an AI author writes by analogy with `$expand`, projection spellings and SQL joins — and it used to answer an empty list indistinguishable from "no matching records", often with the related value reading correctly in the very same response. It now fails loudly, with the remedy in the message. Registered on the same inherited ruling as its siblings — the SORT-axis engine refusal was registered in this ledger although no stored row needs rewriting, re-affirmed for the FILTER axis on 2026-08-13. ADR-0112. + - Done when: No filter key is a dotted path whose head is a relation (`lookup` / `master_detail` / `user` / `tree`), a `formula`, or a plain scalar — grep your saved report definitions (`sys_saved_report.query.filter`), flow node `config.filter`, dashboard widget filters and view filters for keys containing a dot, and for each either denormalise the related value onto a stored field of the queried object, or (for a relation id test) filter the head field itself (`{"project_id": }`). A dotted path into a structured/JSON field (`{"address.city": …}`) needs NO action — it is deliberately not judged. Reads complete with no `INVALID_FIELD` naming a dotted filter key, at either door. +- **`epoch-instant-keys-renamed`** — `four epoch-instant keys whose name carried no unit: WebSocketEvent.timestamp, SimplePresenceState.lastSeen, KernelContext.startTime (inherited by TenantRuntimeContext) and HealthStatus.timestamp` → the same instants named for what they mark and typed with the new shared EpochMs schema (shared/epoch.zod.ts): occurredAt, lastSeenAt, startedAt and checkedAt. The VALUE is unchanged in every case — still milliseconds since the Unix epoch, still Date.now(). Only the key name and the declared schema move + - Why not automatic: Maintainer ruling B (2026-09-05, on the population the 2026-09-02 rule reaches): a duration-shaped z.number() carries its unit in the key NAME, minus two structural classes declared ON THE SCHEMA rather than in a gate ledger. Epoch instants are the first class. They read to the rule exactly like an offending duration — a bare name plus a describe that says "milliseconds" — but renaming them the way the rule prescribes would resolve the wrong confusion: measured on this package own authorable surface, all 51 distinct keys ending in Ms are durations (timeoutMs, backoffMs, latencyMs, uptimeMs) and all 51 distinct keys ending in At are instants (createdAt, expiresAt, lastUsedAt). Spelling an instant with the Ms suffix would move it INTO the duration family. So the exemption is a declaration on the contract: the value becomes EpochMs, which states the epoch-millisecond unit once, and the key takes this package established At convention. Two of the six instants ruling B names (ServiceMetadata.registeredAt and ScopeInfo.createdAt) were already correctly named and only changed schema, so they are not retirements and appear in no table. A SEMANTIC entry rather than a D2 conversion because all four keys are RUNTIME-EMITTED — a WebSocket event and a presence payload are wire messages, a kernel context is constructed by host code at boot, a health report is emitted by the startup orchestrator — so none is ever stored as a sys_metadata row and the conversion chain has no seam that would see one. That is the same disposition kernel/KernelContext:previewMode already carries on one of these very defs, and ruling B prescribes it explicitly: an ADR-0087 conversion where the key is authorable, a semantic entry where it is runtime-emitted. ADR-0087. + - Done when: No producer emits the old key and no consumer reads it. All four are tombstoned with retiredKey(), so each fails tsc at the construction site (the key types never) and fails the parse with the rename prescription. Concretely, check four places. (1) Code building a WebSocketEvent: rename timestamp to occurredAt. (2) Code building a SimplePresenceState: rename lastSeen to lastSeenAt — and note that the neighbouring PresenceState.lastSeen (api/realtime-shared.zod.ts) is a DIFFERENT key holding an ISO-8601 datetime string, which is untouched and must not be renamed with it. (3) Host boot code composing a KernelContext or a TenantRuntimeContext: rename startTime to startedAt. (4) Code building a kernel HealthStatus: rename timestamp to checkedAt. In every case the value is carried across unchanged. One behavioural note: WebSocketEvent.timestamp and SimplePresenceState.lastSeen were declared z.number() with no integer constraint and EpochMs is z.number().int(), so a fractional epoch that used to parse is now refused at those two sites — a tightening, and Date.now() has always satisfied it. +- **`esignature-config-deadline-keys-retired`** — `e-signature deadline keys: `ESignatureConfig.expirationDays` / `reminderDays` (`document.eSignature.expirationDays` / `document.eSignature.reminderDays`)` → nothing to re-declare — delete the keys. No e-signature engine exists on the platform: no signature request is sent, expired or reminded by any layer, so there is no live mechanism to declare an expiry window or a reminder interval to. `ESignatureConfig` itself stays (`provider` / `enabled` / `signers`), unchanged + - Why not automatic: ADR-0049 enforce-or-remove; the 2026-09-02 ruling on the unread deadline keys held this pair on one condition — "no roadmap ⇒ they retire with the other three families" — and the maintainer answered it on 2026-09-05 (no roadmapped e-signature consumer), so the ruling's own branch resolves to retirement. Two day-shaped keys sat on the published authorable surface (`authorable-surface/data.json`) and in the generated reference docs — an author could write `expirationDays: 30` and reasonably expect a signature request to lapse after thirty days — and were read by NOTHING: the reader census over every package outside `packages/spec` (tests and changelogs excluded), over `examples/**` and `skills/**`, and over objectui at the pinned sha returned zero hits for `expirationDays`, `reminderDays`, `eSignature` and the `ESignatureConfig` names, with a lit control inside `packages/spec`. Both carried defaults (30 days, 7 days) that were materialized into every parsed configuration without ever being consulted. `cloud` and real customer configurations are UNMEASURED. Why D3 semantic and not a D2 conversion: `DocumentSchema` is not a stack collection member and `document` is no metadata type, so the chain has no seam that would ever see one (the `kernel/MetadataPluginConfig:additionalTypes` precedent); the prescription reaches authors through the `retiredKey()` tombstones (`tsc` + the parse) and this entry. + - Done when: No `ESignatureConfig` literal — standalone or nested as `Document.eSignature` — carries `expirationDays` or `reminderDays`. TypeScript authors get the refusal at compile time (each key is typed `never`); a value reaching the parse is refused with the prescription (`invalid_type` at the path of the key, on the base schema and through the `DocumentSchema.eSignature` carrier). Parsed configurations no longer carry the two former defaults. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ever read the keys, so removing them removes no behaviour. +- **`evaluated-expression-slots-source-required`** — `every EVALUATED expression slot in the spec — the 34 declaring positions of the census of engine-evaluated slots outside the flow ledger that survive into this major, enumerated by identity and not by a name scan: the formula Field.expression; the predicate keys visibleWhen / visibleOn / readonlyWhen / requiredWhen / visibility / disabledWhen / visible / disabled / condition / when, on Field, SelectOption, InlineGridColumn, ScriptValidation, CrossFieldValidation, ConditionalValidation, Hook, ObjectFieldGroup, RowCrudActionOverride, CriteriaSharingRule, PluginPermission.filter, MultiVersionSupport routing, Action and ActionParam (including each param option), BaseNavItem, BulkActionDef, PageComponent, PageTabs items, RecordAlert, ListViewShape, FormFieldBase, FormSection and the settings-manifest Specifier and manifest visible — authored either as an expression envelope carrying only ast ({ dialect: 'cel', ast: … } with no source), or with a source that is blank after trimming, through the envelope key ({ dialect: 'cel', source: ' ' }) or the bare-string shorthand for it. ⚠️ The census this entry was written against counted 36, and the two that are deliberately absent here are the ServiceLevelIndicator successCriteria and TraceSamplingConfig composite condition expression arms. They are not lost: they were RETIRED OUTRIGHT in this same unpublished major by the observability-cel-predicates-retired entry of this step, under ADR-0049 enforce-or-remove, because nothing evaluated either. Both entries first ship together, so an upgrader never meets those two slots under THIS rule — the composite of the two changes is the retirement alone, and stating the narrowing for a slot that no longer accepts an expression at all would send the upgrader to author one. That absorption is the only reason the count here is not the census figure of 36. The published TypeScript interface RowCrudPredicates narrows with the two slots it mirrors. Reachable wherever metadata is authored or stored: defineStack sources, an exported stack passed to objectstack validate, a POST body on any of these metadata types, and a row already sitting in sys_metadata` → a non-blank `source`. ⭐ For an `ast`-only envelope the recovery is MECHANICAL and lossless for `cel`, which is the one dialect in this population that has an AST at all: `printCelAst(ast)` from `@objectstack/formula` (shipped with this narrowing, the inverse of `parseCelToAst`) prints the AST back to surface syntax, and the recovered string is the new `source` — keep the `ast` beside it if you want, an `ast` BESIDE a string `source` is untouched and stays admitted everywhere. ⚠️ Lossless is about MEANING, not bytes: the printer re-renders from the parse tree, so single-quoted literals come back double-quoted (`record.p == 'x'` → `record.p == "x"`) and parentheses the parser dropped do not come back. It answers `null` — never a guess — for an `ast` it cannot round-trip through the platform's own bounded parser; that `null` is the hand-migration case. For a BLANK `source` there is nothing to print from, so this entry delegates the judgment, and it is the same fork the flow-edge `condition` narrowing named: author the predicate the slot was meant to carry, or REMOVE the key entirely. ⚠️ Those two are not interchangeable and the choice is per slot, not per file. A refused predicate reached its evaluator and faulted, and what the fault DID differs by slot: on the fail-closed ones (`ObjectFieldGroup.visibleWhen`, `RowCrudActionOverride.visibleWhen`, `BulkActionDef.visible`, the two settings-manifest `visible` slots) it HID or EXCLUDED, so removing the key REVEALS what was hidden; on the fail-soft ones (the rest) it left the gate open, so removing the key preserves what was happening. Removing to clear the refusal is therefore safe on one half of the population and a silent disclosure on the other + - Why not automatic: Maintainer ruling 2026-09-12, option A: every engine-evaluated expression slot requires a non-blank `source`, with an ADR-0087 migration path, while the persistence contract stays wide. The rule first set for the flow-node ledger, and then carried to `FlowEdgeSchema.condition`, generalises to every other slot an engine evaluates. Each of those slots now composes `EvaluatedExpressionInputSchema` instead of `ExpressionInputSchema`, so an evaluated slot is held to what the engine can actually run. The engine reads `source` alone (`cel-engine.ts` `evaluate`: "AST-only evaluation not yet supported; persist `source`"), so both refused spellings landed in its fault arm on every release that carried them — and measured at the chokepoint, the engine never silently SUCCEEDS on either: it returns a `parse` fault, and what happened next was decided entirely by the slot's fail policy. Nothing between the author's keystroke and that fault said a word — the authoring lint `validateVisibilityPredicates` measured 0 findings on an `ast`-only envelope and 0 on a blank `source`, against two control legs that each measured 1. The refusal is one rule with one sentence, `EVALUATED_EXPRESSION_SOURCE_REQUIRED`. ⚠️ `ExpressionSchema` / `ExpressionInputSchema` are deliberately NOT narrowed and neither is their alias `PredicateInputSchema`: they are the PERSISTENCE contract (`source` OR `ast`) and stay wide by the same ruling's item 2. The narrowing is at the evaluated slots only. ⚠️ Why this is a D3 entry and not a D2 conversion, even though a printer now exists. The conversion layer lives in `packages/spec`, which is dependency-free by Prime Directive #2 and carries no engine — `packages/formula`'s own `normalize.ts` header states the same boundary from the other side ("Spec layer cannot do step 2 because it must remain dependency-free; this package owns the engine import"). A conversion that had to call the CEL printer could not live where conversions live, and a conversion that guessed without one would be the platform inventing a predicate. So the printer ships as a named, tested export the migration PRESCRIBES, and the judgment the printer cannot make — a blank `source`, an opaque `ast` that does not round-trip, any future dialect with no printer — stays here as the structured TODO, naming the object, field and slot. ⚠️ And for a row ALREADY STORED the consequence is wider than the key. `applyConversionsToStoredItem` replays the conversion chain on rehydration, but no conversion can supply a `source` that was never written, so a stored row carrying either spelling now fails its schema parse at the seam that loads it rather than parsing and faulting later. That is the intended direction — the refusal moves from run time, where it was invisible on the fail-soft slots and destructive on the fail-closed ones, to load time, where it names the row. ADR-0087, ADR-0058, ADR-0049. + - Done when: Sweep every authored metadata source and every `sys_metadata` row for the two spellings on the slots named in `surface`: an expression envelope with no `source` key, and a `source` (or bare-string shorthand) that is empty after trimming. ⚠️ Sweep by SLOT, not by key name — `visible` is on this list for actions, action params, nav items, bulk actions, record alerts and settings manifests, and is NOT an expression slot elsewhere; and ONE of the positions is a union member, `RecordAlertProps.visible`, whose sibling arm is untouched: it still takes a boolean literal, so a boolean there is not a hit. ⚠️ The two OTHER union members the census listed — `ServiceLevelIndicator.successCriteria` and `TraceSamplingConfig.composite[].condition` — are deliberately NOT on this sweep, because their expression arms were retired outright in this same major (see `surface`). Sweep those two under `observability-cel-predicates-retired` instead, whose instruction is the opposite of this one: there, an expression is not repaired, it is replaced by the structured shape or moved out of application metadata. For each hit: if it carries an `ast`, run `printCelAst(ast)`; a string result IS the migration and needs no judgment beyond reading it back. A `null` result, or a blank `source`, is the hand-migration case — decide per the `replacement` note whether the slot was meant to carry a predicate (author the `source`) or to be ungated (remove the key), and ⛔ do not default to removal on a fail-closed slot, where removal reveals rather than preserves. Two proofs. (1) `objectstack validate` is clean on a stack authored in config files: each offender is located by path with the `EVALUATED_EXPRESSION_SOURCE_REQUIRED` sentence — one `invalid_union` issue at the slot for an `ast`-only envelope or a blank bare string, one `custom` issue at `source` for a blank `source` inside an envelope. There is no CLI verb that lowers a stored row back into a config file, so this proof does not reach metadata that exists only in `sys_metadata`. (2) For stored rows, load the tenant and confirm every metadata item of the affected types still rehydrates: a row carrying either spelling now fails its parse at the load seam and is reported there, naming the object, the field and the slot. A row whose every evaluated slot carries a non-blank `source` parses byte-identically to before — the narrowing removes accepted shapes and adds none. +- **`event-name-schema-retired`** — ``EventNameSchema` and its `EventName` type (`@objectstack/spec/shared`, `shared/identifiers.zod.ts`), and the dot-notation grammar it imposed on its only three binding fields: `EventTypeDefinitionSchema.name` and `EventSchema.name` (`kernel/events/core.zod.ts`) and `EventMessageSchema.eventName` (`api/websocket.zod.ts`).` → (removed — no replacement grammar layer. The three binding fields stay and widen to plain `z.string()`; the event vocabulary the platform actually checks is the closed literal enums `DataEventType` / `BulkDataEventType` (`@objectstack/spec/api`, `api/events.zod.ts`), which stand as the only event-name contract. A caller that imported `EventNameSchema` for standalone validation deletes the import; if it was validating platform event names, it parses through the enums instead.) + - Why not automatic: Maintainer ruling 2026-09-01 (director decision batch C, verbatim 「同意」: retire) — ADR-0049 enforce-or-remove. The schema presented itself as the platform's event-name grammar while nothing that runs consumed its three binding schemas, and the closed enums that do the real checking never referenced it. The event surface is platform-defined, not author-extensible, so a grammar layer for a hypothetical extension surface is a trap, not a reserve: a generator satisfying `EventNameSchema` has satisfied nothing the platform will check, while one emitting outside the closed enums is refused by a rule the identifier file never mentioned. + - Done when: No code imports `EventNameSchema` or `EventName` from `@objectstack/spec/shared` (TS2305 after upgrade); `EventTypeDefinitionSchema.name`, `EventSchema.name` and `EventMessageSchema.eventName` parse as plain strings (the accept set at those three fields widens — every previously valid document stays valid, so no source rewrite ships and `objectstack migrate meta` has nothing to visit); `DataEventType` / `BulkDataEventType` are byte-for-byte untouched; `WebSocketEventSchema.channel` remains a deliberate bare `z.string()` (the ruling adds no constraint there); the `shared/EventName` def key leaves `json-schema.manifest/shared.json` in the same change that registers this entry. +- **`execution-step-iteration-single-valued`** — ``ExecutionStepLog.iteration` on a step whose `regionKind` is `parallel-branch` — the per-step records under `ExecutionLog.steps`, as the automation run endpoints return them — and the new optional `ExecutionStepLog.branch` key` → Read the parallel branch index from `branch`. `iteration` is now single-valued: the zero-based iteration of the enclosing `loop`, carried through any nesting, so a branch step of a `parallel` node that sits inside a loop body carries BOTH keys — `iteration` for the row and `branch` for the branch. A consumer that grouped or labelled steps by `iteration` under `regionKind: parallel-branch` moves that read to `branch`; a consumer reading `iteration` on `loop-body`, `try` or `catch` steps changes nothing. + - Why not automatic: The key was declared as the zero-based loop iteration OR the parallel branch index of the enclosing region — one field, two meanings, told apart only by reading `regionKind` first. The engine tagged each step with its innermost region only, so for a `parallel` node inside a `loop` body every branch step recorded the branch index and no step of that branch recorded the loop iteration: a per-row failure inside a branch was attributable to a branch, never to the row the sweep was processing. The sibling try/catch rule had already settled the containment case — a try/catch region has no index of its own, so it carries the loop iteration — and deliberately left `parallel` open, because there the two indexes genuinely compete for one field. The maintainer ruling of 2026-09-03 took option A: `iteration` always means the enclosing loop iteration and the branch index moves to its own optional key, so a reader no longer has to branch on `regionKind` to know which number it holds, and getting that wrong no longer silently books a failure against the wrong row. Option B — keep the overload and add a second index whose presence depends on nesting shape — was not taken. This is not a mechanical conversion: a step record written before this change carries `iteration` under `parallel-branch` with the branch-index meaning, and only its producer knows whether the parallel node sat inside a loop. The measured corpus held zero `loop { parallel }` nestings and one consumer reading the key — a grouping key in the objectui flow-runs panel — so the migration is a consumer-side read move, not a data rewrite. The engine tagger that writes both keys follows this contract change as its own card; until it lands, `branch` is declared and unwritten, and `iteration` on a `parallel-branch` step written by an older engine still holds the branch index. + - Done when: No consumer reads `iteration` as a branch index: every read of a `parallel-branch` step's index goes through `branch`, and every read of the enclosing loop iteration goes through `iteration` regardless of `regionKind`. A step record carrying `regionKind: parallel-branch`, `iteration: 3`, `branch: 1` parses under `ExecutionStepLogSchema` with both numbers intact, and a negative or fractional `branch` is refused at the `branch` path. A record written before the engine follow-on carries no `branch` key; treat its `iteration` under `parallel-branch` as the legacy branch index only when the record predates the engine build that writes `branch`. +- **`export-job-family-retired`** — `the export-job API family, retired whole: the twelve defs api/ExportJobStatus, api/CreateExportJobRequest, api/CreateExportJobResponse, api/ExportJobProgress, api/ScheduledExport, api/GetExportJobDownloadRequest, api/GetExportJobDownloadResponse, api/ListExportJobsRequest, api/ExportJobSummary, api/ListExportJobsResponse, api/ScheduleExportRequest and api/ScheduleExportResponse with every name api/export.zod.ts exported for them from @objectstack/spec/api (the Schema consts, their z.input aliases and their Parsed aliases) and the ExportApiContracts route map; the IExportService contract with its six types (CreateExportJobInput, CreateExportJobResult, ExportJobDownload, ListExportJobsOptions, ExportJobListResult, ScheduleExportInput) from @objectstack/spec/contracts; and automation/ScheduleState (ScheduleStateSchema, ScheduleState, ScheduleStateParsed) from @objectstack/spec/automation` → nothing to re-declare for the job family — no route ever served it, so no caller holds a job id, a progress body or a download link to carry over. The export the platform DOES serve is the synchronous streaming door GET /api/v1/data/:object/export (@objectstack/rest, the SDK method data.export): it answers the file itself as CSV, JSON or XLSX. ExportFormat stays published (ExportImportTemplate still references it). A recurring export is a Job (system/job.zod.ts) whose handler you write, with its cadence on Job.schedule.expression — the one cron slot the platform evaluates. A scheduled flow declares its cadence on its start node (config.schedule), and its run history is ExecutionLog / FlowRunSummary; ScheduleState had no counterpart to point at because no scheduler ever kept one. The import-job family in the same module (ImportJob…, ListImportJobs…, ImportJobApiContracts) is served and is NOT part of this retirement + - Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling A of 2026-09-12 (retire the family, IExportService and ScheduleExportInput; ScheduleState retired with it unless a live consumer is measured), the landing route the maintainer ruled on 2026-09-24 (route A: objectui retires its own side of the unimplemented async-export path first, then this retirement), and a scope note the maintainer agreed on 2026-09-25 that folds in the declared limit / cursor of the export-job list — one of three sibling list doors found declaring them and never reading them. The family declared an asynchronous export API, create / progress / download / list / schedule / cancel under /api/v1/data/export and a POST on /api/v1/data/:object/export, that NOTHING served: @objectstack/rest mounts no /api/v1/data/export route and only the GET on /api/v1/data/:object/export, IExportService recorded no evidenced provider binding, and the reader census over objectstack outside packages/spec, over objectui at the pinned sha (which carries objectui's own retirement) and over cloud main returned zero code files naming any of the forty-three exported names, each beside a lit control. An AI reading the contract found a complete, well-typed export-job API and wrote calls that answer 404 — and once the retirement of the cron-typed positions nothing read had deleted theirs, ScheduledExport / ScheduleExportRequest kept a REQUIRED schedule block that could hold no schedule, so an author who filled in its timezone believed they had scheduled something. ScheduleState described the runtime state of a scheduled flow that no scheduler wrote or read. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and applyConversionsToStoredItem maps a metadata type onto one of its collections; none of these shapes is either — they are HTTP bodies, a route map, a service interface and an unpersisted runtime record — so a conversion would be a transform with no seam that ever runs, and with no carrier key there is no shape on which a tombstone could sit. Those earlier cron-position deletions on three of these defs registered nothing and stay unregistered; the defs themselves are now the RETIRED_DEFS_BY_MAJOR[18] entries. + - Done when: No code imports any of the twelve export-job Schema consts or their type aliases from @objectstack/spec or @objectstack/spec/api, reads ExportApiContracts, implements or imports IExportService or its six types from @objectstack/spec/contracts, or imports ScheduleStateSchema / ScheduleState / ScheduleStateParsed from @objectstack/spec/automation: every such import is TS2305 after upgrade, and there is no working replacement to point at because nothing ever served them. The thirteen defs are absent from json-schema.manifest/api.json and json-schema.manifest/automation.json, from the api-surface / declaration-map / export-origins shards and from the generated reference docs. ExportFormat, ExportImportTemplate, the import validation shapes and the whole import-job family (including ImportJobApiContracts) are unaffected. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: GET /api/v1/data/:object/export answers exactly as before, and every request to a retired path answers exactly as it always did, because nothing ever mounted one. ⚠️ Readers outside objectstack, objectui and cloud are NOT MEASURED — @objectstack/spec is published. +- **`field-currency-scale-refused`** — `object.fields..scale on a field whose `type` is `currency` — any declared value, `scale: 0` included; the `Field.currency` helper passes it through unchanged. `scale` on `number`, `percent`, `rating`, `slider` and `formula` is untouched` → no `scale` on a currency field. DELETE the key — that is the whole migration: a currency amount's decimal places are its currency's, not a field setting. The currency's ISO 4217 minor unit decides how the amount displays, and the field's write allowance stays unconstrained — a currency write is accepted with the decimals it carries, as it always was on a currency field that declared no `scale`. ⛔ Nothing replaces the key: do not re-declare its value under any other key. + - Why not automatic: The maintainer's ruling of 2026-09-23 (option B) retires `scale` from the `currency` field type, and the ruling of 2026-09-24 (option 乙 — a currency's decimal places are the currency's, not a setting) words the remedy. On a currency field the key was three-faced: the metadata-admin field designer offered it as stored metadata, the amount's cell never read it (fraction digits come from the currency's ISO 4217 minor unit), and the record validator's `max_scale` branch still refused writes carrying more decimals — so an author who set it bought a narrower write contract and no visible change. `FieldSchema` now refuses the key on `currency` at parse, and the validator stops reading it for the type in the same release, so a stored declaration narrows nothing either. ⛔ No alias and no grace window, per the ruling. NOT mechanically converted, deliberately: a conversion that dropped the key would accept it on every load, which is the grace window the ruling refused; the refusal names the key and its one-line fix instead. Two behaviour changes ride along and are part of what an upgrade means: (1) a currency write with more decimals than a former `scale` is now ACCEPTED — the write allowance stays unconstrained, the contract every currency field without `scale` already had; (2) at the console pin measured when this was written, the grid summary footer and the dashboard metric widget read a currency column's `scale ?? 0`, and the ruling lands this change only after the console derives both faces from the currency, the way the cell does, and the pin has moved past that change. Population measured at the change, on origin/main 1f89ba0d70 by AST sweep: 15 `Field.currency` declarations in `examples/` (app-crm 4, app-showcase 11) and 13 documentation examples carried `scale`, every one of them `scale: 2`; all were deleted in the same change. + - Done when: Every field in the stack parses: `ObjectSchema.parse()` / `objectstack validate` report no issue on a `scale` path of a `currency` field. A currency field that carried `scale` no longer declares it, and a diff of the field shows that one line deleted and no key added. Its amount's cell renders the currency's ISO 4217 minor-unit digits, as before, and a write with more decimals than the old `scale` is accepted where it was refused with `max_scale`; `number` / `percent` / `rating` / `slider` fields keep their `scale` and still refuse over-scale writes. +- **`field-inline-and-related-list-columns-closed`** — `field.inlineColumns[] and field.relatedListColumns[] on lookup and master_detail fields — the two column lists that used to accept any object` → `inlineColumns` entries are strict, name-keyed columns — `{ name, label?, type?, … }`, where `{ name }` alone hydrates the rest from the child object field. `relatedListColumns` entries are child field-name strings. + - Why not automatic: The D2 conversion `field-column-lists-canonicalized` rewrites what it can resolve without guessing: an inline column spelled `{ field: 'x' }` becomes `{ name: 'x' }` with every other key kept, and a related-list column object folds to its identity string. Two things remain the author's. First, the conversion leaves alone, on purpose, an inline entry that carries BOTH `field` and `name` (rewriting a live key on the strength of a stale one would guess) and a related-list object with no resolvable identity (a conversion must not invent data) — those now fail the parse and only the author knows which column was meant. Second, the fold DROPS a related-list object's decoration keys — a label, a width — because no object spelling rendered reliably on that list; the author decides whether a label they wrote there belongs on the child field itself instead. Both lists used to accept any object, so a mis-keyed column published clean and drew blank cells: a column that was blank before this release was usually one of these, and the author should confirm it now names a real child field. + - Done when: The object parses: no `inlineColumns` entry carries `field`, and every `relatedListColumns` entry is a string. Every inline column `name` and every related-list string names a field that exists on the child object, and the inline grid and the related list render a value — not a blank cell — in each column for a record that has one. Any label that the fold dropped from a related-list column is either no longer wanted or now lives on the child field definition, where the list reads it from. +- **`field-master-detail-set-null-refused`** — `object field `deleteBehavior: 'set_null'` authored on a `master_detail` field` → an explicit `deleteBehavior: 'restrict'` or `'cascade'` (or no declaration, which is the cascade default) — re-declared deliberately, because only the author knows which they meant. There is deliberately NO automatic conversion: `'set_null'` here asked for the child rows to be KEPT, and both mechanical rewrites betray that intent in a different direction — stripping the key silently ratifies the cascade the author did not ask for (the same collapse of intent that produced the defect), while `'restrict'` is the only rewrite that cannot lose data (the parent delete is refused while children exist — the closest honest reading of "keep my children") but turns a delete that silently succeeded into a loud refusal. If the children genuinely must survive the parent, the field wants to be a `lookup`, not a `master_detail` + - Why not automatic: `FieldSchema` accepted `deleteBehavior: 'set_null'` on a `master_detail` while the engine's `cascadeDeleteRelations` resolves every value except `restrict` on that type to `cascade` — so the declaration asked for the children to be kept and the engine DELETED them, silently, at the moment the parent went away: data loss relative to the declared intent, the ADR-0049 declared-but-unenforced shape on a delete path. Honoring the value is ruled out (maintainer, 2026-08-19): a detail row whose master reference is nulled becomes an unreachable orphan, which is precisely what the orphan-detail work exists to prevent. The schema now refuses the authored combination at parse time (declared = enforced), and the engine logs loudly if a raw registration or a pre-tightening stored row still carries it to the coercion site. A BARE `master_detail` is untouched: the default still materializes as `'set_null'` in parse output (byte-identical to before) and still resolves to cascade. + - Done when: No `master_detail` field declares `deleteBehavior: 'set_null'`. Bare `master_detail` declarations, authored `cascade`/`restrict`, and `set_null` on `lookup` parse byte-identically to before. Stored `sys_metadata` rows carrying the refused combination keep loading and serving (registry validation is a diagnostic, not a gate) but flag `metadata_spec_invalid` and are refused on their next authoring-path save — re-declare the field deliberately when that happens. +- **`field-max-length-malformed-or-misplaced-refused`** — `object field `maxLength` declarations — `maxLength: 0`, negative or non-integer values on any type, and the key with any value on field types outside `BOUNDED_STRING_FIELD_TYPES` (`boolean`, `lookup`, `autonumber`, `formula`, `select`, `json`, `secret`, …)` → a positive-integer `maxLength` (>= 1) on a bounded-string field type — `text`, `textarea`, `email`, `url`, `phone`, `password`, `markdown`, `html`, `richtext`, `code`, plus `signature`/`qrcode`, which joined once the write seam enforced a declared bound on them (the set is `BOUNDED_STRING_FIELD_TYPES`; the narrowing itself landed on the ten-member set of its day) — or no declaration at all. Deleting the key is mechanical and behaviour-preserving for a MISPLACED declaration: the write-time validator only ever applied `max_length` inside its bounded-string branch, so the key was inert by construction on every other type. A MALFORMED value on a bounded-string type is the judgment case — the validator's raw `>` comparison did consume it (`maxLength: 0` accepted only the empty string, a negative value refused every write, `maxLength: 12.5` behaved as "at most 12"), and the SQL schema-drift planner consumed `maxLength: 0` as `varchar(0)` DDL until it was taught to stop reading a malformed bound as authoritative — so only the author knows the bound they MEANT: re-declare it as a positive integer, or delete it deliberately accepting the unbounding + - Why not automatic: Maintainer ruling of 2026-08-24, tightening both halves — the value's shape and the types the key applies to (enforcement shipped on the 17.x line — accept-set narrowings ride minors, and this entry tells `migrate meta` users at the major boundary; registration was deferred to a follow-up because the registry file was serialized behind an in-flight change when the enforcement landed). Shape: a character length is a positive integer, so the key tightened from `z.number()` to `z.number().int().min(1)` — `maxLength: 0` measurably sent schema-drift planning `varchar(0)` DDL no server accepts, at severity error/destructive, before that consumer was taught to stop reading a malformed bound as authoritative (the house pattern the `precision`/`scale` integer refusal set). Applicability: the key sat on the BASE field schema — authorable on `boolean` / `lookup` / `autonumber`, types where nothing bounded is stored — while the write-time validator (objectql `record-validator.ts`) only ever enforced it on its ten bounded-string types, the one list of the three that had a measured reader; that list is promoted to the protocol as `BOUNDED_STRING_FIELD_TYPES`, the schema refuses the key outside it (ADR-0078 declared=enforced), and both authoring forms (`field.form.ts`, previously 3 types; `object.form.ts`, previously 9) show the key for exactly that set. + - Done when: Every field declaring `maxLength` carries a positive integer and is a bounded-string type. Well-formed declarations (a positive-integer `maxLength` on a bounded-string type) parse byte-identically to before; fields declaring no `maxLength` are untouched, and absence stays absence — no default materializes. Deleting a misplaced key changes no runtime behaviour (it sat outside the validator's bounded-string branch and enforced nothing). For a malformed value on a bounded-string type the author decides: re-declare the intended positive-integer bound (enforced by the write-time validator from the next write on, and honoured by schema drift as `varchar(n)`), or delete the key and accept the type's unbounded/default column shape — either way the accidental old behaviour (empty-only writes under `maxLength: 0`, unwritable fields under a negative value) is gone by decision, not by silence. +- **`field-min-length-malformed-or-misplaced-refused`** — `object field `minLength` declarations — `minLength: 0`, negative or non-integer values on any type, and the key with any value on field types outside `BOUNDED_STRING_FIELD_TYPES` (`boolean`, `lookup`, `autonumber`, `formula`, `select`, `json`, `secret`, …)` → a positive-integer `minLength` (>= 1) on a bounded-string field type — `text`, `textarea`, `email`, `url`, `phone`, `password`, `markdown`, `html`, `richtext`, `code`, `signature`, `qrcode` (the twelve-member `BOUNDED_STRING_FIELD_TYPES` set; `signature`/`qrcode` joined once the write seam enforced a declared bound on them) — or no declaration at all ("no minimum" is expressed by OMITTING the key, never by `minLength: 0`). Deleting the key is mechanical and behaviour-preserving for a MISPLACED declaration (the write-time validator only ever applied `min_length` inside its bounded-string branch, so the key was inert by construction elsewhere) and for `minLength: 0` / negative values anywhere (a string length is never below zero, so the check could not fire). A FRACTIONAL value on a bounded-string type is the judgment case: the validator's raw `<` comparison did consume it (`minLength: 2.5` behaved as "at least 3"), so only the author knows the integer they MEANT — re-declare it deliberately if the constraint was wanted + - Why not automatic: Maintainer ruling of 2026-08-25 (option B, the lower bound at 1): `minLength` carried the exact defect pair the 2026-08-24 ruling closed for `maxLength` (`field-max-length-malformed-or-misplaced-refused`), and converges on the same template. Shape: the key was `z.number()`, so `minLength: -5` and `minLength: 2.5` parsed cleanly while describing no character length; it is now `z.number().int().min(1)`. The lower bound is 1 by ruling: `minLength: 0` is refused loudly — a vacuous always-true declaration is exactly the noise an AI metadata author mass-produces, and the refusal surfaces it at authoring time. Applicability: the key sat on the BASE field schema — authorable on `boolean` / `lookup` / `autonumber`, types where nothing bounded is stored — while the write-time validator (objectql `record-validator.ts`) only ever enforced it on the bounded-string set; the schema now refuses it outside `BOUNDED_STRING_FIELD_TYPES` (ADR-0078 declared=enforced), and both authoring forms (`field.form.ts`, previously 3 types; `object.form.ts`, previously 9) show the key for exactly that set. + - Done when: Every field declaring `minLength` carries a positive integer and is a bounded-string type. Well-formed declarations (a positive-integer `minLength` on a bounded-string type) parse byte-identically to before; fields declaring no `minLength` are untouched, and absence stays absence — no default materializes. Deleting a misplaced key or a `0`/negative value changes no runtime behaviour (misplaced keys sat outside the validator's bounded-string branch; a `0`/negative bound could never fire). Deleting a fractional value on a bounded-string type relaxes the write seam by up to one character — the author decides whether to delete or re-declare the integer they meant; a wanted minimum is re-declared as a positive integer and enforced by the write-time validator from the next write on. +- **`field-multiple-non-capable-type-refused`** — `object.fields..multiple — an authored `multiple: true` on a field whose `type` is outside MULTI_CAPABLE_TYPES (`select` / `radio` / `lookup` / `user` / `file` / `image`) union MULTI_OPTION_TYPES (`multiselect` / `checkboxes` / `tags`) — e.g. `master_detail`, `tree`, `text`, `boolean`, `datetime`, `avatar`` → a multi-capable type that actually holds several values: `multiselect` / `checkboxes` / `tags` for several option codes, a `lookup` with `multiple: true` for several related records (the replacement for a multi-valued `master_detail` / `tree`), `file` / `image` with `multiple: true` for several attachments — or, where the field really does hold one value, dropping the `multiple` key. `MULTI_CAPABLE_TYPES` and `isMultiValueField` are unchanged, so every field that was ALREADY multi-valued by that predicate keeps its declaration, its storage and its read path verbatim. + - Why not automatic: Maintainer ruling of 2026-09-13, option 1′ (the earlier rule refusing an authored `radio` with `multiple: true`, generalised): two definitions of "multi-valued" disagreed. `FieldSchema` accepted `multiple: true` on ANY type; driver-sql's `isJsonField` read it raw (`|| !!field.multiple`) and built a JSON ARRAY column; `isMultiValueField` — the spec predicate consumers shape queries from — answered "not multi-value" for the same field. A related list therefore composed `=` against a JSON array column and the driver answered the user a 400 (the console's related list pinned the divergence on the consumer side when it began shaping that filter from the spec predicate, and its follow-up recorded the driver half as owed and not filed). There is NO lossless conversion: the column was physically built as a JSON array, so the stored value is an array while the replacement type may want one scalar, several ids, or several option codes — which of those the author meant is a business judgment the chain cannot make. Hence a structured TODO rather than an auto-rewrite (ADR-0087 D3 "never silence", ADR-0032 "no silent failure"). Population measured at ruling time: 0 in-tree and 0 in HotCRM (shallow clone c716a2c) — every `multiple: true` there is on `lookup` / `select`; re-measured on origin/main 689d606f by AST sweep, still 0. WIDER THAN THE JSON-COLUMN DECISION ALONE: every site in driver-sql that asked `field.multiple` "is this value multi-valued" now asks `isMultiValueField` — the DDL writer, the read-side deserializer, the varchar-width mirror, the cross-field comparison class, the four scalar read-coercion registries on both of their fills, the two MySQL temporal-widening candidate sets, and the schema differ. So a stored field in the retired shape also LEAVES the JSON read path and ENTERS the scalar one: its column is no longer deserialized as JSON, the declared-type text-operator gate applies to it, and a `$contains` against it answers the declared no-match instead of a membership test. + - Done when: Every field in the stack parses: `ObjectSchema.parse()` / `objectstack validate` report no issue on the `multiple` path. For each field the refusal names — the message states the object-qualified field name and its `type` — the author has either dropped `multiple` or moved the field to a multi-capable type AND migrated the stored column, because the two storages differ: the old column holds a JSON array, the new one holds a scalar (dropping `multiple`) or a differently-shaped array (changing `type`). Prove the data half by reading one migrated row back through the API and asserting the value shape the new declaration promises; `=` filters against the field answer rows instead of a 400, and a `$contains` against it answers by member rather than the declared no-match. Fields already multi-valued by `isMultiValueField` need no change and must read back byte-identically. +- **`field-predicate-reference-traversal-refused`** — `the field-level predicates objects[].fields[].requiredWhen and objects[].fields[].readonlyWhen, and a select option's objects[].fields[].options[].visibleWhen, whose CEL reads THROUGH a reference field (a lookup, master_detail, user or tree field): record.account.tier where account is such a field; likewise previous.account.tier, and parent.account.tier on a master-detail line item whose master declares account. Refused wherever objects are validated as authored: objectstack validate, build and lint over defineStack({ objects }) sources and exported stacks` → the check as a `validations[]` rule of `type: 'script'` — the one predicate the server reads one hop through a reference (the related record is loaded before it runs) — whose `condition` states the FAILURE. For `requiredWhen: P` on field F: P and F empty, e.g. `record.account.tier == 'enterprise' && (record.po_number == null || record.po_number == '')`. For `readonlyWhen: P` on F: P and F changed, on updates only (`events: ['update']`), e.g. `record.account.tier == 'gold' && record.discount != previous.discount`. For an option gated by P: that option picked while P does not hold — the option is then offered to everyone and refused on save. Or read a column the object itself declares (denormalise the related value onto it). A read through `previous` or `parent` has no hydrated seam at all, a validation rule included: read a column the bound record declares instead + - Why not automatic: Triage routed this on 2026-09-25 to remedy A: refuse the traversal at authoring, with a prescription. The field level is never hydrated: `rule-validator.ts` evaluates `requiredWhen` / `readonlyWhen` / an option's `visibleWhen` against the record alone, so a reference there holds the related record's bare id and every read through it faults, on every row. Measured on the engine before this change: a traversing `requiredWhen` refused every insert and every update that reached it, a traversing `readonlyWhen` refused every update that wrote its field (an insert is exempt), and an option gated through a reference was admitted whatever the related record said (option visibility is fail-open) — while `objectstack validate` passed a stack carrying all three, exit 0. ADR-0137 D2 made the runtime fail closed; the defect was that authoring did not say so first (NORTH-STAR priority rule 4). The same traversal inside a `validations[]` `script` rule is served, one hop deep, and stays accepted. ⚠️ No D2 conversion, and the reason is the judgment this entry delegates: moving a field predicate into a validation rule turns a condition into a FAILURE condition, moves an option from hidden to offered-then-refused, and the right `events` scope depends on what the author meant — none of it mechanical. Hydrating the field level instead is a capability of its own and is not done here. ADR-0087, ADR-0137. + - Done when: Run `objectstack validate` over the stack. Each such predicate is refused as `expression-invalid`, located at `object 'O' · field 'F' requiredWhen` (or `readonlyWhen`, or `option 'V' visibleWhen`), and the message names the reference path read through (`through record.account`) and the repair — that is the TODO's locator. Rewrite each per the `replacement` note until validate is clean. Then prove the behaviour on a running stack: a write meeting the condition is refused by the new rule (`rule_violation` carrying its `message`), and one that does not is accepted — where before, every write reaching the field predicate was refused with `could not be evaluated … write rejected`, or the option was admitted unchecked. ⚠️ An object already stored in `sys_metadata` is not re-validated by this change: its writes keep being refused at run time exactly as before, and that refusal names the reference for a `record` read — its own locator. +- **`field-reference-to-spelling-retired`** — `field.reference_to — the legacy runtime spelling of a lookup or master_detail target, on object fields and object-extension fields` → `reference` — the one spelling the field schema has ever accepted, and the one the wire serves. + - Why not automatic: The D2 conversion `field-reference-to-alias` renames `reference_to` to `reference` in author sources and on every stored-row rehydration, so the wire only ever carries `reference`; for a row with only the legacy spelling the rename is lossless. Two things are left. First, a row carrying BOTH spellings with DIFFERENT targets is left untouched, on purpose: the loader will not pick a target for the author, so that field keeps failing its parse until someone decides which object it points at. Second, code is out of reach: a plugin, script, custom renderer or external client that read `reference_to` off served field metadata worked only because a stored row happened to carry the legacy spelling, and it now reads nothing — the frontend fallback that tolerated the spelling is scheduled to go, after which a missed reader degrades a lookup to a picker with no target. The camelCase `referenceTo` is a different surface (resolved action params) and is not part of this family. + - Done when: No object or object-extension field carries `reference_to` in source or at rest — every stored field serves `reference`. Each field that had carried both spellings names one target, chosen by the author. No code outside the metadata reads `reference_to` from a field definition. Every lookup and master_detail field opens a picker scoped to the object its `reference` names, and saving a selection stores that object's record id. +- **`field-scale-precision-integer-refused`** — `object field `scale` / `precision` declarations (`Field.number` and friends) — non-integer or negative values (`scale: 2.5`, `precision: -1`)` → a non-negative integer digit count, or no declaration at all. The mechanical conversion (`field-malformed-scale-precision-removed`) deletes a malformed value — behaviour-preserving, because the write-time `scale` enforcement deliberately skipped malformed declarations, so they enforced nothing — but only the author knows the count they MEANT (`scale: 2.5` was probably `2` or `3`): re-declare it deliberately if the constraint was wanted + - Why not automatic: Both keys are digit COUNTS ("Total digits" / "Decimal places"), and `z.number()` admitted values with no defined meaning as a count. That looseness became load-bearing when `scale` was made enforced at write time (an over-scale write refused, never rounded): the runtime branch deliberately guards on `Number.isInteger(def.scale) && def.scale >= 0` — inventing floor/round semantics in a consumer would be PD #12 guessing — so a typo'd declaration (`scale: 2.5`) silently got no enforcement at all: exactly the declared-but-inert shape that hides AI-authored metadata errors. The schema now refuses non-integer and negative values for both keys at parse time (`z.number().int().min(0)`, ADR-0078 declared=enforced). `CurrencyConfigSchema.precision` (under `currencyConfig`) was a different surface with its own bounds and alias table — retired in this same protocol major by `currency-config-precision-removed`, not enforced here. + - Done when: Every field declaring `scale` or `precision` carries a non-negative integer. Well-formed declarations (`0`, `2`, any non-negative integer) parse byte-identically to before; fields declaring neither key are untouched. Stored `sys_metadata` rows carrying a malformed value keep loading (the rehydration seam replays the conversion, which drops the meaningless key). +- **`filter-between-blank-endpoint-refused`** — `either endpoint of a $between range, authored BLANK — the empty string, or an absent (undefined) bound — in any filter this platform stores or executes. The carriers split in two, because they are DETECTED differently and only one half answers on save. (a) REFUSED AT SAVE — the enforced FieldOperatorsSchema / RangeOperatorSchema copy itself, reached by a caller that validates a filter against it directly, and the NormalizedFilter AST. (b) NOT JUDGED AT SAVE — every stored metadata carrier, and that is BOTH authoring dialects, not only the loose one. A view, page or component filter RULE (ViewFilterRuleSchema) admits it: the rule value accepts a string and the operator-shape check judges ARITY alone, so a two-element range with a blank element is a well-formed rule. A dashboard widget filter, a dashboard options-source filter, a dataset filter, a dataset measure filter, a report runtimeFilter, a rollup summaryOperations filter and a relatedListFilter are typed FilterConditionSchema, a loose record intersected with the $and / $or / $not shape and carrying one refinement. ⚠️ That refinement DOES judge an operator map, so the slot is not unjudged: a bare date-range PRESET name in an ordering position — a $gt / $gte / $lt / $lte value, or a $between endpoint — is refused at that position's own path, and it is refused through an otherwise GREEN document; the sibling entry filter-preset-ordering-comparand-refused is that rule, on this same carrier set. What these carriers never judge is a $between endpoint for BLANKNESS or for ARITY. Measured: a dashboard widget whose filter reads close_date $between 2026-01-01 and an empty string parses GREEN, as do a one-element, a three-element and an empty $between, while the same widget carrying a preset endpoint is refused at filter.close_date.$between.0. So for THIS shape every one of those documents parses GREEN and the endpoint is refused only when the filter is EXECUTED, at the engine comparand-shape door. ARITY is not what changed: a blank bound is a well-formed TWO-element range one of whose elements means nothing` → two endpoints that are present and non-empty — the bound the author meant, written out. If only ONE side is genuinely bounded, that is not a range at all: drop `$between` and write the side you have as a scalar comparison, `{"$gte": min}` for a lower bound and `{"$lte": max}` for an upper one, which every backend already answers. ⛔ There is no replacement that can be DERIVED from what was written: the bound the author did not type is not recoverable from the one they did, and picking either reading (drop the operator, or treat the blank side as unbounded) would be the platform inventing a filter. `null` bounds are a different entry: they were already refused by the 2026-08-31 ruling, whose message prescribes the null predicate because a `null` author was reaching for absence, not for a bound + - Why not automatic: Maintainer ruling A of 2026-09-17: a blank `$between` endpoint is refused at the authoring door, and the refusal names the blank side. `FieldOperatorsSchema.safeParse({ $between: [1, ''] })` answered `success: true` — measured on the card against the installed spec 17.4.0 and re-measured on `origin/main` before the change. This is a NEW RULE narrowing a published face, ⛔ not a pull-back to a declared one: the endpoint contract shared by both bounds says verbatim that "Each endpoint is a number, a Date, or a string", and the empty string is a string, so the acceptance was conformant. What made it wrong is the other half of the same contract — "Closed interval [min, max]" — which no backend can honour against a blank: driver-sql binds it into `whereBetween`, the JS matchers compare it as a value, and the range stops bounding on that side while still reading as a complete range. The reference matcher had already been taught to survive the null-bound form of exactly this (a bounded range answered EVERY valued row, because both of the arm's comparisons are false against a missing bound); the door that admitted it was never addressed. The only producer ever measured is a UI builder padding a HALF-TYPED pair with `''` so that a length-based completeness check passes it — nobody WANTS a blank bound, which is why it is refused rather than given a published meaning (option B was declined: a semantics nobody asked for, to be honoured per driver). The refusal names the blank SIDE (MIN / MAX plus the index) because with a padded pair both bounds are present and the author is the one person who cannot see which is empty. Scope is the empty string and `undefined` and nothing wider: whitespace-only endpoints are deliberately NOT judged, since narrowing a published face further than the ruling is the seat call this card's whole history refuses to make. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). ⚠️ No D2 conversion and no stored-metadata rewrite, and the load path was MEASURED rather than assumed: `applyConversionsToStoredItem` — the one primitive every stored-row rehydration seam calls — never throws and never validates, and replays only the positively-recognised lossless transforms in the conversion registry; measured on `origin/main`, a stored view carrying `{ close_date: { $between: ['2026-01-01', ''] } }` comes back as the SAME object reference. So the load path today neither drops a refused operator nor refuses the row, and no conversion in the registry drops a filter OPERATOR (the three filter-adjacent entries are key strips and a key rename). That is also the precedent the two nearest narrowings of this same surface set — `filter-preset-ordering-comparand-refused` and `analytics-date-range-array-two-bounds-required` — both of which decline a D2 conversion on the ground that rewriting would be the platform guessing which bound was meant. Dropping the operator would be worse than guessing: it deletes a constraint the author wrote and WIDENS the result set silently, the failure mode `$nin` carries in the same file. The read path does not re-validate stored rows, so no stored view becomes unreadable — and, because every stored carrier is typed loosely or judged by arity alone (see `surface`), re-saving one is not refused either. What changes is the enforced operator schema itself, which answers at the endpoint's own path with the blank side named, and the engine comparand-shape door, which refuses an executed filter carrying one. The objectui half — the builder stops padding a half-typed pair, so the console never meets this refusal mid-typing — is a change to the console's own filter builder and lands on its own schedule, either side of this one. ADR-0049 / ADR-0078 / ADR-0087. + - Done when: Grep every authored `$between` array — view, page and component filter rules, dashboard widget and options-source filters, dataset and dataset-measure filters, report runtimeFilters, rollup and related-list filters, saved AST filters, SDK and MCP callers — and read BOTH of its elements. A range with two present, non-empty endpoints parses byte-identically to before, numbers, Dates, ISO days, UTC instants, clock times and non-temporal text included, and `['0', '9']` and `[0, 100]` are untouched (the rule is blankness, not falsiness). An empty-string or absent bound now answers one prescriptive issue at that endpoint's own path (`$between.0` / `$between.1`) naming MIN or MAX, and a range blank on BOTH sides reports both positions. ⚠️ THE DETECTOR IS NOT THE SAME ON EVERY CARRIER, and re-saving the document is mechanical on NONE of them. `FieldOperatorsSchema.safeParse` is mechanical, and it is the whole of what the save door offers: it answers for a caller that validates a filter against that schema, or against the NormalizedFilter AST, directly. ⛔ Re-saving surfaces NOTHING for a stored document — not a dashboard, dataset, report, rollup or related list, whose filter slots are FilterConditionSchema, and not a view, page or component filter RULE either, whose value check judges arity and not blankness, so a range written `['2026-01-01', '']` saves exactly as green as it always did. Measured, not assumed. For those carriers the detectors are the GREP above and EXECUTING the surface, where the engine comparand-shape door refuses with INVALID_FILTER / 400 naming the index and the side. ⛔ Do not read a clean re-save of a dashboard — or of a view — as a completed sweep. Nothing is normalised on the way through — no bound is trimmed, defaulted or copied from its neighbour — so an accepted range arrives byte-identical to what was written. ⚠️ Do not assume a converted range was previously showing the window it named: a blank bound stopped bounding on that side at every backend, so the surface was reading a wider set than its filter claimed. Decide the window from what the surface was SUPPOSED to show, and if only one side was ever meant, write it as `$gte` / `$lte` rather than inventing a second bound. `null` bounds are unaffected by this entry and keep their own refusal and prescription. +- **`filter-between-field-reference-endpoint-refused`** — `either endpoint of a $between range, authored as a { $field } column reference, in any filter this platform stores or executes. The carriers split in two, because they are DETECTED differently and only one half answers on save. (a) REFUSED AT SAVE — a view, page or component filter RULE (ViewFilterRuleSchema, whose value is shaped by the operator) and the NormalizedFilter AST the query faces validate against, plus the enforced FieldOperatorsSchema copy itself. (b) NOT JUDGED AT SAVE — a dashboard widget filter, a dataset filter, a report runtimeFilter, a rollup filter and a relatedListFilter: every one of those slots is typed FilterConditionSchema, a loose record intersected with the $and / $or / $not shape and carrying one refinement. ⚠️ That refinement DOES judge an operator map, so the slot is not unjudged: a bare date-range PRESET name in an ordering position — a $gt / $gte / $lt / $lte value, or a $between endpoint — is refused at that position's own path, and it is refused through an otherwise GREEN document; the sibling entry filter-preset-ordering-comparand-refused is that rule, on this same carrier set. What these carriers never judge is a $between endpoint for a COLUMN REFERENCE or for ARITY. Measured: a dashboard widget whose filter reads close_date $between { $field: "contract.start" } and 2026-12-31 parses GREEN, as do a one-element, a three-element and an empty $between, while the same widget carrying a preset endpoint is refused at filter.close_date.$between.0. So for THIS shape every one of those documents parses GREEN and the endpoint is refused only when the filter is EXECUTED, at the runtime lowering door this change closes. ARITY is not what changed: a reference endpoint is a well-formed TWO-element range one of whose elements no backend resolves` → a literal bound — the value the range was meant to stop at, written out. If the range was genuinely meant to be COLUMN-TO-COLUMN, that is not a $between at all: write the two bounds separately as scalar comparisons, {"$gte": {"$field": "a"}} for the lower bound and {"$lte": {"$field": "b"}} for the upper one, which is the position the column-to-column comparison compiles on every face. ⛔ There is no replacement that can be DERIVED from what was written: the literal a reference stood for is not recoverable, and dropping the operator would delete a constraint the author wrote and WIDEN the result set silently. A reference remains legal, unchanged, as the WHOLE comparand of $eq / $ne / $gt / $gte / $lt / $lte + - Why not automatic: Maintainer ruling of 2026-08-11 on column-reference range endpoints, ADR-0049 enforce-or-remove: REMOVE. Both $between endpoint unions carried FieldReferenceSchema and no backend ever resolved one in a list position — matches-filter.ts leaves the list unresolved and orders against the raw reference OBJECT, so the range silently matches nothing, and both SQL faces refuse the position with INVALID_FILTER / 400. The published endpoint contract has stated the rule verbatim since that day: "A { $field } reference is NOT an endpoint shape" (RANGE_ENDPOINT_DESCRIPTION, packages/spec/src/data/filter.zod.ts). ⚠️ That ruling shipped at the AUTHORING SCHEMA door alone, and no ledger entry was written for it — measured before this change: no semantic entry, no retired key, no spec-changes row and no upgrade-guide line named the shape. That was not an omission, and this entry SUPERSEDES a recorded answer rather than filling a silence: the 2026-08-11 changeset carried the disposition not-required (no-migration-prescription), reviewed and accepted with that ruling and shipped in the published CHANGELOG. What changed is the fact that disposition rested on. It was claimed for a removal whose reach was believed to be the authoring schema alone; the runtime half now ships with a migration prescription of its own (below), and a body carrying a prescription is exactly what that category refuses. So the transition is registered here, covering BOTH doors, and the earlier not-required reading is retired by this record. The runtime lowering door disagreed with the declaration for the whole of that window: parseFilterAST({ f: { $between: [{ $field: "a" }, "M"] } }) returned the filter unchanged, same object reference, measured on origin/main immediately before the change and re-measured after. One published sentence, two truth values, decided by which door a caller came through — and the door that passed it is the one an embedder reaches by handing a lowered filter straight to a driver. This entry therefore registers the transition for BOTH doors, not only the second, which is why it is filed as an entry of its own rather than as an already-registered rider. ⚠️ No D2 conversion and no stored-metadata rewrite, and the load path was MEASURED rather than assumed: applyConversionsToStoredItem — the one primitive every stored-row rehydration seam calls — never throws and never validates, and replays only the positively-recognised lossless transforms in the conversion registry; measured on this branch, a stored view carrying { close_date: { $between: [{ $field: "contract.start" }, "2026-12-31" ] } } comes back as the SAME object reference. Rewriting is not available in principle here, not merely declined: the literal the author meant is not recoverable from a reference, and the column-to-column reading has a different OPERATOR SHAPE (two scalar bounds), so producing it would be the platform rewriting one filter into another. That is the same ground the two nearest narrowings of this surface set stand on — filter-between-blank-endpoint-refused and filter-preset-ordering-comparand-refused. The read path does not re-validate stored rows, so no stored view becomes unreadable; what changes is that RE-SAVING one is refused, at the endpoint's own path, with the side named. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). ADR-0049 / ADR-0087 / ADR-0112. + - Done when: Grep every authored $between array — view and dashboard widget filters, dataset filters, report runtimeFilters, page and component filters, rollup filters, saved AST filters, SDK and MCP callers — and read BOTH of its elements for a { $field } key. A range with two literal endpoints parses byte-identically to before, numbers, Dates, ISO days, UTC instants, clock times and non-temporal text included; nothing is trimmed, defaulted or copied from its neighbour, so an accepted range arrives byte-identical to what was written. ⚠️ THE DETECTOR IS NOT THE SAME ON EVERY CARRIER, and re-saving the document is mechanical on only one half of them. Re-saving DOES answer for a view, page or component filter RULE and for the NormalizedFilter AST: a reference endpoint reports an issue at the rule's own value path (for a list view, list.filter.N.value) or at the endpoint's own AST path ($between.0 / $between.1) — note the schema door names the INDEX, while the runtime door additionally names the side, MIN or MAX. ⛔ Re-saving surfaces NOTHING for a dashboard widget filter, a dataset filter, a report runtimeFilter, a rollup filter or a relatedListFilter: those slots are FilterConditionSchema, whose one refinement judges bare date-range preset comparands and nothing about a reference endpoint, so such a document parses green — measured, not assumed. For those carriers the detectors are the GREP above and EXECUTING the surface, where the runtime lowering door now refuses with INVALID_FILTER / 400 naming the index and the side. ⛔ Do not read a clean re-save of a dashboard as a completed sweep. A range whose BOTH endpoints are references reports both positions at the schema door; the runtime door throws on the first. ⚠️ Do not assume such a range was showing the window it named: at every backend it either matched NOTHING (the in-memory matchers) or was refused (both SQL faces), so a surface carrying one was never answering the query its filter claimed — decide the window from what the surface was SUPPOSED to show. If the intent was column-to-column, the replacement is the two-bound spelling and it needs testing as a NEW filter, because nothing was ever evaluating the old one. Endpoints that are null, blank or of the wrong type keep their own refusals and their own entries. $in / $nin MEMBERS carrying a reference are ruled out by the same 2026-08-11 decision and refused at the authoring schema door (SET_MEMBER_DESCRIPTION); they are outside THIS entry's transition and are worth sweeping in the same pass. +- **`filter-comparand-types-and-widget-nested-slots-refused-at-save`** — `data.FilterCondition — a comparand the comparand-type face refuses, now refused when the document is PARSED: a plain object where a single value belongs (an $eq, $ne, ordering, text or flag comparand such as { a: 1 }, including a { $field } whose name is not a string), a Map, a class instance, a function, a Symbol, undefined, or a bigint beyond plus or minus 2^53, whether it is the comparand itself, an implicit-equality comparand or an $in / $nin / $between list member. On every schema that carries a FilterCondition, at the reach the save door already had (the field entries of a condition and of every $and / $or / $not member); and, on a dataset filter, a dataset measure filter and now a dashboard widget filter, a report runtimeFilter and a joined report block runtimeFilter, INSIDE a nested-relation condition as well, for these shapes and for every shape the earlier entry names — so ui.DashboardWidget.filter, ui.Report.runtimeFilter and ui.JoinedReportBlock.runtimeFilter gain the nested-relation reach of the two dataset carriers` → a value of one of the six accepted comparand types — a string, number, bigint within plus or minus 2^53, boolean, null or Date — or a { $field: "column" } reference where a column is meant. A value set belongs in $in; an absent value is the null predicate ($eq null / $ne null) or an omitted key, never undefined; a bigint beyond 2^53 is compared as a string or within range. Inside a nested relation on a widget filter or a report runtimeFilter, write the same spelling the top-level refusal prescribes. A Date, a { $field } reference, a {placeholder} string resolved at request time (such as {current_user_id} or {today}) and a bigint within 2^53 are untouched, and the save door keeps a bigint as written + - Why not automatic: The save door narrows to exactly what the query faces already refuse (the second stage of closing the family of comparand shapes the save door accepted and the query faces refused). The comparand-type face (normalizeFilterComparandTypes, the accepted set the maintainer ruled on 2026-08-12: string, number, bigint, boolean, null and Date) refuses these values on every query: parseFilterAST, the engine seam, the analytics where door and the read-scope compiler all run it. Measured on origin/main 17bd3187 before the change: FilterConditionSchema, a dataset filter, a dataset measure filter, a dashboard widget filter, a report runtimeFilter and a joined report block runtimeFilter each parsed GREEN for { stage: { $eq: { a: 1 } } }, { stage: { $in: [{ a: 1 }] } } and a Map comparand, top level and nested, while the type face and the analytics where door refused each with INVALID_FILTER / 400. And a dashboard widget filter, a report runtimeFilter and a joined report block runtimeFilter parsed GREEN for { acct: { stage: { $in: ["won", null] } } } and for a list in a nested equality slot, which the analytics where door refuses when they are charted, because only the two dataset carriers had the nested-relation walk. The save door now asks the type face itself, read-only, after the shape face, so it refuses exactly what the face refuses and passes what it passes; one slot raises one refusal, in the query doors' order (shape, then type, then the flag rule), in the face's own words less its location clause. At the top level of a filter and in its $and / $or / $not members, a second issue on a slot a face already refused (the schema door's own $icontains and date-preset arms) is no longer raised; inside a nested relation on an analytics carrier those two arms still judge the slot beside the faces, so a nested $icontains with a refused comparand, or a nested one-bound $between of a preset name, can carry two issues. Neither moves a verdict. The widget filter and both report runtimeFilters declare the same analytics-carrier filter as the dataset carriers, so their nested-relation slots are judged by the same walk: every stored filter the analytics where door charts now refuses on save what that door refuses on chart. Metadata AT REST is not rewritten and this entry adds no D2 conversion: none of these values has a single honest meaning as a comparand, which is why each was refused. The read path does not re-validate stored rows, so a stored document keeps loading; re-saving it through the metadata protocol (422 INVALID_METADATA), defineStack or os validate is refused at the filter's path. A JSON document can carry only the plain-object cells; the others arrive only from TypeScript authoring. Such a filter has failed every query since the type face's ruling, so the refusal is a repair and not a loss. ADR-0049 / ADR-0087 / ADR-0112. + - Done when: Validate every stack and re-save every stored document that carries a filter: os validate or defineStack, and a save through the metadata protocol, report each refused slot by path, for example widgets.0.filter.acct.stage.$in.1 or filter.stage.$eq, with the type face's sentence and the accepted set. A producer census before the change — a literal scan with a lit control per shape over examples, the non-test packages of this repository, the console repository at its pin and the cloud repository, plus a runtime walk of every filter in the example stacks — found no authored filter carrying one of these values and no widget filter or report runtimeFilter with a nested-relation condition holding a list or an operator map. +- **`filter-equality-array-comparand-refused`** — `data.FilterCondition — an ARRAY as an EQUALITY comparand, at the runtime filter doors (the shared comparand-shape face that parseFilterAST and the engine lowering seam both run): the implicit form { field: [...] } — which the FilterArray sugar ["field", "equals", [...]] lowers to, and likewise "=", "==" and "eq" — and the explicit form { field: { $eq: [...] } }, at any depth under $and / $or / $not, the empty array included` → the operator the list was standing in for. "One of these values" is $in: { field: { $in: ["a", "b"] } } (authoring spelling "in"). "The stored multi-value field holds this value" is $contains with ONE member: { field: { $contains: "a" } } (authoring spelling "contains"), and an $or of those for any-of. A filter that meant a single value writes that value: { field: "a" }. The list operators ($in / $nin / $between) keep their arrays, empty lists included; every scalar equality comparand, null above all (the has-no-value predicate), is untouched; and $ne is NOT judged by this entry + - Why not automatic: Maintainer ruling of 2026-09-23 (option 乙 — rather than declaring an array equality the SQL-family backends would have to invent, or documenting a divergence that stays silent on one backend): an array in the implicit-equality slot is refused at the shared face, for every driver at once — no alias, no grace window. The comparand-shape face declared that moving a rule to it 「closes that door for every driver at once」, and before this change it judged only the list-operator slot; the equality slot passed both shared doors and each backend answered it alone. Measured on the lowered node { tags: ["a"] } at this release, beside a scalar and an $in control. driver-sql on SQLite REFUSED it with INVALID_FILTER / 400 at the top level, and nested under $and / $or / $not answered 500 DATABASE_ERROR instead (driver-turso and driver-sqlite-wasm are built on driver-sql and were not run separately). driver-memory REFUSED it with INVALID_FILTER / 400 at every depth. The formula matcher returned no row, including a row storing exactly ["a"]. driver-mongodb ANSWERED it: its translateFilter emits the array unchanged, and MongoDB equality on an array operand selects a stored array equal to ["a"] or holding ["a"] as an element — mingo 7.2.4, the named proxy, over ["a"], "a", ["a","b"], ["b","a"], [["a"],"x"], [["a"]], "b" and [] selected ["a"], [["a"],"x"] and [["a"]]. The service-analytics filter normalizer read the FilterArray form as MEMBERSHIP: ["stage", "=", ["won", "lost"]] charted as stage IN (won, lost), and its OBJECT form read the same list four ways: { stage: [...] } as IN, $eq with a list as its first member alone, $eq with an empty list as no predicate, and an empty implicit list as the FALSE constant. A live mongod, MySQL, PostgreSQL and a live Turso server were NOT measured. So one stored filter was a 400 on most backends and a silent, differently-shaped row set on one. The shared face now refuses it with INVALID_FILTER / 400 before any driver runs, naming the field, the path and both remedies. Which doors refuse it at this release, and with what: the shared face, inside parseFilterAST and at the engine lowering seam, with INVALID_FILTER / 400; the analytics where door in BOTH spellings, the FilterArray form through parseFilterAST and the OBJECT form because that door hands each equality-slot list to the shared face before it builds a node, with the same INVALID_FILTER / 400 and the same sentence (that door alone, among the runtime doors, also refuses a list inside a nested-relation condition, which it flattens to a dotted member); and, on SAVE, the schema door (FilterConditionSchema and the $eq operator slot), with the same sentence as a parse issue at the filter's own path, which is the sibling entry filter-equality-array-comparand-refused-at-save, plus the two carriers that analytics door charts (a dataset filter and a measure filter) inside a nested relation too, which is dataset-filter-nested-relation-equality-array-refused-at-save. The ruling records the hosted product as running on the SQL family, where the top-level shape was already a 400, so the population that can observe a change is self-hosted driver-mongodb, plus any filter nested under a combinator on the SQL family (a 500 becomes a 400). $ne carrying an array measured the same split and is deliberately left to its own ruling. Metadata AT REST is not rewritten and this entry adds no D2 conversion: an array on equality has no single honest value, and choosing between $in and $contains is the author's call, not the platform's. ADR-0049 / ADR-0087 / ADR-0112. + - Done when: Grep stored filters, dataset and widget filters, flow node filters and code that builds a where for a field whose value is an array — { field: [...] }, { field: { $eq: [...] } }, or a FilterArray triple on =, ==, eq or equals carrying an array — then decide per filter what it meant: one of these values ($in), the stored list holds a value ($contains, an $or of them for several), or one value. Each is refused at query time with INVALID_FILTER / 400 naming the field and the path, so a test suite that exercises the query finds every one; a stored carrier is also refused on save, which is the sibling entry filter-equality-array-comparand-refused-at-save. A dashboard or dataset filter written as the FilterArray sugar with an array on equality, or as the implicit object form, charted as membership through the analytics normalizer; $in is the spelling that charts the same rows. On driver-mongodb re-check what the query is supposed to return rather than assuming the old rows were right: the old answer was MongoDB array equality, which neither $in nor $contains reproduces. +- **`filter-equality-array-comparand-refused-at-save`** — `data.FilterCondition and the $eq slot of data.FieldOperators — an ARRAY as an EQUALITY comparand, now refused when the document is PARSED: the implicit form { field: [...] } and the explicit form { field: { $eq: [...] } }, the empty array included, on every schema that carries a FilterCondition — a dataset filter and a dataset measure filter, a dashboard widget filter and an options-source filter, a report and joined-report-block runtimeFilter, a field relatedListFilter and a rollup summaryOperations filter, a solution-blueprint summary filter, an analytics query where, a dataset selection runtimeFilter, a query where and having, the data-engine aggregate call's having, an aggregation filter and a query-filter where — plus FieldOperatorsSchema.$eq, its documentation copy EqualityOperatorSchema.$eq, and the NormalizedFilter AST that validates against it` → the operator the list was standing in for, exactly as in filter-equality-array-comparand-refused. "One of these values" is $in: { field: { $in: ["a", "b"] } } (authoring spelling "in"). "The stored multi-value field holds this value" is $contains with ONE member: { field: { $contains: "a" } } (authoring spelling "contains"), and an $or of those for any-of. A filter that meant a single value writes that value: { field: "a" }. The list operators ($in / $nin / $between) keep their arrays, empty lists included; every scalar equality comparand, null above all, a Date and a { $field } reference are untouched; and $ne is NOT judged by this entry + - Why not automatic: Ruled on 2026-09-24 (option A), applying the standing refusal of an array in the equality slot to the schema door: FilterConditionSchema (implicit equality) and FieldOperatorsSchema.$eq refuse an array comparand at parse, with the SAME remedy text the shared compile face emits — one constant, two doors; a stored filter carrying the shape is refused loudly on its next save, and never silently dropped, because a dropped filter shows MORE rows than intended. Measured on origin/main a0920b42dc before the change: a dataset whose filter was { stage: ["won", "lost"] }, and one whose measure filter was { stage: { $eq: ["won", "lost"] } }, both parsed GREEN, as did FilterConditionSchema and FieldOperatorsSchema on the bare shapes, while the shared comparand-shape face refused both with INVALID_FILTER / 400. So such a document published clean and then failed every query that used it, for a different person, later. The schema door now prints the face's own sentence, from one builder both doors import; the only difference is that the face appends the location (at where.stage) and the schema door does not, because its issue carries the location as its path (filter.stage, measures.0.filter.stage.$eq). The reach is the face's and no wider: the field entries of a condition and of every $and / $or / $not member, but NOT a field spec with no $ key (a nested-relation or deep-equality condition), which the face never descends either. ⚠️ Three positions therefore still refuse only at execution. (1) A list inside a nested-relation condition, { account: { region: ["a"] } }: the analytics where door flattens that to the dotted member account.region and refuses it when the filter is charted; a dataset filter and a measure filter refuse it on save as well, which is the sibling entry dataset-filter-nested-relation-equality-array-refused-at-save. (2) The where option of the data-engine calls (find, count, update, delete, aggregate, vector find): its type is a union whose first arm is an open record, so it parses and the face refuses it when the call runs. (3) $ne carrying a list, which no ruling has decided. Two request doors parse these carriers and now answer the shape before the analytics compiler does: the REST dataset selection (its runtimeFilter) and the analytics query body (its where) refuse with VALIDATION_FAILED / 400 and this sentence at the field, one step ahead of the compiler's INVALID_FILTER / 400. Metadata AT REST is not rewritten and this entry adds no D2 conversion, for the reason the runtime entry gives: an array on equality has no single honest value. The read path does not re-validate stored rows, so a stored document keeps loading; what changes is that re-saving it through the metadata protocol (422 INVALID_METADATA), defineStack or os validate is refused at the filter's path. Such a filter has failed every query since the runtime entry, and on the SQL family before it at the top level, so the refusal is a repair and not a loss. ADR-0049 / ADR-0087 / ADR-0112. + - Done when: Validate every stack and re-save every stored document that carries a filter: os validate or defineStack, and a save through the metadata protocol, report each array in an equality slot by path with the field, the received list and both remedies, so the sweep is mechanical for the carriers listed in the surface. Decide per filter what it meant — one of these values ($in), the stored list holds a value ($contains, an $or of them for several), or one value — and re-check what the surface is supposed to show rather than assuming the old rows were right: on most backends the filter had been failing every query. ⛔ A clean re-save is NOT a complete sweep for the three positions the reason names: grep nested-relation conditions and data-engine where options for a field whose value is a list, and exercise them, where the runtime doors refuse with INVALID_FILTER / 400 naming the field and the path. A dataset filter or a measure filter is the exception: its nested-relation lists are refused on save too (dataset-filter-nested-relation-equality-array-refused-at-save). +- **`filter-icontains-comparand-refused-at-parse`** — `the case-insensitive contains comparand, in BOTH authoring vocabularies — the $ dialect key $icontains inside FilterConditionSchema (query where clauses, read-scope rules, dashboard and analytics filters) and the infix spelling icontains on ViewFilterRuleSchema (view, tab, page and block filters) — where the comparand is the EMPTY STRING or is not a string at all` → a NON-EMPTY STRING, or no condition at all. A comparand that was empty is a predicate that constrains nothing, so the repair is to DROP the condition rather than to write something in it. A comparand that was a number, boolean or null is written as the string it was meant to match: value 42 becomes value "42" only if a substring match on the two characters is really what was meant, and if it is not, the operator was the wrong one. On a view rule an OMITTED value is untouched — absence is not a comparand and this rule says nothing about it + - Why not automatic: The protocol half of the maintainer's 2026-09-20 ruling (option C-prime) on the console's filter converter, whose first rule reads, verbatim and untranslated: 「the differences are the protocol's to close」. The platform already DECLARED both refusals, as data, in this package: FILTER_TEXT_CASES carries a REJECTION row for an empty comparand and one for a non-string comparand, each with code INVALID_FILTER and each requiring the refusal to name the operator. All five driver packages run both rows in their own suites, and the drivers re-run for this change (driver-sql on SQLite, driver-memory, driver-mongodb's translateFilter) each refuse both comparands with INVALID_FILTER / 400; the formula matcher does not refuse them, it answers false for every row. Nothing applied them at PARSE on either vocabulary, so the protocol declared the refusal and then admitted the document that would hit it — the declared-not-enforced shape ADR-0049 exists to close. The narrowing is DERIVED from the table, not transcribed beside it: both doors call the published predicate isRefusedTextComparand and the published reason text textComparandRefusalReason, the pair published in this package beside FILTER_TEXT_CASES for exactly this reason, so a row added to the table reaches both doors without an edit at either. $contains, $startsWith, $endsWith, $like and $ilike keep the answer they give today, because widening by analogy is the table's decision and not a door's. The two vocabularies differ on one point and it is a fact about them rather than an extra rule: a view rule's value key is OPTIONAL, so an absent comparand is left unjudged there; the $ dialect has no absent, so an explicit undefined in a comparand slot is the refused non-string shape — the same reading the comparand-type door already takes of that cell. Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion. An empty comparand has no lossless replacement (dropping a condition changes which rows a view returns, which is the author's decision) and a non-string one has no honest coercion (the platform refuses to answer a query nobody wrote). The read path does not re-validate stored rows, so a stored filter keeps loading; what changes is that RE-SAVING it is refused, with the reason text three shipped consumer faces already show at query time. ADR-0049 / ADR-0087 / ADR-0112. + - Done when: Grep your authored filters for the case-insensitive contains operator in either spelling and read each comparand: an empty one means the condition was a placeholder and the repair is to delete it, and a non-string one means either a missing pair of quotes or the wrong operator. At this release neither comparand returns rows: each of the five driver packages answers both with INVALID_FILTER at query time, and the formula matcher answers false for every row. How earlier releases answered them was NOT measured, so re-check what the view is supposed to show rather than assuming the old result set was correct. Both refusals now arrive at the authoring path. +- **`filter-is-empty-lowers-to-empty-operator`** — `data.FilterCondition — the lowering of the view operators is_empty, isempty, is_not_empty and isnotempty (on a ViewFilterRule, a sharing rule and any filter array), and a $empty object written as a record field value` → Nothing to rewrite for a rule on a declared field: is_empty now lowers to { field: { $empty: true } } and is_not_empty to { field: { $empty: false } }, answered by the field's declared type — a text-like field is empty when it is null or the empty string, a multi-value field when it is null or the empty list, every other type only when it is null. Where no face holds the column's declared type the rule is refused: on the built-in id, write is_null / is_not_null; on a federated object whose driver does not implement external-object registration (driver-memory, driver-mongodb), bind it on a driver that implements federation (the boot error names the object); on an AnalyticsService built without sourceFieldMeta, pass sourceFieldMeta or write is_null / is_not_null; on a multi-value column over a SQL dialect driver-sql does not model, write is_null / is_not_null. A record write that carries a $empty object as a field value writes the value itself instead; a filter belongs in where + - Why not automatic: One ruling set what 「is empty」 means once, per field type; a second spelled it as the $empty operator, which each compile face expands from the field's declaration. It was staged out of FILTER_OPERATORS until every face answered it, then added in the same change that flipped the lowering, after measuring that no face drops it. Two consequences reach stored metadata. A stored 「is empty」 on a text or multi-value field finds more rows: the ones holding the empty string or the empty list, which the $null lowering missed. And the rule is refused where the face that answers it holds no declaration for the column — the four compositions the replacement names — where the $null lowering compiled IS NULL. The same change made the write door refuse a $empty object as a field value, because that door refuses every filter operator the protocol enforces as a value: before, a text-like field stored it. Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: the stored spelling is unchanged, and its new meaning is the ruled one. ADR-0087 / ADR-0112. + - Done when: Grep your stored views, sharing rules and filter arrays for is_empty / is_not_empty. On a text or multi-value field, re-check what the view or rule is supposed to select. On the built-in id, rewrite it to is_null / is_not_null. If a federated object on driver-memory or driver-mongodb, or an AnalyticsService host without sourceFieldMeta, carries such a rule, the query now fails instead of answering — bind the object on a federation-capable driver, or pass sourceFieldMeta. No insert or update payload carries a $empty object as a field value. +- **`filter-ne-array-comparand-refused`** — `data.FilterCondition and the $ne slot of data.FieldOperators — an ARRAY as the comparand of $ne. At the runtime filter doors (the shared comparand-shape face that parseFilterAST and the engine lowering seam both run): { field: { $ne: [...] } }, which the FilterArray sugar ["field", "ne", [...]] lowers to, and likewise "!=", "<>", "neq", "not_equals" and "notequals", at any depth under $and / $or / $not, the empty array included. At parse: FieldOperatorsSchema.$ne, its documentation copy EqualityOperatorSchema.$ne, and the NormalizedFilter AST that validates against it` → the declared list-negation operator. "None of these values" is $nin: { field: { $nin: ["a", "b"] } } (authoring spellings "nin", "not_in", "notin"). A filter that meant a single value writes that value: { field: { $ne: "a" } }. $ne: null (the has-a-value predicate), every scalar, a Date and a { $field } reference are untouched, and the list operators ($in / $nin / $between) keep their arrays, empty lists included + - Why not automatic: Ruled on 2026-09-24 by the director seat, on the standing contract text (option A): the shared comparand-shape face refuses an array under $ne for every driver, and FieldOperatorsSchema.$ne refuses it at parse, with one remedy text naming the declared list-negation operator by its spec spelling — no alias, no window. The governing text is $ne's own published describe: the comparand is a literal, or a { $field } reference to another column of the same table. An array is neither, so the refusal pulls the doors back to what $ne already declared. Measured on the card before any stage landed, on the lowered { tags: { $ne: ["a"] } }: driver-sql and driver-memory REFUSED it with 400; driver-mongodb ANSWERED it as MongoDB reads $ne against an array operand, not equal to that array and not holding it as an element, which is every scalar row (mingo, the named proxy; a live mongod was NOT measured); and the formula evaluator matched EVERY row, which on the row-level write check admitted every write a != policy against a list was written to refuse. Those two answering faces were closed first, each at its own face, under rls-predicate-array-comparand-refused and cel-predicate-list-comparand-refused. Measured on origin/main 9e7824a4, after both and before this change: the shared face passed the shape at every depth (so did its FilterArray lowering, and the engine's delegating wrapper), and FieldOperatorsSchema, EqualityOperatorSchema and the NormalizedFilter AST all parsed it GREEN. Now the face refuses it with INVALID_FILTER / 400 before any driver runs, and the operator slot refuses it on parse, with one sentence from one builder: the face names the field and appends the location (at where.tags.$ne); the slot cannot see either, and its issue carries the location as its path. On the SQL family and driver-memory the verdict does not move (400 before, 400 after); the text and the moment move, to the face, before any driver. ⚠️ Not moved by this entry: FilterConditionSchema, the schema every stored filter carrier parses through (dataset, dashboard widget, report, rollup and the rest), does not parse a field's operator map through FieldOperatorsSchema and its own walk does not judge $ne, so such a carrier still SAVES a $ne list and the face refuses it at query time; the ruling names the face and the operator slot, not that walk. Metadata AT REST is not rewritten and this entry adds no D2 conversion: a list under $ne has no single honest value, and whether it meant none of these values or one value is the author's call. ADR-0049 / ADR-0087 / ADR-0112. + - Done when: Grep stored filters, dataset and widget filters, flow node filters and code that builds a where for $ne whose comparand is an array — { field: { $ne: [...] } }, or a FilterArray triple on ne, !=, <>, neq, not_equals or notequals carrying an array — then decide per filter what it meant: none of these values ($nin), or one value ($ne with that value). Each is refused at query time with INVALID_FILTER / 400 naming the field, the path and $nin, so a test suite that exercises the query finds every one; code that parses a filter with FieldOperatorsSchema or the NormalizedFilter AST is refused on parse at the $ne path. ⛔ A clean re-save of a stored carrier is NOT a sweep: the carrier schema does not refuse the shape, so exercise each stored filter or grep it. On driver-mongodb re-check what the query is supposed to return rather than assuming the old rows were right: the old answer was MongoDB array inequality, which $nin does not reproduce. +- **`filter-preset-ordering-comparand-refused`** — `a dashboard date-range preset name (last_7_days / last_30_days / last_90_days, today, yesterday, this_week, last_week, this_month, last_month, this_quarter, last_quarter, this_year, last_year) authored as a bare ORDERING comparand in a filter — a $gt / $gte / $lt / $lte value or a $between endpoint, a greater_than / less_than / before / after / between view filter rule value, or an ordering [field, op, value] filter triple. WHICH DOOR refuses it at publish is decided by the carrier's declared type and by its key. The carriers measured fall in groups, and the groups are a list of what was measured, not a closed partition: the grep in the acceptance criteria is the catch-all. (1) A slot typed FilterConditionSchema — a dashboard widget filter, a dashboard global-filter options-source filter (optionsFrom.filter), a dataset filter, a dataset measure filter, a report runtimeFilter (on the report or on a joined-report block), a rollup summaryOperations.filter and a relatedListFilter — is refused at PARSE, at the comparand's own path, and the @objectstack/lint filter-preset-comparand rule reports it as well. (2) A filter under a key the lint walks, whose declared type carries no preset check, parses GREEN, and the lint rule is the only door that refuses it: a ViewFilterRuleSchema rule array (a view's filter, a page element's dataSource.filter, a page component's filter prop), and a Mongo-shape filter record typed as a loose record rather than FilterConditionSchema (a flow CRUD node's config.filter). The lint is likewise what refuses a preset in an ordering filter triple wherever its walk meets one` → the date-macro window the preset already means — { $gte: "{30_days_ago}" } for last_30_days, { $between: ["{week_start}", "{week_end}"] } for this_week, and so on (the rejection names the exact window per preset; DATE_RANGE_PRESET_MACRO_WINDOWS in @objectstack/spec/data is the table) — or an ISO date such as 2026-01-15. The preset names themselves stay fully legal where a layer resolves them to a window: the dashboard date-filter positions (dateRange.defaultRange, a date global filter defaultValue) and an analytics query's timeDimensions[].dateRange. A filter comparand is not one of those positions + - Why not automatic: The authoring half (option C) of the maintainer's 2026-08-15 ruling on uninterpretable temporal comparands, ruled alongside the engine door (option B) that refuses them at query time. The preset vocabulary is declared in the dashboard schema and lowered to {date-macro} bounds by the shipped console before any query is sent — so the names were declared in one layer and unrecognised in the next, with no error at the boundary. Authored as a bare comparand (a saved report, an integration, an MCP client, an AI-authored query), the name reached the driver as written and compared false against every row: HTTP 200, count 0, indistinguishable from "there is no data" (measured on the defect report: $gte "last_30_days" returned 0 of 51 seeded rows where the macro spelling returned the 38 in-window). The engine now refuses the bare name on a declared temporal field at query time (INVALID_FILTER / 400); this entry records the AUTHORING-time half, and that half is two doors with different reach, not one: the FilterConditionSchema parse refuses the shape on the slots typed that way, and the @objectstack/lint filter-preset-comparand rule refuses it on every filter its walk reaches, which makes it the only door for a walked filter whose declared type carries no preset check. Both answer at publish, where the author — an AI author in particular — can still act on the message; the surface's groups say which measured carrier sits under which door. Ordering positions only at the schema door, deliberately: it judges no equality or membership, because a select/picklist column legitimately stores values that collide with preset names and a schema has no field type in hand. The lint rule, which reads the stack's object metadata, additionally refuses a preset in an equality or membership position, in a filter its walk reaches, on a field it can resolve to a declared date or datetime (where the filter binds to no object, or the field resolves to nothing, that arm cannot fire), and on a temporal field the engine door already refuses those with the field type in hand. ⚠️ Metadata AT REST is deliberately not rewritten and there is no D2 conversion: this shape was never written by any first-party producer (every preset in this repo and the example apps sits in a dashboard date-filter position — measured) and never executed usefully (it returned a silent zero before the engine door and a 400 after). Coercing it at load would be the platform guessing which bound the author meant. The read path does not re-validate stored rows, so no stored dashboard becomes unreadable; what changes is that RE-SAVING one is refused with the window named. ADR-0049 / ADR-0078 / ADR-0112. + - Done when: Grep your authored filters for the thirteen preset names in ordering positions — a $gt/$gte/$lt/$lte value, a $between endpoint, a greater_than/less_than/before/after/between view rule value, an ordering filter triple, a gt/gte/lt/lte lookup filter value — and rewrite each to the {date-macro} window the rejection names (or an ISO date). That grep is the catch-all; the surface's groups are the carriers measured. The sweep is mechanical for groups (1) and (2): `os validate` / `os lint` report each one by path, and a group (1) slot is also refused by a `safeParse` of the schema that declares it, at the comparand's own path. Leave presets in dashboard date-filter positions (dateRange.defaultRange, date global filter defaultValue) untouched — they remain the declared vocabulary there. A filter that carried one of these shapes was never returning the window it named (silent zero before the engine door, 400 after), so re-check what the surface was supposed to show rather than assuming the old result set was correct. +- **`filter-query-face-comparands-refused-at-save`** — `data.FilterCondition — every comparand slot the query faces refuse, now refused when the document is PARSED: a $null or $exists flag that is not a boolean (a string such as "false", null, a number); a null $gt / $gte / $lt / $lte comparand; an $in or $nin comparand that is not a list, or a list holding null; a $between comparand that is not a two-element list, or whose endpoint is null, blank or a { $field } reference; and an array under $ne. On every schema that carries a FilterCondition: a dataset filter and a dataset measure filter, a dashboard widget filter and an options-source filter, a report and joined-report-block runtimeFilter, a field relatedListFilter and a rollup summaryOperations filter, a solution-blueprint summary filter, an analytics query where, a dataset selection runtimeFilter, a query where and having, the data-engine aggregate call's having, an aggregation filter and a query-filter where; and, on a dataset filter and a dataset measure filter only, the same slots INSIDE a nested-relation condition` → the spelling the refusal prescribes, which is the one the query faces already prescribe. A flag is the boolean itself: $null true is "has no value", $null false is "has a value", and $exists is the inverse. Absence is the null predicate, never null in an ordering or list position: $eq null is "has no value", $ne null is "has a value", and "one of these values OR has no value" is an $or of an $in and a $null true. A single value for $in is a one-member list, or plain equality. A range is two bounds in a two-element list; a range bounded on one side is a $gte or a $lte; a column-to-column range is a $gte and a $lte whose comparands are { $field } references. "None of these values" is $nin, never $ne with a list. The null predicate itself, a { $field } reference as a whole comparand, an empty $in or $nin list and a whitespace endpoint are untouched + - Why not automatic: The save door narrows to exactly what the query faces already refuse (the family of comparand shapes the save door accepted and the query faces refused; the $ne member is route A, the same reach and the same one sentence as the equality slot of filter-equality-array-comparand-refused-at-save). The shared comparand-shape face refuses on every query a null ordering comparand (ruled 2026-09-01), a non-list $in / $nin and a malformed $between range, a null list member or endpoint (ruled 2026-08-31), a blank endpoint (ruled 2026-09-20), a { $field } endpoint (ruled 2026-08-11) and an array under $ne (ruled 2026-09-24); every query face refuses a non-boolean $null / $exists flag, because the backends read one in opposite directions. Measured on origin/main af32cf9a before the change: a dataset filter, a dataset measure filter, a dashboard widget filter and a report runtimeFilter each parsed GREEN for one instance of every shape the surface names, while the face refused each one with INVALID_FILTER / 400 and the analytics where door refused every one of them, the flags included. So such a document published clean and then failed every chart built on it. The save door now asks the face itself about each slot, so it refuses exactly what the face refuses and passes what the face passes; the words are the face's, or the sentence the enforced operator slot already prints for the same comparand, never the face's location clause, which the issue's path carries instead. The reach is the face's and no wider: the field entries of a condition and of every $and / $or / $not member, and NOT a field spec with no $ key (a nested-relation condition), which neither the face nor the drivers' flag checks descend. The analytics where door DOES descend one (it flattens the relation to dotted members and judges each), so the two dataset carriers, whose own nested-relation walk already refused an equality list there (dataset-filter-nested-relation-equality-array-refused-at-save), now ask the same judge about every slot inside a relation. ⚠️ So one position still refuses only at execution: a refused shape INSIDE a nested-relation condition on a dashboard widget filter or a report runtimeFilter, which reach the analytics where door too but carry the shared schema's reach only. Metadata AT REST is not rewritten and this entry adds no D2 conversion: none of these shapes has a single honest meaning (that is why each was refused), and a conversion would have to pick one. The read path does not re-validate stored rows, so a stored document keeps loading; re-saving it through the metadata protocol (422 INVALID_METADATA), defineStack or os validate is refused at the filter's path. Such a filter has failed every query since the runtime refusal of its shape, so the refusal is a repair and not a loss. ADR-0049 / ADR-0087 / ADR-0112. + - Done when: Validate every stack and re-save every stored document that carries a filter: os validate or defineStack, and a save through the metadata protocol, report each refused slot by path with the operator, the field and the prescription, so the sweep is mechanical for the carriers the surface lists. Decide per filter what it meant and write that spelling; on most backends the filter had been failing every query, so re-check what the surface is supposed to show rather than assuming the old rows were right. One producer was measured before the change: a filter builder that writes "is empty" / "is not empty" as an $in / $nin list holding null and the empty string (the Studio filter-condition widget, at the console pin of that date); what it wrote is refused on its next save. ⛔ A clean save is NOT a complete sweep for the one position the reason names: search dashboard widget filters and report runtimeFilters for a nested-relation condition whose inner field carries one of these shapes, and chart it, where the analytics where door refuses with INVALID_FILTER / 400 naming the field and the path. +- **`filter-text-operator-declared-type-refused`** — `a STORED filter body the engine executes, where a text operator names a field whose declared type can never store a string. Measured carriers: `sys_saved_report.query_json.filter` (executed verbatim as `engine.find(object, { where: q.filter })`, and reached again by every `sys_report_schedule` row through its `report_id`), `FieldSchema.summaryOperations[].filter` (ANDed with the parent-FK match and handed to `engine.aggregate`), `ListView.filter` and tab filters (`ViewFilterRuleSchema`, whose `contains` / `not_contains` / `icontains` / `starts_with` / `ends_with` spellings lower to the same operators through `AST_OPERATOR_MAP`), and the `FilterConditionSchema` carriers on dashboards (widget `filter`, `GlobalFilter`), datasets and reports (`runtimeFilter`), plus `FieldSchema.relatedListFilter`. NOT this surface: an RLS / sharing / tenant predicate, which the platform composes onto the AST AFTER this door and which the door therefore never judges.` → compare the field with an operator its declared type can answer — `$eq` / `$ne` / `$in`, or a range (`$gte` / `$lt`) for a temporal or numeric field — or aim the text operator at a text-valued field instead. A dotted path into a structured-JSON field (`address.city`) stays legal and is deliberately unjudged. NO rewrite is mechanical: the author's intent is not recoverable from the stored condition — `{ amount: { $contains: '5' } }` may have meant `$eq: 5`, a range, or a filter on a different column altogether — so the loader must not choose one. + - Why not automatic: Maintainer ruling of 2026-09-05 (option C-deny: refuse now, over the type sets the contract already declares, minting no new vocabulary), landed at the engine seam. A text operator (`$contains` / `$notContains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike`) over a field whose DECLARED type can never store a string — `NUMERIC_VALUE_TYPES` ∪ `BOOLEAN_VALUE_TYPES` ∪ `CALENDAR_DATE_TYPES` ∪ `INSTANT_TYPES` ∪ `CLOCK_TIME_TYPES` ∪ `STRUCTURED_JSON_TYPES` — is refused at the engine's field-aware door with `INVALID_FILTER` 400 instead of reaching a driver. It is a RUNTIME narrowing over an AUTHORED surface, which is why it is registered here rather than disposed of as needing no prescription: NO schema changed, so a stored filter carrying the refused shape still parses and still loads — `FilterConditionSchema` constrains no field type, and `ViewFilterRuleSchema` takes `field: z.string()` with `contains` in its operator enum — and the first sign of it is a 400 on the read that executes it. Before the door those reads answered `[]` (or every row for `$notContains`, or a SQLite coercion accident) with no diagnostic, which is the silent cell the ruling closed. `objectstack migrate meta` cannot repair the stored bodies for the reason `replacement` records, so this is a structured TODO rather than a graduated conversion. + - Done when: Every stored filter body listed under `surface` executes without an `INVALID_FILTER` 400 naming a declared type: run each saved report, list view, dashboard widget, dataset and roll-up once after the upgrade and read the refusals — each message names the filter key, the field's declared type and the operator, which is the whole repair list. A filter re-authored onto a typed operator returns the rows its author meant; one left as written keeps answering 400, and NOTHING silently rewrites it. This door judges a field by its declared type alone, so it never refuses a text operator over a text-valued field — `select` / `radio` codes, `multiselect` / `checkboxes` / `tags`, lookup and `user` ids, `autonumber` and the file classes. Over every such field that is not stored as a JSON column (below), filters must keep answering exactly as before; that is the control which proves a repair pass did not over-reach. A DIRECT driver call bypasses this door entirely and keeps answering the `FILTER_TEXT_CASES` stored-value row (a stored value that is not a string never satisfies a positive text operator and satisfies `$notContains`), so a driver-level test is not evidence about this migration in either direction. A field stored as a JSON column is NOT that control, because a separate door judges it by its storage rather than its declared type: `multiselect` / `checkboxes` / `tags`, any field declared `multiple: true` (a multi-valued lookup or `user` among them) and, on a SQL deployment still inside the ADR-0104 dual-encoding window (its media columns not yet moved), a single-value file-class field. That door refuses every text operator there except the membership pair `$contains` / `$notContains` — `$startsWith`, `$endsWith`, `$icontains`, `$like` and `$ilike`, beside the scalar comparisons it already refused — with an `INVALID_FILTER` 400 that names no declared type, so a stored filter left on one of those operators there answers that 400 after the upgrade and is outside this entry's repair list. On a multi-valued field its repair is membership, which no rewrite chooses either: `$contains` for one member, an `$or` of `$contains` for any-of. A single-value file-class field is not a membership question: it answers text operators again once its deployment finishes the media-column move (the column step of `objectstack migrate files-to-references --apply`). +- **`flow-approval-node-config-contract-refused`** — `an approval flow node whose config the approval node contract (ApprovalNodeConfigSchema) refuses — a key it does not declare (escalation.bogusKey, a top-level key such as steps or onApprove, an alias such as escalation.timeout), a value it refuses (escalation.timeoutHours below 1, an unknown behavior or escalation.action, an empty approvers list, a fallbackApprovers list under any policy but fallback), or a key it requires left out (approvers; escalation.timeoutHours inside an escalation block). Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved from the Studio flow designer, and a flow row already sitting in sys_metadata` → the shape the approval contract declares, written on the node's `config`: `approvers` with at least one approver, and inside an `escalation` block a `timeoutHours` of at least 1 (wall-clock hours; `timeoutHours: 1` is the shortest SLA the contract accepts). An undeclared key is renamed to the key the refusal's did-you-mean names (`timeout` → `timeoutHours`, `mode` → `behavior`, `quorum` → `minApprovals`) or deleted; a process-level key (`steps`, `entryCriteria`, `onApprove`, `onReject`, `rejectionBehavior`) moves onto the flow graph as the refusal's guidance says. To turn an SLA off, delete the whole `escalation` block — an `escalation: { enabled: false }` with no `timeoutHours` is refused like any block missing it + - Why not automatic: An approval node's executor (`plugin-approvals`) parses `node.config` against `ApprovalNodeConfigSchema` before it does anything else and fails the node on ANY issue. Registration already refused an undeclared key, against the descriptor's published `configSchema`, but a refused value (`timeoutHours: 0.5`) registered and then failed every run that reached the node — the config is metadata, and no rerun could succeed. The build doors asked about neither: `FlowSchema.parse` judged only the builtin node types' executor contracts, and only for a key left out, so `objectstack validate` and `objectstack compile` exited 0 on an `escalation.bogusKey` or a `timeoutHours: 0.5` and compile copied it into the artifact. The contract is the spec's own, so the build can judge it with no plugin loaded: the approval node joins a declared contract map beside the builtin executor contracts, read by the one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share (`flowNodeConfigRefusals`), and is judged WHOLE — every issue the contract raises is refused, because the executor refuses on every one. An undeclared key or a refused value is `node-config-refused-by-contract`, anchored at the key, in the contract's own sentence (its did-you-mean included); a key left out keeps `node-config-key-missing` or `node-config-key-required-by-rule`. The builtin arm is unchanged and stays presence-only. A plugin node type whose contract the spec does not declare stays outside the build doors, as before. ⚠️ No D2 conversion: the platform cannot know the approvers, the key or the value the author meant, and no value it could write would keep what the flow did. ⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0019. + - Done when: Run `objectstack validate` over every stack authored in config files, and boot every deployed stack. Each refusal names the node and the key: `FlowSchema.parse` anchors a `custom` issue at `nodes.N.config.` (`nodes.N.config.escalation.bogusKey`, `nodes.N.config.escalation.timeoutHours`, `nodes.N.config.approvers`), `objectstack validate` prints the same path, and `validateStackExpressions` phrases it as `node 'gate' (approval) config.escalation.bogusKey`. For each hit write what the contract accepts, per the replacement. Two proofs. (1) For a stack authored in config files, `objectstack validate` is clean. (2) Boot the stack and confirm each flow REGISTERS: no `failed to register flow` warn for it — that warn line is the locator for a row that exists only in `sys_metadata`. An approval node the contract accepts parses and registers byte-identically to before. +- **`flow-builtin-node-config-undeclared-keys-refused`** — `a get_record, create_record, update_record, delete_record, notify, http, screen, map, loop or parallel flow node whose config carries a key its executor contract does not declare — a typo (titl), a key the walk at registration already named (fieldValues on a write node, bulk on update_record, visibleIf on a screen field), a key copied from another node type (outputVariable on an http node, flowName on a loop), or a key nothing reads (bogusKey) — at the config itself, or on a screen field or one of its options, a body-less legacy loop included. Never a key inside a free-form map (a filter, fields, headers, defaults, input, payload or templateData key is author data), never a key on a region object (a loop body, a parallel branch) or on its nodes and edges (the region check at registration owns those), and never a try_catch key, which try-catch-and-retry-policy-undeclared-keys-refused covers once the retry policy closed. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved from the Studio flow designer, and a flow row already sitting in sys_metadata` → the key the contract declares, or no key: rename a typo to the declared key it meant (the refusal carries the contract's did-you-mean for a near miss), follow the contract's own prescription for a known slip (`fieldValues` → `fields`, `bulk` / `all` / `multiple` → `multi: true`, `options: { multi }` → a top-level `multi`, a screen field's `visibleIf` → `visibleWhen`, a loop's `itemVariable` → `iteratorVariable`), and delete a key nothing reads (an `http` node's `outputVariable` among them: the http executor binds no output variable) + - Why not automatic: Each of these executors (`service-automation` `builtin/crud-nodes.ts`, `notify-node.ts`, `http-nodes.ts`, `screen-nodes.ts`, `map-node.ts`, `loop-node.ts`, `parallel-node.ts`) parses the node's `config` against a strict contract before it acts. Until now the build doors' executor-contract arm held key membership back on these types, on the premise that registration judges it: `registerFlow`'s undeclared-key walk (`validateNodeConfigKeys`) refuses such a key against the node type descriptor's `configSchema`. So a `notify` node carrying `bogusKey` passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile` (which copied it into the artifact), and then registration refused the whole flow: at boot it was skipped with a warn, and a flow saved from Studio was stored and then silently not registered. The one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first), `objectstack validate` and the metadata save door share (`flowNodeConfigRefusals`) now refuses such a key on these types as `node-config-refused-by-contract`, anchored at the key, one refusal per key, in the contract's own words and closed with the rename-or-remove remedy, and the descriptor walk stands aside for every type that judge covers (`builtinNodeConfigKeysJudged`), so each type has one judge. Measured before the move: on each of these types the descriptor's declared key sets, at every position the walk descends to, equal the keys the contract accepts there, so registration refuses exactly what it refused before. ⚠️ `try_catch` is the one builtin not moved here: its contract's `retry` was the shared `RetryPolicySchema`, which stripped an unknown key, while its descriptor closes `retry` to five keys. It moves in `try-catch-and-retry-policy-undeclared-keys-refused`, once that schema closed. ⚠️ A body-less legacy `loop` is not parsed at run time, and it is judged here on key membership alone, which is what registration refused there already. ⚠️ A spelling an ADR-0087 D2 conversion still rewrites at load (`object` and `filters` on a CRUD node, `to` / `subject` / `body` / `url` on a `notify`, `flow` on a `map`) is converted before the judge at every door that converts first; met by a direct `FlowSchema.parse` or `defineFlow()` it is refused like any other undeclared key. ⚠️ No D2 conversion: the platform cannot know what an undeclared key was meant to be. ⚠️ Where such a node already sits the whole flow is refused, as registration already refused it: from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load; a save from Studio answers 422 naming the key. ADR-0087, ADR-0031. + - Done when: Run `objectstack validate` over every stack authored in config files, and boot every deployed stack. Each refusal names the node and the key: `FlowSchema.parse` anchors a `custom` issue at `nodes.N.config.` (`nodes.N.config.bogusKey`, `nodes.N.config.fields.0.visibleIf`, or the region path `nodes.N.config.body.nodes.M.config…`), `objectstack validate` prints the same path, and `validateStackExpressions` phrases it as `node 'n' (notify) config.bogusKey`. For each hit rename or delete the key per the replacement. Two proofs. (1) For a stack authored in config files, `objectstack validate` is clean. (2) Boot the stack and confirm each flow REGISTERS: no `failed to register flow` warn for it — that warn line is the locator for a row that exists only in `sys_metadata`. A node of these types whose keys its contract declares parses and registers byte-identically to before. +- **`flow-builtin-node-config-values-refused`** — `a builtin flow node (get_record, create_record, update_record, delete_record, notify, http, screen, script, subflow, map, loop, parallel, try_catch) whose config carries a value its executor contract refuses — a value of the wrong type (create_record outputVariable 42, a screen field min written as the string 1, get_record limit as a string, update_record multi as a string), a value outside the declared set or range (notify severity loud, screen mode view, loop maxIterations 0, try_catch retry.maxRetries above 10), an empty script function or subflow flowName, or a rule finding on present keys (a notify template beside an inline title). Never a value carrying a token in braces, an undeclared or retired key, a region slot, or an http signingSecret. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved from the Studio flow designer, and a flow row already sitting in sys_metadata` → the value the contract declares, written at the key the refusal names: a string where it wants a string (`outputVariable: 'taskId'`), a number where it wants a number (`min: 1`, `limit: 10`, `maxIterations: 5`, `timeoutMs: 5000`), a boolean where it wants a boolean (`multi: true`, `durable: true`), one of the declared values (`severity: 'warning'`, `mode: 'edit'`), or a value inside the declared range. Outside `http`, a number or boolean slot takes a LITERAL only: those executors parse the config as authored, so a `{token}` template there (`limit: '{page.size}'`, `maxIterations: '{cap}'`) passes the build doors and still fails every run. Only `http` interpolates its config before it parses, so only an `http` slot may also take a sole-token template that resolves to the declared type (`timeoutMs: '{timeout}'`, `durable: '{durable}'`). For a rule finding, follow the rule's own sentence (keep `template` or the inline `title` / `message`, not both) + - Why not automatic: Every builtin executor (`service-automation` `builtin/`) parses its node's `config` against the contract `getBuiltinNodeConfigContracts()` names before it acts, and refuses the node on any finding. The build doors judged only the keys that contract requires, left out, so a present value it refuses — `create_record` `outputVariable: 42`, a screen field `min: '1'` (the shape the Studio designer used to store for a field's Min / Max) — passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile`, registered, and then failed every run that reached the node: the config is metadata, and no rerun could succeed. The one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share (`flowNodeConfigRefusals`) now refuses such a value as `node-config-refused-by-contract`, anchored at the key, in the contract's own words — the code the approval contract already uses. It judges only what the build can know the run will parse, and holds one more class back by ruling: a value carrying a `{token}` is never refused at the build doors for its pre-interpolation type — which is no promise it runs, since every builtin but `http` parses its config as authored and so still refuses a token in a number or boolean slot at its first run; `http` parses after interpolating its whole config, so only token-free values are judged there and never `signingSecret`, which the credential channel may supply; a `loop` with no `body` is not parsed by its executor and is judged for nothing; the region slots of `loop`, `parallel` and `try_catch` are judged as graphs of their own and by `validateControlFlow`. An undeclared or retired key, a `predicate` ledger slot (a screen field `visibleWhen`) and a `value` ledger slot (a CRUD `fields` value) keep the judges they had. ⚠️ No D2 conversion: the platform cannot know the value the author meant. ⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0031. + - Done when: Run `objectstack validate` over every stack authored in config files, and boot every deployed stack. Each refusal names the node and the key: `FlowSchema.parse` anchors a `custom` issue at `nodes.N.config.` (`nodes.N.config.outputVariable`, `nodes.N.config.fields.0.min`, or the region path `nodes.N.config.body.nodes.M.config…`), `objectstack validate` prints the same path, and `validateStackExpressions` phrases it as `node 'mk' (create_record) config.outputVariable`. For each hit write the value the contract declares, per the replacement. Two proofs. (1) For a stack authored in config files, `objectstack validate` is clean. (2) Boot the stack and confirm each flow REGISTERS: no `failed to register flow` warn for it — that warn line is the locator for a row that exists only in `sys_metadata`. A node whose values its contract accepts parses and registers byte-identically to before. A `{token}` template parses and registers as before too, and runs only where the run parses it after interpolation (`http`) or where the slot takes a string; in a number or boolean slot of any other builtin it fails at its first run exactly as it did, so write a literal there. +- **`flow-decision-branch-expression-absent-refused`** — `a decision node branch — an element of config.conditions[] — written without its expression key, or with expression: null, at any depth including an ADR-0031 region body. That includes a branch whose predicate sits under another key (condition is the edge spelling). Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate, a flow saved from the Studio flow designer with a branch row whose expression cell is empty, and a flow row already sitting in sys_metadata` → the predicate the branch was meant to test, as non-blank bare CEL text under `expression` (`{ label: 'high', expression: 'record.amount > 10000' }`); a predicate written under `condition` moves to `expression`. To keep the branch and its label but never take it, write `expression: 'false'` — that is a CHANGE of behaviour, not a preserved one: a run that reached the branch used to fail there (`condition evaluation error`), and now routes on to the next branch or the declared fallback. ⚠️ Not by dropping a decision's only branch: with no `conditions` the node routes by its out-edges alone, so the out-edge that branch labelled is no longer held back + - Why not automatic: `DecisionConditionSchema` declares a branch `{ label, expression }` with `expression` a required `z.string()`, but nothing parses a decision node's open config against it, and the expression-ledger resolver skipped an absent value as "not authored" — so a branch with no predicate passed `FlowSchema.parse`, `AutomationEngine.registerFlow` and `objectstack validate`, and the decision executor then handed `evaluateCondition` an envelope with no `source`, which it refuses: the build accepted what the run refused. The ledger now marks the slot `required` (reconciled against that schema's own `required` list), the resolver emits the absent value there, and all three doors refuse it through `predicateSlotRefusal`, leading with `PREDICATE_SLOT_STRING_REFUSAL` — the walk, function and sentence that already refuse the blank string. ⚠️ No D2 conversion: the platform cannot know the rule the author left out, and `'false'` would change what the flow does rather than keep it. ⚠️ Where such a branch already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0032. + - Done when: Grep every flow node in `defineStack({ flows })` sources, exported stacks and every flow row in `sys_metadata` — including nodes inside a `loop` / `parallel` / `try_catch` region body — for a `decision` node whose `config.conditions[i]` has no `expression` key, or `expression: null`. Each refusal names the node and the branch: `FlowSchema.parse` anchors a `custom` issue at `nodes.N.config.conditions.I.expression` (or the region path `nodes.N.config.body.nodes.M.config…`), and `objectstack validate` prints the same path; `validateStackExpressions` phrases it as `node 'check' (decision) decision branch expression at config.conditions[0].expression`. For each hit write the predicate the branch was meant to test, or `expression: 'false'` where the branch should keep its label and never be taken. Two proofs. (1) For a stack authored in config files, `objectstack validate` is clean. (2) Boot the stack and confirm each flow REGISTERS: no `failed to register flow` warn for it (the three boot paths spell it `[Automation] failed to register flow`, `[Automation] flow re-sync: failed to register flow` and `[Automation] cold-boot flow bind: failed to register flow`) — that warn line is the locator for a row that exists only in `sys_metadata`. A branch carrying a non-blank predicate parses and registers byte-identically to before, a decision with no `conditions` still routes by its out-edges, and an absent screen field `visibleWhen` is still legal. +- **`flow-decision-edge-branching-first-match`** — `flow.nodes[].config.mode (decision) — an OMITTED mode on a decision that branches on its out-edges and carries two or more conditioned ones` → nothing, where the out-edge conditions partition (exactly one can hold for any record): an omitted `mode` now means exclusive, the first true edge in declaration order wins, and the run is what it always was. `mode: 'inclusive'` where the flow RELIES on more than one branch running for one record — the value the D2 conversion `flow-decision-mode-inclusive-explicit` writes onto every such decision so nothing changes silently. Where the conditions overlap by accident (a `!=` guard beside a later `==` branch), neither: narrow them into a partition, or mark the fallback `isDefault: true`, and delete the written key. + - Why not automatic: A DEFAULT FLIP of a shipped node type, ruled rather than patched: the schema, the docs and the engine's own comment all called an edge-branched decision an exclusive gateway while the traversal took EVERY out-edge whose condition held, one after another, and reported nothing — a CRM application's lead-conversion flow rendered a refusal screen AND ran the conversion in one execution. The traversal now matches the declaration (BPMN exclusive gateway, Salesforce Flow Decision, n8n Switch default), and the every-true-edge behaviour is the BPMN inclusive gateway an author must write down. The KEY converts mechanically and does: `flow-decision-mode-inclusive-explicit` writes `mode: 'inclusive'` wherever two or more conditioned out-edges leave a decision that declares no `conditions` list, so the migrated source runs exactly as before. What does NOT convert is the INTENT: the count cannot tell a partition (where the key is redundant) from a reliance on multi-branch runs (where it is load-bearing) from an accidental overlap (where the old behaviour was the bug), so the mechanical edit list the chain replay prints is where that judgment is made, node by node. And the conversion replays ONLY there: it is a default flip, so the authoring funnel never rewrites a source written against the new contract, and the automation engine's flow rehydration seam and the artifact-ingestion door both refuse it by id (a code-shipped flow, a REST body, a Studio save and a scaffolded artifact all arrive undated). BREAKING for stored rows, by maintainer ruling: the promise that a flow keeps its behaviour is kept by authored sources and built artifacts only. A decision stored in `sys_metadata` with no `conditions` list, no `mode` and two or more conditioned out-edges takes the new meaning on upgrade — it evaluates first-match — and nothing rewrites the row: no stored-row migration, no cutoff, no read-path completion, because nothing about a stored row says it was saved before the flip. The one-line fix, for a stored node that meant every branch, is `mode: 'inclusive'`; `os migrate meta --stored` lists every such node, report only, so an operator can review the candidates before and after the upgrade. + - Done when: Review every `flow-decision-mode-inclusive-explicit` line the chain replay lists for each authored stack: (1) where the two (or more) out-edge conditions partition — a predicate and its negation, `>` beside `<=`, or a guard beside `isDefault: true` — delete the written `mode`; the run is unchanged either way and the exclusive default is the honest declaration; (2) where the flow relies on more than one branch running for one record, keep `mode: 'inclusive'`; (3) where the conditions overlap by accident, narrow them into a partition and delete the key, then re-run the flow on a record that satisfied both and confirm exactly one successor ran — the passed-over branch now leaves a `skipped` step in the run log. `os validate` reports `flow-decision-inclusive-overlap` on every decision that keeps the key with two or more conditioned out-edges, so the review list is the lint output. Then each deployment: `os migrate meta --stored` lists, under `decisionModeReview`, every stored decision with two or more conditioned out-edges and no `mode` — each one already evaluates first-match, and the pass writes none of them — so where one of those nodes meant every branch, declare `mode: 'inclusive'` on it in the designer; a node that declares `mode` either way leaves the list. A decision registering with `mode` beside a non-empty `conditions` list, or with a `mode` outside `'exclusive' | 'inclusive'`, is refused at registration and by `os validate` with the schema's own sentence; nothing else about `conditions`-list decisions changes. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. +- **`flow-edge-condition-evaluated-slot-source-required`** — `a structural flow condition, BOTH slots — edges[].condition on FlowEdgeSchema, the branch predicate AutomationEngine.evaluateCondition runs at every traversal, and config.condition on a flow NODE, which is a decision node predicate and on a start node the trigger gate — authored either as an expression envelope carrying only ast ({ dialect: 'cel', ast: … } with no source), or with a source that is blank after trimming, through the envelope key ({ dialect: 'cel', source: ' ' }) or the bare-string shorthand for it (condition: ' '). The node slot joined this entry with the two later changes that rebound AutomationEngine.registerFlow and objectstack validate to the edge door's own rule rather than deriving a second one; it is the same decision reaching the second slot, which is why it is named here instead of in an entry of its own. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, an exported stack passed to objectstack validate, a POST /api/v1/automation body, and a flow row already sitting in sys_metadata` → a non-blank `source` — `{ dialect: 'cel', source: 'record.amount > 10' }`, or the bare string `'record.amount > 10'` — if the edge was meant to branch; or REMOVE the `condition` key entirely if it was meant to be unconditional. ⚠️ Those two are not interchangeable, and the choice is the judgment this entry delegates: a refused condition evaluated to a silent `false`, so the edge NEVER fired, while an absent `condition` is an unconditional edge that ALWAYS fires. Deleting the key to clear the refusal inverts the edge rather than preserving it. An `ast` BESIDE a string `source` is untouched and stays admitted everywhere + - Why not automatic: The evaluated-slot rule, carried to the edge condition — the line that first refused an `ast`-only envelope no engine can evaluate, and refused a non-string node predicate at registration instead of letting the evaluator answer it a silent `false`: `FlowEdgeSchema.condition` now composes `EvaluatedExpressionInputSchema` instead of `ExpressionInputSchema`, so an evaluated slot is held to what the engine can actually run. The engine reads `source` alone (`cel-engine.ts` `evaluate`: "AST-only evaluation not yet supported; persist `source`"), so both refused spellings landed in its empty-source arm and answered a SILENT `false` on every release that carried them — they parsed, registered, passed `objectstack validate`, and then produced a branch that quietly never fired (measured on 2026-09-05 by driving an `ast`-only envelope through `AutomationEngine.evaluateCondition` directly). The refusal is one rule with one sentence, `EVALUATED_EXPRESSION_SOURCE_REQUIRED`. ⚠️ No D2 conversion is possible, and this is exactly why the change needs a D3 entry rather than none. An `ast`-only envelope carries no `source` to derive one from — lowering an AST to surface syntax is the compiler direction the platform does not run — and dropping a blank `condition` would flip the edge from never-fires to ALWAYS-fires, which is the platform guessing which of two different flows the author meant. ⚠️ And the consequence for a flow ALREADY STORED is wider than the edge, which is the part no author-time prescription reaches. `applyConversionsToStoredItem` is deliberately not applied to `flow` (`spec/src/conversions/stored.ts`, and the same skip in `metadata/src/loaders/database-loader.ts` `rowToData`) because flow-node conversions need the automation engine's live executor registry; flows canonicalize at `registerFlow` instead, which parses through `canonicalizeStoredFlow` → `FlowSchema.parse`. Each of the three boot paths in `service-automation/src/plugin.ts` wraps that call in try/catch, logs one `warn` naming the flow, and CONTINUES — so a stored `sys_metadata` flow with such an edge is no longer registered at all: its trigger is never armed and the WHOLE flow stops running, not just the branch, announced only by that warn line. A repo-wide census at `ae19f5edb` (examples/, packages/, content/, skills/) found zero edge conditions of either spelling against a lit control, so there is nothing in THIS repository to rewrite — a repo reading, which is why the notification is registered here rather than skipped. ADR-0087, ADR-0032. + - Done when: Grep every authored structural condition — BOTH `edges[].condition` and a node's `config.condition` (a `decision` node's predicate, and on a `start` node the trigger gate) — in `defineStack({ flows })` sources, exported stacks and `POST /api/v1/automation` bodies, and every flow row in `sys_metadata`, for an envelope with no `source` key and for a `source` (or bare string) that is empty after trimming. ⚠️ Sweeping only the edge key leaves the node key unswept, and the node key is the one with no schema in front of it. For each hit decide, per the `replacement` note, whether the condition was meant to branch (author the `source`) or to be unconditional (remove the key) — do not default to removal; on a `start` node removal opens the trigger gate rather than preserving it. Two proofs, and the second is the one that matters for stored rows. (1) For a stack authored in config files, `objectstack validate` is clean: it locates each offender with the `EVALUATED_EXPRESSION_SOURCE_REQUIRED` sentence — an edge at `flows.N.edges.N.condition`, and a node by the slot phrase the structural pass builds, e.g. `node 'gate' (start) condition` — and an `ast`-only envelope is also reported by the lint path as `STRUCTURAL_CONDITION_SHAPE_REFUSAL`, which is the sentence the node slot earns for that spelling as well. There is no CLI verb that lowers a stored row back into a config file, so this proof does not reach a flow that exists only in `sys_metadata`. (2) Boot the stack and confirm each flow REGISTERS: no `failed to register flow` warn for it (the three boot paths spell it `[Automation] failed to register flow`, `[Automation] flow re-sync: failed to register flow` and `[Automation] cold-boot flow bind: failed to register flow`), and its trigger is armed. That warn line IS the locator for a stored row: for an edge its `issues[].path` names `edges[N].condition`, and for a node the refusal carries that same slot phrase. A flow that boots without that warn is unaffected; every structural condition carrying a non-blank `source` parses byte-identically to before. +- **`flow-edge-unresolved-or-repeated-refused`** — `a flow edge whose source or target is not the id of a node in the graph that declares it — the flow's own nodes for a top-level edge, the region body's nodes for an edge inside a loop, parallel or try_catch region, so a top-level edge into a region node is one of them — and a later edge of the same graph with the same source, target, type, condition and branch label as an earlier one. Reachable wherever a flow is authored or stored: defineStack flows sources, defineFlow, an exported stack passed to objectstack validate, a flow saved from the Studio flow designer after a node was removed (its edges were left behind, and a node added later under the reused id picked them up), and a flow row already sitting in sys_metadata` → an edge whose `source` and `target` are node ids declared in the same graph as the edge: re-point the endpoint at the node it was meant to reach, or delete the edge. Deleting a dangling edge changes nothing a run did, with one exception: a conditioned edge into a missing node still counted as the branch taken when its condition held, so a default sibling was passed over and, on an exclusive `decision`, the later conditioned siblings were skipped — where a flow relied on that, point the edge at a node that ends the branch. For a repeated edge, delete the later copy: the target then runs once per traversal instead of once per copy — a CHANGE of behaviour wherever the copies ran it more than once, which is the defect being removed. An edge meant to take its own route needs its own `condition` or branch `label` + - Why not automatic: The engine resolves an edge's endpoints in the graph that declares it — traversal looks the target up there, and a region runs against a view of its own nodes and edges — and runs a target once per out-edge it selects. `FlowSchema` held node ids and edge ids unique and checked neither that an edge names a node of its graph nor that it is not a copy of another, so a draft holding an edge into a node it no longer had, or one edge three times, passed `FlowSchema.parse`, `objectstack validate` and the metadata save door, published with `_diagnostics.valid: true`, and ran: the dangling edge carried the run nowhere, silently, and the repeated edge ran its target once per copy (one record update created three identical records). The parse now refuses both at every depth the region walk reaches: an endpoint at `edges.N.source` / `edges.N.target` (or the region path `nodes.N.config.body.edges.M.target`), naming the missing id and, when it is a node of another graph, that graph; a repeated edge at `edges.N`, naming the earlier copy. Repeated means the key the engine selects on — `source`, `target`, `type`, `condition` (its dialect and source) and branch `label` — so two nodes joined by edges with different conditions, a `fault` edge beside a default one, or `approve` and `reject` branches into one node stay legal. A region edge naming no node of its region was already refused at registration by the region analysis; the top-level half had no refusal anywhere. ⚠️ No D2 conversion: a dangling endpoint carries no intent a rewrite could recover, and dropping a repeated edge changes how many times its target runs. ⚠️ Where such an edge already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack` flows source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0031. + - Done when: Grep every flow in `defineStack` flows sources, exported stacks and every flow row in `sys_metadata` — including the `edges` of each `loop` / `parallel` / `try_catch` region body — for an edge whose `source` or `target` is not the `id` of a node in the `nodes` list beside that `edges` list, and for two edges in one `edges` list with the same `source`, `target`, `type`, `condition` and `label`. Each refusal names the edge: `FlowSchema.parse` anchors a `custom` issue at `edges.N.source`, `edges.N.target` or `edges.N` (or the region path `nodes.N.config.body.edges.M…`), and `objectstack validate` prints the same path. For a dangling endpoint, point it at the node the edge was meant to reach or delete the edge; for a repeated edge, delete the later copy. Two proofs. (1) For a stack authored in config files, `objectstack validate` is clean. (2) Boot the stack and confirm each flow REGISTERS: no `failed to register flow` warn for it (the three boot paths spell it `[Automation] failed to register flow`, `[Automation] flow re-sync: failed to register flow` and `[Automation] cold-boot flow bind: failed to register flow`) — that warn line is the locator for a row that exists only in `sys_metadata`. A flow whose edges all resolve in their own graph and repeat nothing parses and registers byte-identically to before. +- **`flow-node-config-required-keys-refused`** — `a flow node whose config leaves out a key its executor contract requires — objectName on get_record / create_record / update_record / delete_record, recipients on notify (and title when there is no template), url on http, function on script, flowName on subflow, collection and flowName on map, collection on a loop that has a body, branches on parallel, try on try_catch, and on screen each field name, each option value and label, and a lookup field reference — and a decision node whose conditions is not an array, holds a branch that is not an object, or holds a branch whose label is absent, null, blank or not a string; at any depth including an ADR-0031 region body. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate, a flow saved from the Studio flow designer (a node added and saved before it is configured; a decision branch row whose label cell is empty; a screen field row whose name cell is empty), and a flow row already sitting in sys_metadata` → the missing key, written on the node's `config` — the value the node was meant to act on (`objectName: 'account'`, `url: 'https://…'`, `collection: '{rows}'`, …). For a decision branch, the label of the out-edge the branch should take (`{ label: 'approved', expression: 'record.amount > 1000' }`, beside an out-edge labelled `approved`), `conditions` written as an array of such objects, and a bare predicate string moved under `expression`. To branch on the out-edges instead, delete `conditions` and put each predicate on its edge's `condition`. A legacy flat-graph `loop` (no `body`) needs no `collection` and is untouched + - Why not automatic: A flow node's `config` is an open record, so what its executor requires was checked by no build door: `FlowSchema.parse`, `AutomationEngine.registerFlow` and `objectstack validate` all admitted a node missing a key its executor contract requires, and the executor's own contract parse then refused the node on every run that reached it — the config is metadata, so no rerun could succeed. A decision branch with no label was worse: it never failed, the matched branch reported no label and traversal took EVERY out-edge, so the flow ran green down the wrong paths. All three doors now refuse these shapes through one judge, `flowNodeConfigRefusals`, which parses each builtin node's config against the very contract its executor parses against (`getBuiltinNodeConfigContracts()`, reconciled against the executors' own parse calls) and keeps only the keys left out — a present value of the wrong type and an undeclared key are judged where they were before — plus the decision branch shape its executor reads raw. A key a rule of the contract requires (a notify with no template needs a title; a lookup screen field needs its reference) is refused in the contract's own words. ⚠️ No D2 conversion: the platform cannot know the object, URL, collection, function or out-edge label the author left out, and no value it could write would keep what the flow did. ⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0031. + - Done when: Run `objectstack validate` over every stack authored in config files, and boot every deployed stack. Each refusal names the node and the key: `FlowSchema.parse` anchors a `custom` issue at `nodes.N.config.` (`nodes.N.config.fields.0.name`, `nodes.N.config.conditions.0.label`, or the region path `nodes.N.config.body.nodes.M.config…`), `objectstack validate` prints the same path, and `validateStackExpressions` phrases it as `node 'fetch' (get_record) config.objectName`. For each hit write the key the node was meant to carry, per the replacement. Two proofs. (1) For a stack authored in config files, `objectstack validate` is clean. (2) Boot the stack and confirm each flow REGISTERS: no `failed to register flow` warn for it (the three boot paths spell it `[Automation] failed to register flow`, `[Automation] flow re-sync: failed to register flow` and `[Automation] cold-boot flow bind: failed to register flow`) — that warn line is the locator for a row that exists only in `sys_metadata`. A node carrying every key its contract requires parses and registers byte-identically to before, a decision with no `conditions` (or `conditions: null`, or an empty list) still routes by its out-edges, and a legacy `loop` with no `body` still needs no `collection`. +- **`flow-predicate-slot-blank-string-refused`** — `the two ledger predicate slots on a flow node — config.conditions[].expression on a decision node (a branch predicate) and config.fields[].visibleWhen on a screen node (a field visibility predicate) — authored as a string that is blank after trimming ('', ' ', a tab or a newline), at any depth including an ADR-0031 region body. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate, and a flow row already sitting in sys_metadata` → the predicate the branch or field was meant to test, as non-blank bare CEL text (`expression: 'record.amount > 10'`, `visibleWhen: 'amount > 0'`); or KEEP what the blank did. On a screen field, drop the `visibleWhen` key: an absent `visibleWhen` shows the field unconditionally, which is what a blank one already did at run time (the resume contract treated it as absent, and the renderer fell back to showing the field). On a decision branch, write `expression: 'false'`: the evaluator answered the blank `false`, so the branch keeps its label and is still never taken. ⚠️ Not by dropping a decision's only branch: with no `conditions` the node routes by its out-edges alone, so the out-edge that branch labelled is no longer held back. On a structural condition removal differs again: dropping a blank `condition` turns a never-firing edge into an always-firing one (`flow-edge-condition-evaluated-slot-source-required`) + - Why not automatic: Maintainer ruling A of 2026-09-13: the two sibling predicate slots refuse a blank string at authoring. Both slots are declared bare CEL text (`z.string()`) and both admitted a blank string at every door: the expression ledger resolver skipped it as "not authored", and `AutomationEngine.evaluateCondition` answered it `false` — so a decision branch carrying it was never taken, with nothing said at any layer, and a screen field carrying it was shown with its predicate ignored. An earlier fix had pinned that admission as correct because the two sides agreed. The ruling is that self-consistency between parser and evaluator is not a defence when the author's intent is silently dropped — the third instance of one rule, after the structural `config.condition` and a blank evaluated `source`. The blank is now refused at `FlowSchema.parse`, at `AutomationEngine.registerFlow` (which parses first) and at `objectstack validate`, all three through `predicateSlotRefusal`, leading with `PREDICATE_SLOT_STRING_REFUSAL`. ⚠️ No D2 conversion, and the reason is the judgment this entry delegates: the blank is where an author meant to write a rule, and the platform cannot tell a predicate somebody forgot from one they meant to delete. Keeping what ran is mechanical; writing the predicate is what the author intended; only the author knows which. ⚠️ Where such a blank already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0032. + - Done when: Grep every flow node in `defineStack({ flows })` sources, exported stacks and every flow row in `sys_metadata` — including nodes inside a `loop` / `parallel` / `try_catch` region body — for a `decision` node whose `config.conditions[i].expression`, or a `screen` node whose `config.fields[i].visibleWhen`, is a string that is empty after trimming. Each refusal names the node and the branch or field, which is the TODO's locator: `FlowSchema.parse` anchors a `custom` issue at `nodes.N.config.conditions.I.expression` (or `…config.fields.I.visibleWhen`, or the region path `nodes.N.config.body.nodes.M.config…`), and `objectstack validate` prints the same path; `validateStackExpressions` phrases it as `node 'check' (decision) decision branch expression at config.conditions[0].expression`. For each hit decide, per the `replacement` note, whether to write the predicate or to keep what the blank did. Two proofs. (1) For a stack authored in config files, `objectstack validate` is clean. (2) Boot the stack and confirm each flow REGISTERS: no `failed to register flow` warn for it (the three boot paths spell it `[Automation] failed to register flow`, `[Automation] flow re-sync: failed to register flow` and `[Automation] cold-boot flow bind: failed to register flow`) — that warn line is the locator for a row that exists only in `sys_metadata`. A non-blank predicate parses and registers byte-identically to before, and a non-string in these slots keeps its own earlier refusal (at `registerFlow` and `objectstack validate`). +- **`flow-script-subflow-config-undeclared-keys-refused`** — `a script or subflow flow node whose config carries a key its executor contract does not declare — a typo (funtion), a key copied from another node type (a subflow timeoutMs written inside config, an approvers list on a script), or a key nothing reads (bogusKey). script declares function, inputs and outputVariable; subflow declares flowName, input and outputVariable. Never a retired script key (actionType, template, recipients, variables, script), which keeps its own path, and never a key on any other builtin node type, whose undeclared keys registration already judges against the node type descriptor. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved from the Studio flow designer, and a flow row already sitting in sys_metadata` → the key the contract declares, or no key: rename a typo to the declared key it meant (`function`, `inputs`, `outputVariable` on a `script`; `flowName`, `input`, `outputVariable` on a `subflow`), move a value the function or the child flow should receive into `inputs` (script) or `input` (subflow), move a `subflow` timeout to the node itself (`{ id, type: 'subflow', timeoutMs: 30000, config: { … } }`), and delete a key nothing reads. The refusal carries the contract's own sentence, with its did-you-mean for a near miss + - Why not automatic: The `script` and `subflow` executors (`service-automation` `builtin/screen-nodes.ts`, `builtin/subflow-node.ts`) parse the node's `config` against a strict contract (`ScriptConfigSchema`, `SubflowConfigSchema`) before they act, and refuse the node on an undeclared key. No door before the run judged one: `registerFlow`'s undeclared-key check derives the declared set from the node type descriptor's `configSchema`, and these two descriptors publish none (the schemaless class, `SCHEMALESS_NODE_CONFIG_SCHEMAS`), while the build doors' executor-contract arm judged required keys and present values but held key membership back on the premise that registration judges it. So a `script` node carrying `bogusKey` passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile` (which copied the key into the artifact), registered, and then failed every run that reached the node: the config is metadata, and no rerun could succeed. The one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share (`flowNodeConfigRefusals`) now refuses such a key on these two types as `node-config-refused-by-contract`, anchored at the key, one refusal per key, in the contract's own words — the code the value half and the approval contract already use. Every other builtin keeps its undeclared keys where they were judged: at registration, against its descriptor, with that check's own prescriptions. `decision` is schemaless too, but its executor parses no contract, so an undeclared key there fails no run and stays unjudged. A retired `script` key keeps its tombstone path. ⚠️ A spelling the ADR-0087 D2 conversion `flow-node-script-config-aliases` or `flow-node-subflow-flow-alias` still rewrites at load (`functionName`, `input` on a `script`; `flow` on a `subflow`) is converted before the judge at every door that converts first; met by a direct `FlowSchema.parse` or `defineFlow()` it is refused like any other undeclared key, as the missing canonical key already was. ⚠️ No D2 conversion: the platform cannot know what an undeclared key was meant to be. ⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0031. + - Done when: Run `objectstack validate` over every stack authored in config files, and boot every deployed stack. Each refusal names the node and the key: `FlowSchema.parse` anchors a `custom` issue at `nodes.N.config.` (`nodes.N.config.bogusKey`, or the region path `nodes.N.config.body.nodes.M.config…`), `objectstack validate` prints the same path, and `validateStackExpressions` phrases it as `node 'summarize' (script) config.bogusKey`. For each hit rename, move or delete the key per the replacement. Two proofs. (1) For a stack authored in config files, `objectstack validate` is clean. (2) Boot the stack and confirm each flow REGISTERS: no `failed to register flow` warn for it — that warn line is the locator for a row that exists only in `sys_metadata`. A `script` or `subflow` node whose keys its contract declares parses and registers byte-identically to before. +- **`flow-text-slot-single-brace-refused`** — `flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a single-brace template token` → a double-brace template hole, rendered by the formula template engine over the flow's variables: a variable path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, {{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an assignment node — arithmetic and functions as a CEL value envelope, the date macros and the run-user paths as the value-slot spelling that still reads them — and written as {{ variable }} + - Why not automatic: ADR-0032 Decision 3 fixes one template delimiter, double braces, and deletes the single brace: it collides with CEL map literals, and an author who meets both dialects in one flow mixes them. The 17.x interpolator and the template engine render the same text for a path holding a string, a number, a boolean, null, an absent key or variable, an ISO date string, an object or an array, but not for every value — a Date rendered JSON-quoted under the interpolator and as its ISO text under the engine, and a screen title, screen description or end message that was one token holding an object, an array or a Date rendered String(value) — so no conversion is lossless (ADR-0087 D2) and none is applied. Arithmetic, function calls, the date macros and the run-user paths have no hole spelling: a hole is a path with a formatter, never logic. A flow carrying a single-brace token in a text slot is refused at registration, by objectstack validate and by the node contract; a stored flow carrying one is skipped at boot with a warn naming it. + - Done when: Run objectstack validate: it reports each refused text slot as expression-invalid at the node and the slot's key, with the double-brace spelling of every path token. Rewrite each slot as that spelling; for a token no hole can spell, add the assignment the refusal names and write its variable as a hole. Re-run the flow paths that send those notifications or show those screens and compare the text with the text the 17.x renderer produced — in particular any slot that renders a date value or a whole object. +- **`flow-trigger-record-credential-masked`** — `the record and previous roots a record-change flow receives — a password or secret field, and an internal field, of the triggering record, on every object` → read a credential through a privileged binder — the flow credential channel for an http node's signing secret, or a privileged server-side read such as the engine's resolveSecretField — never off `record` or `previous`; on those roots a set credential-class field now reads as the mask `SECRET_MASK`, an unset one as null, and an `internal: true` field is absent + - Why not automatic: ADR-0100: a credential-class value leaves the engine only through a privileged dereference, and every generic channel serves the mask. The record-change trigger built a flow's record and previous from the engine's own write result, which keeps the stored row whole for privileged in-process callers, so a password field's plaintext, a secret field's stored handle and an internal field's value reached the flow — and from there its variables, a paused run's persisted state and that state's read doors. The trigger now projects both roots through the same helper every external write response uses: a credential-class field (secret, and password outside the exempt managedBy buckets) carries the mask, or null when unset, and an internal field is omitted. Every other field keeps its value, every other variable is untouched, and the engine's own write result, the stored row and the privileged read paths are unchanged. + - Done when: No flow reads a password, secret or internal field off its trigger record or previous values expecting the stored value; a flow that needs a credential obtains it through a privileged binder; a start or edge condition that compared such a field against a literal is rewritten to test whether it is set (not null). +- **`flow-value-slot-template-dialect-refused`** — `flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token` → a CEL value envelope, { dialect: "cel", source: "…" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, list[0]; a variable whose name starts with $ is read through vars, vars["$error"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal + - Why not automatic: The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. Two spellings are kept with their old meaning, because CEL cannot write them yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has no string form for one) and the run-user paths beginning $User. (the flow CEL scope binds no user). A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it. + - Done when: Run objectstack validate: it reports each refused value as expression-invalid at the node and the value's path, with the CEL spelling of its tokens. Rewrite each as that envelope; where a variable or key may be absent, guard it (has(record.owner) ? record.owner : null, has(vars.x) ? vars.x : null for a variable) or route around the node. Re-run the flow paths that write those fields and compare the stored values with the ones the template wrote. +- **`flow-write-node-stored-metadata-target-refused`** — `a create_record, update_record or delete_record flow node whose config.objectName is the string sys_metadata or sys_metadata_history, at any depth including an ADR-0031 region body` → Change metadata through the metadata API (`PUT /api/v1/meta/:type/:name`, the metadata protocol), where it is validated and its provenance is recorded. Delete the node, or point its `objectName` at the object the flow really means to write. Elevation (`runAs`, a system context) does not change this. + - Why not automatic: `FlowSchema` accepted a `create_record`, `update_record` or `delete_record` node whose `objectName` names `sys_metadata` or `sys_metadata_history`, the tables that hold stored metadata. The maintainer ruled (2026-10-03) that app-authored work may not write those tables: the metadata protocol is their only writer, where a change is validated and its provenance recorded, and a flow is app-authored automation. The runtime enforces that at the node, refusing the write before it resolves a filter, computes a field or calls the data engine, under every run identity; but every authoring door still accepted such a flow, and the author learned otherwise only at its first run. The parse now refuses it too, through the one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` and `objectstack validate` share (`flowNodeConfigRefusals`), with the runtime's prescription: `objectstack validate`, `defineStack`, compile, an artifact's parse, `registerFlow` and the metadata save door each name the node at `nodes.N.config.objectName`. The refused set is exactly the runtime's: one of those three write nodes, whose `objectName` is a string naming a stored-metadata table by exact name. A `get_record` node is outside it (a read is not a write), and so is a dynamic target, a `{token}` template or an expression envelope: the parse cannot read it as a name, and the run judges the name it hands the data engine. No authored flow writing either table was measured in this repository, its examples, its skills or its docs. There is no mechanical rewrite: retargeting the node or deleting it each changes what the author wrote, and the runtime already never ran it. Where such a node already sits, the whole flow is refused: registered from `sys_metadata` at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register. + - Done when: `objectstack validate` reports no issue at a flow node's `config.objectName`: no `create_record`, `update_record` or `delete_record` node names `sys_metadata` or `sys_metadata_history`. Every change those nodes made to metadata is made through the metadata API instead. Saving each formerly affected flow through the metadata API succeeds instead of answering a 422 that names `config.objectName`, and boot logs no `failed to register flow` warn for it. +- **`form-field-public-picker-retired`** — `view.form.sections[].fields[].publicPicker — the anonymous public-form record-search picker` → No record search on an anonymous public form. For a choice from a fixed list, a `select` field with static `options`. For a choice of an existing record, the same form behind sign-in, where the lookup field renders with the signed-in user's access. + - Why not automatic: The D2 conversion `form-field-public-picker-removed` deletes `publicPicker` from every form field, and the delete is lossless in effect: the block's only reader was the anonymous lookup route, which is gone, and the public-form resolve route now leaves lookup, `master_detail` and `user` fields off the anonymous rendering whatever the row carries. What the strip cannot decide is the visitor's path. A public form that used the picker let an anonymous visitor search and pick a record; after the upgrade that field is simply absent from the form, so a submission arrives without the value. Whether the choice was really from a small fixed set (a `select` with static `options`), or needs a real record and therefore a signed-in user, is a product decision only the author can make. + - Done when: No form field carries `publicPicker`; the parse refuses it. Every public form that had carried one either replaces the lookup field with a `select` field whose static `options` list the allowed choices, or is served behind sign-in, or the author has confirmed the form works without the value. Fetching the public form anonymously (`GET /forms/:slug`) shows no lookup, `master_detail` or `user` field in its sections. +- **`form-view-option-default-retired`** — `view.form.sections[].fields[].options[].default — the per-option pre-selection on a form view's own option list` → The object field's own option list, where `default` is enforced: `default: true` on that field's options entry, or the field-level `defaultValue`. + - Why not automatic: The D2 conversion `form-view-option-default-removed` deletes `default` from every option of every form-view field it reaches, and the delete is lossless: nothing on the form path ever read it — the insert-path default falls back to the OBJECT definition's options, and no form renderer seeds a value from a form view's. So a form that marked an option as default never pre-selected it, and still does not. The judgment is in the replacement. The form-view key was scoped to ONE form; the object field's `default` applies on EVERY insert path — every form of that object, the API, imports. Moving the marker there makes the form do what its author wanted and also changes what records created elsewhere receive when the value is omitted. Only the author can say whether that wider default is correct, or whether the pre-selection should be dropped. + - Done when: No form-view option carries `default`; the parse refuses it. For each form field that had marked one: either the object field now declares the default and the author has accepted it for every insert path — a record created through the form or the API with the field left empty is stored with that value — or the author has decided the form needs no pre-selection and the object field is unchanged. +- **`form-view-subform-columns-closed`** — `view.form.subforms[].columns[] and view.formViews..subforms[].columns[] — the form view's inline grid columns, which used to accept any value` → each entry is the strict, name-keyed inline grid column a relationship field's `inlineColumns` takes — `{ name, label?, type?, … }`, where `{ name }` alone hydrates the rest from the child object's field. Write `name` where a column said `field` (or `fieldName`, `key`); delete `scale` from a column declaring `type: 'currency'`; delete any key the column schema does not declare. + - Why not automatic: Both carriers feed the one console grid, which reads only the keys the column schema declares and keys a column by `name` alone. On the form view the columns were never judged, so a mis-keyed column published clean and drew a blank grid column, and a key the other carrier refuses — `scale` on a currency column, under the maintainer's ruling of 2026-09-23 (option B, `scale` retired from the currency type) and the remedy ruled on 2026-09-24 (option 乙 — a currency's ISO 4217 minor unit decides its display) — published green here. The carrier now references the column schema, so every rule it holds applies here too, with its own prescription. Only the `field` spelling is converted mechanically — by the conversion `form-view-subform-columns-canonicalized`, which rewrites stored rows and assembled artifacts and lists the edit under `os migrate meta`, while an author writing `field` meets the refusal. Which column an unknown key or a mixed `field`/`name` entry meant is the author's call — a conversion that dropped the key would accept on every load what the parse now refuses. Population measured at the change, on origin/main cb4c31dd52: zero authored `subforms` in the repository (the showcase derives its master-detail grids from the data model instead), against one authored `inlineColumns` block as the control. Deployed metadata NOT MEASURED. + - Done when: Every view in the stack parses: `objectstack validate` and a view parse report no issue on a `subforms[].columns[]` path. Every column entry is an object carrying `name`, no entry carries `field`, `fieldName` or `key`, and no column declaring `type: 'currency'` carries `scale`. Each column `name` names a field of the subform's `childObject`, and the master-detail grid renders a value — not a blank cell — in each column for a row that has one. +- **`hook-body-stored-metadata-target-refused`** — `hook.object naming sys_metadata or sys_metadata_history, as the string or as any member of the list, on a hook that carries a body` → Change metadata through the metadata API (`PUT /api/v1/meta/:type/:name`, the metadata protocol), where it is validated and its provenance is recorded. Delete the hook, or point its `object` at the tables the logic really concerns. Elevation (`runAs`, a system context) does not change this. + - Why not automatic: `HookSchema` accepted a hook whose `body` targets `sys_metadata` or `sys_metadata_history`, the tables that hold stored metadata. The maintainer ruled (2026-10-03) that an app-authored body may not touch those tables: for a body, the metadata protocol is their only writer, where a change is validated and its provenance recorded. The runtime enforces that where a body hook becomes a handler, refusing such a hook at registration so that it never runs, but every authoring door still accepted it: the metadata save door answered 200, and the author learned otherwise only from a server log. The parse now refuses it too, with the runtime's own prescription, so `objectstack validate`, `defineStack`, compile, an artifact's parse and the metadata save door (a 422) each name the target at `object`, or at the list member. The refused set is exactly the runtime's: a hook carrying a `body`, in any form, whose `object` names a stored-metadata table, as the string or as any member of the list, and one such member refuses the whole hook. A hook with no `body` (a code `handler`, which is how the platform writes its own hooks) and the wildcard `'*'` are outside it, as they are at registration: a wildcard names no stored-metadata table, so it binds, and the runtime never runs its body for those tables' events. No authored hook targeting either table was measured in this repository, its examples or hotcrm. There is no mechanical rewrite: retargeting the hook, dropping its body or deleting it each changes what the author wrote, and the runtime already never ran it. A stored hook row of this shape still loads, now with a `[metadata_spec_invalid]` warning and a `_diagnostics` badge, and is still never bound. + - Done when: `objectstack validate` reports no issue at a hook's `object` path: no hook that carries a `body` names `sys_metadata` or `sys_metadata_history` in its `object`, as the string or in the list. Every change those hooks made to metadata is made through the metadata API instead. Saving each formerly affected hook through the metadata API succeeds instead of answering a 422 that names `object`, and boot logs no binding refusal naming one of those tables for a hook. +- **`hook-register-undispatched-lifecycle-event-refused`** — `engine.registerHook('beforeFindOne' | 'afterFindOne' | 'beforeCount' | 'afterCount' | 'beforeAggregate' | 'afterAggregate', handler)` → for the findOne pair, register on 'beforeFind' / 'afterFind' — they already fire for `findOne`; for the count and aggregate pairs there is no hook seam at all, so move the logic to `engine.registerMiddleware(fn)` and read `ctx.operation === 'count' | 'aggregate'`, composing the predicate onto `ctx.ast.where` + - Why not automatic: `registerHook` took `event: string` and, for a name outside the dispatched set, warned and then REGISTERED the handler anyway. Six of those names are inside the engine's own lifecycle namespace — (`before`|`after`) x `OperationContext['operation']` minus the eight the engine dispatches — so an author writing one of them believes they are subscribing to an engine lifecycle event, and what they get back is an inert declaration: ADR-0078's prohibited fourth state (parsed, unmarked, silently inert) on an authorable seam. + +The measured consequence is a data-visibility one, which is why this is not a cosmetic warning. A downstream consumer registered READ FILTERS on `beforeFindOne` and `beforeCount`, expecting them to scope single-record reads and list totals; they sat inert through every boot behind about forty warning lines. `findOne` was still filtered — `beforeFind` covers it — so the mistake gave no signal there. `count` was not: a `limit`ed list answered a `total` counting rows the caller could not see. `aggregate` was not either: a `groupBy` was not narrowed at all. A filter that was supposed to narrow visibility and silently did not run is a guardrail the author believes they armed. + +Refused at REGISTRATION rather than repaired on the dispatch side. Making `count()` and `aggregate()` dispatch hooks would widen what a hook may intercept — a different and much larger decision — and it would also be the wrong seam: read authorization and row filtering are the middleware chain's job, which is what `HookEvent` in `@objectstack/spec` already says and what `count()` already honours (its AST rides the operation context precisely so the security and sharing middlewares can scope it). The refusal names the per-seam repair in its own message, because "this never fires" alone cannot tell the two seams apart: one is a rename, the other is a different API. + +The refusal is scoped to those six names, not to everything outside the dispatched set. `triggerHooks` is public, so a plugin dispatching its own event under a name outside the engine's vocabulary (`'myPlugin:flush'`) is a legitimate reading — that is why the change that collapsed the hook taxonomy to the eight dispatched events made this branch a warn — and it still warns and still registers. The population is DERIVED from the operation union rather than typed out, so a new engine verb widens it without an edit; a hand-written list of refused names would be this same defect one layer up. + +This is a RUNTIME registration API, not stored metadata, so — like `hook-register-empty-object-target-refused` at the previous step — there is no `sys_metadata` row for the D2 chain to rewrite, and the ledger entry is the notification channel. The metadata door was never open on this axis: `HookSchema.events` is `z.array(HookEvent)`, and `HookEvent` enumerates exactly the eight dispatched names, so no authored or stored hook could ever carry one of the six. The exposure was entirely on the code door. ADR-0078. + - Done when: No `registerHook` call site passes `beforeFindOne`, `afterFindOne`, `beforeCount`, `afterCount`, `beforeAggregate` or `afterAggregate`. Every read filter that was written against one of those names has been moved: the findOne pair to `beforeFind` / `afterFind`, the count and aggregate pairs to a middleware registered with `engine.registerMiddleware`. Boot completes with no "[ObjectQL] Hook '...' is an engine lifecycle event name the engine never dispatches" throw, and any list `total` or `groupBy` that was expected to be scoped is scoped by a middleware rather than by a hook. +- **`hook-timeout-unit-in-key`** — `hook.timeout — the per-invocation time limit of a data hook` → `timeoutMs` — the same limit, in milliseconds, with the unit in the key name. + - Why not automatic: The D2 conversion `hook-timeout-to-timeout-ms` renames `timeout` to `timeoutMs` in author sources and on stored hook rows, keeping the value, and the rename is lossless: the key always meant milliseconds, and the conversion leaves an already-canonical `timeoutMs` alone and refuses a pair that disagrees. The judgment is the one the rename exists for. The unit used to live only in the key's description, beside body-level keys that spelled theirs, so an author who wrote a seconds value — `timeout: 30` meaning thirty seconds — got a limit of thirty milliseconds and no error, and the rename carries that 30 over unchanged. Only the author knows which unit they meant, so each value needs reading once. A pair left unconverted because the two spellings disagree needs the author to choose, and code that builds or reads a hook definition in TypeScript is outside the chain's reach. + - Done when: No hook carries `timeout`; the parse refuses it with the rename. Every `timeoutMs` value is the limit the author intends expressed in milliseconds — a hook meant to be allowed thirty seconds reads `timeoutMs: 30000`. A hook that runs longer than its `timeoutMs` fails with a timeout at that limit, and one that finishes inside it completes as it did before the upgrade. No code reads or writes `timeout` on a hook definition. +- **`hot-reload-inert-state-strategies-retired`** — ``HotReloadConfig.stateStrategy` values 'disk' and 'distributed', plus the `HotReloadConfig.distributedConfig` key and the `DistributedStateConfig` def it carried (3 exported names: `DistributedStateConfigSchema` / `DistributedStateConfig` / `DistributedStateConfigParsed`)` → 'memory' for in-process state preservation across a reload, or 'none' to disable it — the two values `PluginStateManager` actually implements. There is no in-tree replacement for durable or distributed plugin state: persist it in the host, which owns the process lifetime these strategies pretended to outlive. Real disk or distributed persistence returns only via the ENFORCE route of ADR-0049 — the implementation first, the declaration with it. + - Why not automatic: ADR-0049 enforce-or-remove, applied one level INSIDE the library the maintainer's 2026-08-25 ruling on the advanced plugin-lifecycle config kept. That ruling retired the authorable lifecycle-config container and deliberately kept `HotReloadConfigSchema` as a host-driven library parameter type; this card measured the kept vocabulary's own remainder and found the same defect in it. Measured at cdbd9204b6 with a firing positive control (`stateStrategy` resolves to real readers in `core/src/hot-reload.ts`, so the scan sees readers): the 'disk' and 'distributed' arms of `PluginStateManager.saveState` both wrote to the SAME in-memory Map as 'memory' — the in-source comments said 'memory fallback' — and announced the substitution at DEBUG level only, so a host that asked for durable or cluster-replicated state got process-local memory and no error: state that does not survive the restart it was configured to survive. `distributedConfig` had ZERO readers anywhere (every reference inside `packages/spec` itself plus the generated reference page; nothing in objectui), so an author could name a Redis endpoint, a TTL and a replication factor and nothing ever opened a connection — the shape of the plugin sandboxing / integrity / approval config that was never wired to anything (an exported schema no runtime reads is read as a capability), sharpened by cluster-persistence vocabulary an AI author (ADR-0033) reads as proof the capability exists. The key left with the enum value its own doc comment named it "required" for, and `DistributedStateConfig` was its orphan value schema. Two routes in one card because the surface has two shapes: an enum-VALUE narrowing is invisible to the four ratchets (the def still emits), so its prescription hangs on the enum's own `error` map dispatched by `issue.input` (the `crypto.hash` / `managedBy: 'system'` precedent); the whole-def removal MUST move them, and that movement is its own evidence. No D2 conversion and no tombstone: `HotReloadConfig` is not an authorable surface — no metadata-type binding, stack collection or manifest embed ever carried it, and nothing in the tree parses `HotReloadConfigSchema` outside its own unit test — so there is no authored document to rewrite and no one who could receive a parse-time prescription. Route 3, the shape of the dynamic plugin-loading family's removal and of that lifecycle-config ruling: this entry IS the declaration. + - Done when: No host passes `stateStrategy: 'disk'` or `'distributed'` to `HotReloadManager.registerPlugin`. TypeScript hosts cannot: `HotReloadConfigParsed['stateStrategy']` is now `'memory' | 'none'`, so either value is a compile error at the call site. JavaScript hosts, and config that arrived as JSON, get a loud registration-time refusal carrying the prescription — an ADR-0112 envelope (`code: VALIDATION_ERROR`, `status: 400`) thrown BEFORE the `enabled` check, so a disabled config cannot smuggle the false declaration through. No import of `DistributedStateConfigSchema`, `DistributedStateConfig` or `DistributedStateConfigParsed` from `@objectstack/spec` or `@objectstack/spec/kernel` survives — every one is TS2305 after upgrade, pinned by resolved symbol identity in `kernel/plugin-lifecycle-advanced-retirement.test.ts`. ⚠️ Runtime state behaviour is UNCHANGED for every config that worked: 'disk' and 'distributed' already stored to memory, so a host that migrates either to 'memory' keeps byte-identical behaviour — what changes is that the two spellings which never described what happened are now refused instead of silently honoured. That ruling's keep itself stands: `HotReloadConfigSchema`, `PluginStateSnapshotSchema` and the health vocabularies still export from `./kernel`, and `HotReloadManager` / `PluginHealthMonitor` still export from `@objectstack/core` with their tests green. +- **`hot-reload-watch-placeholder-retired`** — ``HotReloadConfig.watchPatterns`, and the `HotReloadManager.startWatching` placeholder that read it` → Run your own watcher and call `HotReloadManager.scheduleReload(pluginName, reloadFn)` when a file changes — that is the debounced integration point this class actually implements, and it is unchanged. Declare your globs wherever your watcher reads them; there is no in-tree replacement for the key, because file watching is the HOST's job in this host-driven library. The platform already depends on `chokidar` in `@objectstack/metadata`, `@objectstack/metadata-fs` and `@objectstack/cli` — never in `@objectstack/core` — so a host has a working model to copy. + - Why not automatic: ADR-0049 enforce-or-remove, applied one symbol over from the inert 'disk' / 'distributed' state strategies retired in the same file, and on the same per-key test. `HotReloadManager.startWatching` contained NO watcher: its whole body was a guard plus `logger.info('File watching started', { patterns })` above an in-source note saying real watching "would require chokidar or similar / This is a placeholder for the integration point". `watchHandles` was only ever read, deleted, iterated and cleared and NEVER set, so `stopWatching`'s cleanup branch and the teardown loop over its keys were structurally UNREACHABLE rather than merely untaken (measured with a firing positive control: `reloadTimers.set` resolves a real writer in the same file and the same scan; `watchHandles.set` resolves nothing anywhere). So `watchPatterns` had no reader that ACTED on it — its only two uses were log lines — and an author could declare a glob while no file change could ever trigger a reload. This is the shape of the plugin sandboxing config that was never wired to anything, with the volume turned up: the inert state-strategy fallback at least announced itself at DEBUG, whereas this said "File watching started" at INFO — positive confirmation of a capability that did not exist, which an operator, or an AI author (ADR-0033), reads as proof and stops looking. Neither of the other two ADR-0049 states was available: ENFORCE would build for a caller that does not exist (no runtime composes `HotReloadManager` — only its own unit test and `core/examples/phase2-integration.ts` construct it, the same fact that decided the state-strategy retirement's route), and EXPERIMENTAL requires a roadmap, where a scan of every planning doc returned ZERO mentions of hot-reload file watching against 145 control hits in the same files. Route 3 again: `HotReloadConfig` is not an authorable surface — no metadata-type binding, stack collection or manifest embed ever carried it, and nothing in the tree parses `HotReloadConfigSchema` outside its own unit test — so there is no authored document to rewrite and nobody who could receive a parse-time prescription, so there is no D2 conversion either — it would be a transform with no seam that ever runs. The key is TOMBSTONED rather than deleted, and the BUILD is what decided that: the plain deletion was tried first and `gen:schema` gate (a) refused it, because `HotReloadConfigSchema` is not `.strict()` and a bare deletion would be a silent strip (the failure measured when a field key pruned from a non-strict schema still parsed successfully and simply vanished, ADR-0104) — the very defect being retired, one layer down. The state-strategy retirement could take route 3 because what left there was a whole DEF; a key leaving a SURVIVING def has no such exit. This entry IS the declaration. + - Done when: No host passes `watchPatterns` to `HotReloadManager.registerPlugin`, and no host calls `HotReloadManager.startWatching`. TypeScript hosts cannot do either: `watchPatterns` is typed `never` by the tombstone, and `startWatching` returns `never`. JavaScript hosts, and config that arrived as JSON, get a loud refusal carrying the prescription — an ADR-0112 envelope (`code: VALIDATION_ERROR`, `status: 400`), thrown for a leftover `watchPatterns` BEFORE the `enabled` check so a disabled config cannot smuggle the false declaration through, and thrown unconditionally from `startWatching` so the placeholder can no longer report success. `startWatching` is kept as a throwing door rather than deleted precisely so that caller meets a prescription instead of a bare `TypeError: not a function`. Runtime reload behaviour is UNCHANGED for every config that worked: nothing was ever watched, so nothing that used to happen stops happening — `registerPlugin`, `scheduleReload`, `reloadPlugin` and state preservation are untouched, and `stopWatching` keeps the half that always did something (it cancels a pending debounced reload; its unreachable `watchHandles` branch left with the placeholder). What the maintainer's 2026-08-25 ruling on the advanced plugin-lifecycle config kept still stands: `HotReloadConfigSchema` and `PluginStateSnapshotSchema` still export from `./kernel`, and `HotReloadManager` / `PluginHealthMonitor` still export from `@objectstack/core` with their tests green. +- **`identity-api-key-schema-retired`** — `identity.apiKey (the whole of `ApiKeySchema` in identity/identity.zod.ts — 1 def, 3 exported names: `ApiKeySchema`, `ApiKey`, `ApiKeyParsed`)` → (removed — there is no replacement schema, because the deleted one never described the real table. The single declaration of `sys_api_key` is the ObjectSchema in `@objectstack/platform-objects` (`identity/sys-api-key.object.ts`): columns `name, prefix, user_id, active_organization_id, scopes, expires_at, last_used_at, revoked, key, id, created_at, updated_at`, snake_case, `revoked` as the kill switch — not `enabled`. Rows are minted by `POST /api/v1/keys` (`runtime/src/domains/keys.ts`) and verified by `core/src/security/api-key.ts`, keyed by the `osk_` prefix. Per-key rate limiting returns only via the ENFORCE route of ADR-0049 through a new ADR — the executor first, the vocabulary second) + - Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-08-15, disposition B: delete the schema. `ApiKeySchema` documented better-auth's `apiKey` PLUGIN schema — a plugin this platform does not load (`plugin-auth/src/managed-extension-fields.ts` states the table is hand-rolled ObjectStack): `start` and `lastRefetchAt` name columns that do not exist; `enabled` inverts the real `revoked` column's polarity; `rateLimitEnabled` / `rateLimitTimeWindow` / `rateLimitMax` / `remaining` advertise a per-key rate-limit capability nothing implements (the sharpest PD #10 instance — a reader can reasonably conclude API keys support rate limiting); `permissions` and `metadata` have no columns; `organizationId` is camelCase fiction next to the real snake_case `active_organization_id`. Zero consumers measured (08-14, re-verified at the retirement's base commit): only its own unit test, the export snapshots, the generated reference page and a prose mention in `cloud/developer-portal.zod.ts` (corrected in the same PR — the marketplace-key plan it gestured at is ruled NOT live). One table had two declarations and the published one was fiction; the generated reference page rendered it faithfully, which is how the defect surfaced as a docs card. With no carrier key and no authored document there is nothing to tombstone and no seam for a D2 conversion: route 3, the shape of the earlier removals of the dynamic plugin-loading family, the `ui/` interaction configs, the widget / i18n shapes, five declared-but-inert surfaces and two credential-bearing schemas no `sys_metadata` door reached — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. + - Done when: No code imports `ApiKeySchema`, `ApiKey` or `ApiKeyParsed` from `@objectstack/spec` or `@objectstack/spec/identity` — every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in `identity/api-key-retirement.test.ts`). No metadata document needs editing: the schema was reachable from no metadata-type binding, stack collection or /meta door, so no document could ever carry it. `UserSchema` / `AccountSchema` / `VerificationTokenSchema` and the organization module survive unchanged (the ruling accepts the sibling asymmetry deliberately), and the `sys_api_key` ObjectSchema in `@objectstack/platform-objects` still declares the real column set (pinned in `sys-api-key-single-declaration.test.ts`). ⚠️ Runtime behaviour is deliberately UNCHANGED: nothing ever read the schema, so removing it removes no behaviour — mint and verify work byte-identically before and after. +- **`incident-response-deadline-keys-retired`** — `incident-response deadline keys: `IncidentResponsePhase.targetHours`, `IncidentNotificationRule.withinMinutes` / `regulatorDeadlineHours`, `IncidentNotificationMatrix.escalationTimeoutMinutes`, `IncidentResponsePolicy.triageDeadlineHours` / `retentionDays`` → nothing to re-declare — delete the keys. No incident-response engine exists on the platform: nothing tracks a phase against a clock, sends or times an incident notification, notifies a regulator, walks the escalation chain on a timer or sweeps incident records on a schedule, so there is no live mechanism to declare a deadline to. Retention of stored records is the object-level `lifecycle` block (ADR-0057), declared on the object that stores the records and enforced by the LifecycleService — not a number on this policy document + - Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on the unread deadline keys (ruled A: retire per family). Six hour/minute/day-shaped keys sat on the published authorable surface and in the generated reference docs — an author could write `triageDeadlineHours: 4` and reasonably expect the platform to escalate after four hours — and read by NOTHING: the schemas are exported from `@objectstack/spec/system`, mounted by no stack key, registered as no metadata type, absent from the 2026-06 liveness ledgers, and the reader census over every package outside `packages/spec` (tests and changelogs excluded) and over objectui at the pinned sha returned zero hits for every key. Three of the six carried defaults (30 minutes, 1 hour, 2555 days) that were materialized into every parsed document without ever being consulted. A compliance-shaped deadline that fails silently is the worst form of the declared-but-unenforced shape ADR-0049 names; tagging it `[EXPERIMENTAL — not enforced]` was the fallback the ruling did not take. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections; none of these schemas is either, so a conversion would be a transform with no seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent). + - Done when: No `IncidentResponsePhase`, `IncidentNotificationRule`, `IncidentNotificationMatrix` or `IncidentResponsePolicy` literal — standalone or nested in an `Incident` — carries `targetHours`, `withinMinutes`, `regulatorDeadlineHours`, `escalationTimeoutMinutes`, `triageDeadlineHours` or `retentionDays`. TypeScript authors get the refusal at compile time (each key is typed `never`); a value reaching the parse is refused with the prescription (`invalid_type` at the path of the key). Parsed documents no longer carry the three former defaults. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ever read the keys, so removing them removes no behaviour. +- **`incident-response-family-retired`** — `the incident-response family, retired whole: the eight defs system/Incident, system/IncidentCategory, system/IncidentNotificationMatrix, system/IncidentNotificationRule, system/IncidentResponsePhase, system/IncidentResponsePolicy, system/IncidentSeverity and system/IncidentStatus, and every name system/incident-response.zod.ts exported from @objectstack/spec/system (the eight *Schema consts, their z.input aliases and the three *Parsed aliases)` → nothing to re-declare — no incident-response engine exists on the platform, so there is no working configuration to migrate to. Nothing classified, tracked, escalated or notified an incident and nothing notified a regulator; a compliance record the organisation keeps is ordinary object data, declared as an object with its own fields and enforced by the object engine (validation, permissions, the object-level `lifecycle` block under ADR-0057). If incident response becomes a product capability it re-declares fresh, through the enforce route of ADR-0049 — the engine first, the vocabulary second + - Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 on the families' remaining keys and defs (ruled A: retire the three compliance-shaped families whole via RETIRED_DEFS_BY_MAJOR, the integration/ErrorMappingConfig precedent; not roadmapped). Eight defs and roughly forty declared keys sat on the exported surface and in the generated reference docs, and were read by NOTHING: the schemas were exported from `@objectstack/spec/system`, mounted by no `stack.zod.ts` key, registered as no metadata type, absent from the 2026-06 liveness ledgers, and the reader census over every package outside `packages/spec` (tests and changelogs excluded), over `examples/**` and `skills/**`, and over objectui at the pinned sha returned zero hits for every exported name, with a lit control. Several keys were boolean capability claims of exactly the shape ADR-0049 names — `IncidentNotificationRule.notifyRegulators`, `IncidentResponsePolicy.requirePostIncidentReview` — so an author (very often an AI, ADR-0033) could write `notifyRegulators: true`, parse clean, and hold a compliance promise the platform never kept, with no error and no feedback. Tagging the family `[EXPERIMENTAL — not enforced]` was the fallback the ruling did not take: it is a human-only signal, and an AI generating from the schema still writes the key and believes it. The deadline-key tombstones of the 2026-09-02 per-family ruling (six sites, `RETIRED_KEYS_BY_MAJOR[18]`, D3 `incident-response-deadline-keys-retired`) leave with their defs' source; their registry entries stay as history. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections; none of these schemas is either, so a conversion would be a transform with no seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent), and with no carrier key there is no shape on which a tombstone could sit. + - Done when: No code imports IncidentSchema, IncidentCategorySchema, IncidentNotificationMatrixSchema, IncidentNotificationRuleSchema, IncidentResponsePhaseSchema, IncidentResponsePolicySchema, IncidentSeveritySchema or IncidentStatusSchema — or any of their type aliases — from @objectstack/spec or @objectstack/spec/system: every such import is TS2305 after upgrade, and no working replacement exists to point at because the vocabulary described nothing real. The eight defs are absent from `json-schema.manifest/system.json`, the api-surface / declaration-map / export-origins shards and the generated reference docs. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ever parsed or read these shapes, so removing them removes no behaviour. +- **`inline-grid-column-currency-scale-refused`** — `object.fields..inlineColumns[].scale on an inline grid column that declares `type: 'currency'` — any declared value, `scale: 0` included, computed or not. `scale` on a `number` column is untouched. A column that declares no `type` is judged as the type it renders as: over a `currency` field of the child object it is entry `inline-grid-column-identity-only-currency-scale-refused`` → no `scale` on a currency inline grid column. DELETE the key — that is the whole migration: a currency amount's decimal places are its currency's, not a column setting. The currency's ISO 4217 minor unit decides how the cell displays the amount and the width a computed amount is rounded to. ⛔ Nothing replaces the key: do not re-declare its value under any other key. + - Why not automatic: The maintainer's ruling of 2026-09-23 (option B) retired `scale` from the `currency` field type, and the ruling of 2026-09-24 (option 乙 — a currency's ISO 4217 minor unit decides its display) worded the remedy. Neither reached the inline grid column, the strict mirror of the console grid's column, which still offered per-column decimals on a `currency` column; triage read the column as inherited from both rulings, so `InlineGridColumnSchema` now refuses the key on a column declaring `type: 'currency'` at parse, with the field refusal's first sentence and remedy. ⛔ No alias and no grace window, per ruling B. NOT mechanically converted, deliberately, for the reason the field entry `field-currency-scale-refused` gives: a conversion that dropped the key would accept it on every load, which is the grace window the ruling refused; the refusal names the key and its one-line fix instead. The same change rewords the column's `prefix` description: it replaces the resolved currency's symbol and has no default (the grid no longer falls back to a fixed yen sign). Reach: the column schema judges only a DECLARED column `type` — a column that declares none takes its type from the child field when the console hydrates it, which the schema cannot see; `defineStack` judges that column instead (entry `inline-grid-column-identity-only-currency-scale-refused`). Population measured at the change, on origin/main 1c8b320a89: one authored `inlineColumns` block in the tree (the showcase invoice, seven identity-only columns, none declaring `type` or `scale`), no platform object, skill, documentation example or JSON fixture declaring an inline grid column at all, and one test fixture carrying `scale: 2` on a currency column, re-judged in the same change. Deployed metadata NOT MEASURED. + - Done when: Every field in the stack parses: an `ObjectSchema` parse and `objectstack validate` report no issue on an `inlineColumns[].scale` path of a column declaring `type: 'currency'`. A currency column that carried `scale` no longer declares it, and a diff of the column shows that one line deleted and no key added. `number` columns keep their `scale`, and so does a column declaring no `type` unless it names a `currency` field of the child object (entry `inline-grid-column-identity-only-currency-scale-refused`); a column's `prefix` is still accepted on a currency column. +- **`inline-grid-column-identity-only-currency-scale-refused`** — `object.fields..inlineColumns[].scale and view.form.subforms[].columns[].scale (and each formViews entry) on a column that declares NO `type` and whose `name` is a `currency` field of the child object — any value, `scale: 0` included. A column declaring `type: 'number'`, and a column over a field of any other type, keep `scale`` → no `scale` on the column. DELETE the key — that is the whole migration: the column renders as a currency column, and a currency amount's decimal places are its currency's. The currency's ISO 4217 minor unit decides how the cell displays the amount and the width a computed amount is rounded to. ⛔ Nothing replaces the key: do not re-declare its value under any other key, and do not add `type: 'number'` to keep it on a currency amount. + - Why not automatic: The refusal of `scale` on a currency inline grid column (entry `inline-grid-column-currency-scale-refused`, under the maintainer's rulings of 2026-09-23, option B, and 2026-09-24, option 乙) reached only a column that DECLARES `type: 'currency'`, because the column schema cannot see the child field. An identity-only column — the recommended form — over a currency field renders as a currency column all the same, so it published green carrying the refused key, and the console ignored it. `defineStack`'s cross-reference check, which holds the child object's fields, now judges such a column as the type it renders as and refuses it with the column schema's own message. Reach: the child object must be declared in the same stack; a column naming no field of it, or a subform whose child object comes from another package, is not judged there. Population measured at the change, on origin/main cb4c31dd52: one authored `inlineColumns` block (the showcase invoice, seven identity-only columns, none carrying `scale`) and zero authored `subforms`. Deployed metadata NOT MEASURED. + - Done when: `objectstack validate` and `defineStack` report no cross-reference finding on an `inlineColumns[].scale` or `subforms[].columns[].scale` path. A column that carried `scale` over a currency child field no longer declares it, and a diff of the column shows that one line deleted and no key added. Columns over `number` fields, and columns declaring `type: 'number'`, keep their `scale`. +- **`job-timeout-unit-in-key`** — `job.timeout — the per-attempt time limit of a scheduled job` → `timeoutMs` — the same per-attempt limit, in milliseconds, beside the sibling `retryPolicy.backoffMs` that already spelled its unit. + - Why not automatic: The D2 conversion `job-timeout-to-timeout-ms` renames `timeout` to `timeoutMs` in author sources and wherever the chain is replayed, keeping the value, and the rename is lossless: the key always meant milliseconds. The judgment is whether the author knew that. The unit lived only in the description while `retryPolicy.backoffMs` beside it spelled its own, so one job definition carried two conventions; a seconds value copied in — `timeout: 300` for a five-minute job — became a 300-millisecond limit with no error, and the rename carries the 300 over unchanged. A limit that short fails every attempt and burns the retry budget, which is easy to misread as a flaky job. Only the author can say which unit each value was written in, and code that builds job definitions in TypeScript is outside the chain's reach. + - Done when: No job carries `timeout`; the parse refuses it with the rename. Every `timeoutMs` value is the per-attempt limit the author intends in milliseconds — a job meant to be allowed five minutes reads `timeoutMs: 300000`. An attempt that runs past its `timeoutMs` fails with a timeout and is retried under `retryPolicy`, and an attempt that finishes inside it succeeds as before. No code reads or writes `timeout` on a job definition. +- **`kernel-compatibility-matrix-estimated-migration-time-unit-in-key`** — `CompatibilityMatrixEntry.estimatedMigrationTime, the migration effort estimate whose unit lived only in a source JSDoc (kernel/plugin-versioning.zod.ts)` → estimatedMigrationTimeHours — rename the key AND state the unit in the describe; the value (hours) is unchanged + - Why not automatic: Maintainer ruling A of 2026-09-17 on the last two duration keys no closed duration type could express (this one in hours, the other in fractional seconds): rename the key and record an ADR-0087 conversion-layer entry, with no new closed type and no narrowing of anything already stored. The key said "Estimated migration time in hours" in a source JSDoc and carried no .describe() at all — the JSDoc-channel shape (a unit stated only in a source comment the reference page never prints), one def over. The JSDoc stops at the source file; .describe() is what content/docs/references/** renders, so the published page printed a bare number directly beside migrationComplexity, whose scale IS named (trivial/simple/moderate/complex/major). A reader comparing "major" with "40" had no way to know whether 40 was minutes, hours or days. The remedy is BOTH halves, and the second is not optional: renaming alone would leave the two channels that name the unit — the key name and a source comment — agreeing about something the published page does not print, which check:duration-unit-keys refuses as unit-in-jsdoc-not-in-describe (that agreement shape — a unit in the key name and the JSDoc, none in the describe — was ruled an offence on 2026-09-18). So the unit moves INTO the describe and the key name carries it too. HOURS is kept rather than converted to seconds: the value is unchanged, the ruling forbade narrowing, and an effort estimate is authored in hours by the human who writes the plugin manifest. Tombstoned with retiredKey(): CompatibilityMatrixEntrySchema is a plain z.object, not strict, so a bare deletion would strip the old spelling in silence and a manifest would lose its one effort figure with no error anywhere. Why a semantic entry and not a D2 conversion: a compatibility matrix is a plugin-published version manifest — stack.zod.ts declares no collection of them and it is not a registered metadata kind stored as a sys_metadata row — so the chain has no seam that sees one. ADR-0087. + - Done when: Every plugin manifest that declares a migration estimate spells estimatedMigrationTimeHours and every consumer reads that key. Authoring estimatedMigrationTime fails to compile (input type `never`) and fails to parse with the rename prescription rather than a bare unrecognized-key error. Behaviour is unchanged: estimatedMigrationTimeHours: 8 is the same eight hours estimatedMigrationTime: 8 was, the key stays optional and stays a bare z.number() — a sweep that added .int() or adopted a closed duration type has narrowed a value the ruling refused to narrow. The migration is proved correct when the reference page for CompatibilityMatrixEntry prints the unit rather than a bare number, and when check:duration-unit-keys reports the key as satisfied rather than listing it unjudged. testCoverage on the same shape is a PERCENTAGE, not a duration, and does not move. +- **`kernel-context-preview-mode-retired`** — `context.mode — the value 'preview' left the RuntimeMode enum — and context.previewMode, the whole PreviewModeConfig block it keyed (autoLogin / simulatedRole / simulatedUserName / readOnly / expiresInSeconds / bannerMessage, declared on KernelContext and on the TenantRuntimeContext extension). The exported PreviewModeConfigSchema / PreviewModeConfig / PreviewModeConfigParsed names left with the def` → nothing declarative — the capability the block described was never implemented by any layer, so there is no working configuration to migrate to. Preview/demo DEPLOYMENTS belong to the deployment layer, which owns auth per-project (ArtifactKernelFactory in the cloud distribution); the OS_PREVIEW_MODE environment variable stays exactly as it is — deployment ROUTING (widening the trusted-origin list for preview subdomains), unrelated to identity. If a preview experience becomes a product capability it re-declares fresh, with the production-posture hard-refusal as the first-landed half (as the removal ruling recorded) + - Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-08-27 (Option A: remove). The declaration was the sharpest declared-≠-enforced shape on a SECURITY surface: the schema promised "bypass auth, simulate admin identity" and named a production guard "the runtime must enforce", and NO code path implemented either half. Measured zero consumers in all three repos, each leg with positive controls: objectstack — no runtime branches on the mode; the only non-declaration hits for RuntimeMode or mode === 'preview' are the schema unit test, a type-alias pin and a measurement-test comment (re-verified at dispatch, 2026-08-27, origin/main 15bf9e8). objectui — zero consumers, measured when the removal was ruled. cloud — a census closed 2026-08-26: OS_PREVIEW_MODE there is a routing-only switch (the same switch this repository's serve.ts reads only to add preview-domain wildcards to better-auth's trusted origins); RuntimeMode has zero hits repo-wide; the positive control ArtifactKernelFactory (where serve.ts predicted preview auto-login would live if it existed) has 20+ hits and never touches previewMode. An author — very often an AI (ADR-0033) — could write the six-key block per the reference docs, parse cleanly, and get no behaviour and no diagnostic, while a reader of the docs had no way to tell the block from the keys that work. Bookkeeping: the enum-VALUE half ('preview') puts nothing in RETIRED_KEYS_BY_MAJOR and leaves the four surface ratchets untouched by itself — its prescription hangs on the enum's own error map (the HookBodyCapability precedent); the KEY half is tombstoned with retiredKey() on the non-strict KernelContextSchema (both walked-shape copies registered in RETIRED_KEYS_BY_MAJOR[18]); the DEF half (kernel/PreviewModeConfig, with no carrier left) is registered in RETIRED_DEFS_BY_MAJOR[18]. It is a SEMANTIC entry rather than a D2 conversion because there is no source to rewrite: a kernel context is constructed by host code at boot — not a stack collection member, never stored as a sys_metadata row — so the conversion chain has no seam that would ever see one (the kernel/Manifest:loading disposition). ADR-0049 / ADR-0087. + - Done when: No host constructs a kernel context with mode: 'preview' or a previewMode block: both now fail tsc at the authoring site and fail the parse with the prescription (pinned in kernel/preview-mode-retirement.test.ts). Concretely, check three places. (1) Host boot code composing a KernelContext: delete `mode: 'preview'` (mode defaults to production; use development for local demo work) and delete any previewMode block — neither ever changed runtime behaviour, so removing them changes nothing observable. (2) Code importing PreviewModeConfigSchema, PreviewModeConfig or PreviewModeConfigParsed from @objectstack/spec or @objectstack/spec/kernel: every one is TS2305 after upgrade; no working replacement exists to point at, because the vocabulary described nothing real. (3) TypeScript branching on the RuntimeMode type (a mode === 'preview' arm, a switch over modes): the arm is now unreachable and an exhaustiveness check will fail to compile if it stays — that compile error is the enforced channel for TypeScript consumers. Preview deployment ROUTING is untouched: OS_PREVIEW_MODE and OS_PREVIEW_BASE_DOMAINS keep working exactly as documented (deployment routing, never identity). +- **`kernel-event-bus-retention-unit-in-key`** — `the two event-bus retention windows whose name carried no unit: EventPersistence.retention (kernel/events/handlers.zod.ts) and EventSourcingConfig.retention (kernel/events/queue.zod.ts)` → retentionDays on both — rename each key; both values are unchanged, and so is the 365 default on EventSourcingConfig + - Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. What makes these two one entry rather than two is the neighbour they share and the one they do not. Both hang off EventBusConfig, so an author configuring a bus met the same bare word twice and had to learn the unit twice; and on EventSourcingConfig the bare retention sits two keys below snapshotRetention, which is a COUNT of snapshots to keep, not a span of time. `retention: 365` and `snapshotRetention: 10` read as the same kind of number and are not. Suffixing the duration separates the families at the authoring site; snapshotRetention keeps its name, because a count has no unit to carry. Both are retiredKey() tombstones — neither shape is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: an EventBusConfig is the event bus construction argument a host builds in code (stack.zod.ts declares no eventBus key and no metadata kind is bound to one), so it is never a stack collection member and never a stored sys_metadata row, and the conversion chain has no seam that would see one. That is what ruling B prescribes for a key that is not authorable metadata, and the disposition the epoch-instant renames on this same kernel took (epoch-instant-keys-renamed). ADR-0087. + - Done when: Every EventPersistenceSchema.parse(…) / EventSourcingConfigSchema.parse(…) site and every literal handed to an event bus spells retentionDays; authoring either old spelling fails to compile (input type `never`) and fails to parse with the rename prescription. Behaviour is unchanged in both cases: a bus configured with `retentionDays: 90` keeps events for ninety days exactly as `retention: 90` did, and a config that omits the key still gets the 365 default on EventSourcingConfig. The positive-integer bound rides along with the renamed key, so a zero or negative window is still refused — the pin covering that in kernel/events.test.ts was moved onto the new spelling rather than dropped. +- **`kernel-health-check-and-hot-reload-durations-unit-in-key`** — `the three plugin-lifecycle durations whose unit lived in a source JSDoc only: PluginHealthCheck.interval, PluginHealthCheck.timeout and HotReloadConfig.debounceDelay (kernel/plugin-lifecycle-advanced.zod.ts)` → intervalMs, timeoutMs and debounceDelayMs — rename each key; all three values (milliseconds) and their 30000 / 5000 / 1000 defaults are unchanged + - Why not automatic: Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the maintainer's 「同意」: a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands — the duration-unit rule (the unit lives in the key name or its value type, never in prose alone) executed file by file. Each key named milliseconds in its JSDoc — "Health check interval in milliseconds", "Timeout for health check in milliseconds", "Debounce delay before reloading (milliseconds)" — and the JSDoc above a key is NOT what `content/docs/references/**` renders; `.describe()` is. Measured on this tree by the gate's own census (check-duration-unit-keys --list): all three read [name: -] [prose: -] — no unit in the name and none in the published prose either. `interval` is the sharpest of the three: its describe carried one unit-shaped token, the parenthetical "(default: 30s)", which names SECONDS for a value the schema bounds and defaults in MILLISECONDS (min 1000, default 30000). That is the 1000x confusion the rule exists for, published to the one reader who cannot see the source. The suffix is the family's own spelling, counted on this tree: 100 key-position *Ms declarations across packages/spec, timeoutMs 29 of them and intervalMs 3, so both renames land on names the surface already uses. debounceDelay takes the plain suffix rather than a shortened form: it is the only debounce-shaped key spelling in the whole repo (5 key-position occurrences, all of this one key and its fixtures, no debounceMs variant anywhere), while the Delay-plus-Ms pairing is already attested (maxDelayMs, initialDelayMs, retryDelayMs, delayMs) — so unlike the Ttl-versus-TTL question the sibling round had to settle, there is no competing family spelling to choose between. All three old spellings are retiredKey() tombstones: neither PluginHealthCheckSchema nor HotReloadConfigSchema is .strict(), so a bare deletion would be a SILENT STRIP (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — and here the stripped value lands on a setInterval period, a race deadline and a setTimeout delay. Why a semantic entry and not a D2 conversion: the conversion chain walks a normalized STACK, and neither def is an authorable surface — no metadata-type binding, stack collection or manifest embed carries either, and both are library parameters a host passes to PluginHealthMonitor / HotReloadManager in TypeScript (kept twice: as the hot-reload vocabulary that had an implementation when the manifest-side copy was removed, and as a host-driven library when the declarative lifecycle config container was retired) — so a conversion would be a transform with no seam that ever runs. That is the same disposition plugin-auto-restart-never-reinitialised and hot-reload-watch-placeholder-retired recorded for keys on these two defs. The registration-time refusals in PluginHealthMonitor.registerPlugin and HotReloadManager.registerPlugin are the door for the audience that does not parse. Measured on 884e8347d: the only in-repo readers are packages/core/src/health-monitor.ts and packages/core/src/hot-reload.ts, both moved in this same change; and the pinned objectui checkout — the pin this repo builds against, `.objectui-sha` = `f0268ad784854568aa58a2aa791f6a7502259186` — names neither def and neither key: all thirteen exports of plugin-lifecycle-advanced.zod.ts and the string debounceDelay each occur 0 times across its 8234 tracked files (0 across the 7754 at a58626c88, the 7650 at 0abd4f9f8, the 7632 at 9dfaca654, the 7579 at 2e818d0b5, the 10267 at ab1879721, the 10071 at 89cad75d5, the 9912 at 31971ff1e, the 9800 at e420df310, the 9546 at db11afd49, the 9283 at dd3f7e1be, the 8512 at f8a9d0fb0 and the 8303 at 62597c588 too), against lit controls objectstack 12966 and @objectstack/spec 4997 on the same corpus at 87af769e9, which re-count to 13125 and 5043 respectively at 62597c588, to 13347 and 5123 at f8a9d0fb0, to 13745 and 5466 at dd3f7e1be, to 14704 and 5545 at db11afd49, to 15352 and 6024 at e420df310, to 15691 and 6206 at 31971ff1e, to 16044 and 6461 at 89cad75d5, to 16377 and 6665 at ab1879721, to 17227 and 7134 at 2e818d0b5, to 17313 and 7186 at 9dfaca654, to 17390 and 7209 at 0abd4f9f8, to 17468 and 7246 at a58626c88 and to 17956 and 7522 at this pin (git grep -o -F, the method that reproduces every earlier count). + - Done when: Every producer and reader of a PluginHealthCheck spells intervalMs and timeoutMs, and every one of a HotReloadConfig spells debounceDelayMs — concretely packages/core/src/health-monitor.ts, whose loop now reads setInterval(..., config.intervalMs) and whose race reads config.timeoutMs, and packages/core/src/hot-reload.ts, whose debounce now reads config.debounceDelayMs. Authoring any old spelling fails to compile (input type `never`) and fails to parse with the rename prescription naming the suffixed key; handing one to registerPlugin on either class is refused with an ADR-0112 VALIDATION_ERROR / 400 before the plugin is stored. Behaviour is unchanged: the same milliseconds, the same 30000 / 5000 / 1000 defaults and the same min bounds (1000 / 100 / 0), and the published describes now name milliseconds. The sibling shutdownTimeout on HotReloadConfig is deliberately NOT renamed with them: its JSDoc reads "Graceful shutdown timeout" and names no unit anywhere, so it is the unit-nowhere shape (no unit in the name or in the published describe, first measured on two tenant timeouts) that the duration-unit gate leaves outside its verdict, not part of this row set. +- **`kernel-package-lifecycle-durations-unit-in-key`** — `the three package and version lifecycle durations whose name carried no unit: UpgradePlan.estimatedDuration (kernel/package-upgrade.zod.ts), PackageDependencyResolutionResult.resolvedIn (kernel/plugin-security.zod.ts) and MultiVersionSupport.rollout.duration (kernel/plugin-versioning.zod.ts)` → estimatedDurationSeconds, resolvedInMs and durationMs — rename each key; every value is unchanged + - Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. These three are one entry because they are one story told to one audience — a package being planned, resolved and rolled out — and because the group is precisely where the unit SPLITS: estimatedDuration is SECONDS while resolvedIn and rollout.duration are MILLISECONDS, three adjacent measurements of the same install, two units, none of them named. A reader who learned the unit from one of these three learned it wrongly for the other two. The rollout case adds a second confusion of its own: duration sat directly beside the unit-less percentage, so one block carried a proportion and a span as indistinguishable bare numbers; percentage keeps its name, because a proportion has no time unit to carry. All three are retiredKey() tombstones; no shape here is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: an UpgradePlan is GENERATED by IPackageService.planUpgrade() before an upgrade runs, a PackageDependencyResolutionResult is emitted by a resolution run, and MultiVersionSupport is a version-routing argument a host constructs — none is a stack collection member or a stored sys_metadata row, so the conversion chain has no seam that would see one. That is what ruling B prescribes for a key that is not authorable metadata. ADR-0087. + - Done when: Every IPackageService.planUpgrade() implementation returns estimatedDurationSeconds and every caller reads it under that name; every dependency-resolution producer returns resolvedInMs; every multi-version rollout literal spells durationMs. Authoring any old spelling fails to compile (input type `never`) and fails to parse with the rename prescription. Behaviour is unchanged in every case, and the unit split is the thing to check by hand rather than by search-and-replace: estimatedDurationSeconds: 120 is two MINUTES, while durationMs: 3600000 is one HOUR — a mechanical rename that moved a value between the two would be a thousand-fold error the parse cannot catch, since both bounds accept any non-negative integer. +- **`kernel-plugin-health-report-durations-unit-in-key`** — `the two plugin health-report metrics whose name carried no unit: PluginHealthReport.metrics.uptime and PluginHealthReport.metrics.responseTime (kernel/plugin-lifecycle-advanced.zod.ts)` → uptimeMs and responseTimeMs — rename each key; both values are unchanged + - Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. uptime is the case this rule was written for, and this repo had already paid for it in documentation: the platform serves a SECONDS-valued uptime on GET /health and stores a MILLISECONDS-valued uptime on this report, so the protocol lifecycle page carried a standing paragraph whose whole job was telling the two apart ("metrics.uptime is in milliseconds, unlike the seconds-valued uptime of GET /health above"). A prose warning that has to exist is the symptom; the key name is where the fix belongs. responseTime moves with it because it is a sibling in the same metrics block and because the identical bare name means HOURS on PluginSecurityManifest.vulnerabilityDisclosure.responseTime, renamed by this same card. The other metrics keep their names, deliberately: memoryUsage is bytes, cpuUsage is a percentage, activeConnections is a count and errorRate is a rate — none is a duration, and this rule reaches durations only. Both are retiredKey() tombstones inside the live metrics block, whose siblings must keep parsing. Why a semantic entry and not a D2 conversion: a health report is EMITTED by the monitor each round (packages/core/src/health-monitor.ts) and kept in memory — never authored into a metadata document, never a stored sys_metadata row — so the conversion chain has no seam that would see one, the same disposition HealthStatus.timestamp took (epoch-instant-keys-renamed). ADR-0087. + - Done when: Every producer of a PluginHealthReport spells uptimeMs and responseTimeMs — concretely packages/core/src/health-monitor.ts, the one production writer, whose metrics block now reads `uptimeMs: Date.now() - startTime`. Every consumer reading result.metrics?.uptime moves to result.metrics?.uptimeMs. Authoring either old spelling fails to compile (input type `never`) and fails to parse with the rename prescription. Behaviour is unchanged: the value is still Date.now() - startTime in milliseconds, and a report that omits metrics entirely is still valid. ⚠️ Two identically-spelled keys NEARBY are not part of this and must not be renamed with it: the seconds-valued uptime of the GET /health response body, and the free-form HealthStatus.details record, which is a z.record whose contents this rule does not reach. +- **`kernel-plugin-security-durations-unit-in-key`** — `the four plugin-security durations whose name carried no unit: SandboxConfig.process.timeout, KernelSecurityPolicy.authentication.tokenExpiration, KernelSecurityPolicy.auditLog.retention and PluginSecurityManifest.vulnerabilityDisclosure.responseTime (kernel/plugin-security-advanced.zod.ts)` → timeoutMs, tokenExpirationSeconds, retentionDays and responseTimeHours — rename each key; every value is unchanged + - Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. These four are one entry because they are one document — everything here hangs off a PluginSecurityManifest — and because together they are this rule's clearest case in the whole spec: FOUR durations on one manifest carried FOUR DIFFERENT units (milliseconds, seconds, days, hours) and not one of them said so in its name. The sharpest pair is responseTime. On this manifest it means HOURS (how fast a publisher promises to answer a vulnerability report); on PluginHealthReport.metrics, renamed by the same card, the identical bare name meant MILLISECONDS. So `responseTime: 24` was a day on one kernel shape and a fortieth of a second on another, with nothing at the authoring site to tell them apart. The policy was already inconsistent with itself, too: its rate-limit window two blocks above tokenExpiration was ALREADY spelled windowMs, so one security policy carried both conventions. All four are retiredKey() tombstones inside live blocks whose siblings must keep parsing; no shape here is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: a PluginSecurityManifest is a package artifact a publisher ships and a SandboxConfig is the isolation argument a host constructs, so neither is a stack collection member or a stored sys_metadata row and the conversion chain has no seam that would see one. That is what ruling B prescribes for a key that is not authorable metadata. One key deliberately left alone: RuntimeConfig.resourceLimits.timeout on this same file names its unit only in the JSDoc above it ("Execution timeout in milliseconds"), a channel the gate does not read: it reads `.describe()` and `.meta({ description })`, and that key's describe ("Maximum execution time") names none. So the gate lists it among the duration-shaped keys without judging it — neither an offender nor an exemption — and it is outside this rename; that JSDoc-channel gap was filed as a finding of its own and is closed for this key by kernel-runtime-config-timeout-unit-in-key. ADR-0087. + - Done when: Every SandboxConfigSchema.parse(…), KernelSecurityPolicySchema.parse(…) and PluginSecurityManifestSchema.parse(…) site, and every literal handed to a plugin sandbox or security manifest, spells the suffixed keys; authoring any old spelling fails to compile (input type `never`) and fails to parse with the rename prescription. Behaviour is unchanged in every case: a sandbox given `timeoutMs: 30000` kills a spawned process after thirty seconds exactly as `timeout: 30000` did, a policy with `tokenExpirationSeconds: 3600` still expires tokens hourly, `retentionDays: 90` still keeps ninety days of audit log, and `responseTimeHours: 24` still promises a twenty-four-hour disclosure response. Every integer bound rides along with its renamed key. Verify the sharp pair explicitly: a manifest and a health report in the same codebase must now read responseTimeHours and responseTimeMs respectively, and neither accepts the bare name. +- **`kernel-runtime-config-timeout-unit-in-key`** — `RuntimeConfig resourceLimits.timeout (kernel/plugin-security-advanced.zod.ts)` → resourceLimits.timeoutMs — rename the key; the value (milliseconds) is unchanged + - Why not automatic: This entry COMPLETES what the kernel-directory duration renames deliberately left alone, and the two are meant to be read as a sequence. That round renamed the four plugin-security durations on this same file (`kernel-plugin-security-durations-unit-in-key`) and recorded, accurately, that one key was out of its scope: RuntimeConfig.resourceLimits.timeout named its unit only in the JSDoc above it ("Execution timeout in milliseconds"), a channel check:duration-unit-keys does not read — it reads `.describe()` and `.meta({ description })` — and that key's describe ("Maximum execution time") named none, so the gate listed it among the duration-shaped keys without judging it, neither an offender nor an exemption. That JSDoc-channel gap was filed as a finding of its own, and that round's statement about its own scope stays true. The finding is now ruled and this is its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer's 「同意」, which keeps the refusal of a duration key whose JSDoc names a unit its describe does not, remediates the 21-row JSDoc-channel population per file, and lands that widened gate last, into a tree already clean. So the reader who most needs the unit — the reader of the published reference page, who never sees the source JSDoc — got a bare integer on content/docs/references/kernel/plugin-security-advanced.mdx and could not tell 60000 milliseconds from 60000 seconds. The key is renamed and the describe is corrected in the same stroke, because under the duration-unit rule (the unit lives in the key name or a unit-carrying value, never in the describe prose alone) moving the unit into the describe alone is itself a violation (unit in prose, none in the name). Spelled Ms, the same token SandboxConfig.process.timeoutMs on this very file already carries: counted on this tree, the suffixed family spells it that way in every member (29 key-position `timeoutMs` declarations across packages/spec/src/**/*.zod.ts, 40 distinct *Ms keys) and there is no timeoutMillis, timeout_ms or timeoutMS variant anywhere in packages/spec/src. Tombstoned with retiredKey() because the nested resourceLimits object is not strict, so a bare deletion would silently strip the key. Why a semantic entry and not a D2 conversion: a RuntimeConfig is the engine block of the SandboxConfig a host or a plugin security manifest constructs — stack.zod.ts declares no sandbox, security-policy or runtime-config collection and it is not a stored sys_metadata row — so the conversion chain has no seam that runs on it; the same reading the kernel-directory round recorded for the four keys it renamed. Measured on 146c291943: no in-repo runtime reads the key — packages/core/src/security/sandbox-runtime.ts, the one consumer of this shape, reads resourceLimits.maxCpu (3 occurrences of resourceLimits) and spells timeout 0 times; outside the zod file and its test the only live occurrences are the generated rows in content/docs/references/kernel/plugin-security-advanced.mdx, which this rename regenerates. The pinned objectui checkout — this is the pin we build against, `.objectui-sha` = `f0268ad784854568aa58a2aa791f6a7502259186`, re-read from this tree — spells resourceLimits.timeout 0 times across 8234 tracked files, against lit controls timeout 1658, RuntimeConfig 337 and resourceLimits 2 on the same corpus (0 across 7754, and 1431 / 299 / 2, at a58626c88; 0 across 7650, and 1360 / 293 / 2, at 0abd4f9f8; 0 across 7632, and 1360 / 293 / 2, at 9dfaca654; 0 across 7579, and 1351 / 276 / 2, at 2e818d0b5; 0 across 10267, and 1348 / 273 / 2, at ab1879721; 0 across 10071, and 1331 / 273 / 2, at 89cad75d5; 0 across 9912, and 1303 / 273 / 2, at 31971ff1e; 0 across 9800, and 1293 / 273 / 2, at e420df310; 0 across 9546, and 1197 / 273 / 2, at db11afd49; 0 across 9283, and 1172 / 263 / 2, at dd3f7e1be; 0 across 8512, and 1096 / 245 / 2, at f8a9d0fb0; 0 across 8303, and 1086 / 240 / 2, at 62597c588); both resourceLimits hits are prose in packages/app-shell recording that objectui's own AppShellRuntimeConfig shares not one key with the spec's RuntimeConfig, so nothing there authors this key and no pin bump is owed. ADR-0087. + - Done when: Every RuntimeConfigSchema.parse(…) site, and every literal handed to a plugin sandbox as its runtime block, spells resourceLimits.timeoutMs; authoring resourceLimits.timeout fails to compile (input type `never`) and fails to parse with the rename prescription naming timeoutMs and the shape it belongs to. Behaviour is unchanged: a runtime given timeoutMs: 60000 aborts execution after sixty seconds exactly as timeout: 60000 did, and the min(0) integer bound rides along with the renamed key. The published describe reads "Maximum execution time in milliseconds". Verify the two same-named keys on this one file apart: RuntimeConfig.resourceLimits.timeout and SandboxConfig.process.timeout both retire to a key spelled timeoutMs, and each refusal names its own shape so an upgrading author edits the right block. +- **`kernel-startup-orchestrator-durations-unit-in-key`** — `the three startup-orchestration durations whose name carried no unit: StartupOptions.timeout, PluginStartupResult.duration and StartupOrchestrationResult.totalDuration (kernel/startup-orchestrator.zod.ts)` → timeoutMs, durationMs and totalDurationMs — rename each key; every value is unchanged, and so is the 30000 default on StartupOptions + - Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. These three are one entry because they are one boundary: a host passes StartupOptions in, and the orchestrator hands PluginStartupResult and StartupOrchestrationResult back from the same call. The file already contained its own counter-example — IStartupOrchestrator.startWithTimeout(plugin, context, timeoutMs) named its parameter timeoutMs while the options object beside it said timeout, so one contract carried both conventions and the suffixed one was already the honest half. totalDuration is the sum of the per-plugin durations, so the two had to move together or the aggregate would have been spelled unlike its parts. All three are retiredKey() tombstones; none of these shapes is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: StartupOptions is a boot-time call argument and the two result shapes are emitted measurements, so none is ever a stack collection member or a stored sys_metadata row and the conversion chain has no seam that would see one — the same disposition HealthStatus.timestamp took on this very file (epoch-instant-keys-renamed), and what ruling B prescribes for a runtime-emitted key. ADR-0087. + - Done when: Host boot code calling orchestrateStartup(plugins, options) spells timeoutMs; every implementation that BUILDS a PluginStartupResult spells durationMs and every one that builds a StartupOrchestrationResult spells totalDurationMs. Authoring any old spelling fails to compile (input type `never`) and fails to parse with the rename prescription. Behaviour is unchanged in every case: an orchestrator given `timeoutMs: 5000` waits five seconds per plugin exactly as `timeout: 5000` did, an omitted key still defaults to 30000, and the non-negative bounds ride along with the renamed keys so a negative timeout or a negative duration is still refused. One thing this rename deliberately does NOT touch: packages/core/src/plugin-loader.ts declares its own local PluginStartupResult interface — a different type, carrying startTime rather than any duration key — which is not a reader of this schema and is unchanged. +- **`list-view-navigation-view-retired`** — `view.list.navigation.view` → page assignment — assign a `record` page to the object and let `isDefault` pick the one that opens. That is the machinery that resolves a detail layout by name; a list view's `navigation` block decides only HOW the detail is surfaced (`mode`, `size`, `preventNavigation`, `openNewTab`, `width`), and every one of those keys is unchanged + - Why not automatic: DECLARED, CONSUMED, AND WRONG — which is why this is a semantic TODO rather than a mechanical strip. The key's describe promised "the form view to use for details" and no layer from spec to console ever resolved a view by name. Its only read in the shipped console put the value in the SECOND argument of `onNavigate`, the slot that otherwise carries the navigation-MODE token: an authored `view` did not select a view, it SUBSTITUTED for the mode. At least one consumer in the same bundle reads that argument against a closed two-value vocabulary (`edit` / `view`), so any other authored value matched neither branch — invisible on grids whose handler takes one argument, a dead row click on the ones that do not. The enumeration behind the removal was exhaustive rather than sampled: every `.view` property read in the bundle (three) and every `formViews` read, and NO read anywhere is keyed by an authored view name, so there is no path by which this key or any sibling could have resolved one. ADR-0049 enforce-or-remove; zero authored instances in this repo and the one external author removed its occurrence, so the pull that would justify ENFORCE is zero. A mechanical D2 strip was weighed and declined with the direction: deleting the key silently discards the author's actual intent — "open the detail in THIS layout" — and leaves no record of which list view carried it, which is exactly the judgement a semantic TODO exists to hand back. Should "open the detail in a chosen view" ever be pulled, it belongs to the page-assignment machinery (`record` pages, `isDefault`), not to a string on a list view. + - Done when: For EACH list view that declared `navigation.view` — the TODO names the surface, you name the view: delete the key from that view's `navigation` block, then decide whether the detail layout it asked for was ever actually delivered. It was not, so nothing regresses by deleting it: confirm the record detail opens exactly as it did before (the surviving `mode` and `size` decide that, and both are untouched). If the named layout is one you still want, publish it as a `record` page on that object and mark the one that should open `isDefault`. Done when no `navigation` block in your sources carries `view`; a block that still does fails to parse with the removal prescription, at `ListViewSchema`, at `ObjectListViewSchema` and at the `PUT /api/v1/meta/view` overlay door, and authoring it is a `tsc` error at the call site. ⚠️ Nothing else in the block moves — a `navigation` that carries only live keys (`{ mode: 'drawer', size: 'lg' }`) parses byte-for-byte as it did before. +- **`list-view-page-mount-retired`** — `view.list / view.listViews.* — the list-view type page and its pageName binding` → Publish the page and give the app a navigation item for it — `{ type: 'page', pageName: '' }` under the app `navigation`, the page mount that has always rendered. Keep the list view only if it should draw rows of its object, as a `grid` or one of its siblings. + - Why not automatic: The D2 conversion `view-page-mount-removed` deletes `type: 'page'` (the schema default then parses the view as `grid`) and `pageName` from every view payload in `stack.views[]`, in all three persisted spellings, and the delete is lossless in pixels: no renderer ever routed the page member, so a page view has always drawn an empty grid, and it still does. The author wanted a PAGE in front of users at that place in the app, and the view never showed it. Whether to reach the page through a navigation item, and whether the now-plain grid view should exist at all, are the author's decisions. One boundary is theirs by construction: a page mount declared under `objects[].listViews` is reached by no conversion, so it is refused at its own door until edited by hand. + - Done when: No list view in `stack.views[]` or in any object `listViews` map declares `type: 'page'` or `pageName`; the parse refuses both by name. For each view that did: the page it named is reachable from the app navigation and renders when opened, and the list view either draws rows of its object or has been deleted. No navigation entry points at a view that now renders an empty grid by accident. +- **`list-view-sort-string-clause-retired`** — `view.list.sort / view.listViews.*.sort — the bare string sort clause` → The structured array, `sort: [{ field, order }]`, with `order` written out — a bare field name meant ascending — and one entry per key of a comma-separated clause, in the same order. + - Why not automatic: The D2 conversion `list-view-sort-string-clause-to-array` rewrites a string clause in the grammar the wire normalizer splits on — `'created_at desc'`, a bare field name, a comma-separated list — into the array, losslessly, across every view payload in `stack.views[]`. Two cases are deliberately left for the author. A string that does NOT parse as that grammar — above all the leading-minus dialect, `'-created_at'` — is left alone and refused at the door, because guessing a direction would invent an ordering the author never wrote. And a clause under `objects[].listViews` is reached by no conversion, so it is refused at its own door until rewritten by hand. The clause was minted by the schema and refused by the renderer that lowers it into a query, so a view carrying one may already have been failing to load; which order the author meant is theirs to state. + - Done when: No list view in `stack.views[]` or in any object `listViews` map carries a string `sort`; the parse refuses one with the rewrite prescription. Every rewritten array names fields that exist on the view's object with the direction the author intends, and the view loads — rather than failing at the renderer — with its rows in that order. +- **`list-view-tabs-retired`** — `view.list.tabs / view.listViews.*.tabs — the list view's own tab definitions` → One named list view per tab, under the object's `listViews` — the saved-view switcher above the object's records renders every entry as a tab. The tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry, beside the `columns` the tab should show. A tab whose `view` already named a list view needs nothing more. + - Why not automatic: The D2 conversion `view-list-tabs-removed` deletes `tabs` from every list payload in `stack.views[]`, in all three persisted spellings, and the delete is lossless in pixels: no renderer ever mounted a tab bar for the key, so a view that declared tabs has always drawn without them, and it still does. The judgment the conversion cannot make is the author's intent: each tab was a named preset the author wanted end users to switch to, and the platform delivers that as a named list view, not as a sub-key of one. Which tabs deserve an entry, what each should filter and show, and whether the switcher already lists an equivalent, are the author's decisions. The tab keys with no list-view counterpart — `icon`, `order`, `pinned`, `isDefault`, `visible` — never had an effect either. One boundary is the author's by construction: tabs declared under `objects[].listViews` are reached by no conversion, so such an object is refused at its own door until edited by hand. + - Done when: No list view in `stack.views[]` or in any object `listViews` map declares `tabs`; the parse refuses the key by name at every list-view door. For each view that did: every tab the author still wants is a `listViews` entry with its own `label`, `filter` and `columns`, and it appears as a tab in the switcher above the object's records and shows the rows its filter selects; a tab nobody wants is simply gone. No page-level `userFilters` preset bar changes — that `tabs` is a different key, and it stays. +- **`logging-durations-unit-in-key`** — `HttpDestinationConfig `batch.flushInterval` / `retry.initialDelay` / `timeout` and LoggingConfig `buffer.flushInterval` (system/logging.zod.ts)` → `batch.flushIntervalMs` (default 5000) / `retry.initialDelayMs` (default 1000) / `timeoutMs` (default 30000) on HttpDestinationConfig, and `buffer.flushIntervalMs` (default 1000) on LoggingConfig — rename the keys; every value (milliseconds) is unchanged + - Why not automatic: Maintainer ruling A, 2026-09-11: the gate that reads a duration key's JSDoc lands last, after its offenders are fixed file by file — so this entry executes, per file, the rule that a duration number key carries its unit in its name. All four keys named milliseconds in a source JSDoc — "Flush interval in milliseconds", "Initial retry delay in milliseconds", "Timeout in milliseconds" — and the JSDoc above a key is not what `content/docs/references/**` renders; `.describe()` is, and none of the four carried one at all. Measured by the `check:duration-unit-keys` census on this tree before the change, all four read `[name: -] [prose: -]`: no unit in the key and no published prose to supply it, so `content/docs/references/system/logging.mdx` printed a bare 5000 / 1000 / 30000 / 1000 and nothing on the page decided milliseconds from seconds. Under that rule's gate, moving the unit into the describe alone is itself a violation (unit in prose, none in the name), so each key is renamed and given the describe it never had in the same stroke. ⚠️ `flushInterval` was declared TWICE on this file, in two different defs and with two different defaults — 5000 on the HTTP destination's batch and 1000 on the logging buffer — so they are two keys, each with its own tombstone and its own registered row; the prescriptions name their def so a reader who lands on one is not sent to the other. The `Ms` suffix is the family's own spelling, counted in key position on this tree: 272 `*Ms:` declarations in `packages/spec/src` against 75 `*Seconds:`, and the only competing unit spellings are 3 `*MS:` and 9 `*Millis:` — every one of them a name fixed outside this repo (MongoDB's `maxCommitTimeMS` and `connectTimeoutMS`, node-postgres's `idleTimeoutMillis` and `connectionTimeoutMillis` on `PoolConfigSchema`), so unlike the `Ttl`-versus-`TTL` question a sibling round settled there is no in-repo alternative to choose between. All three target spellings were already attested as key-position `*.zod.ts` declarations before this change: `flushIntervalMs` 1 (`kernel/events/integrations.zod.ts`, same 1000 default), `initialDelayMs` 5, `timeoutMs` 30. Tombstoned with `retiredKey()` rather than deleted because none of the four enclosing objects — `HttpDestinationConfig` itself and its nested `batch` and `retry`, and `LoggingConfig`'s nested `buffer` — is `.strict()`, so a bare deletion would have stripped the value in silence. Why a semantic entry and not a D2 conversion: `stack.zod.ts` declares no logging collection and neither `LoggingConfigSchema` nor `HttpDestinationConfigSchema` is referenced anywhere in `packages/spec/src` outside `system/logging.zod.ts`, so the chain has no rehydration seam that runs on an authored logging document — the same reading `tenant-schema-cache-ttl-unit-in-key` recorded for its sibling key. Measured on 4dab2bc5c: no in-repo runtime reads any of the four — outside `packages/spec/src/system/logging.zod.ts` and its test the only occurrences are the generated rows in `content/docs/references/system/logging.mdx`, which this rename regenerates; and the pinned objectui checkout — `.objectui-sha` = `f0268ad784854568aa58a2aa791f6a7502259186` — spells `flushInterval` 0 times, `initialDelay` 0, `HttpDestinationConfig` 0 and `LoggingConfig` 0 across its 8234 tracked files, against lit controls `useState` 2622 and `timeout` 1658 on the same corpus (all four 0 across 7754, against 2491 and 1431, at a58626c88, 0 across 7650, against 2478 and 1360, at 0abd4f9f8, 0 across 7632, against 2477 and 1360, at 9dfaca654, 0 across 7579, against 2477 and 1351, at 2e818d0b5, 0 across 10267, against 2476 and 1348, at ab1879721, 0 across 10071, against 2470 and 1331, at 89cad75d5, 0 across 9912, against 2469 and 1303, at 31971ff1e, 0 across 9800, against 2464 and 1293, at e420df310, 0 across 9546, against 2449 and 1197, at db11afd49, 0 across 9283, against 2435 and 1172, at dd3f7e1be, 0 across 8512, against 2391 and 1096, at f8a9d0fb0, and 0 across 8303, against 2389 and 1086, at 62597c588). + - Done when: Every HTTP log destination spells `batch.flushIntervalMs`, `retry.initialDelayMs` and `timeoutMs`, and every logging buffer spells `buffer.flushIntervalMs`; authoring any of the four retired spellings fails to compile and fails to parse with a rename prescription naming the suffixed key and its def; the parsed defaults are 5000 / 1000 / 30000 / 1000 as before; and each published describe names milliseconds. +- **`manage-org-presentation-retired`** — `the manage_org_presentation platform capability (its PLATFORM_CAPABILITIES entry in @objectstack/spec security, the ORG_PRESENTATION_AUTHORING_CAPABILITY constant exported by @objectstack/metadata-core) and the arm of metaWriteCapabilityVerdict that admitted its holders to org-scoped writes of the five org-overridable types through the /meta item doors` → grant `manage_metadata` to whoever must author views, dashboards, reports, translations or email templates through Studio or `PUT /api/v1/meta//`; such a write now lands environment-wide (`organization_id` NULL) and is served to every organization of the deployment. There is no organization-bounded authoring capability: delete `manage_org_presentation` from every permission set's `systemPermissions`, and delete any import of `ORG_PRESENTATION_AUTHORING_CAPABILITY`. `metaWriteCapabilityVerdict` takes `{ isSystem, systemPermissions, operation }`: drop the `canonicalType` and `activeOrganizationId` members from the call + - Why not automatic: ADR-0131 D6 retires the per-organization overlay axis, and the /meta doors stop carrying an organization into a metadata write (the companion entry meta-doors-organization-scope-retired). The capability admitted an organization admin to exactly the writes those doors threaded into the admin's own organization; with no organization threaded, keeping it would have admitted its holders to environment-wide authoring, which is the reach of manage_metadata and a wider one than the capability ever granted. It was granted by no shipped permission set, so a deployment that never granted it by hand observes nothing. + - Done when: PLATFORM_CAPABILITY_NAMES no longer holds manage_org_presentation, so the authoring lint resolves the name only where a stack itself declares or grants it. A caller holding manage_org_presentation and not manage_metadata is answered 403 on PUT, DELETE, publish and rollback of /api/v1/meta// (FORBIDDEN on the REST doors, PERMISSION_DENIED on the dispatcher), whatever its active organization. A persisted permission set naming the capability still loads and its other grants still apply. The sys_capability row the platform seeded for it earlier is not pruned (the seeder upserts only); it names a capability nothing consults, and an operator may delete it in Setup. +- **`manifest-id-reverse-domain-required`** — `manifest.id — `ObjectStackManifest.id`, i.e. `defineStack({ manifest: { id } })` and the `id:` key of a package manifest — and its registry face `PackageSchema.manifestId` (`marketplace/package.zod.ts`)` → a reverse-domain identifier matching `MANIFEST_ID_PATTERN` (`kernel/manifest.zod.ts`): two or more lowercase dot-separated segments of letters, digits and inner hyphens, each opening with a letter or a digit, never a hyphen — `com.acme.crm`, `org.apache.superset`. ⛔ Underscores are not admitted, so `manifest.namespace` is never a legal id and never a legal last segment of one: `com.acme.my_app` becomes `com.acme.my-app`. A bare word gains a prefix: `blank` becomes `com.example.blank`. The refusal carries the repaired value it has already checked against the pattern, so the prescription is in the error text, not only here. + - Why not automatic: Two declarations named one identity and drifted. `PackageSchema.manifestId` — what the registry stores and addresses a package by — has always carried the reverse-domain regex; `ManifestSchema.id`, the key an author actually writes, was `z.string()` and accepted anything. So a package scaffolded, validated, built and booted with an id the publish path would refuse, and the author met the rule for the first time at the one moment it was most expensive to meet. The two sites now reference ONE exported constant, which is what makes a future divergence a visible edit rather than a silent one. Why the rule holds for a package nobody publishes: the TSDoc's own words are "unique across the entire ecosystem" — an id names the artifact for the ecosystem it may one day join, so a private app is named under the same rule as a listed one. Why it is a D3 semantic TODO and not a D2 conversion: the value IS the identity. A mechanical rewrite would re-point every install, dependency declaration and stored `manifest_id` row at a package that, to the registry, is a different one — and the safe choice between "rename the package" and "keep the id and change nothing that depends on it" is not derivable from the metadata. + - Done when: Every `manifest.id` you author matches the pattern, and `defineStack` / `objectstack validate` report no `manifest.id` finding. Prove the rename side separately, because the schema cannot: for each id you changed, confirm nothing still addresses the old value — no installed row, no `dependencies` entry in another package's manifest, and no registry listing. If any does, the correct answer is a deliberate republish under the new id, not an in-place edit. +- **`manifest-permissions-string-list-retired`** — `manifest.permissions as a flat list of permission strings (and packages[].manifest.permissions) — the legacy arm of ManifestPermissionsSchema left; the schema is now the structured plugin permission block alone` → the structured block `permissions: { services, hooks, network, fs }` — each a list naming the platform services the plugin resolves, the lifecycle hooks it registers, the network hosts it reaches and the filesystem paths it touches; or no `permissions` key when the plugin needs none + - Why not automatic: ADR-0049 enforce-or-remove: the flat list was parsed and never acted on. The loader registers the consented grant set on the environment artifact with the permission enforcer, never the manifest's request, so a list granted, refused and requested nothing at load; the only code that met one was two reports saying it had been skipped. The D2 conversion `manifest-permissions-string-list-removed` deletes the list from existing sources and stored artifacts, losslessly for every load. What it cannot do is translate: a capability string such as `system.user.read` names no service, hook, host or path, so whether the plugin needs a grant at all, and which, is the author's judgement. Authoring now refuses a list at parse with that prescription, and TypeScript rejects it + - Done when: No manifest — the stack's own, any packages[] entry, any objectstack.plugin.json — declares `permissions` as a list; a list is refused at parse with its prescription, and TypeScript rejects it. Every plugin whose dropped list stood for a real need declares it in the structured block, naming each service, hook, network host and filesystem path it touches, and `os plugin build` parses the manifest clean. A plugin that needs no grant declares no `permissions` key. +- **`manifest-version-semver-2-0-0`** — `manifest.version — `ObjectStackManifest.version`, i.e. the `version:` key of `defineStack({ manifest })` and of a package manifest — and its three sibling declarations `MetadataPluginManifestSchema.version` (`kernel/metadata-plugin.zod.ts`), `PluginRegistryEntrySchema.version` (`kernel/plugin-registry.zod.ts`) and `PluginMetadataSchema.version` (`kernel/plugin-validator.zod.ts`), plus the `PATCH /api/v1/packages/:id` door in `@objectstack/runtime`` → a SemVer 2.0.0 string matching `SEMVER_2_0_0_VERSION_PATTERN` (`kernel/version-grammar.ts`). ⭐ This is a WIDENING for almost every author: prerelease and build suffixes are accepted for the first time, so `2.0.0-beta.1`, `17.0.0-rc.5`, `1.0.0+20230101` and `1.0.0-rc.1+exp.sha.5114f85` now pass a key that refused all of them, and identifiers may carry either ASCII case. ⛔ The one thing that stops being accepted is a leading zero in the numeric core: `01.1.1` becomes `1.1.1` — or a different version, if the padded form was standing in for one. + - Why not automatic: One concept — "the version of a package or plugin" — was judged by four different grammars across ten carriers in two repositories, and the strictest of them, this one, refused `2.0.0-beta.1`: the exact string a sibling declaration documented as an example of itself. The contradiction was observable between doors on the same resource, not merely between schema files — the build step refused a prerelease the publish door accepted, while the install door parsed nothing at all. The maintainer ruled one canon, and named it after the standard the repository already claimed in this key's own `.describe()`, in the generated reference docs, in the Studio help text and in two ADRs: SemVer 2.0.0. Why the narrowing is not losslessly convertible: a version is an identity. `01.1.1` and `1.1.1` are the same release to a reader and different strings to every registry row, dependency declaration and installed artifact that stored one of them, and which of the two an author meant is not derivable from the metadata. + - Done when: Every `manifest.version` you author is a SemVer 2.0.0 string, and `defineStack` / `objectstack validate` / `os plugin build` report no `version` finding. The only values that need touching are those with a leading zero in a numeric segment — the in-repo authoring corpus measured ZERO of them, so most consumers have nothing to change. For each one you do change, confirm nothing still addresses the old string: no installed row, no `dependencies` range in another manifest, no registry listing. Prove the widening separately and cheaply: a prerelease version that used to be refused at build time now builds. +- **`mapping-lookup-params-retired`** — `mapping.fieldMapping[].params.object / .fromField / .toField / .autoCreate — the per-entry reference-resolution keys of a lookup mapping` → Nothing on the mapping. A `lookup` entry copies the cell through, and reference resolution runs afterwards off the TARGET field's own metadata: its `reference` names the object searched, and the cell is matched as a display value (a name, an email or a record id). Records a row points at must exist before the import runs. + - Why not automatic: The D2 conversion `mapping-lookup-params-removed` deletes the four keys from every mapping entry's params, and the delete is lossless: the import path never read them, so stripping them changes no imported row. The judgment is about what the author believed. `autoCreate` read as "create the referenced record when nothing matches", and nothing was ever created — an unresolved cell fails its row with `import_reference_not_found`, with or without the key. An import pipeline built on that belief has been losing those rows, and now needs the referenced records seeded first. `object`, `fromField` and `toField` read as the target and the matching columns, and were never consulted: where they named something OTHER than the target field's own `reference` or a column the resolver matches on, the rows were linked by the field's metadata, not by the mapping — and only the author knows which one they meant. + - Done when: No mapping entry carries the four keys; the parse refuses them. For each mapping that carried them: the target field's `reference` names the object the author meant the rows to link to, and a dry run of a representative file resolves every reference cell (no `import_reference_not_found` row) — or the missing referenced records are created by a step that runs before the import, since the import itself never creates them. Row counts and links match the pre-upgrade import of the same file. +- **`memory-persistence-auto-save-interval-unit-in-key`** — `datasource.config.persistence.autoSaveInterval on the memory driver — the file and auto persistence arms` → `autoSaveIntervalMs` — the same interval, in milliseconds, on both arms; the minimum of 100 and the file arm's 2000 default are unchanged. + - Why not automatic: The D2 conversion `memory-persistence-auto-save-interval-to-ms` renames the key on both persistence arms of every memory-driver datasource and on stored datasource rows, keeping the value, and leaves a string persistence mode, a custom adapter and every other driver's config alone; the rename is lossless because the key always meant milliseconds. The judgment is whether each value was written in that unit. Nothing in the old name said so, and the auto arm's description named no unit at all. A seconds value below 100 was already refused by the bound, but one above it was not — `autoSaveInterval: 300` meant as five minutes saved every 300 milliseconds, and the rename keeps 300. The interval also bounds how much in-memory data a crash can lose, so the author is choosing a durability trade-off, not only a number. + - Done when: No memory-driver datasource carries `autoSaveInterval` on either arm; the parse refuses it with the rename. Every `autoSaveIntervalMs` value is the interval the author intends in milliseconds — a store meant to save every five seconds reads `autoSaveIntervalMs: 5000`. With file persistence on, a write followed by waiting longer than that interval leaves the change in the persisted file, and the author accepts losing at most that interval of writes on a crash. +- **`memory-persistence-placeholder-refused`** — `memory driver config `persistence.path` (file persistence and the `auto` override) and `persistence.key` (localStorage and the `auto` override) — values containing `${…}` placeholder syntax` → the literal path or key. For environment-specific destinations, leave the key unset and let the shared datasource factory scope the default per datasource, or compute the config value in code before it enters `defineStack` + - Why not automatic: The unresolved-placeholder defect one surface over from the datasource connection keys, where it is already refused: a `${…}` placeholder in memory persistence config is resolved by NOTHING — the driver would create and write a literal `./${DATA_DIR}/…` path, or write under the literal placeholder-bearing localStorage key, so the dump lands in a wrongly-named location with no error naming the unresolved placeholder (authored under the same false belief the 2026-08-13 ruling closes: placeholder syntax in connection-material keys is refused at publish, because nothing resolves it). These two keys are config-material like the connection keys, so the parent adjudication applies with its reason intact; the memory driver's `initialData` stays deliberately UNJUDGED — it carries arbitrary record values, where a literal `${…}` may be legitimate data. There is no mechanical rewrite: the placeholder names a value that exists only in the author's intended deployment environment, which a source-file transform cannot know. + - Done when: Every memory datasource parses with no `${…}` span in `persistence.path` or `persistence.key`; `initialData` record values containing literal `${…}` keep parsing byte-identically. +- **`meta-doors-organization-scope-retired`** — `the organization the /meta doors of @objectstack/rest and of the runtime dispatcher thread into a metadata write (PUT, DELETE, publish, rollback) or read (item, list, layered, published, drafts, history, audit, diff, diagnostics, references) of the five org-overridable types; the organizationIdForMetaWrite export of @objectstack/metadata-core; the metaReadOrganizationId export of @objectstack/rest; and the Default Organization read of the email-template boot sweep in @objectstack/plugin-email` → nothing to write: every `/meta` write now lands environment-wide (`organization_id` NULL) and every `/meta` read resolves environment → code, for every caller and every tenancy posture. Delete any import of `organizationIdForMetaWrite` (a write carries no organization) or `metaReadOrganizationId` (a read carries none either). A caller that needs the vetted organization of a request for another purpose still reads `metaCallerOrganizationId` + - Why not automatic: ADR-0131 D6 retires the per-organization overlay axis: environment metadata written by Studio, by the cloud build agent or by a template install belongs to the whole deployment. The doors threaded the active organization for view, dashboard, report, translation and email_template, so under the single posture, where the Default Organization is active, every Studio save of those types was stored under that organization. The read and the write flip together: reads-first would hide the organization rows the doors still wrote, writes-first would let those rows shadow new environment saves. + - Done when: A manage_metadata caller with an active organization saves a view through PUT /api/v1/meta/view/ on either transport: the stored row carries organization_id NULL and GET serves it. An organization-scoped row stored before this release, including a single-posture Studio save filed under the Default Organization, is no longer served by any /meta read (the environment row or the code definition is), nor projected by the email-template boot sweep; it stays in sys_metadata untouched until the promotion ceremony (ADR-0131 C7) carries it to the environment layer. Re-save such an item in Studio to make the edit live on the /meta doors now. Public forms are the exception: until that ceremony the anonymous form doors read a form view in the Default Organization and prefer its overlay for the form's body, while a withdrawal in either layer closes the form, fail-closed. So a legacy organization overlay of a public form keeps serving its body there: a Studio re-save of that body (an environment row) does not change the body the public form serves, and a Studio withdrawal (an environment row) still closes it. +- **`metadata-changed-event-payload-retired`** — `kernel.cluster metadata change event payload (`MetadataChangedEventPayloadSchema` in kernel/cluster.zod.ts — 2 defs, 4 exported names: `MetadataChangedEventPayloadSchema`, `MetadataChangedEventPayload`, `MetadataChangeOperationSchema`, `MetadataChangeOperation`)` → Nothing to migrate to, because nothing ever emitted or consumed it. The cluster invalidation channels that actually run are the three lanes documented in content/docs/kernel/cluster.mdx §6.2: `metadata.changed` (`ClusterMetadataChangedPayload` in `@objectstack/metadata` — the origin node, the metadata type and the replayed watch event), `metadata.mutated` (`ClusterMetadataMutationPayload` in `@objectstack/metadata-protocol`) and `datasource.mutated` (`ClusterDatasourceMutationPayload` in `@objectstack/service-datasource`). A host that needs cross-node cache invalidation subscribes to one of those; a host that held the retired type for a transport of its own keeps a local type — the spec no longer declares one. + - Why not automatic: ADR-0049 enforce-or-remove (triage ruling 2026-09-02 on the spec seat: remove via the ADR-0087 route, not "make a consumer" — that is contract growth with no pull). The docblock declared that all metadata persistence layers MUST emit a `metadata:changed` event with this payload and that every reader MUST subscribe and compare `version` before invalidating. Measured at the retirement's base commit with positive controls: zero runtime producers, zero subscribers, zero imports outside packages/spec (its own unit test, the isomorphic alias pin and the generated artifacts) in objectstack, and nothing in objectui at the pinned sha. It was unenforceable by construction — the `version` field is `z.bigint()`, which the standard JSON serializer refuses, so the payload as declared could not cross any pubsub transport without a codec no driver ships: a MUST-emit contract no conforming emitter could satisfy. The shipped channels all carry an address-only signal whose receiver re-reads its own store (the 2026-09-01 ruling for the registry lane), the opposite of the declared version-compare receipt, so the one plausible future consumer was decided against; the 2026-08-27 ruling on transitions removes a staged window. `MetadataChangeOperationSchema` existed only to type the payload's `operation` field and leaves with it as its orphan value schema (the `DistributedStateConfig` precedent). Route 3: not an authorable surface — no metadata-type binding, stack collection or manifest embed ever carried it, and nothing parsed it outside its own unit test — so no tombstone and no D2 conversion; `RETIRED_DEFS_BY_MAJOR[18]` (`kernel/MetadataChangedEventPayload`, `kernel/MetadataChangeOperation`) plus this entry ARE the declaration. + - Done when: No code imports `MetadataChangedEventPayloadSchema`, `MetadataChangedEventPayload`, `MetadataChangeOperationSchema` or `MetadataChangeOperation` from `@objectstack/spec` or `@objectstack/spec/kernel` — every one is TS2305 after upgrade (pinned by runtime namespace probes in kernel/cluster.test.ts, with `ClusterCapabilityConfigSchema` as the positive control). No metadata document needs editing: the schema was reachable from no metadata-type binding, stack collection or /meta door. ⚠️ Runtime behaviour is deliberately UNCHANGED: no emitter or subscriber ever existed, and the three shipped cluster lanes publish the same bytes before and after — the retirement removes a false declaration, not behaviour. +- **`metadata-customization-protocol-retired`** — `the paper metadata-customization protocol: `kernel/metadata-customization.zod.ts` whole (`MetadataOverlay`, `FieldChange`, `CustomizationOrigin`, `MergeConflict`, `MergeStrategyConfig`, `MergeResult`, `CustomizationPolicy`) / the section-5 Overlay/Customization API contracts (`api/MetadataOverlayResponse`, `api/MetadataOverlaySaveRequest`, `api/MetadataEffectiveResponse`) / the optional `getOverlay`/`saveOverlay`/`removeOverlay`/`getEffective` members of `contracts/metadata-service.ts` / the authorable keys `MetadataPluginConfig.customizationPolicies`, `MetadataPluginConfig.mergeStrategy` and `MetadataManagerConfig.persistence.overlayWritable` (tombstoned; see `RETIRED_KEYS_BY_MAJOR[18]`)` → nothing to re-declare — delete any authored keys. The customization mechanisms that actually ship: ADR-0005's org-scoped overlay (opt-in via `allowOrgOverride` on `DEFAULT_METADATA_TYPE_REGISTRY`, stored as `sys_metadata` org rows, written through the REST meta write doors and read back through `getMetaItemLayered`'s `code`/`overlay`/`effective` layers), and ADR-0126's packaged-metadata customization model (clone with a new machine name + ledger disable — never a field-level patch overlay) + - Why not automatic: ADR-0049 enforce-or-remove; the maintainer's ruling of 2026-08-29 adopted retirement and rejected a re-scope, and it was executed widened to the full coupling set the fork report on that ruling measured: the module declared a three-layer platform/user patch-overlay protocol with field-level change tracking and a 3-way-merge story, published reference docs described it as the customization architecture — and nothing reachable implemented it. The one implementation (`packages/metadata`'s manager limb) was served by no route and called only by its own unit tests; no merge engine ever existed; no code read a `CustomizationPolicy`. ADR-0126 §6 wall 4 supersedes the protocol as a matter of record ("nothing may build against it") — the per-field overlay layer it described is precisely what the 2026-08-24 lock-and-clone ruling (lock the packaged base, customize a clone) left deliberately unchartered. Why D3 semantic and not a D2 conversion: the defs leave with no carrier key in any stack collection, and the three tombstoned keys live on plugin/manager configs, which are not stack collection members (`PLURAL_TO_SINGULAR` has no `plugins` entry) — a MetadataConversion would be a transform with no seam that ever runs (the `kernel/Manifest:loading` precedent). + - Done when: No import of `metadata-customization.zod` (or of the retired names from `@objectstack/spec/kernel` / `@objectstack/spec/api`) compiles anywhere; no `MetadataPluginConfig` carries `customizationPolicies` or `mergeStrategy`; no `MetadataManagerConfig` carries `persistence.overlayWritable` (TypeScript authors get the refusal at compile time — the keys are typed `never` — and a value reaching the parse is refused with the prescription at the key's path). ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: no route ever served the paper `…/overlay` / `…/effective` endpoints, so removing the limb removes no served behaviour — the ADR-0005 org-overlay read/write path (`getMetaItemLayered`, the REST meta write doors) stays exactly as it was, before and after. +- **`metadata-endpoints-switch-radius-repartitioned`** — `restServer.metadata.endpoints.items / restServer.metadata.endpoints.item` → An `endpoints.*` switch now gates exactly the face its name states, reads and writes alike. `items` gates `GET {prefix}/:type` and nothing else; the whole-store operations it used to take with it — `GET {prefix}/diagnostics`, `GET {prefix}/_drafts` and the `POST {prefix}/_migrate-stored` write door — answer to the new key `endpoints.maintenance` (default `true`). `item` now gates the WHOLE per-item face: `GET` / `PUT` / `DELETE {prefix}/:type/:name`, `/references`, `/layers`, the history family (`/history`, `/audit`, `/diff`, `/published`, `/publish`, `/rollback`) and `GET {prefix}/book/:name/tree`. ⇒ An embedder that authored `endpoints: { items: false }` to close the whole-store family writes `endpoints: { items: false, maintenance: false }`. An embedder that authored `endpoints: { item: false }` to close only the per-item READS has no key that keeps the writes: the per-item face is one face, so leave `item` on and close the surface at `api.enableMetadata`, or per object at `enable.apiEnabled` / `enable.apiMethods`. `types` is unchanged and `api.enableMetadata` remains the master switch above all four. + - Why not automatic: Not losslessly convertible, and not compiler-carried either — the two channels that would otherwise reach a consumer are both blind here. No key is renamed, removed or retyped: every one is an optional boolean, so `{ items: false }` compiles and parses exactly as before and simply mounts a different route table. A D2 conversion would have to GUESS which of the four routes the author meant to close, and the two readings differ by a write door — rewriting `{ items: false }` to `{ items: false, maintenance: false }` preserves the old mounts but presumes an intent the author never expressed, while leaving it alone re-mounts `POST {prefix}/_migrate-stored`. That is a judgment, so it is delegated rather than automated. The change itself is the ADR-0049 declared-vs-enforced defect in the direction the liveness ledger structurally cannot look: all three keys were genuinely live, and what had drifted was each one's RADIUS against its own `describe()` — `items` gated a migration write door while naming a listing read, and `item` gated four reads while its own `PUT` / `DELETE` and the history family answered to `api.enableMetadata` alone. The maintainer ruled the two together on 2026-09-06 as one principle: every `endpoints.*` switch gates exactly the face its name states, and the whole-store family gets a key of its own. Measured population at the time of the move: ZERO — no shipped boot path constructs a `RestServerConfig`, so only programmatic embedders can have authored these keys at all. + - Done when: For each `RestServerConfig` the consumer constructs, `new RestServer(...).registerRoutes()` followed by `getRoutes()` yields the route table the consumer intends — specifically: with `endpoints.items: false` authored, `GET {prefix}/diagnostics`, `GET {prefix}/_drafts` and `POST {prefix}/_migrate-stored` are PRESENT unless `endpoints.maintenance: false` is also authored; and with `endpoints.item: false` authored, `PUT {prefix}/:type/:name`, `DELETE {prefix}/:type/:name` and the six history routes are ABSENT. A consumer that authored neither key is unaffected and needs no change: all four switches default `true` and the default route table is byte-identical to before. The reference measurement is `packages/rest/src/rest-config-mount-table.pin.test.ts`, which asserts each switch's radius as a set difference against the all-true baseline in both directions. +- **`metadata-item-name-grammar-enforced`** — `metadata item names (the `name` half of the `type`/`name` addressing pair — `saveMetaItem` / `publishMetaItem`, `PUT /api/v1/meta/:type/:name` and the compound `:type/:section/:name` fold)` → lowercase snake_case segments, optionally dot-qualified — the pattern family of METADATA_ITEM_NAME_PATTERN, i.e. one or more [a-z][a-z0-9_]* segments joined by single dots (`crm_lead`, `crm_lead.pipeline`). A name that spelled a sub-resource with a slash (`views/all_leads`) is re-authored with a dot qualifier (`crm_lead.pipeline` — the `ViewItemNameSchema` convention, now enforced with the qualifier optional) or flattened with an underscore (`views_all_leads`); containment is expressed by structure, never by a separator inside the identity string. + - Why not automatic: Maintainer ruling (2026-08-25): metadata item names must not contain `/` — identity-with-separator is the measured root cause of a defect family (URL arity mismatches, dual-arity route-mount obligations, route shadowing, a two-rule URL spelling split in one SDK file). The grammar was entirely unconstrained at the door: the empty string, `//` and `Views/All Leads` were all accepted and stored as item names, and a slash in the name bypassed the unrecognised-metadata-type refusal (`type=fieldz name=a/b` was accepted while `type=fieldz name=a` was 400). Whether a stored slash-name (out-of-repo deployments only — the in-repo census measured zero) should be renamed, and to what, is a judgment the chain cannot make, so no mechanical conversion ships with the narrowing. + - Done when: Every write through `saveMetaItem` / `publishMetaItem` whose name is lowercase snake_case segments optionally joined by single dots succeeds exactly as before, flat and dotted alike. Any other name — slash, empty, whitespace, uppercase, leading/trailing/double dots — is refused `400 INVALID_REQUEST` with the grammar and the dotted prescription in the message, and nothing is persisted. Reads and `deleteMetaItem` still answer for pre-grammar residue rows, so any stored junk name remains listable and clearable. +- **`metadata-manager-config-cache-ttl-unit-in-key`** — `MetadataManagerConfig `cache.ttl` / `cache.databaseLoader.ttl` (kernel/metadata-loader.zod.ts)` → `cache.databaseLoader.ttlMs` (milliseconds, default 60000) — rename the nested key; the value is unchanged. The outer `cache.ttl` has NO replacement: its respelling `ttlSeconds` was retired before it shipped (see `metadata-manager-config-inert-cache-keys-retired`) — delete the key; nothing ever read it + - Why not automatic: Maintainer ruling B of 2026-09-02 on duration-shaped keys (no grandfathered baseline): the unit of a duration-shaped `z.number()` key lives in the key NAME or in a unit-carrying value, never only in the description. This block was the founding specimen: two keys spelled `ttl` fourteen lines apart, the outer one in SECONDS (3600) and the nested DatabaseLoader one in MILLISECONDS (60000), each unit named only in `.describe()`. An author who copied the outer number into the inner block got a 3.6-second cache with no error anywhere — the number was valid, the type was right, the cache was simply cold. Both keys are retiredKey tombstones (the nested objects are not strict; a bare deletion would strip the old key in silence). Why a semantic entry and not a D2 conversion: `MetadataManagerConfig` is the runtime MetadataManager's constructor config, not a stack collection member and never a stored row, so the chain has no seam that ever runs on it (the `kernel/Manifest:loading` and `metadata-plugin-additional-types-retired` precedent). The one in-repo reader, `DatabaseLoader` (`packages/metadata`), reads `cache.databaseLoader.ttlMs` at the same magnitude it read `ttl`; the outer `cache.ttl` had no runtime reader (measured on ca46f8f12, and retired on its own under ADR-0049 before this rename shipped — so this entry's outer half is a deletion, not a rename, and the `ttlSeconds` spelling never reached a published release). + - Done when: Every `new MetadataManager({ cache: … })` / `MetadataManagerConfigSchema.parse(…)` site spells `cache.databaseLoader.ttlMs` and no outer TTL at all; authoring either old `ttl` fails to compile (input type `never`) and fails to parse with a prescription — the nested one naming `ttlMs`, the outer one prescribing deletion and naming `cache.databaseLoader.ttlMs`; a DatabaseLoader configured with `ttlMs: 60000` expires entries after 60 seconds exactly as `ttl: 60000` did. +- **`metadata-manager-config-inert-cache-keys-retired`** — `MetadataManagerConfig `cache.enabled` / `cache.ttlSeconds` (formerly `cache.ttl`) / `cache.maxSize` (kernel/metadata-loader.zod.ts; tombstoned, see `RETIRED_KEYS_BY_MAJOR[18]`)` → nothing to re-declare — delete the three outer keys. The cache that actually runs is the DatabaseLoader read-through LRU under `cache.databaseLoader`: its `enabled` (default true) is the switch, `ttlMs` (milliseconds, default 60000) the TTL and `maxSize` (an entry count, default 500) the cap + - Why not automatic: ADR-0049 enforce-or-remove (the owning seat's ruling, conditioned on the measurement below and re-taken on the merged ref): the outer `cache` block of `MetadataManagerConfig` advertised three knobs — `enabled` (default true), `ttlSeconds` (default 3600; `ttl` until the duration-unit rename) and `maxSize` ("bytes") — that no runtime read. The only consumer of the block is `MetadataManager` (`packages/metadata`), which hands `cache.databaseLoader` and nothing else to `new DatabaseLoader({ cache })`; a repo-wide reader census over `packages/**` (tests and changelogs excluded) found no runtime reader of any outer key, while the same grep shape found the nested `cache?.databaseLoader` read twice (the positive control). An author writing `cache: { enabled: false }` or `cache: { ttlSeconds: 60 }` got a clean parse and a cache that behaved exactly as before, and the published reference page documented all three as if they configured something. All three are retiredKey tombstones (the nested object is not strict; a bare deletion would strip them in silence — the same no-op one layer down). The duration-unit ruling's `ttl` → `ttlSeconds` rename, registered under this same major and never shipped, is folded into the removal: `cache.ttl`'s tombstone now prescribes deletion rather than a rename to a key that is itself retired, so a 17.x author sees one hop. Why a semantic entry and not a D2 conversion: `MetadataManagerConfig` is the runtime MetadataManager's constructor config, not a stack collection member and never a stored row, so the chain has no seam that ever runs on it (the `kernel/MetadataManagerConfig:persistence.overlayWritable` precedent). The other candidate — wiring readers for a second cache layer — was not taken: no consumer for one exists, and an implementation for an unmeasured need is the shape ADR-0049 refuses. + - Done when: No `new MetadataManager({ cache: … })` / `MetadataManagerConfigSchema.parse(…)` site spells `cache.enabled`, `cache.ttlSeconds`, `cache.ttl` or `cache.maxSize` (TypeScript authors get the refusal at compile time — the keys are typed `never` — and a value reaching the parse is refused with the prescription at the key's path, naming `cache.databaseLoader`). ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: the DatabaseLoader read-through cache configured under `cache.databaseLoader` (`enabled` / `maxSize` / `ttlMs`) behaves exactly as before, and a config that never wrote the outer keys parses to the same output minus the two former defaults (`enabled: true`, `ttlSeconds: 3600`) that were materialized and never consulted. +- **`metadata-plugin-additional-types-retired`** — `metadata plugin `config.additionalTypes` (on `MetadataPluginConfig`)` → nothing to re-declare — delete the key. There is no declared-kind channel: a kind enters the live metadata-type set as a side effect of registering an ITEM of that kind (`SchemaRegistry.registerItem` during app/manifest registration, or `MetadataManager.register` at runtime). Bind the kind's schema with `registerMetadataTypeSchema(type, schema)` from the plugin's `init(ctx)` so `GET /api/v1/meta` serves a real JSON Schema for it + - Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling of 2026-08-14: remove the key, jointly with refusing unknown types at the `/meta` boundary by the static registry. The key was declared, authorable, on the published authorable surface, and documented on four docs pages as THE way a plugin registers a custom metadata type — and read by NOTHING. The only production writer of the manager's type registry is `setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY)` (`packages/metadata/src/plugin.ts`), called exactly once outside tests, and it REPLACES the array outright; nothing ever merged `additionalTypes` into it. Measured against the real `MetadataManager`: declared count == live count (27 == 27), `getRegisteredTypes()` sorted equals the built-in registry sorted. So an author who followed the published instructions wrote the key, got no error, and nothing happened — the same silence trap as the plugin lifecycle's `onInstall` (a documented hook with no invocation site), one level down, in exactly the AI-authoring path (ADR-0033). The joint consequence: with this plugin-declared channel removed, the static registry is the total universe of legal metadata kinds, which makes refuse-by-static-registry at the /meta boundary safe by construction. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections. A metadata-plugin config is neither — `PLURAL_TO_SINGULAR` has no `plugins` entry, so it is not a stack collection member and a conversion would be a transform with no seam that ever runs (the `kernel/Manifest:loading` precedent). + - Done when: No `MetadataPluginConfig` — inline in TypeScript or embedded at the manifest's `config` key — carries `additionalTypes`. TypeScript authors get the refusal at compile time (`additionalTypes` is typed `never`); a value reaching the parse is refused with the prescription (`invalid_type` at path `additionalTypes`). ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ever read the key, so removing it removes no behaviour — the live type set stays exactly `DEFAULT_METADATA_TYPE_REGISTRY` plus item-population growth, before and after. +- **`migrations-entry-split`** — `The ADR-0087 migration chain and change manifest, imported from the package root @objectstack/spec: MIGRATIONS_BY_MAJOR, MIGRATION_MAJORS, MIGRATION_SUPPORT_FLOOR, RETIRED_KEYS_BY_MAJOR, RETIRED_DEFS_BY_MAJOR, applyMetaMigrations, composeMigrationChain, MigrationFloorError, composeSpecChanges, composeReleaseChanges, the seven change-manifest schemas (SpecChangesSchema, SpecConvertedSchema, SpecMigratedSchema, SpecSurfaceAddSchema, SpecSurfaceRemoveSchema, SpecReleaseChangesSchema, SpecReleaseSurfaceSchema), and the types MigrationStep, MigrationApplication, MigrationChainResult, MigrationHopResult, MigrationTodo, SemanticMigration, SpecChanges, SpecConverted, SpecMigrated, SpecSurfaceAdd, SpecSurfaceRemove, SpecReleaseChanges, SpecReleaseSurface, SurfaceDiff, ReleaseSurfaceDiff and PreviousReleaseRegistries` → the same names, unchanged, imported from `@objectstack/spec/migrations` — change the import path and nothing else. The chain, its steps and semantic entries, the retired-key and retired-def tables and the change-manifest schemas are the same objects, and `objectstack migrate meta` replays the same chain. The ADR-0087 conversion layer stays on the package root: `ALL_CONVERSIONS`, `CONVERSIONS_BY_MAJOR`, `applyConversions`, `applyConversionsToFlow`, `applyConversionsToStoredItem`, `collectConversionNotices`, the three `CONVERSION_*_CODE` constants and their types still import from `@objectstack/spec`. + - Why not automatic: The maintainer ruled that the console first-screen size ceiling is raised now and paid back at the source; this split is that payback. The migration registry is mostly the guidance text `objectstack migrate meta` prints, and the package root re-exported it. The registry does work when its module loads (the list of majors and each step's rationale are computed then), so no bundler could prove it unused, and all of that text rode in every bundle of the root, whatever the consumer imported: 1,761,987 of the root ESM bundle's 3,766,221 bytes. With the chain on its own subpath the CommonJS root is 2,009,810 bytes instead of 3,780,033, and a browser bundle of the ten names the Studio console imports from the root drops from 700,884 to 301,287 bytes gzipped. The conversion layer does not move: `defineStack` and `normalizeStackInput` read it at run time, so moving its names would narrow the root and shrink it by under two kilobytes. The split moves an import path, which is TypeScript source rather than metadata — nothing authors, stores or parses it — so there is no source a D2 conversion could rewrite, and the move is recorded here. + - Done when: No code imports any of these names from the package root `@objectstack/spec` — each such import is a TS2305 "has no exported member" error after upgrade, and at run time the binding is undefined. The same names import cleanly from `@objectstack/spec/migrations`. No metadata document, stored row or JSON Schema reference needs editing: the chain, its tables and the schemas did not change, and `objectstack migrate meta` rewrites the same documents it did before. +- **`object-block-sort-item-array`** — `The `sort` prop of `object-grid` and `object-calendar` in `ComponentPropsMap` (the FORM: the accept-anything `z.unknown()` at both block doors, vs the `SortItem` array `[{ field, order }, ...]`)` → `z.array(SortItemSchema)` at both doors — the array `ElementDataSourceSchema.sort`, `ListPageSchema.sort` and `element:record_picker`'s flat `sort` shorthand already carry. The legacy OData-ish clause `sort: 'created_at desc'` becomes `sort: [{ field: 'created_at', order: 'desc' }]`; a bare field name `sort: 'created_at'` meant ascending and becomes `sort: [{ field: 'created_at', order: 'asc' }]` — `order` is required in `SortItemSchema`, so it is written out rather than omitted. A comma-separated clause becomes one array entry per key, in the same order. `record:related_list` is NOT moved by this entry: its string is the `'field'` / `'-field'` dialect read by `RelatedList.normalizeSortSpec`, which never reaches `convertSortToQueryParams`, and retiring it was not ruled. `object-grid.defaultSort` is a different key, retired separately by the `ui__ObjectGridProps__defaultSort` entry. + - Why not automatic: One `sort` spelling platform-wide, the array: the maintainer's ruling of 2026-09-07 (option B) retired the legacy string `sort` clause, and its consumer half is the objectui change that drops the string arm from `convertSortToQueryParams`. One item of that ruling is this entry's subject: 「`ComponentPropsMap` for `object-calendar` and `object-grid` constrains the `sort` value to the array shape (today it accepts anything), so the spec, the registrations and the helper agree; that is a pull-back to the declared contract, ordinary tier」. The `z.unknown()` at both doors was a read-point record from the change that brought the `object-*` blocks into `ComponentPropsMap` (the maintainer's ruling of 2026-08-12), the same vintage as the `filter` doors the `element-data-source-and-object-block-filter-rule-array` entry moved, and not an exception to the ruling: measured on `@objectstack/spec` 17.2.0 an array, a string and a bare NUMBER all returned `success: true` while `bogusProp` was refused by name on the same call, so key checking was live and only the VALUE was unheld. Meanwhile objectui's own html tier has published `type: 'array'` for the grid all along (`plugin-grid/src/index.tsx:222`) and answered `type-mismatch` on the string — a spelling `@object-ui/core` implemented, the docs taught and the validator refused, which is what made this a ruling rather than a mechanical widening. Sequenced measurement-first: at the objectui pin this repo builds against (`53ded82b`) the string is still lowered — `ObjectGrid.tsx:1844-1851` carries an explicit `typeof === 'string'` arm onto `$orderby`, and `ObjectCalendar.tsx:431` hands `schema.sort` to `convertSortToQueryParams`, whose string arm is still present at `sort-query.ts:66-70`. So this declaration lands AHEAD of the pinned consumer, which the ruling permits explicitly (either order; the registrations already declare the array). The in-repo sweep found ZERO authored `sort` on either block — the two showcase pages that author `object-grid` (`command-center.page.ts`, `my-work.page.ts`) declare none — with the same grep shape finding 40+ string `sort` values at OTHER doors (view definitions, ObjectQL `query.sort`) as the control that the sweep fires; so this entry carries the prescription for authors outside the repo. ⚠️ Metadata AT REST is deliberately NOT rewritten and this disposition adds no D2 conversion: `os migrate meta --stored` replays D2 conversions only, and the read path does not re-validate stored rows (`applyConversionsToStoredItem` replays the chain without validating, by its own contract), so a stored page carrying a string `sort` keeps loading and is still rendered by objectui at the pinned `.objectui-sha`. What changes is that RE-SAVING it is refused at the `sort` door, on its next save and not before. ADR-0049, ADR-0087. + - Done when: `ComponentPropsMap['object-grid' | 'object-calendar'].safeParse({ objectName, sort: [{ field: 'created_at', order: 'desc' }] })` succeeds and the parsed `sort` is that same array, equal value-for-value to `ElementDataSourceSchema.parse({ object, sort: }).sort`. The legacy string clause is refused at the `sort` path on both doors (`invalid_type`, expected array), and so is a bare number; a misspelled or ABSENT direction is refused at `sort.0.order` (`invalid_value` — `order` is a required enum, so both take one verdict) and a missing field at `sort.0.field` (`invalid_type`). An undeclared key is still refused BY NAME on the same call (`unrecognized_keys` naming it), the control that makes those refusals verdicts rather than a schema reporting nothing. No `sort` door in `ComponentPropsMap` accepts a string except `record:related_list`, which is the one deliberate exception. At runtime each block orders exactly as the array orders — the same `$orderby` the string lowered to. +- **`object-grid-data-view-data-converged`** — ``object-grid` component props — `data` (the KIND: bare array `z.array(z.unknown())` vs the `ViewDataSchema` provider object)` → `ViewDataSchema` — the provider-discriminated object (`provider: 'object' | 'api' | 'value' | 'schema'`). Static inline rows move from `data: [...]` to `data: { provider: 'value', items: [...] }` — the same rows, wrapped in the one arm that means "hardcoded data array". The other three arms are unchanged `ViewDataSchema` semantics; `staticData` (the deprecated bare-array shortcut the renderer still reads) keeps its shape but is not the prescription + - Why not automatic: Two entries of one contract disagreed on the KIND (contract-vs-contract, found by objectui's declared-arm parity gate): `ComponentPropsMap['object-grid'].data` said bare array ('Static inline rows — bypasses the object query') while `ViewDataSchema` — the authority objectui aligned the grid's registry declaration to, pinned by `gridDataInputContract.test.ts`, and what `ObjectGridSchema.data` resolves to — is an object discriminated on `provider`. Measured on @objectstack/spec@17.2.0: `{ provider: 'value', items: [] }` — the pinned-legal form — was REFUSED by the props-map entry (`expected array, received object`) while the bare array parsed. Whichever authority a value satisfied, the other refused it, and the objectui parity gate had to carry the reasoned exemption `object-grid.data:object` to look away. The maintainer's ruling of 2026-08-25 (option A) converged the props-map entry onto `ViewDataSchema`; the bare-array form is the deprecated `staticData` shortcut that objectui's deprecated-alias carve-out already refuses to publish as authoring surface. The ruled migration check ran with the change: the sweep of generated artifacts, templates and first-party corpora (examples/, skills/, create-objectstack, spec fixtures) found ZERO bare-array `data` authors, so no rewrite ships — this entry carries the prescription for authors outside the repo. + - Done when: `ComponentPropsMap['object-grid'].safeParse({ data: { provider: 'value', items: [] } })` succeeds (and the other `ViewDataSchema` arms parse through the same entry); a bare-array `data: [...]` is refused at the `data` path. An author carrying `data: [...]` writes `data: { provider: 'value', items: [...] }` — same rows, one wrapping object. Downstream (objectui, after a released spec version reaches the pin): the `object-grid.data:object` exemption entry in `registry-inputs-spec-parity.test.ts` becomes deletable, which is what closes the objectui finding that the two authorities disagreed. +- **`object-grid-default-filters-rule-array`** — `the object-grid page block's defaultFilters property — the legacy base-filter fallback in ComponentPropsMap, which was z.unknown and therefore accepted a bare string, a number, a MongoDB-style record, an ObjectQL AST tuple array and a list of malformed rules alike` → the same ViewFilterRule array form its sibling filter takes — [{ field, operator, value }, ...]. A record-form fallback { status: "active" } becomes [{ field: "status", operator: "equals", value: "active" }] and several record keys become several rules, which AND; an operator object { amount: { $gt: 100 } } lifts the operator into the rule, becoming [{ field: "amount", operator: "greater_than", value: 100 }]; an AST tuple array [["owner_id", "=", "{current_user_id}"]] becomes [{ field: "owner_id", operator: "equals", value: "{current_user_id}" }], value placeholders and date macros unchanged. Legacy operator shorthands are accepted and normalized on parse. Better still, write the rules on filter and delete this key: it is read only when filter is absent, and its own description has prescribed filter all along + - Why not automatic: The protocol half of the maintainer's ruling C-prime of 2026-09-20 on objectui's render-time filter converter — the protocol is the only refusal set, so a document it accepts never throws at render time — verbatim, untranslated: 「the differences are the protocol's to close」. This is the SAME value in the SAME role as filter — the key's own description says it is read only when filter is absent — and the consumer reads it through the SAME lowering sink, so every refusal that sink can give was reachable from a document the protocol had just accepted. filter converged on the rule array with the rest of its family; this key was not named by that ruling and kept the pre-convergence read-point shape, which left the block with one declared door and one undeclared door onto one seam. The parse receipt said nothing about what the grid would then do with the value, and in the objectui version this release pins that depended on the shape: ObjectGrid lowers defaultFilters through toFilterNode whenever filter lowers to nothing, so a record form and an AST tuple array were lowered and applied as declared; a bare string or a number was dropped without a word, so the grid sent no filter and listed its rows unfiltered; and a list of malformed rules was refused — on the wire with 400 INVALID_FILTER, or by the client before any request for the value shapes it judges itself. ⛔ This entry is a NARROWING and deliberately not a retirement. Refusing the key outright — the other arm the finding offered — removes an accepted shape and needs its own ruling; the deprecation already stated in the description is unchanged and still says to prefer filter. Metadata AT REST: the record form and the AST tuple array at this key are rewritten to the rule array by the same D2 conversion as its sibling filter, page-component-filter-record-to-rule-array, wherever the mapping is lossless — by os migrate meta --stored, and on every stored-row read until it runs. What it cannot map losslessly is left exactly as stored and keeps rendering as it does today — a combinator, a null value, an operator the rule vocabulary does not spell, or the bare string or number this key also took — and its door refuses such a value only as the component-props gate's advisory finding (os validate, os build, os lint), since a re-save through the metadata API is not refused there: a record form with the message the filter door gives, a worked rewrite computed from the author's own keys and a pointer to this entry's conversion table, and a bare string or number or an AST tuple array with the schema's plain type refusal. ADR-0049 / ADR-0087. + - Done when: Every object-grid node in your pages either omits defaultFilters or carries a ViewFilterRule array on it. The parse of an object-grid node whose defaultFilters is that array raises no issue at the key; a record form is refused AT defaultFilters with the conversion table and a worked rewrite built from the keys that were written, and an AST tuple array is refused one level in, at the first element. What to re-check depends on the shape that was there, as the objectui version this release pins treats it. A record form or an AST tuple array was lowered and applied, so for those the rewrite is a spelling change. A bare string or a number was dropped by that lowering, so the grid has been listing its rows unfiltered — decide which rows it is supposed to show before writing the rule that selects them. A list of malformed rules was refused when the grid loaded. Where both keys are authored, that grid reads defaultFilters only when filter lowers to nothing: beside a non-empty filter, deleting defaultFilters is the whole migration; beside filter: [] the grid reads defaultFilters, so move those rules onto filter rather than deleting them. +- **`object-grid-default-sort-retired`** — `page.component.object-grid.defaultSort — the legacy single-pair second spelling of the grid sort` → `sort: [{ field, order }]` — the array every read path honours; a single pair is a one-entry array. + - Why not automatic: The D2 conversion `object-grid-default-sort-removed` follows the renderer's own precedence: where `sort` was absent the `defaultSort` pair WAS the grid's sort, so it moves to `sort` as a one-entry array; where `sort` was present the pair was never read, so it is deleted. Both are behaviour-preserving, and the second is where the judgment sits. A grid that authored both keys with DIFFERENT orders has always loaded in the `sort` order while its author may believe `defaultSort` governed the initial load — the key's name says it should have. The conversion keeps the order users have been seeing and discards the one the author wrote; only the author can say which one they meant. Code that builds object-grid props (a host, a generator) must also stop emitting the key, which no conversion reaches. + - Done when: No `object-grid` component carries `defaultSort`; the parse refuses it. Each grid's `sort` array lists the fields and directions the author intends, and the grid loads with its rows in that order and shows that column as sorted. For every grid that had authored both keys, the author has compared the discarded `defaultSort` pair with the kept `sort` and confirmed the kept one. +- **`object-grid-resizable-columns-retired`** — `page.component.object-grid.resizableColumns — the legacy second spelling of the grid column-resize switch` → `resizable: true | false` — the one spelling the grid reads; the value is the same boolean. + - Why not automatic: The D2 conversion `object-grid-resizable-columns-removed` follows the renderer's own precedence, `resizable ?? resizableColumns`: where `resizable` was absent the legacy value WAS the grid's setting, so it moves to `resizable` unchanged; where `resizable` held a value the legacy key was never read, so it is deleted. Both are behaviour-preserving, and the second is where the judgment sits. A grid that authored both keys with DIFFERENT values has always behaved as `resizable` said, while its author may believe the other key governed it. The conversion keeps what users have been seeing and discards the value the author also wrote; only the author can say which one they meant. Code that builds object-grid props (a host, a generator) must also stop emitting the key, which no conversion reaches. + - Done when: No `object-grid` component carries `resizableColumns`; the parse refuses it. Each grid that should let users drag column borders either omits `resizable` (the renderer default is on) or sets it to `true`, and each that should not sets `resizable: false`. For every grid that had authored both keys, the author has compared the discarded value with the kept `resizable` and confirmed the kept one. +- **`object-index-unknown-keys-refused`** — `object `indexes[]` entries (`IndexSchema`) — undeclared keys` → the declared surface: `name` / `fields` / `unique` (ADR-0120 scope). A key that names no declared capability is simply removed. `where` — the console fallback editor's drifted spelling for a partial-index predicate, removed when objectui converged that editor onto `IndexSchema` — gets a curated prescription: partial indexes are built at the database layer (`CREATE [UNIQUE] INDEX … WHERE` from a runtime migration), never declared here + - Why not automatic: The unknown-key strictness campaign held this site open on a measured risk, the kind that had already made a console save answer 422 (a strict schema refusing a key the console itself writes): objectui's embedded index editor shipped a drifted hand-copied schema offering `where` and `brin`, spliced its output into `object.indexes[]` and PUT the whole object, so closing the shape would have 422'd a control the console itself rendered. objectui then converged that editor to the declared surface, spending the hold's evidence. Before this close an undeclared key on an index parsed clean and was silently dropped — an admin filling the old "Partial-index predicate" control got a green save while no driver ever read the predicate (`syncDeclaredIndexes` consumes `name`/`fields`/`unique` only). Undeclared keys are now refused at parse time with a prescriptive message; the protocol-17 `type`/`partial` tombstones keep answering their own migration text. + - Done when: Every `indexes[]` entry parses with only `name` / `fields` / `unique`. Declared keys parse byte-identically to before, at every ADR-0120 `unique` spelling. A stored body from the drift window carrying `indexes[].where` is rejected with the database-layer prescription rather than saved with the key silently dropped. +- **`object-kanban-quick-add-retired`** — `page.component.object-kanban.quickAdd — the per-column quick-add switch on the metadata-driven board` → (removed from the metadata board.) Delete the key; `object-kanban` offers no quick-add control. On a metadata board, records are created through the object's ordinary create action. + - Why not automatic: The D2 conversion `object-kanban-quick-add-removed` deletes `quickAdd` from every `object-kanban` component, and the delete is lossless: the board forwarded the flag, but the control also needs a host-supplied `onQuickAdd` function that JSON cannot carry and no producer ever put on an object-kanban node, so the gate was permanently false and no board ever showed the control. The residue is the requirement behind the flag. An author who set `quickAdd: true` wanted users to add a card inside a column; that never happened and still does not. Whether the board can live without it is a product decision about that board — not something a key delete can make. + - Done when: No `object-kanban` component carries `quickAdd`; the parse refuses it. Each board renders the same columns and cards as before the upgrade. For each board that had set the flag, the author has accepted creating records through the object's create action: `object-kanban` offers no quick-add control. +- **`object-master-detail-form-detail-sort-field-retired`** — `page.component.object-master-detail-form.details[].sortField — a detail entry's authored line-position field` → Nothing on the entry: delete the key. The line grid stamps each line's position into the child object's own field, derived from the child object: its first field named `position`, `sort_order`, `sequence`, `line_no`, `line_number` or `sort`. To keep the line order a drag-reorder sets, give the child object one of those fields (under the name the deleted key named, when it is one of them). + - Why not automatic: The D2 conversion `object-master-detail-form-detail-sort-field-removed` deletes `sortField` from every `object-master-detail-form` detail entry, and the delete is lossless: the console stopped reading the authored override, and the line grid stamps the field it derives from the child object whatever the entry says. What the conversion cannot decide is where the line order lives. An entry whose key named a field the derivation does not pick — a name outside that list, or a second sort-named field after the first — saves its line order into the derived field instead, or nowhere when the child object has none. An entry that names `relationshipField` and at least one column and gives every column a `type` is kept exactly as authored: no child schema is loaded for it, so no line position is stamped and a drag-reorder is not saved, before and after the upgrade alike. + - Done when: No `object-master-detail-form` detail entry carries `sortField`; the props lint reports one with the prescription. For each entry that had set it, the child object declares the field the line order is kept in under one of the derived names, and after a drag-reorder and save the lines reload in the order they were dragged into. +- **`object-tenancy-organization-field-retired`** — `object.tenancy.organizationField — the column a platform row is stamped from, as distinct from the column the object is walled by` → `tenancy.tenantField` — one column that both walls the object and stamps its platform rows. The stamp-only divergence is a platform-internal fact now, kept for the platform's own credential table. + - Why not automatic: The D2 conversion `object-tenancy-organization-field-removed` deletes the key from every object's `tenancy` block in author sources and on stored object rows, and the delete is lossless: the key's only readers were three platform-row writers, pinned by name to platform tables, so an application that declared it was never read. The judgment is what the declaration was for. An author who set `organizationField` to a column other than `tenantField` asked for platform rows (audit stamps, approval rows, automation-run records) to carry a different organization column than the one walling the data — and never got it. If the object's real tenant column is not `organization_id`, the fix is `tenancy.tenantField`, which moves the wall as well as the stamp; whether moving the wall is correct for that object is a data-isolation decision only its author can make. + - Done when: No object declares `tenancy.organizationField`; the `tenancy` block refuses it with the prescription. Every object whose tenant column is not `organization_id` declares that column as `tenancy.tenantField`. A record created by a user of one organization is stored with that organization in the tenant column, a user of another organization cannot read it, and the audit stamp on the change names the same organization. +- **`observability-cel-predicates-retired`** — `metrics.slis[].successCriteria, the CEL predicate arm of the union (the structured { threshold, operator, percentile? } arm is untouched) / tracing.sampling.composite[].condition, the CEL predicate arm of the union (the structured filter arm is untouched). Both arms were reachable in two spellings: the bare-string shorthand and the { dialect: 'cel', source } envelope. Reachable wherever metadata is authored or stored: defineStack sources, an exported stack passed to objectstack validate, a POST body on a metrics or tracing config, and a row already sitting in sys_metadata` → the structured arm each slot already carried, or your observability infrastructure. On `successCriteria` write the threshold rule — `{ threshold: 300, operator: 'lt', percentile: 0.95 }` — which is the shape an SLO product consumes. On a composite sampling `condition` write a structured filter: a plain object of match criteria carrying no `dialect` key, e.g. `{ service: 'api', attributes: { 'http.route': '/v1/orders' } }`. ⚠️ Neither replacement is mechanical, and neither is a like-for-like: a criterion or a sampling rule the structured shape cannot express has no home in application metadata at all and belongs in the SLO product or the OpenTelemetry sampler configuration that actually evaluates it + - Why not automatic: DECLARED, DOCUMENTED, AND EVALUATED BY NOTHING — which is why this is a semantic TODO rather than a mechanical strip. Both arms parsed, normalized a bare string to `{ dialect: 'cel', source }`, registered and were served back, and no service, plugin, runtime or CLI path ever read either key: an identity scan over the whole tree finds every hit for `successCriteria`, `ServiceLevelIndicatorSchema` and `TraceSamplingConfigSchema` outside `packages/spec/src` to be a generated artefact or prose, and inside it the only readers are the schemas' own unit tests plus the two census tests that enumerate expression slots. So an author — very often an AI reading the generated reference page, ADR-0033 — who wrote `successCriteria: 'p95 < 300ms'` got a green parse and no signal, indistinguishable from a predicate that ran and answered. ADR-0049 enforce-or-remove, ruled A by the maintainer on 2026-09-18: by the standing criterion that a declared-but-unread capability is kept only when mainstream platforms in the domain have it, application platforms do not carry SLI success criteria or trace-sampling conditions as authorable application metadata — that lives in observability infrastructure (SLO products, OTel sampling policy) and is structured there, not a free expression. The `cron-declared-unwired` family was retired outright under the same ADR after the same measurement. A mechanical D2 strip was weighed and declined: a predicate is an intent no threshold/operator pair or attribute filter records, so stripping the key would delete what the author meant and leave no trace of which SLI or which sampling branch lost it — exactly the judgment a semantic TODO exists to hand back. ⚠️ And a strip here is not merely lossy, it is INVALID: `successCriteria` is a REQUIRED key, so removing it leaves an SLI that no longer parses, and a composite sampling branch that loses its `condition` declares no condition at all — inert today, and the moment a sampler is wired it reads as UNCONDITIONAL. That is the difference from the two error-map precedents this retirement copies its MECHANISM from — `crypto.hash` on HookBodyCapability and `managedBy: 'system'` — both of which also registered a D2 conversion, because for each of them a mechanical rewrite existed. Here none does, which is what makes D3 the right disposition rather than merely an available one. ⚠️ The structured arm of each union is NOT decided here: it is equally unread today, and it is measured on its own card. ADR-0087, ADR-0058 D7, ADR-0049. + - Done when: Sweep every authored metadata source and every `sys_metadata` row of the metrics and tracing config types for a CEL predicate at the two slots — in BOTH spellings: a bare string, and an object carrying a `dialect` key. For each hit, decide per the `replacement` note whether the intent is expressible as the structured shape (write it) or belongs in your observability stack (delete the key and move the rule there). ⛔ Do not translate a predicate into a threshold by guessing the number — nothing was evaluating it, so there is no behaviour to preserve and a wrong number is worse than an absent one. Two proofs. (1) `objectstack validate` is clean on a stack authored in config files: a surviving predicate is refused at the slot with the retirement prescription. ⚠️ TWO CHANNELS, and they do not cover the same set — measured, not assumed. `tsc` catches the BARE-STRING spelling at both slots, and the `{ dialect, source }` envelope at `successCriteria` only (the structured arm is a closed object literal, so the envelope is an excess-property error). It does NOT catch the envelope at `condition`: the surviving arm there is a record of string to unknown, which admits `{ dialect, source }` structurally, so that one spelling compiles and is refused at PARSE by the arm's `dialect` rule. ⛔ Do not read a clean `tsc` as a clean sweep of `condition`. The PRESCRIPTION divides differently again: it reaches the author for every refused spelling at `condition`, and for the string spelling only at `successCriteria`, where the envelope is refused by the structured arm's own missing-key issues (`threshold`, `operator`). All three legs are pinned in the schemas' unit tests. (2) For stored rows, load the tenant and confirm every metrics and tracing config still rehydrates: a row carrying a predicate at either slot now fails its parse at the load seam and is reported there, naming the slot. A row whose `successCriteria` is a structured rule and whose sampling `condition` objects carry no `dialect` key parses byte-identically to before — the retirement removes accepted shapes and adds none. +- **`package-api-contracts-unmounted-entries-retired`** — `api.PackageApiContracts.upgradePackage / api.PackageApiContracts.resolveDependencies / api.PackageApiContracts.uploadArtifact — the three contract-map entries that bound POST /api/v1/packages/upgrade, POST /api/v1/packages/resolve-dependencies and POST /api/v1/packages/upload` → nothing — no route serves any of the three paths, so there is no entry to read instead. Delete every read of `PackageApiContracts.upgradePackage`, `PackageApiContracts.resolveDependencies` and `PackageApiContracts.uploadArtifact`, and every URL built from them or from the three hard-coded paths: a request to any of them was never answered. The per-route request/response schemas (`PackageUpgradeRequestSchema`, `PackageUpgradeResponseSchema`, `ResolveDependenciesRequestSchema`, `ResolveDependenciesResponseSchema`, `UploadArtifactRequestSchema`, `UploadArtifactResponseSchema`) stay published, bound to no route. The four surviving entries (`listPackages`, `getPackage`, `installPackage`, `uninstallPackage`) are unchanged. If the platform later serves a package upgrade, dependency-resolution or upload route, its entry arrives in the same change that mounts it. + - Why not automatic: Maintainer ruling of 2026-09-23 (option A: retire the three contract-map entries that name paths nothing mounts). The contract map is the declaration SDKs, codegen and AI clients are entitled to trust, and three of its seven entries named paths the composed runtime mounts nowhere: the package dispatcher has no branch for a single-segment POST under /packages and `@objectstack/rest` mounts only /packages/publish there, so all three answered handled=false while the four surviving entries answer 200/201 (measured on one HttpDispatcher over a real SchemaRegistry) — and the generated reference page printed all three as live endpoints. Unlike `installPackage` (rebound by an earlier fix onto the serving POST /api/v1/packages), no serving door existed to rebind them onto, and mounting three capabilities with zero measured pull was ruled out (ADR-0049 enforce-or-remove). Zero consumers measured at the retiring PR's base: across this repository the three paths occur only in the declaring file, its unit test and the generated page, and the pinned objectui checkout names none of the three keys, none of the paths and not `PackageApiContracts` itself. A contract-map entry is not metadata — nothing authors, stores or parses it — so there is no source a D2 conversion could rewrite, and the removal is recorded here. + - Done when: No code reads `PackageApiContracts.upgradePackage`, `.resolveDependencies` or `.uploadArtifact` from `@objectstack/spec` or `@objectstack/spec/api` — each is a TS2339 property error after upgrade, and at runtime the key is absent (pinned in api/package-api.test.ts together with the rule that no surviving entry is bound to any of the three paths). No client, route table or generated artefact of yours still names POST /api/v1/packages/upgrade, /resolve-dependencies or /upload. No metadata document needs editing. ⚠️ Runtime behaviour is deliberately UNCHANGED: nothing ever mounted the three paths or built a route from the entries, so every request answers exactly as before — the removal retracts a false claim, not a capability. +- **`package-install-request-unknown-keys-refused`** — `api.installPackage request body, WRAPPED form — an undeclared TOP-LEVEL key beside manifest on POST /api/v1/packages (PackageInstallRequestSchema, the wrapped branch of PackageInstallBodySchema)` → the declared wrapped body: `manifest`, plus any of the declared install options (`settings`, `enableOnInstall`, `overwrite`, `platformVersion`, `artifactRef`). A misspelled option is respelled as the option it meant — `enabledOnInstall` → `enableOnInstall`, which the refusal itself offers — and any other undeclared key is removed. The bare form (a manifest as the whole body) is unchanged: it was already closed, and it still carries no install options. + - Why not automatic: One rule for the whole install contract (the maintainer's ruling of 2026-09-27, option A: the wrapped form refuses an unknown top-level key by name). The manifest and the bare form already refused an unknown key by name; the wrapped top level was the one position still declared strip mode, so `{ manifest, enabledOnInstall: false }` — a misspelled `enableOnInstall` — parsed green with the key DROPPED, and the install door, which answers exactly what this declaration says since it parses the whole body (c02fa1276), installed the package ENABLED: the caller's explicit `false` inverted, with no word said. The sentence that had forbidden this close rested on «the declaration must not refuse a body the door answers 201 to», which held only while the door did not parse its body; with the door answering per declaration the premise became circular and constrains nothing. No alias and no grace window. Not losslessly convertible: an unknown key has no mapping target, and auto-deleting it would repeat the silent drop this closes, so each occurrence needs the caller's decision — respell or remove. First-party reach measured before the close: the SDK install call sends only `manifest`, `settings`, `enableOnInstall` and `overwrite`, and the objectui package dialog sends only `{ manifest }`, so no in-repo caller breaks. Out-of-repo callers are NOT MEASURED — a caller that sends a private top-level key now gets a 400 naming it. + - Done when: Every wrapped install body carries only `manifest` and declared install options. A body with any other top-level key is refused `400` / `VALIDATION_ERROR` at POST /api/v1/packages with the key named and nothing installed, and PackageInstallRequestSchema answers the same body with one `unrecognized_keys` issue at the top level naming the key. `{ manifest }` alone, and `manifest` with every declared option, parse and install exactly as before. +- **`package-manifest-version-grammar-enforced`** — `PackageManifestSchema.version (`marketplace/package-version.zod.ts`) — the `version` key inside the manifest snapshot frozen into `sys_package_version.manifest_json` at publish time` → a SemVer 2.0.0 string matching `SEMVER_2_0_0_VERSION_PATTERN` (`kernel/version-grammar.ts`). This key was a bare `z.string()`, so it is the one carrier where the grammar is entirely new: `latest`, `v1.0.0`, `1.0`, the empty string, a trailing space and `2.0.0-beta.1extra!` were all accepted and sealed into a published snapshot, and each is refused now. A dist-tag becomes the version it pointed at (`latest` → `1.4.2`); a `v`-prefixed string drops the prefix (`v1.0.0` → `1.0.0`); a two-segment string gains its patch (`1.0` → `1.0.0`). + - Why not automatic: A downstream told "the spec validated it" got no validation at all from this carrier. The sibling key it belongs to — `PackageVersionSchema.version`, the row this manifest hangs off — enforced a grammar the whole time, so the SAME release was judged by a rule in one field and by nothing in the adjacent one, and the unjudged value is the one that got frozen and shipped. That is the shape Prime Directive #10 refuses: a declaration advertising a constraint the runtime never applies. The canon ruling gave every carrier of this concept one grammar, and a carrier with no grammar could not be left out of it without keeping the hole open under a new name. Why a D3 semantic TODO rather than a D2 conversion: the repairs above are one-directional guesses. `latest` names whichever release was current when the snapshot was sealed, which is not recoverable from the snapshot, and `1.0` may mean `1.0.0` or the newest `1.0.x` — a transform that picked either would seal a different release under the same checksum. + - Done when: Every package your registry serves still installs, and `manifestJson.version` parses for each one. The check is cheap and exhaustive: read `manifest_json` on each `sys_package_version` row and test its `version` against the grammar. A row that fails was already carrying a value no other carrier would have accepted — confirm what release it was meant to name before choosing the replacement, because the snapshot cannot tell you, and republish rather than editing a frozen snapshot in place. In this repository the measured count of such rows is zero. +- **`package-rollback-response-retired`** — `api.packageRollbackResponse (`PackageRollbackResponseSchema` in api/package-api.zod.ts — 1 def, 3 exported names: `PackageRollbackResponseSchema`, `PackageRollbackResponse`, `PackageRollbackResponseParsed` — plus the `PackageApiContracts.rollbackPackage` contract-map entry that bound it to `POST /api/v1/packages/:packageId/rollback`)` → `RollbackToPackageCommitResponseSchema` (api/package-lifecycle.zod.ts) — the transcription of what the live route actually answers: the dispatcher routes `POST /packages/:id/rollback` (body `{ commitId }`) to `rollbackToPackageCommit`, the ADR-0067 COMMIT rollback, whose declared return is `{ success, revertedCommits: string[], failed: Array<{ commitId, error }> }`. Consumers of the retired type were reading a VERSION-rollback shape (`restoredVersion`) the route has never answered; read `revertedCommits`/`failed` instead. `PackageRollbackRequestSchema` stays published (ruled out of the retirement), bound to no route. + - Why not automatic: Maintainer ruling of 2026-08-27 on the client SDK's unbound response contracts, sub-question 3A: retire this false declaration first, then author the true one. The schema declared a version rollback — `{ success, restoredVersion?, message? }`, matching its file header "Rollback a package" — while the live path it was contract-bound to serves the ADR-0067 commit rollback: a different operation with a different result. Binding it in the SDK would compile and be false (the change that typed the SDK's un-annotated return values left a compile-time guard against exactly that substitution). Zero consumers measured across objectstack, objectui and cloud (the ruling's own survey, re-verified at the retiring PR's base): only its own unit test and that negative guard. A published declaration that outran the implementation is the hazard of response bodies never checked against the schemas that declare them, realised in the opposite direction — not "no declaration" but a WRONG one — and it is retired BEFORE the true schema is authored so no window exists in which both claims are published. + - Done when: No code imports `PackageRollbackResponseSchema`, `PackageRollbackResponse` or `PackageRollbackResponseParsed` from `@objectstack/spec` or `@objectstack/spec/api` — every one is TS2305 after upgrade (pinned by runtime namespace probes in api/package-api.test.ts). `PackageApiContracts` carries no entry whose path is `/api/v1/packages/:packageId/rollback` (same pin). No metadata document needs editing: the schema was reachable from no metadata-type binding, stack collection or /meta door. ⚠️ Runtime behaviour is deliberately UNCHANGED: nothing ever registered routes or generated SDKs from the contract entry, and the route's handler emits the same bytes before and after — the retirement removes a false claim, not behaviour. +- **`package-version-row-semver-2-0-0`** — `PackageVersionSchema.version (`marketplace/package-version.zod.ts`) — the `version` column of a `sys_package_version` row, and through `CreatePackageVersionRequestSchema.version`, which references it, the version a draft release is created with` → a SemVer 2.0.0 string matching `SEMVER_2_0_0_VERSION_PATTERN` (`kernel/version-grammar.ts`). Two changes, opposite in direction. ⭐ WIDER: suffix identifiers may now carry either ASCII case, because SemVer 2.0.0 is case-preserving — `1.0.0-Beta.1` and `1.0.0+Build.5` are accepted where this key used to demand lowercase, and the plugin boot path has always accepted them. ⛔ NARROWER: the forms the standard forbids are refused — `01.1.1` (§2), `1.0.0-0123` and `1.0.0-alpha..1` (§9), `1.0.0+.` (§10). + - Why not automatic: This key's own docstring advertised `2.0.0-beta.1` as an example of itself while a sibling carrier of the same concept refused that exact string — the contradiction the canon card was filed over. The lowercase restriction was the narrowest published accept set of the four and had no standard behind it: it made a release row refuse a version the runtime that loads the release accepts, so a publisher could be turned away for a capitalisation the loader would never have noticed. Why the narrowing is a D3 semantic TODO rather than a mechanical rewrite: a published version row is immutable by contract — `manifestJson` and `checksum` freeze on transition to `published` — so a stored degenerate version is not edited in place at all. It is republished under a version that sorts, and whether the old row should be deprecated or left standing is a release decision the chain cannot make. + - Done when: Publishing and installing every release you have works unchanged. The widening needs no action and can be confirmed cheaply: a mixed-case prerelease that used to be refused at publish now creates a draft. For the narrowing, list your `sys_package_version` rows and check each `version` against the grammar — a leading zero in a numeric segment, or a doubled or trailing dot in a suffix, are the only shapes affected. Any row that fails stays readable and installable; what it can no longer do is receive a NEW draft at that spelling, so cut the next release at a version that sorts. +- **`packages-list-pagination-retired`** — `api.listPackages limit and cursor — the two query parameters of GET /api/v1/packages declared by ListInstalledPackagesRequestSchema. The same entry covers the limit default: the request schema no longer declares default(50)` → the `status`, `type` and `enabled` filters — this route answers the whole installed set and has no page 2. There is no replacement for `cursor`, deliberately: nothing ever minted one, so no caller holds a value to carry over, and the response `nextCursor` it would have paired with was never emitted. Callers that looped on it were re-reading the first and only page. For the removed `limit` default, there is nothing to send instead and nothing to restore: the server has never capped this list, so a caller that omitted the key received every installed row before this change and receives every installed row after it. A client that sized a buffer to the declared 50 should size it to the installed set instead + - Why not automatic: One capability, both halves, never half-deleted (maintainer ruling 2026-09-13, route 2 of three; routes 1 — build paging — and 3 — refuse unknown names — were considered and refused). `limit` and `cursor` were declared on the request and honoured on neither: the serving door filters on `status`, `type` and `enabled` and then returns every remaining row, and no emit site has ever written the response half `nextCursor`. `limit` is the sharper of the two because the repo's own ingress rule names it as the parameter whose silent drop is worst, and it is the silent-WIDENING half that was live: a caller asking for one row was handed the whole table alongside a `hasMore: false` that agreed with it. The `.default(50)` goes with the key because the FICTION WAS THE MECHANISM, not the number: nothing parses a query string through this schema, so the default has never stamped anything onto anything, while a reader of the published contract was entitled to believe an unparameterised list is capped. Re-spelling it as the real cap was not available — there is no cap. Pagination was removed rather than implemented because the installed-packages list is a small bounded collection and paging is not part of its meaning: route 1 would have grown a cursor protocol for a table of tens of rows, and the dispatch checked first whether a platform-wide cursor convention already existed that this door could have joined by reuse. It does not — no REST list door in the tree paginates, the one encode/decode cursor pair in the repo belongs to the storage-adapter list contract and is imported by no door, and the travel of this platform is the other way: `data.query.cursor` and `api/ListNotificationsRequest:cursor` were both retired before this one, for the same reason. Route 2, and the bookkeeping splits exactly as the notifications `cursor` retirement did. There IS a tombstone: the schema is non-strict, so a bare deletion would have made Zod SILENTLY STRIP whatever a generated client kept sending — a clean parse and a parameter that never takes effect, which is this issue's own defect re-created one layer down (ADR-0104). So both keys are `retiredKey()`, typed `never` for tsc and raising the prescription at any parse, and both are registered in RETIRED_KEYS_BY_MAJOR[18]. There is NO D2 conversion: a conversion rewrites an authored source or a stored `sys_metadata` row, and this shape is HTTP-only — nobody authors a `ListInstalledPackagesRequest` and nothing persists one. There is no `acceptRetiredDefaultResidue` stage either, for the same reason one layer along: nothing ever parsed this schema, so the retired default materialized into no artifact and there is no residue to accept. The same card closes the divergence in the OTHER direction, which is not a migration for anyone and is recorded here only so the two are not read apart: `type` (list), `version` (by-id) and `keepData` (uninstall) are query parameters the doors already executed and no request schema declared, and they are now declared where they are executed. No accept set moves — the doors served them before and serve them identically now. ADR-0049 / ADR-0087. + - Done when: No caller sends `limit` or `cursor` to `GET /api/v1/packages`: writing either on a `ListInstalledPackagesRequest` is a `tsc` error (the input type is `never`), which is the enforced channel, and any value reaching a parse raises the prescription rather than a generic unrecognized-key issue. ⚠️ Behaviour on the wire is deliberately UNCHANGED and must be verified as such: a request still carrying `?limit=1&cursor=x` is IGNORED, not refused — the door reads named query keys and no route validates this query against a schema, so an unknown key has never produced a 400 and does not start doing so here. The declaration stopped promising what the wire never did; the wire did not change. `hasMore` stays the constant `false` it already was and is now true by construction rather than by coincidence — with no request-side way to ask for a page there can be no next one — and `nextCursor` stays absent. A caller that omitted `limit` receives every installed row, exactly as it did before. +- **`page-assigned-profiles-audience-to-permission-set`** — ``page.assignedProfiles` — the per-page audience list (REMOVED)` → the object's permission sets, bound to people through positions. The page shows DATA; gate that data with the permission sets on the objects it reads (`objects..allowRead` and the field-level bits), and bind each set to the people who should hold it through a position (`sys_position_permission_set`). There is no per-page audience key to move the list into, and ADR-0090 D2 deleted the Profile concept the old list was written in, so each name in a retired `assignedProfiles` list has to be re-expressed as a permission set + position pair. + - Why not automatic: The D2 conversion `page-assigned-profiles-removed` STRIPS the key mechanically, but the strip is not the whole migration and must not read as one: the author who wrote the list was declaring an intent ("only these people see this page") that the platform never honoured. Measured at the ruling: zero readers in this repository and zero in objectui — no renderer, route or metadata read door consulted the key — so the page has been open to every caller who could reach it for as long as the key existed. Deleting it therefore changes no behaviour and closes no hole; it makes an unkept promise stop being made. Which permission set corresponds to a given profile name is a judgement no walker can derive, which is why this is a TODO rather than a rewrite. + - Done when: No page metadata carries `assignedProfiles` (the D2 conversion `page-assigned-profiles-removed` strips it from authored sources on a chain replay; `os migrate meta --stored` covers rows already at rest). For every page that carried one, each name in the old list resolves to a permission set held by the intended people through a position, and a caller OUTSIDE that audience, signed in, is refused the data the page reads — verified against the running deployment, not against the metadata alone. A caller who was previously outside an `assignedProfiles` list and could nonetheless open the page is the pre-existing state, not a regression introduced by the removal. +- **`page-component-responsive-retired`** — `page.components[].responsive — the per-breakpoint columns / order / hiddenOn block, and the exported ResponsiveConfig shape with its breakpoint maps` → The sibling `responsiveStyles` (ADR-0065): per-breakpoint CSS maps compiled to id-scoped CSS at render — for example `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens. + - Why not automatic: The D2 conversion `page-component-responsive-removed` deletes `responsive` from every page component wherever one can be authored, and the delete is lossless: no renderer ever read the block, so the per-breakpoint columns, order and visibility it declared parsed, validated and did nothing. This was also the block an earlier tombstone prescribed as the live alternative for dashboard widgets, so an author who followed that advice moved an inert key to an inert key and may still believe their page adapts to small screens. What remains is theirs to decide: whether the layout they declared is one they still want, and if so how to say it in CSS that is applied — `hiddenOn` maps to a `display` rule per breakpoint, while column spans and order are layout choices with no one-to-one CSS rewrite. Code that imported the retired shape (ResponsiveConfigSchema, BreakpointName, the breakpoint maps) must drop the import; nothing replaces it. + - Done when: No page component carries `responsive`; the parse refuses it, and no code imports the retired shape (each such import is a compile error). Each page renders exactly as it did before the upgrade at every breakpoint. Where the author re-expressed an intended adaptation through `responsiveStyles`, resizing the viewport across the named breakpoints shows it — a component declared hidden on the narrowest breakpoint is absent there and present above it. +- **`page-header-breadcrumb-retired`** — `page.component.page:header.breadcrumb — the page header's "Show breadcrumb" switch` → Nothing: delete the key, whether it was `true` or `false`. The navigation trail is drawn once, by the app shell's header, and is unchanged. + - Why not automatic: The D2 conversion `page-header-breadcrumb-removed` deletes `breadcrumb` from every page header, and no trail is lost: the renderer drew an empty slot for it and nothing ever filled that slot. The slot was the only thing either value changed — present for `true` and for an absent key, gone for `false` — so a header that said `false` reads as absent after the strip and shows the empty slot's spacing again until the renderer stops drawing it. What the conversion cannot decide is whether a page needs a trail of its own: inside an app the shell already draws one, and a page outside the shell that needs one is a feature to ask for, not a key to keep. + - Done when: No page header carries `breadcrumb`, and the props lint reports one with the prescription. Every page shows the same navigation trail in the app shell's header as before the upgrade, and each page header shows the same title, subtitle and actions. +- **`page-requires-non-compiled-kind-refused`** — `page.requires on a page whose kind is react, full or slotted — a page that omits kind included, since its kind is full` → Nothing: delete the key. On an html page (and its deprecated jsx alias) the platform derives `requires` from the source at save and stores it, so it is omitted there too; on a react, full or slotted page nothing ever derived or enforced it, and nothing takes its place. + - Why not automatic: `PageSchema` admitted `requires` on every page kind, but the platform derives it only on the kinds whose source the metadata save door compiles: saving an html page (alias jsx) on a server that has the deployment's SDUI component manifest compiles the source, stores the plugin namespaces it uses as `requires`, and refuses a written list that disagrees. A react source is executed at render and never compiled at save, and full and slotted pages have no source, so on those kinds nothing derived the key, the Studio page editor dropped it on every save, and its one reader was a load-time warning. The maintainer ruled (2026-10-03) that the key is accepted only on html and jsx pages. The parse now refuses it on react, full and slotted pages, and a page that omits kind is a full page: `objectstack validate`, the metadata save door (a 422) and every other door that parses a page name the key, the page's kind and the compiled kinds. An empty list is refused like a full one, because the key is what is refused. No page body authoring the key on those kinds was measured in this repository, cloud, hotcrm or objectui. The D2 conversion `page-requires-non-compiled-kind-removed` deletes it from such pages: stored rows and built artifacts replay it at load, with a notice, and `objectstack migrate meta --from 17` lists the edit for authored sources, which the parse refuses until it is made. The delete loses nothing a page did. What it cannot decide is whether the page should have been an html page: an author who wrote the list to have plugin presence checked gets that check only on an html page, where the platform derives the list from the source and judges it at save and load. + - Done when: `objectstack validate` reports no issue at a page's `requires` path: no react, full or slotted page, and no page that omits kind, carries the key, and each html or jsx page either omits it or carries exactly the list its source compiles to. Saving each formerly affected page through the metadata API succeeds instead of answering a 422 that names `requires`. Replaying `objectstack migrate meta --from 17` over the edited source lists no `page-requires-non-compiled-kind-removed` edit, and every page renders as it did before the upgrade. +- **`permission-restore-purge-bits-retired`** — `permission.objects..allowRestore / permission.objects..allowPurge — the object-permission bits for undelete and hard delete` → (removed — the `restore` and `purge` operations they claimed to gate do not exist.) A dispatched `restore` or `purge` is denied fail-closed by the permission evaluator's destructive-operation backstop for every principal. The bits return together with the operations they gate. `allowTransfer`, the third lifecycle bit, is enforced and stays. + - Why not automatic: The D2 conversion `permission-allow-restore-purge-removed` deletes both keys from every object permission in author sources (both values, `true` included), and the delete is lossless: no destructive lifecycle verb is in the engine's dispatch vocabulary, so a grant delivered nothing and a denial locked nothing — every such request was, and stays, denied. The judgment is about what people believed. An admin who wrote `allowPurge: false` believed a lock existed; an admin who wrote `allowPurge: true` for a compliance role believed that role could hard-delete a record on request — a GDPR erasure, for instance. Neither was ever true. Any process, runbook or audit statement that relies on either belief needs another path, and deciding that path is a governance decision no conversion can make. Separately, an artifact built by the 17.x toolchain carries both keys materialized as the literal `false`; that one value is tolerated at load as inert residue and stripped, and every other value — `true`, or a string or number spelling — is refused with the prescription. + - Done when: No authored object permission carries `allowRestore` or `allowPurge`; the parse refuses any value but the tolerated `false` residue. Access decisions are unchanged: a request for `restore` or `purge` is denied for every principal before and after the upgrade, and `allowTransfer` behaves as before. Every documented process that assumed a restore or purge grant — an erasure-request runbook, an access review, an audit control — names the mechanism it actually uses instead. +- **`permission-rls-tags-retired`** — `permission.rowLevelSecurity[].tags — the free-form categorization tags on a row-level security policy` → (removed — no mainstream platform tags a row-level policy, and nothing here ever read one.) A policy is identified by its `name` and its `object`, and reported by those and its predicate; its purpose belongs in `description`. Whom a policy applies to is decided by `positions`, never by a tag. + - Why not automatic: The D2 conversion `permission-rls-tags-removed` deletes `tags` from every row-level security policy in author sources and in stored permission rows, and the delete is lossless: the RLS compiler never consulted the key and nothing else acted on it — no report, audit filter or review queue selected on it — so no access decision changes. The judgment is about what people believed. An admin who tagged a policy `gdpr` or `pci` may have expected a compliance report, an audit filter or a review queue to pick it up; none ever did. An author who wrote a tag such as `managers_only` may have believed it scoped the policy; it never did — only `positions` narrows whom a policy applies to. Any report, runbook or control that relies on either belief needs another path, and choosing that path is a governance decision no conversion can make. + - Done when: No authored or stored row-level security policy carries `tags`; the parse refuses the key with the prescription. Access decisions are unchanged: every policy admits and refuses exactly the rows it did before the upgrade. Every policy whose tag expressed an audience has that audience in `positions`, and every compliance report, audit filter or review process that assumed policy tags names the mechanism it actually uses instead. +- **`platform-global-object-organization-column-retired`** — `OrgScopingEntitlement.platformGlobalObjects — an object a deployment declares platform-global no longer keeps its injected organization_id column with the organization wall stood down over it; on that deployment the injected-columns plan withholds the column, and the engine registers the object with no organization_id and declaring systemFields.tenant false` → Nothing to rewrite where no deployment declares the object. On the declaring deployment, the declared object has no `organization_id`: rewrite any authored filter, list-view column, report grouping, formula or seed key that names `organization_id` on it, or drop it; a write naming it is refused `INVALID_FIELD` and a filter `INVALID_FILTER`. The object is governed by object permission, not by the organization wall + - Why not automatic: ADR-0131 D7: "an object a deployment declares platform-global gets no organization column on that deployment (the injected-columns plan reads the declaration), so Layer 0 and the driver agree by having nothing to scope". Before this, the declaration stood the security layer's organization wall down for the object while the column stayed, so the SQL driver went on scoping a read by the caller organization that the wall had stopped scoping — measured on a booted kernel with a fixture provider, before the change. ADR-0131 retires that stand-down ("replaced by D7's no-column"). The engine reads the declaration at its plugin start(), before the first schema sync: every plugin init() has completed by then (ADR-0116, the Phase 1/2 split) and the org-scoping provider registers the service in its init(), declared in providesServices, so an object registered earlier is re-planned before its table is created. An absent declaration leaves every object's plan byte-identical; a malformed one is refused loudly and declares nothing. An object that declares its own organization_id keeps it and stays walled on it. Existing databases: schema sync is additive, so the physical column stays on a declaring deployment and the boot drift report names it orphaned; the operator removes it, and nothing moves at boot. + - Done when: On a deployment whose org-scoping service declares an object platform-global, the object is registered and provisioned with no `organization_id`, the security layer composes no organization wall on it, and a read carrying the caller organization reaches every row of its table; a non-declared object on the same deployment keeps its column and its wall. With no declaration, or a malformed one, every object keeps its column. +- **`platform-timezone-columns-iana-domain-refused`** — `The two platform audit time-zone columns — `sys_job.timezone` and `sys_report_schedule.timezone` — carrying a string that is not a member of the IANA time-zone database (`Asia/Shangai`, `Europe/Munich`, `UTC+8`, `PST`).` → The canonical IANA zone id the deployment meant, written in the spelling the tzdb uses: `Asia/Shanghai`, `Europe/Berlin`, `America/Los_Angeles`. `UTC` is a member and is admitted — membership is the shared `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf('timeZone')` enumeration, which omits `UTC` and would refuse the one fallback this contract names. ⚠️ A non-member is RE-AUTHORED, never repaired on the deployment's behalf: the correct zone behind a typo is a fact only the deployment holds, which is what makes this entry semantic rather than a D2 conversion. + - Why not automatic: The change validating `sys_job.timezone` and `sys_report_schedule.timezone` against the IANA domain gave both columns `valueDomain: 'iana_time_zone'`, which had been declared on `sys_business_unit.timezone` / `sys_organization.timezone` since those two objects first gained a timezone column. It is a WRITE-TIME narrowing of the `min`/`max`/`maxLength` transition-gate class: a value already stored outside the domain is never re-read against it, no DDL is planned, and `objectstack migrate meta` has nothing to rewrite — the changeset that shipped it says so in those words, and this entry does not contradict it. What the changeset had no way to carry is that a deployment holding such a value now has WORK TO DO: the next write of that row is refused with the ADR-0114 field code `value_domain`, and until then `sys_report_schedule.timezone` keeps doing the thing the narrowing exists to stop — `ReportService.nextRunAt` hands a non-member zone to croner, whose throw was caught and turned into a silent fall back to `interval_minutes`, so "every weekday 09:00 Asia/Shanghai" became "every 1440 minutes, forever". Not a throw and not a fall back to UTC: the wrong instant, permanently. ⛔ It went out with NO `**BREAKING**` marker, so the repo's own breaking-change detector classified it non-breaking and asked for no ADR-0087 disposition at all — measured on the shipped changeset. A ruling closed that hole (the declaration now carries a `(narrowing)` arm the gate reads instead of a prose banner) and this row is the other half of the same ruling: the narrowing that already shipped is RECORDED, ⛔ not re-released and ⛔ not ratified in silence. Maintainer ruling 2026-09-07, option B: the clause-② declaration gains a widen / narrow arm that the ADR-0087 classifier reads, and each narrowing that already shipped without a banner is recorded as one ledger row. The direct precedents for registering a change no transform can apply are `schedule-flow-acting-organization-required` (protocol 18) and `rest-requireauth-default-flip` (protocol 12) — behaviour-only, a deployment judgement, registered anyway because the prescription is real. + - Done when: Every `sys_job.timezone` and `sys_report_schedule.timezone` value stored in the deployment is an IANA member. The one-line fix per offending row: write the canonical zone id (`UPDATE … SET timezone = 'Asia/Shanghai'`), or clear the column — `sys_report_schedule` documents a `UTC` default and `sys_job` has no reader at all. Rows already holding a member parse and behave byte-identically to before; rows holding none are readable, are returned unchanged, and fail only on their next WRITE. A report schedule that was silently running on `interval_minutes` resumes its cron cadence once its zone is a member — that resumption, not the absence of an error, is how the fix is verified. ⚠️ The two columns' `maxLength` (100 vs 64) and defaults (none vs `UTC`) are deliberately still unconverged and are NOT part of this entry; no member is longer than 32 characters on the current Node baseline, so neither bound admits anything the domain does not. +- **`plugin-auto-restart-never-reinitialised`** — ``PluginHealthCheck.autoRestart`, `PluginHealthCheck.maxRestartAttempts` and `PluginHealthCheck.restartBackoff`, and the `PluginHealthMonitor.attemptRestart` path that read them` → Poll `PluginHealthMonitor.getHealthStatus(pluginName)` / `getHealthReport(pluginName)` and act on `unhealthy` / `failed` in the HOST. There is no in-tree replacement for the keys, because restarting a plugin is the host's job in this host-driven library and the monitor could not do it even in principle: `Plugin.init(ctx)` needs a `PluginContext`, which only the kernel constructs and which it exposes to nobody (`ObjectKernel.context` is private, `KernelBase.createContext` is protected). Recreate the kernel, or let your supervisor restart the process — whichever level actually owns the plugin's lifetime. The monitor reports; it does not act. + - Why not automatic: ADR-0049 enforce-or-remove, applied one class over from the two hot-reload retirements in the same host-driven lifecycle library — the file-watching placeholder whose `startWatching` logged success while watching nothing, and the `'disk'` / `'distributed'` state strategies that fell back to memory in silence — and for a sharper reason than either: this key HAD a reader that acted, and what it did was not what the key declared. `attemptRestart` called `plugin.destroy()` and stopped there. The comment above the call read "Call destroy and init to restart", and `init` appeared in `health-monitor.ts` ONLY inside that comment. So what a plugin actually got was: destroy, a log line reading 'Plugin restarted', status `recovering`, and periodic health checks continuing to run against the destroyed instance — which the default check when no `checkMethod` resolves (`{ name: 'plugin-loaded', status: 'passed' }`) passes indefinitely. The TERMINAL report on a destroyed, never-re-initialised plugin was therefore `healthy`, reproduced at ee3595cefd with `successThreshold: 3` as failed -> recovering (destroyed=1, alive=false) -> recovering -> recovering -> healthy (destroyed=1, alive=false). The fix that made `successThreshold` bind from every status that records a failure made that MORE convincing rather than less, because reaching `healthy` now costs `successThreshold` CONSECUTIVE passing rounds, so the plugin has to earn a declared number of passes to be misreported. Meanwhile `restartAttempts` was incremented as though a restart had occurred, and `maxRestartAttempts` / `restartBackoff` scheduled further "restarts" of a plugin that was never brought back up. Neither of the other two ADR-0049 states was available: ENFORCE would have to BUILD the restart, and the class cannot host one — `Plugin.init(ctx)` needs a `PluginContext`, and the only two `plugin.init(...)` call sites in the tree are the kernel's own boot loops over the full plugin list, with a context that is private on `ObjectKernel` and protected on `KernelBase`, so a host-provided re-init hook would have had nothing to call (positive control: the same scan resolves five real non-test `plugin.destroy()` call sites, so it sees lifecycle drivers). Building that API for a caller that does not exist — no runtime constructs `PluginHealthMonitor`, which is why the maintainer retired its declarative config container on 2026-08-25 and kept the classes as a host-driven library — is exactly the speculation ADR-0049's staged decision names as the wrong default at this milestone, where the shippable liability is the false promise and not the missing feature. EXPERIMENTAL requires a roadmap, and a scan of the whole `docs/` planning + ADR corpus returned ZERO mentions of plugin auto-restart against 118 control hits for "health" and 13 for "hot reload" in the same corpus. The other two keys leave with the first rather than as a tidy-up: with no restart, "Maximum restart attempts before giving up" and "Backoff strategy for restart delays" have nothing left to be the vocabulary OF — the same test that took `distributedConfig` out with the `stateStrategy` value it was documented as being required for (ruled 2026-08-26: a vocabulary of nothing is not a vocabulary). All three are TOMBSTONED rather than deleted, for the reason the file-watching retirement recorded: a key leaving a SURVIVING def has no route-3 exit, and `PluginHealthCheckSchema` is not `.strict()`, so a bare deletion would be a silent strip (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — a milder form of the very defect being retired. There is no D2 conversion, because `PluginHealthCheck` is not an authorable surface: no metadata-type binding, stack collection or manifest embed ever carried it, so there is no authored document to rewrite. This entry IS the declaration. + - Done when: No host passes `autoRestart`, `maxRestartAttempts` or `restartBackoff` to `PluginHealthMonitor.registerPlugin`. TypeScript hosts cannot: all three are typed `never` by the tombstones. JavaScript hosts, and config that arrived as JSON, get a loud refusal carrying the prescription — an ADR-0112 envelope (`code: VALIDATION_ERROR`, `status: 400`), thrown BEFORE any state is stored so a refused config cannot leave a half-registered plugin behind. `PluginHealthMonitor` no longer calls `plugin.destroy()` at all: `attemptRestart` and `calculateBackoff` are gone with the `restartAttempts` counter, and a plugin that crosses `failureThreshold` is reported `degraded` / `unhealthy` / `failed` and left running. The rest of the monitor is UNCHANGED: registration, periodic checks, the `timeout` race and its guard timer (kept ref'd while the race is undecided, cleared the moment it settles), the two failure routes — a returned failure and a thrown or timed-out check — sharing one failure counter and one threshold comparison, and `successThreshold` binding from every status that records a failure all behave exactly as before — `recovering` is now written only by the success branch, which is the one writer that ever meant it. The maintainer's 2026-08-25 keep of the host-driven library still stands: `PluginHealthCheckSchema` still exports from `./kernel` and `PluginHealthMonitor` still exports from `@objectstack/core` with its tests green. +- **`plugin-manifest-contributes-dead-members-retired`** — `manifest.contributes.events / manifest.contributes.menus / manifest.contributes.themes / manifest.contributes.translations / manifest.contributes.actions / manifest.contributes.drivers / manifest.contributes.fieldTypes / manifest.contributes.functions / manifest.contributes.commands (nine of the block's eleven members; `kinds` and `routes` are NOT part of this retirement)` → delete the keys — each capability already has its one enforced channel: `events` → subscribe imperatively in plugin code (`ctx.hook('kernel:ready', …)` from `init`/`start`); `menus` → the app `navigation` tree or `manifest.navigationContributions` (ADR-0029 D7); `themes` → the stack-level `themes` metadata collection (an unrelated `ThemeSchema` surface); `translations` → the `translation` metadata type, authored with `defineTranslationBundle` in `defineStack({ translations })`; `actions` → the stack `actions` collection or `engine.registerAction`; `drivers` → register a kernel service named `driver.*` (objectql calls `registerDriver` on it); `fieldTypes` → nothing (no registration seam exists; the vocabulary is the spec `FieldType` enum); `functions` → `defineStack({ functions })` → `engine.registerFunction`; `commands` → oclif native plugin auto-discovery (an `oclif` section in the plugin's own `package.json`; see `cli-extension.zod.ts`) + - Why not automatic: ADR-0049 enforce-or-remove: the nine members retire together, once the cloud half of the census below had come back clean. A census, monorepo-wide and non-test with control probes, measured that the ENTIRE monorepo contains exactly one read of `manifest.contributes` — `packages/objectql/src/engine.ts`, member `kinds` — so all nine members above parsed, entered the manifest, and changed nothing. The census stands on three repos: objectstack (re-verified on current main at claim time), objectui (0 property reads; control: 63 files carry the bare word), and cloud (measured clean 2026-08-24 at `5b5925a`: zero `manifest.contributes` reads, controls held). Several members were actively misleading: `events` was authored in-repo by a plugin that already subscribes imperatively; `commands` documented Commander.js resolution the CLI dropped for oclif auto-discovery; `fieldTypes` advertised a registration seam that has never existed. Why D3 semantic and not a D2 conversion: the conversion chain walks a normalized STACK and `PLURAL_TO_SINGULAR` has no `packages` / `plugins` entry, so a manifest is not a stack collection member and a conversion would be a transform with no seam that ever runs (the `kernel/Manifest:loading` precedent, recorded verbatim in its retired-key entry). + - Done when: No `objectstack.config.ts` manifest and no packaged `manifest.json` authors any of the nine members. The enforced channel is the one place a manifest is parsed with an author present: `os plugin build` runs `ManifestSchema.safeParse` and exits non-zero printing the per-key tombstone prescription; TypeScript authors fail earlier still (each key is typed `never`). `contributes.kinds` keeps parsing and registering (`registry.registerKind`), and `contributes.routes` is left to its own enforce-or-remove fork (since decided: retired, see `plugin-manifest-contributes-routes-retired`). ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ever read the nine members, so removing them removes no behaviour. A package ALREADY INSTALLED whose stored manifest carries one degrades to a single `[metadata_spec_invalid]` log line at registration (the registry's `validate()` is a diagnostic, not a gate) rather than a boot failure; clear it by deleting the key from the source manifest and reinstalling. +- **`plugin-manifest-contributes-routes-retired`** — `manifest.contributes.routes (the one member the nine-member retirement deliberately left to its own fork; `kinds` is now the block's sole surviving live member)` → delete the key. A route that needs real handler CODE is mounted imperatively: resolve the `http.server` service from the plugin context and register the handler on `kernel:ready` (plugin-hono-server registers the service; `examples/app-showcase` mounts POST /api/v1/showcase/recalc that way). A declarative endpoint over a pipeline the platform already runs — query/return records, trigger a flow — is `defineStack({ apis })` (live since protocol 17, once the declarative endpoint executor was built and the loud refusal of a non-empty `apis:` became execution) + - Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-08-22 (Option B of the enforce/remove/enforce-later fork, accepted verbatim 「接受所有」 on the decision batch carrying the four-axis analysis): remove the key, and redirect every author-facing recommendation of it to the imperative `http.server` mount. A monorepo-wide census with control probes measured zero readers of the key: the HttpDispatcher never registered a prefix from the declaration, so an entry parsed cleanly and served nothing — while FOUR published surfaces presented it as working machinery, one of them a customer-published skill (`skills/objectstack-api` told authors to choose it when "the endpoint needs real handler CODE"). That is ADR-0049's silent no-op with a published recommendation attached. Per the ruling's own sequencing the author-facing corrections landed FIRST (the skill's decision table, the dispatcher protocol doc, ADR-0088:40 and app.mdx, each redirected to the imperative mount), and the two remaining teaching sites (the plugin-rest-api.zod.ts worked manifest example, the metadata-plugin.zod.ts `router` delivered-form comments) are redirected in the removal PR itself. The cloud precondition was discharged first: a census of the cloud repository at 5b5925a found zero `manifest.contributes` reads, controls green. Enforce (fork A) was weighed and rejected on all four facets: net-new execution surface plus a prefix-claim authority question (who may claim `/api/v1/…`) for a declarative spelling with zero measured authors, while the capability is already reachable imperatively. Why D3 semantic and not a D2 conversion: a manifest is not a stack collection member (`PLURAL_TO_SINGULAR` has no `packages`/`plugins` entry), so a conversion would be a transform with no seam that ever runs. + - Done when: An authored `contributes.routes` is a loud rejection through every spec-validating path — `retiredKey()` types it `never` (tsc error at the authoring site) and the parse raises the prescription itself (`os plugin build` exits non-zero printing it). `contributes.kinds` — the block's sole surviving member — keeps parsing and registering (engine → `registry.registerKind`). No author-facing material still recommends the key: every former teaching site points at the imperative `http.server` mount (and `defineStack({ apis })` for declarative projections). ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ever read the member, so removing it removes no behaviour. A package ALREADY INSTALLED whose stored manifest carries one degrades to a single `[metadata_spec_invalid]` log line at registration (the registry's `validate()` is a diagnostic, not a gate) rather than a boot failure; clear it by deleting the key from the source manifest and reinstalling. +- **`plugin-manifest-dead-containers-retired`** — `manifest.capabilities / manifest.configuration / manifest.extensions (three top-level containers; retiring the container settles every key beneath it — `capabilities.{implements,provides,requires,extensionPoints,extensions}` and `configuration.{title,properties}` — at once)` → delete the keys — each declared purpose either has its one enforced channel or never existed: `configuration` (a `{ title, properties }` settings surface no UI rendered and no loader resolved) → pass options to the plugin's constructor in `defineStack({ plugins: [new MyPlugin({ … })] })`, the channel hosts already use; `capabilities` (protocol/interface declarations sold as "interoperability and automatic discovery") → nothing — no discovery path ever existed; real dependency resolution runs off top-level `manifest.dependencies`, which stays; `extensions` (an untyped `z.record(z.string(), z.unknown())` catch-all) → the enforced extension channels: `contributes.kinds` registers metadata kinds, `navigationContributions` (ADR-0029 D7) injects navigation, and code-level extension lives in the plugin itself (`init`/`start`) + - Why not automatic: ADR-0049 enforce-or-remove, dispatched once the cloud half of the census below came back clean. A census, monorepo-wide and non-test with control probes, measured ZERO reads of each container itself, which settles all eight keys beneath them — a key cannot be read if the object holding it never is. The census stands on three repos: objectstack (re-verified on current main at claim time; every bare `.capabilities` hit classifies to a different surface — driver loader contracts, the QuickJS sandbox argument set, REST discovery, the ADR-0066 stack-level `capabilities` collection), objectui (0 container reads; control: `manifest.(id|name|namespace|version)` reads findable), and cloud (measured clean 2026-08-29 at `15f55df`: zero reads of all three, controls positive). `configuration.properties.secret` made this false compliance rather than tidying: its describe() promised "value is encrypted/masked (e.g. API Keys)" and nothing ever encrypted, masked or parsed it, so the key's own text was an unkept assurance about credential handling. Why D3 semantic and not a D2 conversion: the conversion chain walks a normalized STACK and `PLURAL_TO_SINGULAR` has no `packages` / `plugins` entry (re-verified — its `capabilities` entry is the unrelated ADR-0066 stack collection), so a manifest is not a stack collection member and a conversion would be a transform with no seam that ever runs (the `kernel/Manifest:loading` precedent, recorded verbatim in its retired-key entry). `PluginCapabilityManifestSchema` stays published: the plugin-registry surface (`plugin-registry.zod.ts`) still declares it, so this is a carrier-key tombstone with no def removal. + - Done when: No `objectstack.config.ts` manifest and no packaged `manifest.json` authors any of the three containers (the two in-repo authors — driver-memory and plugin-hono-server, both writing `configuration` and `capabilities` blocks nothing read — were cleaned with this retirement). The enforced channel is the one place a manifest is parsed with an author present: `os plugin build` runs `ManifestSchema.safeParse` and exits non-zero printing the per-key tombstone prescription; TypeScript authors fail earlier still (each key is typed `never`). Live neighbours are untouched and must be verified as such: `manifest.dependencies` keeps resolving dependencies, `contributes.kinds` keeps registering, `navigationContributions` keeps merging. ⚠️ Runtime behaviour is deliberately UNCHANGED: nothing ever read the three containers, so removing them removes no behaviour. A package ALREADY INSTALLED whose stored manifest carries one degrades to a single `[metadata_spec_invalid]` log line at registration (the registry's `validate()` is a diagnostic, not a gate) rather than a boot failure; clear it by deleting the key from the source manifest and reinstalling. +- **`plugin-manifest-kind-globs-retired`** — `manifest.contributes.kinds[].globs (the `kind` bucket itself and its `id` are untouched)` → delete the key — a kind entry is `{ id, description? }`. File-type discovery is single-channel on the metadata type registry's `filePatterns` (`MetadataTypeSchema`, registered via `registerMetadataTypeSchema` / the default registry), which `contributes.kinds` never extended; if plugin-extensible discovery is ever wanted, it gets designed against that registry, not revived here + - Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-08-24 (「接受你的建议。」) on the aligned four-facet analysis: remove, through the full ADR-0049 ceremony. The sub-field was declared-but-unenforced on an authorable published surface: the schema promised that declaring `globs` "enables the system to parse and validate new file types" (its own example: a BI plugin handling `*.report.ts`), and the platform accepted it, stored it, and served it back through `GET /metadata/kind` — while the discovery the description promised never ran, because real glob-driven artifact discovery reads `filePatterns` off the metadata type registry and `metadata-plugin.zod.ts` records outright that `contributes.kinds` does not extend it. Measured by the engine-lane fix that made kind registration log its declared `id` (which also found the `kind` bucket itself reachable through `GET /metadata/:type`), and re-verified at claim time with a positive control: zero value reads anywhere (the only non-test occurrences of the path are the schema declaration and two type positions), and no in-repo manifest authors the key outside test fixtures. Enforce was weighed and rejected on all four facets: it would build a SECOND discovery channel parallel to `filePatterns` for a spelling with zero pull. Why D3 semantic and not a D2 conversion: a manifest is not a stack collection member (`PLURAL_TO_SINGULAR` has no `packages`/`plugins` entry), so a conversion would be a transform with no seam that ever runs. + - Done when: An authored `contributes.kinds[].globs` is a loud rejection through every spec-validating path — `retiredKey()` types it `never` (tsc error at the authoring site) and the parse raises the prescription itself (`os plugin build` exits non-zero printing it). `contributes.kinds` with `{ id, description? }` still parses and still registers (`engine` → `registry.registerKind`), and the registered bucket stays reachable via `GET /metadata/kind`. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing read the value, so removing it removes no behaviour; the `registerKind` / `getAllKinds` type positions drop `globs` from their declared shapes (a type-only change — the parameter widens). A stored kind item that still carries `globs` keeps serving as stored data; clear it by deleting the key from the source manifest and republishing. +- **`plugin-security-scan-result-surface-retired`** — `the plugin-security scan-result family: the defs KernelSecurityScanResult and KernelSecurityVulnerability (kernel/plugin-security-advanced.zod.ts), their two authorable carriers on PluginSecurityManifest — scanResults and vulnerabilities — and the sibling verdict block PluginQualityMetrics.securityScan (kernel/plugin-registry.zod.ts)` → nothing to re-declare — delete the keys and every import of the two types. Plugin security scanning is not a platform capability and there is no replacement schema. What the platform does still enforce, and what to reach for instead: `permissions` and `sandbox` on the same PluginSecurityManifest are unchanged, and artifact provenance is answered by `verifyPluginArtifactIntegrity` and the plugin signature verifier — which tell you an artifact is the one its publisher signed, and never that it is safe. For dependency vulnerabilities use the tools built for it against your own project (npm audit / pnpm audit, Dependabot, the GitHub Advisory Database, OSV) and treat an unaudited third-party plugin as untrusted code. A publisher who used scanResults to advertise diligence keeps the surviving securityContact and vulnerabilityDisclosure blocks, which are contact terms rather than a verdict. + - Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-09-07 (adopted verbatim 「同意」): retire the scan-result family and its securityScan sibling, because once the scanner was gone nothing so much as imported their types. This is the second half of the scanner retirement recorded as plugin-security-scanner-retired. That retirement removed PluginSecurityScanner — a @objectstack/core class that shipped as a SECURITY control and could not fail, whose verdict was status "passed" for every plugin it was ever handed. The SCHEMAS the scanner fed survived it, and the scanner had been their only importer of any kind (a type-only import in packages/core/src/security/security-scanner.ts), so the family went from one type-only importer to zero consumers while staying fully published: 27 authorable rows across kernel.json, six api-surface exports, two authorable defaults and two json-schema manifest keys. An author could write any of it, be accepted, and get nothing — declared-not-enforced, Prime Directive #10, one layer out from the class removed for the same reason. The census was taken on origin/main after that removal landed, with a lit control (five hits for PluginSecurityManifest inside the declaring module) proving the file greppable, and found no .parse or .safeParse site against either schema anywhere in packages/**. securityScan is the sharpest member: scanResults published a report, but securityScan.passed published a VERDICT, so a plugin could declare itself clean with nothing behind it. Route: the two defs leave the build whole (RETIRED_DEFS_BY_MAJOR[18]) because nothing parses them and a prescription nobody can receive is not worth its cost; the three authorable keys are retiredKey() tombstones (RETIRED_KEYS_BY_MAJOR[18]) because both carrying shapes are non-strict, where a bare deletion is a silent strip (ADR-0104). Why this entry and not a D2 conversion: a plugin security manifest and a plugin registry entry are package artifacts a publisher ships, never stack collection members and never stored sys_metadata rows, so the conversion chain has no seam that would see one — the disposition the sibling kernel-plugin-security-durations-unit-in-key entry already records for this same manifest. No deprecation window (maintainer 2026-08-27: 「项目在创业阶段,用户也很少,短期不考虑渐进」). Scope note, recorded rather than acted on: PluginSecurityManifest.vulnerabilities is a forced consequence rather than a name the ruling listed — it was the last authorable referent of KernelSecurityVulnerability and could not outlive the def. Two neighbours the ruling made CONDITIONAL are deliberately untouched here because the repository the condition names, objectstack-ai/cloud, is not reachable from the session that executed this: the marketplace "scanning" status stays exactly as it is — unremoved, and NOT recorded as checked. Its two siblings were MEASURED rather than assumed, and the record is corrected here: both were ALREADY GONE when that ruling was written. The incident "malware" type was a member of system/IncidentCategory, and the whole incident-response family was retired whole, with the training and change-management families (maintainer ruling 2026-09-05: not roadmapped, so retired rather than marked experimental — two days BEFORE the 2026-09-07 ruling that made it conditional); see incident-response-family-retired. And marketplace-admin.zod.ts was deleted outright with the cloud subpath (ruled 2026-09-07: cloud does not re-host the control-plane files it never consumed); see cloud-subpath-retired. Verified on this tree by shape: both files return zero tree entries and no *.zod.ts names malware at all, against a lit control where "scanning" still returns a live declaration in marketplace.zod.ts. So the conditional question is ONE enum member wide, not three, and the objectstack-ai/cloud producer grep it still owes is that much smaller. ⚠️ The out-of-repo consumer population is NOT MEASURED. @objectstack/spec is published, so this removal is breaking for consumers no download, dependent or source telemetry was consulted for — accepted as an input to the ruling, exactly as that retirement states of its own three exports, and not a reason to soften the removal. ADR-0049, ADR-0087. + - Done when: No source imports KernelSecurityScanResult, KernelSecurityVulnerability or either Schema from @objectstack/spec/kernel: both defs are absent from the built kernel barrel and from api-surface/kernel.json, so a TypeScript consumer gets the refusal at compile time at the import site rather than a missing runtime value. Authoring PluginSecurityManifest.scanResults, PluginSecurityManifest.vulnerabilities or PluginQualityMetrics.securityScan fails to compile (input type `never`) and fails to parse with the tombstone prescription naming that key — verified by refusal pins that assert the issue code, the path naming WHICH key was refused, and the prescription text, plus a positive pin that the surrounding manifest still parses and grows no such property. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ever read any of these keys, so deleting one removes no check that was running. A publisher who believed a declared scanResults entry gated anything was never getting that gate; the remediation is to audit with a real tool, not to find a replacement key. The surviving neighbours must still parse and still be exported — permissions, sandbox, policy, codeSigning, certifications, securityContact and vulnerabilityDisclosure on the manifest, testCoverage/documentationScore/codeQuality/conformanceTests on the quality metrics, and the separately-declared SecurityScanResultSchema / SecurityVulnerabilitySchema in kernel/plugin-security.zod.ts, which this change does not touch. +- **`plugin-security-scanner-retired`** — ``@objectstack/core` runtime exports: `PluginSecurityScanner`, and the two types declared only to feed it, `ScanTarget` and `SecurityIssue`` → nothing to re-declare — delete the import and every call. Plugin security scanning is not a platform capability and there is no replacement export. A caller that branched on `result.status === "passed"` takes that branch unconditionally, because it is the only branch the scanner ever produced. What the platform does still enforce, and what to reach for instead: artifact integrity and signatures (`verifyPluginArtifactIntegrity`, the plugin signature verifier) answer "is this the artifact the publisher signed?" and never "is this artifact safe?"; plugin permissions and the sandbox resource limits are unchanged. For dependency vulnerabilities use the tools built for it against your own project — `npm audit` / `pnpm audit`, Dependabot, the GitHub Advisory Database, OSV — and treat an unaudited third-party plugin as untrusted code. + - Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05: retire the class and its two companion types with no replacement export, rather than repair it. The class shipped on `@objectstack/core`'s public barrel as a SECURITY control and could not fail. `scan()` composed five private scanners: four of them (`scanCode`, `scanMalware`, `scanLicenses`, `scanConfiguration`) allocated an empty issue array, logged and returned it with no code in between, so none could report a finding for any input; the fifth, `scanDependencies`, ran a real loop but matched only against an in-memory vulnerability database whose sole writer, the public `addVulnerability`, had zero callers in objectstack, in objectui at the pinned sha, or in the one demonstration that constructed the scanner, and `updateVulnerabilityDatabase()` logged twice and fetched nothing. The database was therefore empty on every code path that has ever executed: no issue was ever produced, the score stayed 100, and the verdict was `status: "passed"` for every plugin the scanner was ever handed — a malicious one as readily as a benign one. Repair was refused by name: a real vulnerability scanner is a feature with a design surface, not a defect fix. Why this entry exists at all, and why D3 semantic rather than a D2 conversion: `PluginSecurityScanner` has no spec schema and never had one — it is a runtime TS class, so there is no authorable key to tombstone with `retiredKey()`, no stored `sys_metadata` row that could carry it (a scanner was constructed per call and every result lived in a per-instance Map discarded with the object), and hence no seam `applyConversionsToStoredItem` would ever reach. The enforced channel is tsc, at the consumer's own import site; for anyone it does not reach, this ledger entry and the generated upgrade guide are the only channel there is. That is the disposition of `contracts.IDataDriver.findStream` (removed with no tombstone, because nothing parses a driver object) and of `actor-user-roles-to-positions` (the `ctx.user` `roles` alias, closed at once on the maintainer's word rather than given a window) — a TS/API contract, no stored source, no tombstone, tsc at the call site — applied to a surface one layer further out than either: those are declared in `packages/spec`, this one only in `packages/core`. ⚠️ The out-of-repo consumer population is NOT MEASURED. Zero constructors were found in objectstack, in objectui at the pinned sha, and in the deleted example, but no download, dependent or source telemetry was consulted for consumers of the published package, so this is breaking for an unmeasured population rather than a removal proven to break nobody. + - Done when: No source imports `PluginSecurityScanner`, `ScanTarget` or `SecurityIssue` from `@objectstack/core` (or from `@objectstack/core/security`, a subpath the package has never declared in its `exports` and which therefore resolved for nobody). A TypeScript consumer gets the refusal at compile time at the import site — the export is absent from the built `dist/index.d.ts`, not merely undocumented. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: every scan this class ever performed returned zero issues and `status: "passed"`, so deleting a call removes no check that was running. A caller that treated a passing scan as evidence of safety was never getting any, and its remediation is to audit dependencies with a real tool, not to find a replacement symbol — there is none. Verified in-repo by export-list assertions on both barrels (`packages/core/src/security/security-scanner-retirement.pin.test.ts`), not by a grep: the name legitimately survives in the tombstone comments that explain the retirement. +- **`plugin-version-semver-2-0-0`** — `plugin.version — `PluginSchema.version` (`kernel/plugin.zod.ts`), the key a plugin object carries into `kernel.use()`, and the boot-path predicate that judges the same string in `@objectstack/core` (`plugin-loader.ts`)` → a SemVer 2.0.0 string matching `SEMVER_2_0_0_VERSION_PATTERN` (`kernel/version-grammar.ts`). ⭐ Only EIGHT strings stop loading, all of them forms the standard forbids: `01.1.1`, `1.01.1`, `1.1.01` (§2, a leading zero in a numeric identifier — drop it); `1.0.0-0123`, `1.0.0-alpha..1`, `1.0.0-alpha..`, `1.0.0-.` (§9, a prerelease identifier that is empty or carries a leading zero — name it, or remove the empty segment); `1.0.0+.` (§10, an empty build identifier — name it or drop the `+` suffix). ⛔ Nothing else moves: every valid prerelease and build form this key accepts today it still accepts, `1.0.0-alpha.1` and `1.0.0-rc.1+exp.sha.5114f85` included. + - Why not automatic: The canon ruling made one grammar serve every carrier of "the version of a package or plugin", and named it after the standard: SemVer 2.0.0. This key had the widest of the four accept sets, which is why it is the only one that narrows without also widening. The narrowing is bounded deliberately, and the bound is what keeps the earlier widen-never-narrow ruling on this path honoured rather than reversed: that ruling's subject is what LOADS, and none of the eight is a valid prerelease. What they have in common is that no precedence order exists for any of them — `dependency-resolver.ts` in `@objectstack/core` can place none of them in an order — so a plugin versioned this way could be published and never compared against its own successor, which is a worse outcome than the refusal. Why it is a D3 semantic TODO and not a D2 conversion: each of the eight has several defensible repairs and the metadata does not say which was meant, and a version is how a release is addressed — rewriting one silently re-points whatever already resolved the old string. + - Done when: Every plugin you ship boots: `kernel.use(plugin)` resolves for each of them, on both `ObjectKernel` and `LiteKernel`. The only versions needing an edit are the eight forms above — a `git grep` for a leading zero in a numeric segment and for a doubled or trailing dot in a suffix finds them all, and the in-repo authoring corpus measured ZERO producers of any of them. For each one you change, confirm nothing still resolves the old string: no `dependencies` range in another manifest, no installed row, no lockfile pin. ⛔ Do not repair one by widening the check back — the grammar is the contract now, on nine carriers at once. +- **`predicate-write-unreadable-row-not-matched`** — `the data write doors — a predicate-scoped (multi) update or delete, on every object and for every principal` → read a predicate update or delete as reaching only the rows the caller can read: the result counts those rows alone, a predicate that reaches only hidden rows succeeds with zero rows, and a predicate whose readable match exceeds one write's row ceiling is refused with 400 `INVALID_FILTER` — narrow it and write in batches + - Why not automatic: A WRITE-DOOR ANSWER, made one with the read door's, on the predicate door as on the by-id door. The rows a predicate update or delete matched came from its write scope alone, so a row the caller cannot read was matched whenever that scope reached it: a per-row gate then refused the write with a 403, or the row was written and counted. Either answer told a hidden row apart from no row. The write middleware now asks the read door which rows the caller's own predicate returns — a read in the caller's context that every data middleware's visibility applies to — and narrows the matched set to them, so a row the caller cannot read is not written, not counted and not refused. A read the read door refuses keeps the write's previous answer, and a readable match larger than one predicate write's row ceiling is refused rather than cut off. A caller who can read a matched row but may not write it keeps its answer. Writes the platform issues under the caller's context — a cascade, a hook's own write, the referential clear of a lookup — keep their previous answer, and by-id writes are unchanged. + - Done when: Every caller that issues a predicate update or delete reads its count as the rows it can see and no longer reads a 403 there as proof a hidden row matched; an operator who needs a user to change rows grants that user read access to them first; a predicate whose readable match exceeds the row ceiling is narrowed and written in batches. +- **`qa-scenario-requires-plugins-retired`** — `qa.scenarios[].requires.plugins` → `requires.services` — the discovery service keys the scenario needs (for example `auth`, `analytics`, `automation`, `ai`), each judged against the target's discovery document: met only when the target declares the service `enabled` with status `available`. The plugin → service mapping follows the provider table discovery itself reports (`CORE_SERVICE_PROVIDER`): `@objectstack/plugin-auth` fills `auth`, `@objectstack/service-analytics` fills `analytics`, `@objectstack/service-automation` fills `automation`, and so on. A plugin that fills no discovery service slot has no service to require. + - Why not automatic: `requires.plugins` was declared as a precondition and checked by nothing: `os test` reaches its target over HTTP, no served surface lists the loaded plugins, and the plugin spelling (package name or `plugin.name`) was never defined — so a scenario naming a missing plugin ran anyway and failed, or passed, on whatever the missing plugin caused. The block is now enforced (ADR-0049): core's TestRunner judges `requires` before the first step, and an unmet entry makes the scenario SKIPPED with a reason, counted separately and never as passed. `plugins` could not join that judgement honestly, so it retires into `services`, which the target's discovery document already answers (ADR-0076 D12: advertise only what is mounted). The consumer still owes the judgement because a plugin name does not always map to one service — a plugin that fills no discovery slot was never a checkable precondition, and only the suite's author knows what the scenario needed it for. + - Done when: No QA suite (`qa/*.test.json`) carries `requires.plugins` — `os test` now refuses such a file at load time with the retirement prescription, naming the key, instead of running it; `tsc` refuses the key at a typed authoring site (`never`). Each scenario that declared plugins declares the services it needs in `requires.services`, and `os test` against a target that serves them runs the scenario, while against one that does not it prints the scenario as skipped with the unmet service and the services the target declares available. A suite without `requires` runs exactly as before. +- **`realtime-event-type-unemitted-values-retired`** — `api.RealtimeEventType — the values 'record.created', 'record.updated', 'record.deleted' and 'field.changed' left the enum. It types SubscriptionEvent.type, so it reaches Subscription.events[].type and RealtimeConfig.subscriptions[].events[].type` → the names the runtime emits, which are now the whole enum: 'data.record.created' / 'data.record.updated' / 'data.record.deleted' for a single-record write, and 'data.records.updated' / 'data.records.deleted' for a predicate write (multi: true), which carries a count and no record. 'record.created' becomes 'data.record.created'; 'record.updated' and 'record.deleted' become their data.record twins, plus the data.records twin where a predicate write must be heard too; 'field.changed' becomes 'data.record.updated', whose DataEvent payload lists the changed fields in changes + - Why not automatic: ADR-0049 enforce-or-remove. RealtimeEventType was published in the generated API reference as the vocabulary of a realtime subscription, and no producer anywhere emitted any of its four values. What the runtime publishes is the DataEventType / BulkDataEventType vocabulary: the ObjectQL engine sends data.record.created, data.record.updated and data.record.deleted for each written record and data.records.updated / data.records.deleted for a predicate write, and it parses every event through DataEventSchema / BulkDataEventSchema before publishing. A subscription written with the only names the reference showed could therefore never fire, and nothing said so. The direction was settled before this change: the enum moves to the emitted names, and the runtime keeps publishing exactly what it published — changing the runtime's live event names to match an enum nothing had ever used would break every real subscriber. field.changed is the same dead spelling that DataEventType already dropped in protocol 17 (the entry data-field-changed-event-retired): no per-field event exists, because an update's per-field detail rides on data.record.updated as changes. Metadata change events (metadata.{type}.{action}) were not added: a subscription event is record-shaped (object names a data object, filters narrows records), and metadata events have their own MetadataEventType contract and client primitive. Bookkeeping: an enum VALUE puts nothing in RETIRED_KEYS_BY_MAJOR and leaves the four surface ratchets untouched; its prescription hangs on the enum's own error map (the HookBodyCapability precedent). It is a SEMANTIC entry rather than a D2 conversion because there is no source to rewrite: stack.zod.ts has no realtime key, no metadata type holds a subscription, and the open framework mounts no realtime transport that would parse one (maintainer ruling of 2026-09-04: realtime stays out of open core) — so the conversion chain has no seam that would ever see a subscription. ADR-0049 / ADR-0087. + - Done when: No code or document names 'record.created', 'record.updated', 'record.deleted' or 'field.changed' as a RealtimeEventType value. TypeScript rejects each one at a RealtimeEventType or SubscriptionEvent position, because the type no longer contains it, and a SubscriptionSchema, SubscriptionEventSchema or RealtimeConfigSchema parse refuses it with its per-value prescription (pinned in api/realtime.test.ts). A subscriber that meant field.changed listens on data.record.updated and reads the field from the DataEvent payload's changes map. A handler keyed on an old name never ran, since nothing emitted it, so renaming it changes behaviour only by making it fire. +- **`record-chatter-position-vocabulary-converged`** — ``record:chatter` / `record:discussion` component props (one shared schema object): `position` vocabulary, and the schema defaults on `position` / `collapsible` / `defaultCollapsed` (DROPPED)` → `position: 'bottom' | 'right' | 'left'` — the renderer's own vocabulary (`right`/`left` dock a side panel, `bottom` renders in flow). 'sidebar' → 'right', 'inline' → 'bottom', 'drawer' → 'right' (no overlay drawer ever existed). No key replaces the dropped schema defaults: an unset key now stays unset and the renderer's own fallbacks apply (position 'bottom', collapsible off, defaultCollapsed off) + - Why not automatic: The schema declared a `position` vocabulary no read point ever compared (`sidebar`/`inline`/`drawer`), while the renderer chain — panel branches, designer registration, merge fallback, three sites in agreement, measured at objectui pin `665661ab0932` — speaks exactly `bottom`/`right`/`left`. So the spec-valid `sidebar` (the schema's own DEFAULT, materialized onto every parsed node that said nothing) silently fell through to the in-flow render, and the value that actually docks the panel (`right`) was refused at publish — declared ≠ enforced in both directions on the same key. The maintainer ruling of 2026-08-15 on this row converged it on the renderer's vocabulary with no mapping layer, and dropped all three schema defaults per the `maxVisible` principle (renderer fallbacks stay the renderer's facts): the old `collapsible` default (`true`) additionally INVERTED the renderer merge's own fallback (`false`), so "the author said nothing" parsed into "the author asked for collapsible". The mechanical rewrite is the ADR-0087 D2 conversion `record-chatter-position-vocabulary` (retired from the load path — the enum refuses the old spellings at parse with a per-value prescription; stored rows replay clean via the rehydration seam). This semantic entry exists for the two judgements the chain cannot make: whether `drawer` → `right` (a docked panel standing in for a never-implemented overlay) is the presentation the author wants, and whether a page that relied on the old materialized `collapsible: true` default should now author it explicitly. ADR-0087, maintainer ruling 2026-08-15. + - Done when: No authored `record:chatter` / `record:discussion` component carries `position: 'sidebar' | 'inline' | 'drawer'`; `objectstack validate` passes. Review the rewritten values against intent: 'sidebar' and 'drawer' became 'right' (a docked side panel — what both spellings meant, but NOT what they did: both used to fall through to the in-flow render, so the page's visible layout changes to the docked panel the author originally asked for). Where the in-flow presentation was actually wanted, write 'bottom'. If a panel relied on the old schema default `collapsible: true`, author `collapsible: true` explicitly — an unset key now defers to the renderer, which does not collapse. +- **`record-highlights-field-icon-retired`** — `page.component.record:highlights.fields[].icon — the per-chip icon on an object entry of the highlights field list` → (removed — the highlight chip has no icon slot.) Carry whatever the icon was meant to signal in what the chip does render: its label, or the field's own value. + - Why not automatic: The D2 conversion `record-highlights-field-icon-removed` deletes `icon` from the object entries of every `record:highlights` field list, and the delete is lossless: the chip renders a label and a value and nothing else, the registration path carries field names only, and the Studio designer publishes the list as plain strings, so an authored icon was accepted and drawn by nothing. The residue is the author's intent. Six author-facing surfaces advertised the key, so an author may have chosen an icon to carry meaning — a warning glyph beside a risk score, a flag beside a region — and designed the page assuming a reader would see it. That meaning was never shown and is not shown now; only the author can say whether it matters and where it should live instead. + - Done when: No `record:highlights` field entry carries `icon`; the parse refuses it. The highlights strip renders the same chips, in the same order, with the same labels and values as before the upgrade. For each chip whose icon carried meaning, a reader who sees only the label and value can still tell what the icon was meant to say. Any tooling that generated highlight entries (a code generator, a template) no longer emits the key. +- **`rest-api-config-dead-keys-retired`** — `restServer.api.responseFormat / restServer.api.documentation.enabled` → (removed — delete each key; neither had an effect to preserve. Whether the server publishes its OpenAPI document and the docs viewer is `api.enableOpenApi`, the switch the mount already reads. Response shapes are fixed — each route answers in the response schema `@objectstack/spec/api` declares for it — and are not a server-wide option, so there is no replacement for `responseFormat`.) + - Why not automatic: The `rest_api` liveness census found every member of these two keys `dead`: `normalizeConfig` parsed them, applied their defaults and copied them into the REST server's config, and no site ever read them back. So `responseFormat.envelope: false` unwrapped no response, `includeMetadata` and `includePagination` gated nothing, and `documentation.enabled: false` turned no document off — the document's existence was, and is, decided by `api.enableOpenApi` at the mount. Enforce-or-remove (ADR-0049) resolved both to REMOVE: mainstream data APIs keep a fixed response envelope that no administrator toggles server-wide, a configurable envelope would fork the declared response shapes the client SDK parses and the served /openapi.json describes, and `documentation.enabled` duplicates a switch that is already enforced. `RestApiConfigSchema` and its inline `documentation` block are non-strict `z.object()`s, so each key is a `retiredKey()` tombstone and its ledger row stays `dead` with a REMOVED note. No stored or built artifact carries either key, so no emitted default needs to be tolerated as residue: the config is a construction argument that is parsed and consumed in the same process. The consumer still owes the judgment because a host that WROTE `envelope: false` or `documentation.enabled: false` believed its clients saw a different shape or no document, and only that host knows which clients were built on the belief. + - Done when: No `RestServerConfig` value passed to the REST plugin carries `api.responseFormat` or `api.documentation.enabled` — a config that does now fails `RestServer` construction (and so the REST plugin's `start`) with the retirement prescription, naming the key and `RestApiConfigSchema`, instead of being accepted and ignored; `tsc` refuses the key at the authoring site (`never`). A host that meant "serve no OpenAPI document" sets `api.enableOpenApi: false` and sees `GET /openapi.json` and `GET /docs` unmounted. Every client that parses REST responses reads each route's declared response shape. Every LIVE key of the `api` block — including `documentation`'s other members — parses byte-identically to before, and the mounted REST surface is unchanged: neither key ever reached it. +- **`rest-api-documentation-version-retired`** — `restServer.api.documentation.version` → (removed — delete the key. The served OpenAPI document's `info.version` is the protocol version, i.e. the version of the `@objectstack/spec` package that generated the document, with no configured override. An app that wants to publish its own release number writes it into `api.documentation.description`, which the served `info.description` now carries.) + - Why not automatic: The `rest_api` liveness census found `documentation.version` `dead`: `normalizeConfig` parsed it and copied it into the REST server's config, and no site read it back, so `version: '2.3.0'` never reached the served document. Enforce-or-remove (ADR-0049) split the `documentation` block by who owns each field. The title, description, terms of service, contact and license are the publisher's identity and are now enforced. `info.version` is a fact of the protocol: an earlier ruling made the served `info.version` equal the published artifact's, so an integrator can read which protocol version they are talking to, and it removed the serve-time override that had made the field mean the route identifier. A publisher-set version would give the field a third meaning, so the key is retired instead of enforced. `RestApiConfigSchema`'s inline `documentation` block is a non-strict `z.object()`, so the key is a `retiredKey()` tombstone and its ledger row stays `dead` with a REMOVED note. The consumer still owes the judgment because a host that WROTE `documentation.version` believed its integrators read that number from the document, and only that host knows whether any client was built on the belief and where the number should be published instead. + - Done when: No `RestServerConfig` value passed to the REST plugin carries `api.documentation.version` — a config that does now fails `RestServer` construction (and so the REST plugin's `start`) with the retirement prescription, naming the key and `RestApiConfigSchema`, instead of being accepted and ignored; `tsc` refuses the key at the authoring site (`never`). `GET {apiPath}/openapi.json` and its environment-scoped twin serve `info.version` equal to the one the bundled `@objectstack/spec/openapi.json` carries, whatever the config says. A release number the host still wants published appears in the served `info.description` after it is written into `api.documentation.description`. +- **`rest-api-endpoint-handler-status-retired`** — `RestApiEndpoint.handlerStatus (the implemented / stub / planned marker an endpoint in a REST API plugin route registration could carry), the HandlerStatusSchema / HandlerStatus value def it was typed with, and the RouteCoverageEntrySchema / RouteCoverageReportSchema report shapes (with their RouteCoverageEntry / RouteCoverageReport types) that re-declared it` → nothing declarative — the key never changed what the platform served, so there is no working configuration to migrate to. Delete the key; an endpoint that has no handler yet is simply not registered. Route readiness that IS measured is unchanged and lives elsewhere: the discovery payload reports each service's status and handlerReady (api/discovery.zod.ts), and packages/runtime/src/route-ledger.ts asserts per-route coverage in CI. A declared-but-unbuilt route answering 501 instead of 404 is a new capability the ruling explicitly excluded (zero pull); if it is ever wanted it re-declares fresh under its own ruling, executor first + - Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling of 2026-09-01 on this key: remove it with a tombstone; enforce excluded. The key was DOCUMENTED to cause a specific runtime behaviour — its docstring said a stub handler "returns 501 Not Implemented" — and that behaviour has a different cause: every DispatcherErrorCode.enum.NOT_IMPLEMENTED site (runtime/src/endpoint-executor.ts ×3, runtime/src/api-mapping.ts, runtime/src/api-endpoint-step.ts) is the declarative-endpoint executor refusing a target or mapping it cannot serve, and none of them consults handlerStatus. Measured at the retirement base (origin/main a9b2be0b0, 2026-09-02, skills/** and tests excluded): the only identifier hits were the declaration on RestApiEndpointSchema, the re-declaration on RouteCoverageEntrySchema and a docblock saying adapters SHOULD warn on it; RouteCoverageReportSchema — the one shape that would have carried the status outward — had zero constructors in objectstack, objectui (pinned sha) and cloud. So an author who wrote handlerStatus: 'stub' expecting the dispatcher to answer 501 got an ordinarily served route, and the declaration reported progress to nobody — a declared ≠ enforced gap on the same endpoint vocabulary whose ApiEndpointSchema had already been closed strictly once `api` became a registered metadata type, and the surface a published skill had been teaching as working machinery (this finding came out of correcting that skill sentence, in a factual sweep of the API skill). Bookkeeping: the KEY is tombstoned with retiredKey() on the non-strict RestApiEndpointSchema (api/RestApiEndpoint:handlerStatus in RETIRED_KEYS_BY_MAJOR[18]); the DEFS leave whole — api/HandlerStatus (orphan value enum once both carriers are gone — an exported value schema with no consumer reads as a capability, so it leaves with its key), api/RouteCoverageEntry and api/RouteCoverageReport (route 3: nobody ever parsed or constructed one) — all three in RETIRED_DEFS_BY_MAJOR[18]. It is a SEMANTIC entry rather than a D2 conversion because there is no source to rewrite: nothing in the tree parses RestApiEndpointSchema outside its own unit tests — a REST API plugin route registration is not a stack collection member and never a sys_metadata row — so the conversion chain has no seam that would ever see one (the kernel/Manifest:loading disposition). ENFORCE was excluded by the ruling: mounting a 501 stub for stub / planned endpoints is a zero-pull new capability, not a repair. The same ruling records the class direction for two sibling ADR-0049 findings (the unbound branded identifier schemas and the event-name schema no runtime reads; not ruled by it): a declared-but-unenforced key with no pull retires; enforce/bind only on a named consumer or measured pull. ADR-0049 / ADR-0087. + - Done when: No source writes handlerStatus on a RestApiEndpoint: authoring it is now a tsc error at the site (the tombstone types the key never) and a parse error carrying the prescription at path handlerStatus, for every former value including the documented default 'implemented' (which was prose only — the key never carried a Zod .default(), so no built artifact materialised it and there is no residue window). Pinned in api/plugin-rest-api.handler-status-retirement.test.ts. Concretely, check two places. (1) Every RestApiEndpoint literal — in a route registration passed to the REST API plugin, or standalone: delete the handlerStatus line; nothing served changes, because nothing ever read it. (2) Code importing HandlerStatusSchema, HandlerStatus, RouteCoverageEntrySchema, RouteCoverageEntry, RouteCoverageReportSchema or RouteCoverageReport from @objectstack/spec or @objectstack/spec/api: every one is TS2305 after upgrade; no replacement exists to point at, because no producer ever emitted the report. Everything else on RestApiEndpointSchema — method, path, handler, category, public, permissions, the OpenAPI and performance keys — parses exactly as before, and the shipped default route registrations (getDefaultRouteRegistrations) never carried the key and still parse. +- **`rest-api-plugin-durations-unit-in-key`** — `the three REST-plugin durations whose name carried no unit: RestApiEndpoint.timeout, RestApiEndpoint.cacheTtl and RestApiPluginConfig.performance.defaultCacheTtl (api/plugin-rest-api.zod.ts)` → timeoutMs (milliseconds), cacheTtlSeconds (seconds) and defaultCacheTtlSeconds (seconds, default 300) — rename each key; every value is unchanged + - Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. RestApiEndpoint is this rule's clearest specimen after the founding one: `timeout` in MILLISECONDS and `cacheTtl` in SECONDS sat three lines apart on one shape, each unit named only in its describe, so the two numbers were indistinguishable at the authoring site and a value copied from one to the other was off by 1000x with no error anywhere. performance.defaultCacheTtl travels with them rather than in its own entry because it is the plugin-wide DEFAULT behind the per-endpoint override: renaming the override and leaving the default bare would have spelled one value two ways across one config. All three are retiredKey() tombstones — these shapes are not strict, so a bare deletion would strip the old key in silence, and defaultCacheTtl is a tombstone INSIDE the live `performance` block, whose siblings must keep parsing. Why a semantic entry and not a D2 conversion: a RestApiPluginConfig is the REST plugin's construction argument and a RestApiEndpoint is a route registration inside it — neither is a stack collection member or a stored row, so the chain has no seam that ever runs on them. That is the disposition api/RestApiEndpoint:handlerStatus already carries on this very shape (rest-api-endpoint-handler-status-retired), and what ruling B prescribes for a key that is not authorable metadata. ADR-0087. + - Done when: Every RestApiEndpointSchema.parse(…) and RestApiPluginConfigSchema.parse(…) site spells `timeoutMs`, `cacheTtlSeconds` and `performance.defaultCacheTtlSeconds`; authoring any old spelling fails to compile (input type `never`) and fails to parse with the rename prescription naming the suffixed key. The built-in route tables shipped from this module (DEFAULT_METADATA_ROUTES, DEFAULT_BATCH_ROUTES, DEFAULT_I18N_ROUTES, DEFAULT_ANALYTICS_ROUTES, DEFAULT_AUTOMATION_ROUTES, DEFAULT_DISCOVERY_ROUTES) author the new spellings at the same magnitudes they authored the old ones — a batch endpoint still gets 60000 ms and a discovery response is still cached for 3600 s. +- **`rest-server-config-dead-keys-retired`** — `restServer.crud.patterns / restServer.crud.objectParamStyle / restServer.metadata.cacheTtl / restServer.metadata.endpoints.schema / restServer.batch.operations.upsertMany / restServer.batch.defaultAtomic / restServer.routes.includeObjects / restServer.routes.excludeObjects / restServer.routes.nameTransform / restServer.routes.overrides` → (removed — delete each key; none had an effect to preserve. Per-object API exposure is declared on the object: `enable.apiEnabled: false` hides it from the REST data surface (404) and `enable.apiMethods` whitelists its operations (405). The data base path is `crud.dataPrefix`, deployment-wide. An endpoint on a custom path or method — or one that needs its own `summary` / `description` / `cacheTtl` — is a declarative `api` endpoint (`type: 'object_operation'`). Batch atomicity is the per-request `options.atomic` (ADR-0119 D4); upsert is an operation type of the generic `POST /data/:object/batch` endpoint, gated by `batch.enableBatchEndpoint`.) + - Why not automatic: The liveness census that enrolled the four `RestServerConfig` sub-objects found 15 of their 32 rows `dead`: parsed, defaulted and normalized into the REST server's config by `normalizeConfig` (which parses them, rather than casting them, since an earlier fix) and never read back. `crud.patterns` and `routes.overrides` described route customization the server mounts from fixed pairs; `routes.includeObjects` / `excludeObjects` and `overrides.enabled` / `operations` duplicated the object's own enforced exposure keys; `nameTransform` and `objectParamStyle` were enums validated and then ignored; `metadata.endpoints.schema` and `batch.operations.upsertMany` gated routes that were never built; `metadata.cacheTtl` fed no cache and no header; `batch.defaultAtomic` would have silently overridden a per-request contract ADR-0119 D4 had deliberately set. Enforce-or-remove (ADR-0049) resolved every family to REMOVE because each promised capability either already exists at its proper seat (the object, the declarative endpoint, the batch request) or would contradict a fixed contract (the client SDK, the discovery document and the served /openapi.json all describe the mounted CRUD paths; the object `name` is the REST path segment). All four schemas are non-strict `z.object()`s, so each key is a `retiredKey()` tombstone and its ledger row stays `dead` with a REMOVED note; `api/CrudEndpointPattern`, the value def of `crud.patterns`, leaves with it. No D2 conversion: a `RestServerConfig` is plugin TS configuration, never a stack collection member or a `sys_metadata` row (the `openApi31` precedent). A closed-set sweep of the cloud repository at 9b6abe0f2fd5: zero hits, structural — cloud never authors a `RestServerConfig`. + - Done when: No `RestServerConfig` value passed to the REST plugin (or `plugin-hono-server` `restConfig`) carries any of the ten keys — a config that does now fails `new RestServer(...)` / `createRestApiPlugin().start()` with the retirement prescription (naming the sub-object, the key and the declaring schema) instead of being accepted and ignored; `tsc` refuses the key at the authoring site (`never`). Every LIVE key of the four sub-objects parses byte-identically to before: `crud.operations.*`, `crud.dataPrefix`, `metadata.prefix` / `enableCache` / `maskObjectFields` / `endpoints.types|items|item`, `batch.maxBatchSize` / `enableBatchEndpoint` / `operations.createMany|updateMany|deleteMany` keep their defaults and their mounts. The mounted REST surface is byte-identical before and after — none of the ten keys ever reached it. No code imports `CrudEndpointPattern(Schema)` from `@objectstack/spec/api` (TS2305 after upgrade). +- **`rls-check-on-select-or-delete-policy-refused`** — `security.PermissionSet rowLevelSecurity[].check (RowLevelSecurityPolicySchema) on a policy whose operation is select or delete. A blank check (empty or whitespace only) declares nothing and is not refused` → what the predicate was meant to guard, written where it runs. To limit which rows a select policy lets a caller read, or which rows a delete policy lets a caller delete, write the predicate as `using` on that policy (remove `check`; if the policy already has a `using`, AND the two with &&). To validate rows as they are written, declare the `check` on a policy whose `operation` is `insert`, `update` or `all` instead. The refusal lands at rowLevelSecurity[N].check, names the operation, and states both rewrites + - Why not automatic: ADR-0049 enforce-or-remove and ADR-0058 D4. A `check` judges the post-image of a write: the new row of an insert, the changed row of an update. A select or delete writes no row, and the plugin-security write gate collects only the policies whose operation is the write's own or `all`, so a `check` on a select or delete policy was accepted, stored and never evaluated. Measured on main before this change: a policy carrying only check record.status != 'archived' on select or delete admitted every insert and update of an archived row, and beside a USING-only `all` sibling it did not replace that sibling's `using` default the way a `check` on an insert, update or all policy does. An author (an AI author above all) who wrote a check on a delete policy believed deletes were guarded by it. The refusal is a non-transforming refinement on the policy schema, so it reaches every door that parses a permission set: defineStack, os validate, and the metadata save path, whose permission type validates against PermissionSetSchema. Metadata AT REST is not rewritten and this entry adds no D2 conversion: dropping the key would silently discard the predicate the author wrote, and moving it to `using` would start filtering reads or deletes the policy never filtered before — both change which rows the policy admits, which is the policy author's decision. Ships at once, no transition window and no advisory lint phase. + - Done when: Search every authored and stored permission set for a rowLevelSecurity policy whose operation is select or delete and whose check is non-blank — metadata files, sys_metadata permission rows, and the row_level_security column of sys_permission_set. For authored metadata the sweep is mechanical: PermissionSetSchema.safeParse answers one custom issue at rowLevelSecurity[N].check whose message begins "`check` is never evaluated on a `select` policy" (or `delete`). For each, decide what the predicate was meant to guard: reads or deletes ⇒ express it in that policy's `using`; writes ⇒ move it to an insert, update or all policy. Then re-check the policy set's behaviour rather than assuming it is unchanged: the removed check never ran, so dropping it changes nothing, but a predicate moved into `using` now filters rows it never filtered, and a `check` moved onto an insert, update or all policy now replaces the `using` default of its USING-only siblings for that write. A policy with `using` only, and a `check` on an insert, update or all policy, parse exactly as before. The repo, its example apps and the pinned console carried no such policy at the time of the change; two test fixtures that used select incidentally were moved to all and insert. +- **`rls-predicate-array-comparand-refused`** — `security.PermissionSet rowLevelSecurity[].check — a CEL predicate comparing a field with != or == against a list, a list literal or a current_user membership array, and the negation of such an ==. They lowered to { field: { $ne: [...] } }, { field: [...] } and { $not: { field: [...] } }, which the @objectstack/formula evaluator matchesFilterCondition now refuses, together with { field: { $eq: [...] } }, at any depth under $and / $or / $not, the empty array included` → the list operator the comparison was standing in for. "One of these values" is in: record.status in ["open", "pending"]. "None of these values" is the negated in: !(record.status in ["closed", "archived"]). Scalar != and ==, null, Date comparands, and { $field } references between single-valued columns evaluate exactly as before + - Why not automatic: Ruling A of 2026-09-24 refuses an array comparand under $ne, and ruling 乙 of 2026-09-23 refuses one in the implicit-equality slot, each for every driver at once; this change lands both on the formula face, the evaluator plugin-security runs against the post-image of an insert or update to enforce a row-level check. It compared strictly, and no stored value ever equals an array, so a check written record.status != ["closed", "archived"], or != against a current_user membership array, matched EVERY post-image, and a check written !(record.status == ["closed", "archived"]) did the same: every write such a policy was written to refuse was admitted and stored. The positive record.status == ["open", "pending"] refused every write (403). The evaluator now refuses all of these shapes before any record is judged. The message withholds the field, the operator and the value. Metadata AT REST is not rewritten and this entry adds no D2 conversion: the platform cannot tell which list operator a list comparison was standing in for, and a policy rewritten on the author's behalf would change which writes it admits (the negated forms would start refusing writes they admitted, the positive form would start admitting writes it refused), which is the policy author's decision. ADR-0058 D4 / ADR-0087 / ADR-0112. + - Done when: Grep the rowLevelSecurity check and using predicates of your permission sets for != or == whose right-hand side is a list literal or a current_user membership array, and for the negation of such an ==, then rewrite each with in or !(... in ...). Then re-check what each policy is supposed to refuse rather than assuming the writes it admitted before were right: before this change a != or a negated == against a list admitted every write. +- **`rls-predicate-cross-class-field-comparison-refused`** — `security.PermissionSet rowLevelSecurity[].using and .check, and sharingRules[].condition — a CEL predicate comparing a field with another field (==, !=, >, >=, <, <=) where the two declared columns share no comparison class: text against a number, a date against a datetime, a boolean against text, and any column against a file field (file, image, avatar, video, audio) or a formula field. In a filter passed to matchesFilterCondition together with the object's declared columns (options.fields), a { $field } comparison under $eq, $ne, $gt, $gte, $lt or $lte between two such columns, and between a column and a json or multiple field` → a comparison between two columns of one comparison class: a number with a number (number, currency, percent, rating, slider, progress, summary), text with text (the string types, autonumber, a single select or radio, a single lookup or user, a master_detail or a tree), a boolean with a boolean, a date with a date, a datetime with a datetime, a time of day with a time of day. A file field and a formula field cannot be compared with another column at all: compare the field with a literal or test it for null. If the two columns do hold comparable values, one of them is declared with the wrong type, so correct that declaration rather than the predicate. Comparisons between two columns of one class lower and evaluate exactly as before + - Why not automatic: A column-to-column comparison has one meaning only within one comparison class: across classes SQLite orders every TEXT above every INTEGER while the in-process evaluator coerces ("open" > 5 is false). A formula field is virtual, with no stored column to reference. The file family is refused by name, whatever the deployment stores: during the ADR-0104 dual-encoding window one media column can hold a bare id and another the JSON-quoted form of the same id, so no comparison against the family is provably one answer on every path. driver-sql has refused such a comparison on the read since it first compiled a { $field } reference to a column-to-column comparison (a text column ordered against a number answered differently on SQLite than in memory, so the pushdown refused it), so a policy written record.status != record.amount (text and a number), record.status != record.photo (text and an image) or record.status != record.is_open (text and a formula field) got three answers, measured through the real plugin-security on driver-sql, on SQLite and PostgreSQL: os validate called it valid, every read it scoped answered INVALID_FILTER / 400 and every by-id update or delete it scoped 403, and an insert or update its check judged, or its using standing in as the check, was admitted and stored, because the write check compared the two raw values. The classification is now exported once from @objectstack/spec/data (crossFieldComparisonVerdict) and read by every judge. The authoring arm: the rls-predicate-unenforceable rule refuses the comparison in using and check, on every operation, at os validate, build and lint and at the metadata save door for a permission set, and the sharing-rule-unlowerable-condition rule refuses it in a sharing-rule condition at os validate, build and lint. The write-check arm: the row-level write gate hands matchesFilterCondition the object's declared columns, and a comparison the classification does not define is refused INVALID_FILTER / 400 for every insert and update the check judges, before any record is read, with nothing stored; the message withholds the columns and the server log names the policy and both. A comparison against a json or multiple field is now refused by its declared type on the write too, where the earlier refusal of an array comparand under $ne judged it by the value each record held. driver-memory, a test driver with no field-reference arm, still reads such a comparison as a literal. Shipped producers were counted before the change: no shipped row-level policy or sharing-rule condition compares two fields of different classes. Metadata AT REST is not rewritten and this entry adds no D2 conversion: the platform cannot tell which comparison the author meant, and rewriting it on the author's behalf would change which rows and writes the policy admits, which is the policy author's decision. ADR-0058 D4 / ADR-0087 / ADR-0112. + - Done when: Run os validate over your stack: it names every row-level or sharing-rule predicate that compares two fields of different comparison classes, with both declarations. Rewrite each as the replacement says. A policy that never passed os validate (stored before the authoring arm, or written by another path) is refused at request time instead: every read it scopes answers 400 on the SQL drivers, and so does every insert or update its check judges, so re-check what each such policy is meant to admit rather than assuming the writes it admitted before were right. +- **`rls-predicate-stored-list-ordering-refused`** — `security.PermissionSet rowLevelSecurity[].check, and .using where it stands in as the check — a CEL predicate ordering a field against a bound (>, >=, <, <=) where the field holds a list or an object on the record being written, as a json column or a multiple lookup does, or as a list written into a text or number field does. In a filter passed to matchesFilterCondition, $gt / $gte / $lt / $lte and $between on a field whose value on the record is a list or a plain object, whatever the comparand` → a comparison that names one value. Order a single-valued column (record.priority > 2), or test membership in the list with in (record.status in ["open", "pending"]); a json or multiple field has no ordering. A record whose json column holds one scalar is compared exactly as before, and so are null and Date values, and every equality (==, !=, in) against a stored list + - Why not automatic: The mirror, with the list on the record's side, of the earlier refusal of an ordering operator against an array comparand (one of the same-class leaks that followed the 2026-09-24 ruling refusing an array under $ne), measured through the real plugin-security on driver-sql and driver-memory. record.tags > "a", with tags a json column holding ["m"], lowered to { tags: { $gt: "a" } }, and the write-check evaluator compared the list's JavaScript string form ("m" > "a"), so the check admitted and stored the write; record.meta < "a" with meta holding { a: 1 } compared "[object Object]" and did the same, and so did a multiple lookup. driver-sql's read refuses every ordering comparison, and $between, on a column it stores as JSON text, by declared type (400), because such a comparison can never mean what the caller wrote; the in-process write check now follows it, per record: INVALID_FILTER / 400 and nothing stored, on an insert and on a by-id update, including one that edits another field of a row whose stored column holds a list. A list written into a text or number field under an ordering check, admitted before and stored as the text "[500]" by driver-sql, is refused the same way. driver-memory, a test driver, still compares a stored list element by element on a read, so there the write and the read part. Shipped producers were counted before the change: no shipped row-level or sharing-rule predicate orders a field at all. Metadata AT REST is not rewritten and this entry adds no D2 conversion: the platform cannot tell which comparison an ordering over a list was standing in for, and rewriting it on the author's behalf would change which writes it admits, which is the policy author's decision. ADR-0058 D4 / ADR-0087 / ADR-0112. + - Done when: Grep the rowLevelSecurity check predicates of your permission sets, and the using predicates of policies that declare no check, for >, >=, < or <= whose field is a json field or a multiple lookup, and rewrite each as the replacement says. Then write a record through each such policy: a write whose compared field holds a list now answers 400 rather than being admitted by string comparison, so re-check what the policy is supposed to admit. +- **`saved-report-stack-retired`** — `the saved-report stack, whole: the `reports` platform capability token (`requires: ['reports']`, its `PLATFORM_CAPABILITY_TOKENS` member and its `PLATFORM_CAPABILITY_PROVIDERS` row); the saved-report service contract in `@objectstack/spec/contracts` (`IReportService`, `SavedReport`, `ReportSchedule`, `ReportQuery`, `ReportFormat`, `ReportRunResult`, `SaveReportInput`, `ScheduleReportInput`); the `sys_saved_report` and `sys_report_schedule` platform objects (`SysSavedReport` / `SysReportSchedule` in `@objectstack/platform-objects/audit`) and their names in `PLATFORM_PROVIDED_OBJECT_NAMES`; the eight `/api/v1/reports` routes (list, save, get, delete, run, schedule, list schedules, unschedule); the `reports` namespace of `@objectstack/client`; and the `@objectstack/plugin-reports` package that served them. NOT the `report` metadata kind (`ReportSchema`, `/meta/report`, datasets, analytics), which is unchanged.` → Delete `'reports'` from `requires` — `defineStack` now refuses it with this prescription. A report is `report` metadata: `ReportSchema` over a dataset (ADR-0021), served by the analytics service every server mounts. A saved ad-hoc object query — what a `sys_saved_report` row held — is a ListView on that object. Code that imported the contract types or called the client namespace deletes those lines; there is no successor API and no scheduled-delivery replacement. + - Why not automatic: Maintainer ruling 2026-09-25 (verbatim: 「A. 退役」, then 「你直接派发处理这个退役任务。」). The stack persisted a raw object query (`object_name` plus `{filter, fields, orderBy, limit, groupBy}`) with a render format and an owner — the same object-plus-raw-query shape ADR-0021 removed from the `report` kind as its legacy inline query form, alive in a parallel table under the same word. Measured on the main branch of all three repos before removal: zero callers of the routes, the client namespace or the service contract outside their own tests, and no app declaring the capability. A declared capability with no consumer is a surface an author (most often a model) reaches for and confuses with the real report kind, so it is retired at once, with no deprecation window. + - Done when: `defineStack({ requires: ['reports'] })` throws `STACK_CAPABILITY_UNKNOWN` (422) whose message names the retirement and the replacement; `classifyRequiredCapability` answers `unknown` for the token; every `/api/v1/reports` path answers the standard unmounted-route 404; nothing imports the retired contract types or objects (TS2305 after upgrade). Existing `sys_saved_report` / `sys_report_schedule` tables in deployed databases are left in place untouched — no backfill, no reaper, no drop (the platform never drops a table that metadata stops declaring); `os migrate plan` lists them in its informational unmanaged-tables section, and dropping them is the operator's decision. +- **`schedule-flow-acting-organization-required`** — `The START NODE `config.organization` key of every time-triggered flow — a `type: 'schedule'` flow carrying a `config.schedule` cadence, and the `timeRelative` sweep that carries its cadence in the same slot (`FlowTriggerKind` `schedule` / `time_relative`) — TOGETHER WITH the deployment variable that decides whether such a flow arms at all, `OS_AUTOMATION_SCHEDULED_WORK_ENABLED`. Nothing is renamed, retired or re-typed: the start node's `config` is an OPEN record (ADR-0018), so the key is an ADDITION to a slot that already accepted it, and every flow that parses today parses byte-identically after the change. What narrows is the BIND-time accept set and the RUN-time data plane — and what the 2026-09-12 and 2026-09-16 amendments narrow further is WHERE that narrowing applies: the declaration is required under tenancy posture `isolated` only, is OPTIONAL under `group` (where an undeclared run acts as the swept record's own organization), is not read under `single`, and no time-triggered flow arms anywhere until the deployment switches package-authored scheduled work on.` → Two deployment decisions, in this order. (1) DECIDE WHETHER THIS DEPLOYMENT RUNS PACKAGE-AUTHORED SCHEDULED WORK AT ALL: `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true` arms time-triggered flows and packaged `defineJob` cron jobs; unset — the global default, in every posture and every kernel — arms neither, and every such flow is listed by `getTriggerBindingAudit()` and the CLI startup summary as DISABLED BY DEPLOYMENT POLICY rather than as a binding failure. Platform-internal jobs (approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership backfill) are NOT gated by it: the boundary is "authored by a package", not "runs on the job service". (2) ONLY IF THE SWITCH IS ON AND THE POSTURE IS `isolated`, declare the organization each flow runs as, on the start node beside the cadence: `config: { schedule: { … }, organization: '' }`. There is deliberately NO fan-out — a sweep wanted in N organizations is N flows, one per organization — and deliberately no fallback: nothing on this path ever chooses an organization, because a wrong `organization_id` is silently authoritative to every report, export and cleanup that filters by organization, while a refusal is visible at boot and names its flow. Under the `single` posture with the switch on, declare NOTHING: the run carries no organization and every tenant-scoped insert beneath it resolves the deployment's one organization through the guard that makes a system-context write resolve the install's organization. Under the `group` posture with the switch on, declaring is OPTIONAL and both shapes are supported: a declared flow behaves exactly as under `isolated` (the declaration bounds SELECTION and identity alike), while an UNDECLARED flow arms, reads group-wide — which ADR-0105 D1 makes inherent to the posture — and stamps each run it launches with the SWEPT RECORD's own organization, the same subject-first order `sys_automation_run` already uses. ⚠️ An undeclared `group` flow that reaches a tenant-scoped write with NO record to derive from — a record-less cron emitting a notification — is REFUSED at that write (`walled-posture`, ADR-0112), loudly and by name; declare `config.organization` on that flow, which is the remedy the refusal itself prints. ⚠️ Three consequences apply to an `isolated` deployment that splits one flow into N, and each is deployment work: (1) rows whose tenant column is NULL stay visible to a scoped read (`org = :tenant OR org IS NULL`), so after the split each such row is matched ONCE PER FLOW — N runs and N notifications for one row, each acting as a different organization; (2) the dispatch-claim key embeds the flow name (`schedule::`, `time-relative:::`), so renaming one flow into N abandons the current window's claims and a window already delivered under the old name can deliver once more under the new ones; (3) a run SUSPENDED before the upgrade rehydrates its context from `context_json`, which carries no `tenantId`, so it resumes org-less — drain or accept in-flight suspended runs rather than assuming the upgrade confines them retroactively. + - Why not automatic: Three maintainer rulings, all verbatim and untranslated, in the order they were given. 2026-09-08: 「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。」 A time-triggered run is launched from a job tick and a job tick carries no identity, so the run reached the tenancy guard with nothing to offer it: the notification wrote `organization_id = NULL`, every tenant-scoped row beneath it was refused, and the tick still summarised itself as healthy. 2026-09-12, on the same surface: 「schedule 是风险很大的模型,尤其在云端,无算是单独多租户还是每库一租户,可能造成极大的资源浪费。对于单租户或着集团版私有部署,我觉得不需要做限制。定时任务 如果不好处理,现在也没想清楚,有没有可能定义为一个环境变量,根据环境变量控制?」 and 「group 默认也关,云端每库一租户全局默认关」. Whether clock-driven work is affordable is a fact about the DEPLOYMENT — its database, its tenants, its budget — that no author can know and no metadata key should ask them for, so the gate is a deployment variable read at boot and the global default is OFF. 2026-09-16, reopening the `group` half of that amendment and nothing else: 「group 模式是本地部署的,运行 schedule 应该是可以的,但是你没有权限,可以单独开一个决策卡」 — ruled A′ the same day: under `group` with the switch on, a flow binds without a declaration. The 2026-09-08 ruling was made for the MULTI-TENANT shape, and `group` is not one: ADR-0105 D1 defines it as one legal group over one database with group-wide visibility and cross-org workflow INHERENT to the shape, so a group-level batch job is a capability of the posture rather than the cross-organization task the ruling forbids. What was genuinely unanswered — recorded as unanswered by ruling G item 3 — was which organization such a run's inserts belong to, and the answer is the one `sys_automation_run` was already ruled to use: the SUBJECT RECORD's organization, with the acting context as the fallback and never the primary. Filling the acting context the same way makes the inbox, delivery and history rows of one run agree about its owner; leaving them to disagree was the defect, not the fix. ⛔ The rejected arm is recorded too, because it is the one a later reader will re-propose: falling back to the bootstrap organization (`slug='default'`) for a record-less run. Under a wall that organization is minted ADMIN-KEYED by the enterprise organizations runtime and may not exist at all, and where it does it is whichever organization the platform owner registered under — plausibly one plant of many. That is the silently-authoritative wrong owner this entry already forbids, so a record-less undeclared run is refused instead. Where the switch is on, the 2026-09-08 ruling therefore stands unchanged under `isolated`, is satisfied per-record under `group`, and is moot under `single`, which holds exactly one organization and therefore has no cross-organization task to forbid. ⛔ NOT losslessly convertible, and the reason is that both remedies are values only the deployment holds: an organization id is minted per install at runtime and the switch is an operator decision about cost, so there is no authored artifact and no stored representation a transform could rewrite — `objectstack migrate meta` cannot know which organization a given sweep belongs to, nor whether this deployment wants scheduled work at all, and inventing either is precisely what the rulings forbid. Registered under ADR-0087 D3 rather than left silent because the change DOES carry a prescription — "decide the switch, then declare one flow per organization under a wall" is deployment work a human must do, which is what D3 says a structured TODO is for. The direct precedent is `rest-requireauth-default-flip` (protocol 12): behaviour-only, no shape moved, a deployment judgement no transform can make, registered anyway. + - Done when: The deployment has DECIDED the switch, and the decision is visible: `os doctor` prints the effective `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` value. A deployment that leaves it unset — the default — accepts that no packaged time-triggered flow and no packaged `defineJob` runs, and confirms that every such flow appears in `getTriggerBindingAudit()` and the CLI startup summary with the reason DISABLED BY DEPLOYMENT POLICY and NOT as "binding failed"; no boot line reads `[schedule] NOT BOUND` / `[time-relative] NOT BOUND`, because nothing was refused for a declaration. A deployment that sets it to `true` under posture `single` confirms that its time-triggered flows are armed while declaring no `config.organization`, and that the runs they launch carry none. A deployment that sets it to `true` under posture `group` confirms the shape it wants PER FLOW: for a flow it left undeclared, that boot logs the bind line naming per-record ownership, that a sweep tick launches runs stamped with each swept record's own organization (NOT one organization for the batch), and that any record-less cron among them either declares `config.organization` or is accepted to fail loudly at its first tenant-scoped write; for a flow it declared, the `isolated` criteria below apply unchanged. ⚠️ The discriminating observation for the undeclared case is the SET of organizations across the runs one tick launched — a pin that reads only "a run was stamped" passes on the defect too, which stamped them all alike. A deployment that sets it to `true` under posture `isolated` confirms that every `schedule` / `time_relative` flow in the stack declares a non-empty `config.organization` on its start node, that boot logs no `NOT BOUND` line, that `getFlowRuntimeStates()` reports `bound: true` and that `getTriggerBindingAudit()` lists no time-triggered flow — and, where it ran ONE flow across all organizations, that it has split it into one flow per organization and re-checked the three consequences above (NULL-tenant rows, abandoned dispatch claims, suspended runs). ⛔ There is NO authoring-time lint for the declaration: it was retired with this amendment because neither the switch nor the posture is knowable from a stack, so `os lint` reporting nothing is the criterion being met, not a check that was skipped. ⚠️ `@objectstack/driver-memory` has NO legal configuration for a time-triggered flow that touches per-organization data under a wall: it refuses any call handed a tenant scope (`MEMORY_MULTI_TENANT_UNSUPPORTED`), so a declared flow is refused per call, an undeclared `isolated` one is not armed at all, and an undeclared `group` one arms and sweeps unscoped but is refused at the first write it derives an organization for. Multi-organization deployments use `@objectstack/driver-sql`. +- **`scim-provider-object-retired`** — `the `sys_scim_provider` platform object (`SysScimProvider` in `@objectstack/platform-objects/identity`, re-exported from the package root) and its name in `PLATFORM_PROVIDED_OBJECT_NAMES` (`@objectstack/spec/system` constants). The rc.1-era `@better-auth/scim` connection row: one row per SCIM bearer connection, written only by the retired `/scim/generate-token` endpoint.` → (removed — no direct replacement row. The stable `@better-auth/scim` 1.7.x line, which the platform adopted as one whole-model migration, derives no `scimProvider` model: SCIM state lives in the seven stable platform objects (`sys_scim_connection_binding`, `sys_scim_group`, `sys_scim_group_member`, `sys_scim_identity_tombstone`, `sys_scim_projection_grant`, `sys_scim_subject`, `sys_scim_user`) and connection credentials in the ObjectStack-owned `sys_scim_connection_credential`, minted/verified by `scim-connection-service.ts` behind the application-owned `verifyBearerToken`. A SCIM-enabled deployment re-registers its connections on the stable surface; rc.1 token digests are not portable on any path, so the IdP reissues its token — a migration-day operator action, not a code rewrite.) + - Why not automatic: Maintainer ruling 2026-08-24 on the disposition of `sys_scim_provider` (verbatim, in part: 「不需要考虑历史数据」) — disposition A: retire, with no data-migration path owed for existing rows (reaffirmed 2026-08-25: SCIM has no real customers; the binding constraint is a smooth upgrade). Executed as a retirement of its own after the stable-1.7.1 migration landed: the installed library derives no `scimProvider` model, so the object backed nothing — nothing could write a row to it any more. Retiring it also removes its `provider_id` unique index, whose stricter-than-upstream uniqueness (one `provider_id` across every organization, where upstream scopes it per organization) was flagged while the SCIM upgrade was parked, and left pending exactly this retirement. + - Done when: No code imports `SysScimProvider` from `@objectstack/platform-objects` (TS2305 after upgrade); `isPlatformProvidedObjectName('sys_scim_provider')` returns false, so a stack referencing the name is flagged as a probable typo rather than resolved; plugin-auth provisions no `sys_scim_provider` object and `AUTH_MODEL_TO_PROTOCOL` carries no `scimProvider` entry; the spec registry conformance test (`platform-object-names.test.ts`) pins the absence bidirectionally — re-adding either the object file or the registry name alone reds `registry group "platform-objects" is out of date` (measured both ways when the object was retired). Existing `sys_scim_provider` tables in deployed databases are left in place untouched, by ruling — no backfill, no reaper, no migrate command. +- **`screen-field-lookup-reference-required`** — `The `reference` key of a `type: 'lookup'` field on a `screen` node — `flows[].nodes[].config.fields[]` where the node `type` is `screen` and the field `type` is `lookup` (`ScreenFieldConfigSchema`). Nothing is renamed, retired or re-typed and the key set does not move: `reference` was already declared and already optional in the shape. What narrows is the ACCEPT SET for one value of the sibling `type` — a `lookup` field with no `reference`, or with a blank one, parsed before this major and is refused now. Every other widget hint is untouched, and a `lookup` field that already names its target parses byte-identically.` → Name the object whose records the picker offers, beside the type: `{ name: 'resolved_by_article', type: 'lookup', reference: 'crm_knowledge_article' }`. The value is an object NAME (the canonical id — same string `FieldSchema.reference` carries), not a label and not a record id. ⚠️ There is deliberately no default and no inference: a picker pointed at the wrong object is worse than one that refuses to load, because it offers a human a plausible list of the wrong records and the flow stores the id it is given. Where the field genuinely has no target object — the author was using `lookup` to mean "type an id here" — the fix is the other direction: change `type` to `'text'`, which is what that field actually was, and keep the prose that asked for an id in `inlineHelpText`. + - Why not automatic: Maintainer ruling A′, 2026-09-13, verbatim, untranslated: 「同意」. ADR-0078 forbids metadata that parses, carries no marking and does nothing — and its own worked example of that state is a `lookup` with no `reference`: the field renders a picker, the picker has no object to query, and nothing anywhere says so. The key shipped OPTIONAL on this surface one release earlier, on the argument that flows declaring a bare `lookup` already exist; the ruling reversed that, holding that a degraded shape which ships is not a reason to bend the contract to it. ⛔ NOT losslessly convertible, and the reason is the same one `schedule-flow-acting-organization-required` gives: the remedy is a value the artifact does not contain. A bare lookup records the field name and nothing about its intended object, so `objectstack migrate meta` can identify every site but can answer none of them — and a conversion that guessed (the first object with a matching-looking name, the flow's trigger object) would write an authoritative wrong answer into metadata a human then trusts. Registered under ADR-0087 D3 rather than left silent because the change DOES carry a prescription a human can execute, which is what D3 says a structured TODO is for. + - Done when: Every `type: 'lookup'` field on every `screen` node in the stack declares a non-empty `reference`, and the stack parses: `ScreenFieldConfigSchema` refuses the bare form with a message addressed to `reference` (`SCREEN_FIELD_LOOKUP_REFERENCE_REQUIRED`), so a full metadata parse — `os lint`, or any publish — reports one issue per unfixed site and names the FLOW and the FIELD in its path. Work the list to empty rather than sampling it: a flow whose screen never reaches that node in testing is refused at publish just the same. For each site, answer which object the picker was meant to offer — the declaration is the answer, and where there is no such object the field was never a lookup (retype it `'text'`). ⚠️ Runs SUSPENDED at a screen before the upgrade rehydrate their `ScreenSpec` from stored context, so an in-flight run parked on an unfixed screen carries the old shape: drain or re-drive those rather than assuming the fix reaches them retroactively. +- **`send-template-input-org-retired`** — `contracts.emailService.sendTemplate input.org` → (removed — never implemented; delete the key from the call. It is NOT replaced by `organizationId`: that member is the delivery row's tenant stamp (`sys_email.organization_id` pass-through, added so the email writer stamps a delivery row's organization at the source) and opts into no template overlay resolution) + - Why not automatic: ADR-0049 enforce-or-remove. `SendTemplateInput.org` was declared as "Tenant id for org-overlay resolution (when supported)" and no implementation ever read it: `@objectstack/plugin-email` — the only IEmailService implementation — resolves templates on `(name, locale)` only, so a caller passing `org` got no org-overlay resolution and no error; the "(when supported)" hedge was the declaration admitting the gap. After the delivery-row stamp landed `organizationId` beside it, the input carried two org-shaped keys of which one did nothing — exactly the shape that invites an AI author to pick the wrong one. There is no behaviour to preserve and nothing stored to rewrite: the key only ever appeared in a call-time input bag (the `data.engine.update options.upsert` precedent), which is why this is a D3 semantic entry with no D2 conversion — no metadata seam ever runs on it. Org-overlay template resolution, if it ever earns a measured business pull, is a new capability with its own ruling — not this key revived. + - Done when: No caller passes `org` to `IEmailService.sendTemplate()`. The enforcement channel is the compiler: `SendTemplateInput` is a programmatic contracts interface with no Zod surface, so authoring `org` is an excess-property `tsc` error (pinned in `packages/spec/src/contracts/email-service.test.ts`). Runtime behaviour is deliberately UNCHANGED: nothing ever read the member, so removing it removes no behaviour — a JavaScript caller still passing `org` keeps its exact pre-removal outcome (the key is carried inert and ignored). Template resolution still keys on `(name, locale)`, and `organizationId` still stamps `sys_email.organization_id` without acquiring any overlay semantics. +- **`session-payload-positions-security-axis`** — `GET /api/v1/auth/get-session -> user.positions[] (and the client CEL root `current_user.positions` bound from it)` → the SAME key, carrying the SECURITY positions — the set `/auth/me/permissions` reports and `resolveUserAuthzGrants` resolves. A reader that wanted the better-auth role scalar reads `user.role`, which is unchanged and still published + - Why not automatic: A MEANING change, not a rename: no key moved, so nothing in this entry can be found by grepping for a removed spelling — which is exactly why it needs a ledger row. `customSession` built `user.positions` from the better-auth `sys_user.role` scalar split on commas, PLUS the active membership mapped to `org_*`, PLUS `platform_admin`, and read NOTHING from `sys_user_position` (ADR-0057 D4), the source of truth for custom positions. The Console binds that array straight through as the CEL root `current_user` (objectui `expressionUser.ts`: `positions: user.positions ?? []`), so an `action.visible` / `visibleWhen` / nav `visible` narrowed by a business position answered FALSE for EVERYONE, including the user who genuinely held it. ⭐ The failure was silent and in the invisible direction: the root was bound and the key was present, so `has(current_user.positions)` was true and CEL raised nothing — the predicate simply returned FALSE. A predicate that FAULTS fails OPEN in the shell and would have shown the button; a successful FALSE shows nothing and reports nothing. The documented example `'org_admin' in current_user.positions` kept working throughout, because `org_admin` is the one name that sits on BOTH axes — which is why no example, test or doc could reveal the split. This was a DECLARED contract being violated rather than an ambiguous name: `EvalUserSchema` already specified `positions` as "built-in identity names + position names", exposed to "every predicate surface (server formula, server RLS, client UI gates) ... with an identical shape" so a predicate "evaluates identically wherever it is written". `/auth/me/permissions` and every server-side evaluator (`ExecutionContext.positions`) already resolved the security axis; the session payload was the one producer that did not, because it derived the value itself instead of asking the authority `resolve-authz-context.ts` reserves that job for. ⚠️ NO renamed auth-role array accompanies this, and that is a measured disposition rather than an omission: everything the old union contributed beyond the security axis was the `sys_user.role` scalar's own tokens, and that scalar is ALREADY published unchanged as `user.role` — the single exception ADR-0090 D3's "role" word ban carves out for third-party schema. Minting a `roles` array would revive the exact banned identifier `check:role-word` ratchets against, to publish information the payload already carries. Maintainer ruling 2026-09-05: option A, one name, one meaning — `current_user.positions` means the security positions everywhere. ADR-0068 D1/D2, ADR-0090 D3/D5, ADR-0057 D4. + - Done when: No predicate and no client reader treats `current_user.positions` / `session.user.positions` as the better-auth role scalar. Audit every authored `visible` / `visibleWhen` / RLS predicate that names `current_user.positions` and classify each comparand: a real `sys_position` name, a built-in identity name (`platform_admin` / `org_owner` / `org_admin` / `org_member`), or `everyone` needs NO change and starts working where it silently answered FALSE before; a comparand that was only ever a `sys_user.role` token (`user`, and `admin` — note `admin` is NOT a built-in identity name; a membership `admin` is projected as `org_admin`) either moves to `user.role`, or — the supported route — becomes a real position assigned through `sys_user_position`, the governed ADR-0090 D12 channel. ⚠️ Verify against a REAL session rather than a fixture, and assert the axis by a name that exists on ONE side only: `org_admin` sits on both and cannot discriminate, which is precisely how this defect survived its own documented example. Sign in as a user holding a custom position, read `GET /api/v1/auth/get-session`, and assert that position is in `user.positions` and that the payload agrees set-for-set with `GET /api/v1/auth/me/permissions`. Assert the absence of a role-scalar token by VALUE rather than by the predicate's verdict: a gate that reads `false` cannot tell "the name is gone" from "the name was never there", and both of those from a faulting predicate, which fails OPEN in the shell and renders anyway. A deployment that stored business role names in `sys_user.role` instead of assigning positions is the one that must act; a name in `sys_member.role` is still projected, so membership-derived names are unaffected. +- **`session-user-language-retired`** — `api.session.user.language` → `GET /auth/me/localization` → `locale` (the user's own `sys_user.locale` when set → the request's `Accept-Language` → the deployment default) + - Why not automatic: `SessionUserSchema.language` was declared with a permanent default of `'en'` and described as "Preferred language", and had no producer and no consumer anywhere: no session endpoint wrote it, no client read it (objectui measured zero readers at its pinned sha), so a reader trusting the published contract received a constant that was not the user's language. Meanwhile the user's real preference landed as the first-class column `sys_user.locale` (ruled 2026-09-01 once measured demand for a per-user notification locale arrived), which the session type could not see — three spellings of one concept on the published surface, none of them right. The maintainer ruled option D (2026-09-03): retire the dead key under ADR-0049 enforce-or-remove and make `GET /auth/me/localization` the ONE read face, with its `locale` projecting the user column first. This is a RESPONSE surface — the server mints a `SessionUser` and nobody authors or persists one — so there is no source for the chain to rewrite; the schema tombstones the key via retiredKey() and consumers move their read to the endpoint. No replacement field joins the session contract until a session endpoint really produces one (no dual-spelling window, 不渐进). ADR-0049, ADR-0087. + - Done when: No client reads `user.language` off a `SessionResponse` / `UserProfileResponse`; a client that seeded its UI language from it now reads `locale` off `GET /auth/me/localization`, where a user who set `sys_user.locale` sees that value, a user who did not sees the request's `Accept-Language` preference, and a request expressing none sees the deployment default. Constructing a `SessionUser` with `language` fails to parse with its own prescription instead of being silently stripped, and assigning it is a `tsc` error at the authoring site. +- **`stack-config-default-export-unbuilt-refused`** — `the default export of objectstack.config.ts when it is not the value defineStack or composeStacks returned: a plain object literal, a spread or Object.assign copy of a built stack, a JSON copy of one, a module with no default export — and each input handed to composeStacks` → export what the producer returned: `import { defineStack } from '@objectstack/spec'; export default defineStack({ … });` with every stack key inside the call (`api`, `plugins`, `requires`, …), or `export default composeStacks([defineStack({ … }), …])`. Named exports beside it (`onEnable`, `functions`) are unaffected. `defineStack(config, { strict: false })` also satisfies the doors — it is still the producer — but skips its judgement, so reserve it for sources a strict parse cannot yet read + - Why not automatic: The stack family's cross-field refusals — unknown `requires` capability, cross-references to objects the stack does not define, the namespace prefix, one app per app package, the hierarchy-scope and trigger capability requirements — run inside `defineStack` and nowhere else. A config exporting a plain object skipped all of them: `objectstack validate` and `objectstack build` ran only the schema parse, answered success, and the build shipped the artifact, so the defect surfaced at deploy or never (a trigger flow that silently never fires). Judging the export at the door instead is not possible: a built stack carries each bound standalone action twice (top level and merged into its object), so re-running the family on `defineStack` output refuses every correct project with a bound action. So both producers stamp a non-enumerable provenance mark on what they return (`hasStackProvenance`), and `objectstack validate` / `objectstack build` refuse an unmarked default export right after load with `STACK_PROVENANCE_MISSING` (exit 1), before any other judgement; `composeStacks` refuses an unmarked input with the same code. A copy of a built stack is refused too, because the mark does not survive a spread or JSON round-trip — by design, since the copy is not what the producer judged. ⚠️ No D2 conversion: the module shape is source code, not metadata. `objectstack serve`, `objectstack migrate` and `objectstack lint` load the config as before. ADR-0087. + - Done when: Run `objectstack validate` (and `objectstack build`) in every project. A refusal prints `objectstack.config.ts: the default export was not built by defineStack` and, under `--json`, carries `code: STACK_PROVENANCE_MISSING`. Wrap the export in `defineStack({ … })`, move any key that was spread onto a copy inside the call, and re-run: the command either passes or now reports the stack family's own findings (`STACK_CAPABILITY_UNKNOWN`, `STACK_CROSS_REFERENCE_INVALID`, …) that the plain export had been hiding — fix those as each message prescribes. A project already exporting `defineStack(...)` or `composeStacks([...])` of `defineStack` inputs is unaffected and passes byte-identically. +- **`stack-themes-carrier-retired`** — `stack `themes` (the carrier collection, and `ThemeSchema` with its sub-blocks)` → delete the `themes:` key (and any `defineTheme` calls). To colour the shipped console, set `app.branding.primaryColor` / `accentColor` — the one live colour surface (read by objectui, driving `--primary`, `--accent` and their derived CSS variables). A palette value your own stylesheet consumed has no spec slot any more: move it into your own CSS. + - Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21 (disposition B: 退役授权面 — objectui engine code and its unit tests are retained). The pipeline was live from the authoring gate (`ObjectStackDefinitionSchema.themes`, `defineTheme`) through artifact ingest (`ARTIFACT_FIELD_TO_TYPE.themes`) and stopped there, measured: zero non-test readers of `.themes` or stored `theme` items across core/runtime/rest/services/plugins; `theme` never in `MetadataTypeSchema`, `DEFAULT_METADATA_TYPE_REGISTRY` or `BUILTIN_METADATA_TYPE_SCHEMAS`; the only mounted ThemeProvider is the app-shell chrome light/dark toggle, unrelated to `ThemeSchema`; and no key anywhere selected an active theme. So an author (human or AI) who wrote a theme shipped it through every green gate and saw nothing change — the declared-but-unenforced shape ADR-0049 exists to delete. What colours a console today is `app.branding`, and that path is live and untouched. + - Done when: No stack source authors `themes:`; a stack that still does is refused at parse with the prescription (unrecognized_keys carrying the retirement's guidance — pinned in `stack-top-level-strict.test.ts`). `PUT /meta/theme/:name` gets the unrecognised-type refusal (a `/meta` type name the platform does not have is refused, never minted as a namespace) instead of the store-anything branch it had before `theme` was validated at the `/meta` write door (pinned in `protocol.unrecognised-meta-type.test.ts`). Legacy stored `theme` rows are untouched: `applyConversionsToStoredItem` passes them through, reads still answer, and DELETE still works, so the residue is removable. ⚠️ On-screen behaviour is deliberately UNCHANGED and must be verified as such: nothing ever read an authored theme, so removing the surface removes no behaviour — `app.branding` colours the console before and after. +- **`stack-top-level-unknown-keys-refused`** — `top-level stack definition keys (`ObjectStackDefinitionSchema`) — undeclared keys` → the declared top-level surface. A key the schema does not declare is refused at parse with a prescriptive message naming the key, suggesting the closest declared key on a near miss (`objectz` → `objects`, `flow` → `flows`), and carrying a curated prescription for the known retirements (`approvals`/`approvalProcesses` → Approval-node flows per ADR-0019; `workflows` → `state_machine` validation rules per ADR-0020; `portals` removed with the dead `PortalSchema`; `storage` is deployment config, OS_STORAGE_*; `onDisable` was never invoked, and left with the lifecycle-hook family the kernel never implemented). `onEnable` is now DECLARED rather than silently stripped — the runtime has always executed it off the authored bundle (a config-booted app keeps it too, since the fix that stopped the loader dropping it and every script action handler it registered) + - Why not automatic: The outermost authoring door was the last strip-mode surface of the unknown-key strictness campaign: an unknown top-level stack key parsed green and its value was silently dropped. Measured on 17.0.0 GA: three injected bogus top-level keys added ZERO warnings to `os validate` and exited 0 — even `--strict` could not catch them, because the `defineStack:` naming diagnostic printed at load, outside the warning tally. The failure population is a typo or stale key (`flow` for `flows`, `approvalProcesses` after the 7.4 removal) shipping an artifact with a whole metadata family absent at runtime, debugged from the far end — the root of a downstream application's report of a top-level typo that shipped an artifact minus a whole family with `validate` and `build` both green. Unknown top-level keys are now refused at parse time, which fails `validate` (and every other path through this one parse) outright; the near-miss guidance that used to arrive as a load-time warning now rides the refusal itself. + - Done when: A stack authoring only declared top-level keys parses byte-identically to before, `onEnable`/`functions` included. Any undeclared top-level key fails the parse with `unrecognized_keys` naming the key; a one-edit near miss carries a rename suggestion; the curated retirements answer with their prescriptions. `os validate` exits non-zero on a stack carrying any undeclared top-level key, with or without `--strict`. +- **`standard-error-code-batch-members-retired`** — ``error.code` values `BATCH_PARTIAL_FAILURE`, `BATCH_COMPLETE_FAILURE` and `TRANSACTION_FAILED` — three `StandardErrorCode` members retired from the closed catalog (ADR-0112 amendment 2026-08-18), so constructing or parsing an ApiError with any of them now refuses at the vocabulary boundary` → branch on the codes the batch surface actually speaks: a rolled-back atomic batch marks each row `errors[0].code = ROLLED_BACK`, rows the abort never reached `NOT_ATTEMPTED`, and the causal row keeps its own error — all per row, at HTTP 200, both codes ledger-registered. Delete any branch on the three retired spellings outright: it never fired, because nothing ever emitted them + - Why not automatic: ADR-0049 enforce-or-remove applied to the error vocabulary. No producer has ever emitted any of the three — measured when a sweep of the error catalogue found these three entries publishing no HTTP status: outside the enum declaration the only occurrences in the whole repo were two spec tests using them as arbitrary fixture strings, and `git log -S` shows they never had a producer since ADR-0112 introduced the vocabulary. A catalog member no producer can speak teaches an AI author a branch that can never fire; after removal the wrong spelling fails parse at authoring time instead. This is a WIRE vocabulary, not stored metadata — no `sys_metadata` row exists for the D2 chain to rewrite, so (like `driver-sql-upsert-cross-row-identity-merge-refused`) this entry is the notification channel. No mechanical rewrite exists: a dead branch has no correct mechanical target — the per-row codes carry strictly more information than the envelope code the branch expected. Maintainer ruling 2026-08-18: option A, retire all three from `StandardErrorCode`. ADR-0112, ADR-0049. + - Done when: No consumer branches on the three retired spellings; batch failure handling reads the per-row `results[].errors[].code` (`ROLLED_BACK` / `NOT_ATTEMPTED`) instead of an envelope-level code; constructing an ApiError with a retired spelling fails `StandardErrorCode`/`ApiErrorSchema` parse rather than passing silently. +- **`standard-error-code-concurrent-limit-exceeded-retired`** — `error.code value CONCURRENT_LIMIT_EXCEEDED — a StandardErrorCode member retired from the closed catalogue, so constructing or parsing an error with it now refuses at every catalogue door (StandardErrorCode, ErrorCode / ApiErrorSchema.code, makeApiErrorSchema) with the removal prescription` → delete any branch on `CONCURRENT_LIMIT_EXCEEDED` — a branch on a catalogue code no producer emits has nothing to match. For request pacing branch on `RATE_LIMIT_EXCEEDED` (HTTP 429; wait `retryAfterSeconds` before retrying). A service that enforces its own concurrency limit registers a code for it in its own error-code ledger rather than reusing the retired spelling. `QUOTA_EXCEEDED`, its catalogue neighbour, is unchanged. + - Why not automatic: ADR-0049 enforce-or-remove applied to the ADR-0112 error catalogue. Ruling A of 2026-09-13 (maintainer 「同意」) retired both producerless 429 members; the closure-review ruling of 2026-09-24 (letter 留·收窄, maintainer 「其他同意」) narrowed it to this code alone after `QUOTA_EXCEEDED` was found emitted by a hosted AI agent route and read by the console chatbot plugin. The ledger doctrine in error-code-ledger.zod.ts names a producerless row with no card behind it as the registered-but-unemittable retirement class, and a catalogue member no producer speaks teaches an author a branch that cannot fire; after removal the stale spelling fails parse with its prescription instead. An error code is WIRE vocabulary, not a metadata key, so there is no authored source for a D2 conversion to rewrite and this entry is the notification channel, as it was for `standard-error-code-batch-members-retired`. No mechanical rewrite exists: a dead branch has no correct mechanical target. + - Done when: No consumer branches on `CONCURRENT_LIMIT_EXCEEDED`; request pacing is handled on `RATE_LIMIT_EXCEEDED`. Constructing or parsing an error with the retired spelling fails `StandardErrorCode`, `ErrorCode` / `ApiErrorSchema` and the `makeApiErrorSchema` envelope parse, and the failure message is the removal prescription rather than the bare enum listing. `QUOTA_EXCEEDED` still parses at every door. +- **`startup-orchestrator-retired`** — `the startup-ORCHESTRATION surface of kernel/startup-orchestrator.zod.ts and contracts/startup-orchestrator.ts — 3 emitted defs and 8 exported names: StartupOptionsSchema / StartupOptions / StartupOptionsParsed, HealthStatusSchema / HealthStatus, StartupOrchestrationResultSchema / StartupOrchestrationResult, and the IStartupOrchestrator interface (orchestrateStartup / rollback / checkHealth / startWithTimeout). The startup RESULT survives, re-declared: PluginStartupResultSchema and PluginStartupResult stay on both entries` → (removed — there is no declarative replacement, because nothing ever implemented the interface or parsed the schemas. Plugin startup is the kernel own boot loop: ObjectKernel.start() calls startPluginWithTimeout() per plugin, which races that plugin start() against PluginMetadata.startupTimeout and, when KernelConfig.rollbackOnFailure is set, destroys the already-started plugins and rethrows the original error as the new error cause. So: instead of StartupOptions.timeoutMs declare startupTimeout on the plugin; instead of StartupOptions.rollbackOnFailure set rollbackOnFailure on the kernel config; instead of StartupOrchestrationResult.results read the per-plugin durations through ObjectKernel.getPluginStartupDurations(). StartupOptions.healthCheck and HealthStatus have NO replacement at all — no startup probe system exists, and one returns only through the enforce route of ADR-0049 with a new ADR, the probe first and the vocabulary second. StartupOptions.parallel and StartupOptions.context likewise: the kernel starts plugins sequentially and passes its own PluginContext) + - Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-09-06, option 3: keep a startup-result contract re-declared as the shape the kernel ships, and retire the rest. The module declared an orchestration design that never landed, and the spec and the kernel had already drifted into disagreement about the one shape that did: PluginStartupResultSchema described a plugin object, a required durationMs and a health member, while @objectstack/core shipped pluginName, an optional durationMs and timedOut. The ruling keeps a startup-result contract that describes what the kernel actually produces, and retires the rest. Re-measured on this card: zero implementers and zero consumers of the four retired surfaces in this repository and in the pinned objectui checkout, with lit same-corpus controls (defineStack, ManifestSchema); every remaining reference was a generated artifact or a released CHANGELOG.md. healthCheck and HealthStatus are the sharpest of the four: they name a per-plugin health probe the runtime has never had, the shape of the plugin sandboxing / integrity / approval config that was never wired to anything, which an AI author (ADR-0033) reads as proof the capability exists. With no authored document carrying any of the three defs there is no seam for a D2 conversion and no author to tombstone for: route 3, the shape of the dynamic plugin-loading family's removal and the advanced plugin-lifecycle config's retirement — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. The two keys of the SURVIVING result schema that leave (plugin, health) are tombstoned instead, and registered in RETIRED_KEYS_BY_MAJOR, because that def keeps emitting and its type is imported by @objectstack/core. A third key arrives on the spec surface only to leave it: core deprecated startTime alias, which held the same elapsed milliseconds as durationMs under a name that promises an instant. The re-declaration had to either mirror it or tombstone it, and mirroring is refused by check:duration-unit-keys (ruling B on duration-shaped number keys: the unit lives in the key name) since it is an elapsed number whose key name carries no unit and matches neither of that rule two schema-declared exemptions. So the L1 window closes here and the kernel stops populating it in the same change. + - Done when: No code imports any of the 8 retired names from @objectstack/spec, @objectstack/spec/kernel or @objectstack/spec/contracts — every one is TS2305 after upgrade, pinned by resolved symbol identity in kernel/startup-orchestrator-retirement.test.ts. No metadata document needs editing: none of the three defs was reachable from a metadata-type binding, a stack collection or a manifest embed, so no authored document could ever carry one. PluginStartupResult SURVIVES on both entries with the shape the kernel ships — pluginName, success, optional durationMs, the serializable error projection, timedOut — and @objectstack/core now imports that type instead of declaring a twin, so the drift cannot recur. Writing plugin, health or startTime on a PluginStartupResult is a tsc error and a parse error carrying the rename or the deletion; a reader of the removed startTime alias reads durationMs, which has always carried the same value. Runtime behaviour is unchanged except for that one alias: nothing ever read the retired ORCHESTRATION surfaces, the kernel boot loop is untouched, and the only observable difference is that a startup result no longer carries startTime beside durationMs. +- **`strategy-context-aggregation-method-narrowed`** — `StrategyContext.executeAggregate aggregations[].method (contracts/analytics-service.ts, exported from @objectstack/spec/contracts) - the parameter type, declared as bare string` → AggregationFunction (count | sum | avg | min | max | count_distinct, data/query.zod.ts) - the same closed vocabulary IDataEngine.aggregate already declares for the identical slot (AggregationNodeSchema.function; the analytics bridge renames method to function and forwards). A caller filling method from a string-typed value narrows the value to the enum - typing it AggregationFunction, or parsing with the spec's own AggregationFunction zod enum where the value enters from data. Values outside the six were never served: the bridge has parsed-and-refused them at runtime since it stopped declaring its own engine type and began parsing the method with the spec enum, and that refusal stays as defence in depth + - Why not automatic: Maintainer ruling 2026-08-28 (option A, census-first): one slot, one declaration. Two spec-declared surfaces described the same value and disagreed about its type: IDataEngine.aggregate's aggregations[].function is the closed six-value AggregationFunction enum while StrategyContext.executeAggregate declared the same slot aggregations[].method: string, so nothing on the analytics side of that seam was compile-checked against the engine's vocabulary - an author, very often an AI (ADR-0033), writing an analytics strategy got no compile-time help and could carry any method name all the way to the bridge's runtime refusal. One slot now has one declaration. Bookkeeping: this is a TYPE narrowing on a runtime TS interface member - no authorable metadata key, no wire shape and no walked-shape def changed, so nothing lands in RETIRED_KEYS_BY_MAJOR / RETIRED_DEFS_BY_MAJOR and the surface ratchets are expected byte-identical. It is a SEMANTIC entry rather than a D2 conversion because there is no authored document or sys_metadata row for the chain to rewrite: the only consumers are TypeScript call sites, and the compile error is the channel that reaches them. In-repo census at the ruling (hard precondition, measured before the narrowing landed): every implementor and every call site filling method is legal under the enum - ObjectQLStrategy.resolveMeasureAggregation emits only the six once it refuses a custom-SQL measure up front, the two literal producers write count, and every test fixture is implementor-side and stays assignable by contravariance. + - Done when: External implementors of StrategyContext stay source-compatible: a handler accepting method: string accepts a superset and remains assignable to the narrowed member. External callers filling method with a string-typed or out-of-vocabulary value fail tsc at the executeAggregate call site on upgrade; the fix is narrowing the value's type to AggregationFunction (parsing with the spec enum where it enters from data), never widening a local mirror of the contract. Runtime behaviour is unchanged: the bridge's parse-and-refuse accepts and rejects exactly the same sets before and after, and no stored metadata or document needs editing. +- **`structured-region-body-pause-and-end-refused`** — `The BODY of every ADR-0031 structured region — `loop.config.body`, each `parallel.config.branches[]`, and `try_catch.config.try` / `.catch` — at every depth the flow parse walks. Two node populations become undeclarable there: a node whose TYPE parks the run on EVERY execution (`screen`, `wait`, `approval`, `approval_revise`) and an `end` node, whatever its `outcome`. ⛔ `subflow` and `map` are NOT in the population, although their executors also declare `supportsPause: true`: they pause exactly when the child flow their `config.flowName` names pauses, which is a DIFFERENT metadata record and is not in hand while this flow is parsed. Refusing them by type would also refuse `loop { map(synchronous child) }`, a shape that runs correctly today, so a region-nested `map` or `subflow` still parses and is met at RUN time instead.` → Move the node onto the TOP-LEVEL graph and route the region's exit to it. For an `end`: delete it from the body, give the region a normal exit, and put the terminator (with its `outcome` / `message`) on the top-level graph — `loop { body: [ …, end ] }` becomes `loop { body: [ … ] } → end`. For a pausing node: hoist it out of the container — `loop { body: [ try_catch { try: [ approval ] } ] }` becomes a top-level `approval` with the loop fanning out around it, or the pausing half of the branch is split into a `subflow` the top-level graph calls; where the repetition is genuinely needed, make the TOP-LEVEL graph the repeating construct with the pause on it rather than nesting the pause inside a region. A `try_catch` whose only purpose was to contain the region's refusal has nothing left to contain and is deleted with it. + - Why not automatic: Maintainer ruling of 2026-09-17, verbatim and untranslated: 「同意,其他也同意」, carrying the presented option C (a durable pause inside a structured region is refused at authoring time); extended the same day by a second ruling, which attached the `end` half (an `end` node inside a region body is refused as well) and ruled 禁 on building durable pause into structured regions — structured regions do not support durable pause and a region body cannot terminate the run, so this is that limit's authoring-time enforcement rather than an interim. The POPULATION was then fixed by the 2026-09-18 ruling, letter D (maintainer 「其他同意」): 「inside `loop` / `parallel` branch / `try_catch` (try and catch) bodies at any depth, the node types `screen`, `wait`, `approval`, `approval_revise` and `end` are refused by `FlowSchema.superRefine` … `map` and `subflow` are ⛔ not refused by type.」 A parse-time rule refuses what is STATICALLY wrong; refusing `map` / `subflow` by type would refuse a correct working shape on a guess about another record. The refusal already existed AT RUN TIME and said nothing an author could act on: the engine converts a suspension raised inside a region into an error at the region boundary, AFTER the executor has written its progress state into the enclosing scope, so a `try_catch` that contains that error leaves residue the next entry reads back as progress. Measured on a real `AutomationEngine`, `loop { try_catch { map(pausing child) } }` over 3 iterations x 2 items: not one item's subflow ever completed, only two of three iterations reached the catch, and iteration 3 read `started === collection.length`, ran nothing, and returned SUCCESS with `summary.failed = 0`. ⚠️ Read that measurement for the MECHANISM: the shape it was taken on is a `map`, which this parse rule deliberately does not reach — making the run-time refusal of a region-contained node that durably suspends LOUD is the second half of ruling D and ships as its own `domain:services` change. ⛔ NOT losslessly convertible: hoisting a node out of a region is a GRAPH REWRITE — new edges, a changed exit, sometimes a deleted container — and which of several shapes the author meant is an intent no artifact records, so a transform that picked one would be inventing the design. That leaves D3, a structured TODO naming each node to edit. ⚠️ Two further boundaries this refusal deliberately does NOT reach, because a parse cannot: a pausing node type contributed by a PLUGIN (ADR-0018 left the node-type namespace open and a parse has no registry), and a region nested past `MAX_REGION_DEPTH` (32), where the walk stops. For both, the engine's run-time refusal is still the only one — unchanged by this step, not fixed by it. + - Done when: No `screen` / `wait` / `approval` / `approval_revise` node and no `end` node sits inside any `loop` body, `parallel` branch or `try_catch` try/catch region in the stack, at any depth. `FlowSchema.parse` (and therefore `defineFlow`, `registerFlow`, `os validate` and a Studio publish) accepts the stack: a node still nested is refused with the node AND the region named in one message (`A \`approval\` node may not sit inside a structured region — \`loop 'sweep' body → try_catch 'guard' try\` is a region body …`), anchored at `nodes[i].config.body.nodes[j].type` so a designer can jump to it. ⛔ A region-nested `map` or `subflow` is NOT part of this migration and needs no edit to load — if such a flow reports `success` having processed nothing, that is the run-time half of the same ruling and not a stack edit. Behaviour to re-check after editing, because the fix CHANGES IT deliberately: a region-nested `end` was a no-op, so moving it to the top level makes the run actually TERMINATE there — check that the nodes after the container were not relying on continuing past it. A hoisted `approval` / `wait` / `screen` now parks the run where the enclosing graph can see it, so anything that polled for the sweep to finish sees a suspended run instead of a green-but-empty one. +- **`sys-account-issuer-retired`** — ``sys_account.issuer` — the column, its `{ fields: ['issuer', 'account_id'], unique: true }` index, its label in the four generated translation bundles, and the `@objectstack/plugin-auth` symbols that existed only to serve it (`backfillAccountIssuer`, `CREDENTIAL_ISSUER`, `oauthIssuerFor`, `ResolvedSocialProvider`, `BackfillAccountIssuerOptions`, `BackfillAccountIssuerResult`). The `accounts.list()` client type loses `issuer` with the route that stopped returning it.` → nothing — account identity is `(provider_id, account_id)`, which `sys_account` has declared UNIQUE since the object was created. A caller that read `account.issuer` reads nothing in its place: the authority is `sys_sso_provider.issuer`, resolved through the account's `provider_id`, which is unique per environment. A host that called `backfillAccountIssuer` on its own schedule deletes the call; there is no successor pass. Existing deployments run the ceremony below before the column is dropped. + - Why not automatic: better-auth 1.7.3 removed the issuer-scoped account identity outright: `createLocalAccountIssuer` is deleted, `accountSchema.issuer` is gone, `AccountKey` is `(providerId, accountId)` again, and the `account.issuer` column and its unique index are gone from `get-tables`. There is no drop-in replacement. Maintainer ruling 2026-09-10: adopt the rollback rather than own a fork of an identity model the vendor abandoned — a permanent fork on the authentication library was refused, and staying pinned was refused as the durable answer (the exact pin to 1.7.2, taken after a floating 1.7.3 broke a fresh seeded boot, was the stopgap and has done its job). The column was a net liability in its own right: a credential row whose `issuer` was not the local credential issuer was invisible to `findAccountByKey`, so sign-in failed `INVALID_EMAIL_OR_PASSWORD` behind a "User not found" warn pointing at the `sys_user` row rather than at the account — four checklist items rediscovered that independently. Its discriminating power here was near zero: `sys_sso_provider` declares `{ fields: ['provider_id'], unique: 'global' }`, so `provider_id → issuer` is a function within an environment. + - Done when: BEFORE the column is dropped, `os migrate account-issuer` reads zero on the deployment: no `(provider_id, account_id)` key is held by more than one row. That pre-flight reads ROWS, never the index declaration, because `syncDeclaredIndexes` logs a plain UNIQUE whose CREATE failed on existing duplicates and lets the boot continue (a plain unique over duplicate rows was made loud and non-fatal, the MySQL hash-shadow arm included) — so a database can carry the declaration without the constraint, and on such a database the drop degrades SILENTLY rather than failing. A dirty read refuses; so does a read that throws or a scan that truncates. `os migrate apply --allow-destructive` re-runs the same pre-flight and refuses the drop before writing any DDL; the boot refusal on unapplied destructive drift is unchanged, so a runtime never auto-migrates. Colliding rows are resolved by the operator — keep the row whose provider account is live, delete the rest so a fresh sign-in re-links — never merged or dropped by the platform. AFTER the drop, a fresh install and an existing-data upgrade both sign in over the real auth route. A `provider_id` re-pointed at a different IdP must have its account bindings REBUILT: no column records which IdP vouched for a row, so the key cannot separate the old IdP's subjects from the new one's, and the `sys_sso_provider` update door refuses an issuer change while accounts are still bound to that provider. +- **`sys-audit-log-organization-column-retired`** — `sys_audit_log.organization_id — the injected organization column left the compliance ledger (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts, which now declares systemFields.tenant false); the organization a row is about stays in the attribution field tenant_id, and an organization reader is scoped on it by a platform row policy` → `sys_audit_log.tenant_id`, the attribution field every writer stamps. Rewrite any authored filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_audit_log` to name `tenant_id`. A row about a deployment-level action leaves it empty. Under an organization wall an organization reader is scoped to the rows about its active organization by the platform row policy `sys_audit_log_org`, and a platform administrator reads every row + - Why not automatic: ADR-0131 D7: the audit ledger may hold rows about deployment-level actions, so the organization a row is about becomes a plain attribution field under a name the tenant-field resolver does not claim, never the tenancy anchor, and the object is governed by object permission, not by the wall. Writer census at commit 3ae59661dc of this repository's main branch: the record mirror, the record-view writer and the sign-in writer in plugin-audit, and the settings change writer in service-settings, stamp tenant_id and stamped the injected column with the same value; the platform-admin standing writer in plugin-security stamps both NULL, by ruling; the two administrative user writers in plugin-auth stamp neither. So the attribution field already carries every organization the column did. Under a walled posture the tenant wall compared the column to the caller organization, which hid every row about no organization from every reader, platform administrators included. The read scope moves to the security layer, where the engine computes it once: the platform row policy tenant_id equal to the caller organization, shipped in organization_admin, member_default and viewer_readonly and stripped when no wall is enforced, plus an explicit organization_admin entry for the ledger without viewAllRecords or modifyAllRecords, because the wildcard superuser bypass would otherwise skip the policy on an object with no tenant column and hand each organization administrator every organization's rows. Per-tenant retention windows partition on tenant_id. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; once its values are confirmed equal to tenant_id, the operator drops it with os migrate apply --allow-destructive, and any row where they differ is reported rather than dropped. + - Done when: No authored metadata names `organization_id` on `sys_audit_log`: the field resolver (lint and the data door) answers it as an unknown field. A row about a deployment-level action is written with `tenant_id` empty and no refusal. Under an organization wall an organization administrator lists the rows whose `tenant_id` is its active organization and no other, and a platform administrator lists every row, the rows with no `tenant_id` included. Under `single` the policy is stripped and the organization administrator lists every row, as before. +- **`sys-flow-dispatch-organization-column-retired`** — `sys_flow_dispatch.organization_id — the injected organization column left the flow trigger dispatch claim ledger (packages/services/service-automation/src/sys-flow-dispatch.object.ts, which now declares systemFields.tenant false), and reading the table now requires the manage_platform_settings capability` → nothing on this table — `sys_flow_dispatch` is deployment-level state (ADR-0131 D7) and no organization owns a row. Delete any authored filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_flow_dispatch`. A principal that must read the table needs the `manage_platform_settings` capability, which platform administrators hold + - Why not automatic: ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level and loses the column; the writer decides membership, not the name. Writer census at commit e67ba80049 of this repository's main branch: the sole writer is ObjectStoreFlowDispatchStore in @objectstack/service-automation: two write sites (the claim insert and the settle update), each under a system context whose row is a dispatch key and its outcome, naming no organization. So the injected column only ever held NULL. The census is the same procedure that reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the caller organization and hid every row from every reader, platform administrators included; with no column there is no wall, and the table is governed by object permission instead (D7). The capability gate is part of the same change, not a follow-up: the organization_admin grant carries the superuser bits on every object, so without it a walled deployment would hand each organization administrator every other organization's rows. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, the remedy the drift report names. + - Done when: No authored metadata names `organization_id` on `sys_flow_dispatch`: the field resolver (lint and the data door) now answers it as an unknown field. On every tenancy posture a principal without `manage_platform_settings` is refused 403 PERMISSION_DENIED on a read of `sys_flow_dispatch`, and a platform administrator lists every row with no organization filter. After os migrate apply --allow-destructive the boot no longer reports the orphaned column. +- **`sys-job-organization-column-retired`** — `sys_job.organization_id — the injected organization column left the platform background-job catalogue (packages/platform-objects/src/audit/sys-job.object.ts, which now declares systemFields.tenant false), and reading the table now requires the manage_platform_settings capability` → nothing on this table — `sys_job` is deployment-level state (ADR-0131 D7) and no organization owns a row. Delete any authored filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_job`. A principal that must read the table needs the `manage_platform_settings` capability, which platform administrators hold + - Why not automatic: ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level and loses the column; the writer decides membership, not the name. Writer census at commit e67ba80049 of this repository's main branch: the sole writer is DbJobAdapter in @objectstack/service-job: four write sites (the update and insert arms of the schedule upsert, the active toggle and the run summary bump), each under a system context whose row literal names no organization. So the injected column only ever held NULL. The census is the same procedure that reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the caller organization and hid every row from every reader, platform administrators included; with no column there is no wall, and the table is governed by object permission instead (D7). The capability gate is part of the same change, not a follow-up: the organization_admin grant carries the superuser bits on every object, so without it a walled deployment would hand each organization administrator every other organization's rows. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, the remedy the drift report names. + - Done when: No authored metadata names `organization_id` on `sys_job`: the field resolver (lint and the data door) now answers it as an unknown field. On every tenancy posture a principal without `manage_platform_settings` is refused 403 PERMISSION_DENIED on a read of `sys_job`, and a platform administrator lists every row with no organization filter. After os migrate apply --allow-destructive the boot no longer reports the orphaned column. +- **`sys-job-queue-organization-column-retired`** — `sys_job_queue.organization_id — the injected organization column left the durable job and message queue (packages/platform-objects/src/audit/sys-job-queue.object.ts, which now declares systemFields.tenant false), and reading the table now requires the manage_platform_settings capability` → nothing on this table — `sys_job_queue` is deployment-level state (ADR-0131 D7) and no organization owns a row. Delete any authored filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_job_queue`. A principal that must read the table needs the `manage_platform_settings` capability, which platform administrators hold + - Why not automatic: ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level and loses the column; the writer decides membership, not the name. Writer census at commit e67ba80049 of this repository's main branch: the sole writer is DbQueueAdapter in @objectstack/service-queue: nine write sites (the publish insert and the worker update and delete paths), each under a system context whose row literal names no organization. So the injected column only ever held NULL. The census is the same procedure that reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the caller organization and hid every row from every reader, platform administrators included; with no column there is no wall, and the table is governed by object permission instead (D7). The capability gate is part of the same change, not a follow-up: the organization_admin grant carries the superuser bits on every object, so without it a walled deployment would hand each organization administrator every other organization's rows. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, the remedy the drift report names. + - Done when: No authored metadata names `organization_id` on `sys_job_queue`: the field resolver (lint and the data door) now answers it as an unknown field. On every tenancy posture a principal without `manage_platform_settings` is refused 403 PERMISSION_DENIED on a read of `sys_job_queue`, and a platform administrator lists every row with no organization filter. After os migrate apply --allow-destructive the boot no longer reports the orphaned column. +- **`sys-job-run-organization-column-retired`** — `sys_job_run.organization_id — the injected organization column left the platform job run history (packages/platform-objects/src/audit/sys-job-run.object.ts, which now declares systemFields.tenant false), and reading the table now requires the manage_platform_settings capability` → nothing on this table — `sys_job_run` is deployment-level state (ADR-0131 D7) and no organization owns a row. Delete any authored filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_job_run`. A principal that must read the table needs the `manage_platform_settings` capability, which platform administrators hold + - Why not automatic: ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level and loses the column; the writer decides membership, not the name. Writer census at commit e67ba80049 of this repository's main branch: the sole writer is DbJobAdapter in @objectstack/service-job: two write sites (the run start insert and the run finish update), each under a system context whose row literal names no organization, including for a job that declares the organization it runs as, whose stamp reaches the job data writes and never this ledger. So the injected column only ever held NULL. The census is the same procedure that reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the caller organization and hid every row from every reader, platform administrators included; with no column there is no wall, and the table is governed by object permission instead (D7). The capability gate is part of the same change, not a follow-up: the organization_admin grant carries the superuser bits on every object, so without it a walled deployment would hand each organization administrator every other organization's rows. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, the remedy the drift report names. + - Done when: No authored metadata names `organization_id` on `sys_job_run`: the field resolver (lint and the data door) now answers it as an unknown field. On every tenancy posture a principal without `manage_platform_settings` is refused 403 PERMISSION_DENIED on a read of `sys_job_run`, and a platform administrator lists every row with no organization filter. After os migrate apply --allow-destructive the boot no longer reports the orphaned column. +- **`sys-migration-journal-organization-column-retired`** — `sys_migration_journal.organization_id — the injected organization column left the migration run journal (packages/platform-objects/src/system/sys-migration-journal.object.ts, which now declares systemFields.tenant false), and reading the table now requires the manage_platform_settings capability` → nothing on this table — `sys_migration_journal` is deployment-level state (ADR-0131 D7) and no organization owns a row. Delete any authored filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_migration_journal`. A principal that must read the table needs the `manage_platform_settings` capability, which platform administrators hold + - Why not automatic: ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level and loses the column; the writer decides membership, not the name. Writer census at commit e67ba80049 of this repository's main branch: the sole writer is the @objectstack/core migration runner: one append site, under a system context or under the transaction it opened with one, and the row contract MigrationJournalEventSchema has no organization field to carry. So the injected column only ever held NULL. The census is the same procedure that reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the caller organization and hid every row from every reader, platform administrators included; with no column there is no wall, and the table is governed by object permission instead (D7). The capability gate is part of the same change, not a follow-up: the organization_admin grant carries the superuser bits on every object, so without it a walled deployment would hand each organization administrator every other organization's rows. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, the remedy the drift report names. + - Done when: No authored metadata names `organization_id` on `sys_migration_journal`: the field resolver (lint and the data door) now answers it as an unknown field. On every tenancy posture a principal without `manage_platform_settings` is refused 403 PERMISSION_DENIED on a read of `sys_migration_journal`, and a platform administrator lists every row with no organization filter. After os migrate apply --allow-destructive the boot no longer reports the orphaned column. +- **`sys-migration-organization-column-retired`** — `sys_migration.organization_id — the injected organization column left the deployment data-migration flag ledger (packages/platform-objects/src/system/sys-migration.object.ts, which now declares systemFields.tenant false), and reading the table now requires the manage_platform_settings capability` → nothing on this table — `sys_migration` is deployment-level state (ADR-0131 D7) and no organization owns a row. Delete any authored filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_migration`. A principal that must read the table needs the `manage_platform_settings` capability, which platform administrators hold + - Why not automatic: ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level and loses the column; the writer decides membership, not the name. Writer census at commit e67ba80049 of this repository's main branch: eleven write sites in six files (the platform-objects migration flag helpers, the ObjectQL lax-deviation and boot-admission revocation writes, and the seed-tenancy, membership-backfill and flow-credential receipts), each under a system context, and the row contract DataMigrationFlagSchema has no organization field to carry. So the injected column only ever held NULL. The census is the same procedure that reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the caller organization and hid every row from every reader, platform administrators included; with no column there is no wall, and the table is governed by object permission instead (D7). The capability gate is part of the same change, not a follow-up: the organization_admin grant carries the superuser bits on every object, so without it a walled deployment would hand each organization administrator every other organization's rows. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, the remedy the drift report names. + - Done when: No authored metadata names `organization_id` on `sys_migration`: the field resolver (lint and the data door) now answers it as an unknown field. On every tenancy posture a principal without `manage_platform_settings` is refused 403 PERMISSION_DENIED on a read of `sys_migration`, and a platform administrator lists every row with no organization filter. After os migrate apply --allow-destructive the boot no longer reports the orphaned column. +- **`sys-presence-organization-column-retired`** — `sys_presence.organization_id — the injected organization column left the realtime presence table (packages/services/service-realtime/src/objects/sys-presence.object.ts, which now declares systemFields.tenant false), and reading the table now requires the manage_platform_settings capability` → nothing on this table — `sys_presence` is deployment-level state (ADR-0131 D7) and no organization owns a row. Delete any authored filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_presence`. A principal that must read the table needs the `manage_platform_settings` capability, which platform administrators hold + - Why not automatic: ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level and loses the column; the writer decides membership, not the name. Writer census at commit e67ba80049 of this repository's main branch: nothing writes the table through ObjectQL at all (presence travels the realtime path, and the generic data door exposes reads only), and a person present in several organizations is one person. So the injected column only ever held NULL. The census is the same procedure that reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the caller organization and hid every row from every reader, platform administrators included; with no column there is no wall, and the table is governed by object permission instead (D7). The capability gate is part of the same change, not a follow-up: the organization_admin grant carries the superuser bits on every object, so without it a walled deployment would hand each organization administrator every other organization's rows. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, the remedy the drift report names. + - Done when: No authored metadata names `organization_id` on `sys_presence`: the field resolver (lint and the data door) now answers it as an unknown field. On every tenancy posture a principal without `manage_platform_settings` is refused 403 PERMISSION_DENIED on a read of `sys_presence`, and a platform administrator lists every row with no organization filter. After os migrate apply --allow-destructive the boot no longer reports the orphaned column. +- **`sys-setting-global-rung-moved`** — `sys_setting.scope global — the settings cascade global rung left the tenant-scoped settings table: a value for a key declared at global scope is stored in the new tenant-less object sys_platform_setting (packages/platform-objects/src/system/sys-platform-setting.object.ts), and the global option of sys_setting.scope is retired` → `sys_platform_setting`, one row per `(namespace, key)` for the deployment, with the same `value`, `value_enc`, `encrypted`, `locked`, `locked_reason` and `updated_by` columns and no `scope`, `user_id` or `organization_id`. Write it only through the settings door (`/api/settings/:namespace`), which routes a global-scope key there. Reading it through the generic data API requires the `manage_platform_settings` capability. Delete any authored filter, list-view column or seed that names `scope = global` on `sys_setting` + - Why not automatic: ADR-0131 D7: deployment-level runtime settings leave the tenant-scoped table, and a tenant-less object holds the values an operator must change without a restart. A census of every manifest at commit 51290bca2c of this repository's main branch found seven namespaces whose keys sit at the global rung (ai, auth, knowledge, mail, sms, storage and the ObjectQL lifecycle defaults), every one edited live in Setup, so none of them moves to boot configuration. The settings service is the only writer of a global row, it writes under a system context, and the row names no organization, so on sys_setting the injected organization column only ever held NULL there and a walled posture hid the row from every reader. The resolver reads the rung from the new object alone and excludes scope global from its sys_setting reads, so a row a pre-v18 database still holds there is not a second source; no write path produces one any more, which is why the select option retires rather than staying a declared value no write can reach. The cascade order, the lock semantics, SpecifierScopeSchema and the global resolution source are unchanged. An encrypted value moves without re-encryption: the ADR-0128 AAD binds the settings scope, namespace and key, never the holder object or an organization, so a sys_secret handle copied into the new row opens as it did. Existing databases: nothing moves automatically (ADR-0131 D14). The v18 upgrade ceremony moves each sys_setting row at scope global into sys_platform_setting by namespace and key, value_enc handle included; until it runs, those values read as their next rung or the manifest default. + - Done when: A write of a global-scope settings key creates or updates exactly one `sys_platform_setting` row for its `(namespace, key)` and no `sys_setting` row, and a read of the key answers that value with source `global`. A `sys_setting` row still at `scope = global` is not answered by any read. An encrypted global value keeps its `sys_secret` handle in `sys_platform_setting.value_enc` and opens. On every tenancy posture a principal without `manage_platform_settings` is refused 403 PERMISSION_DENIED on a generic data read of `sys_platform_setting`, while the settings door keeps answering it for a holder of the manifest capability. After the v18 ceremony no `sys_setting` row is at `scope = global`. +- **`sys-view-definition-retired`** — `the sys_view_definition platform object (SysViewDefinitionObject, exported by @objectstack/metadata-core and re-exported by @objectstack/platform-objects and its metadata subpath), its registration by MetadataPlugin and by the metadata protocol assembly, its name in PLATFORM_OBJECTS_BY_PACKAGE (@objectstack/spec system constants), its kernel:ready active-row index migration and that migration's exports from @objectstack/metadata-protocol (ensureViewDefinitionActiveIndex, resolveIndexExec, buildActiveIndexSql, VIEW_DEFINITION_TABLE, VIEW_ACTIVE_INDEX_NAME, VIEW_ACTIVE_PROBE_INDEX_NAME, VIEW_ACTIVE_INDEX_COLUMNS and the EnsureViewIndex types), and its idx_sys_view_def_active entry in the os migrate duplicates runtime-index pre-flight` → nothing replaces the table — a runtime-authored view is a `view` metadata item in `sys_metadata`, written through `PUT /api/v1/meta/view/` (the client's `meta.saveItem` for type `view`), which is what every framework and Studio view door already does. Delete any import of the removed symbols; `classifyIndexFailure` and the `IndexExec` type are still exported by `@objectstack/metadata-protocol`, from the shared index-migration module. A stack that names `sys_view_definition` (a lookup target, a flow trigger, a permission entry, a platform-global declaration) removes the reference: the name no longer resolves to a platform object + - Why not automatic: ADR-0131 D13: an object no framework code writes or reads is inert and retires. Census at commit 41d0d4038c of this repository's main branch, run with the glob pathspec over packages/**/src (41 files; control word sys_metadata 769) and repo-wide (65 files): no framework writer of the table's rows and no reader of them — the only statements that touched its rows were the active-row index migration's own presence and duplicate probes and the os migrate duplicates pre-flight's copy of the latter. The sibling Studio repository never referenced it (0 hits against 93 for sys_metadata, at its main branch and at the pinned console commit): its view create, update and list doors write the ADR-0005 view overlay through the metadata API. The only way a row could ever have reached the table was a caller using the generic data door on the object by name. Keeping it registered kept an API-enabled table, a boot-time index migration and a pre-flight probe alive for no consumer, and kept the name resolving as a real platform object for authored metadata that named it. + - Done when: No code imports SysViewDefinitionObject or the removed metadata-protocol exports (TS2305 after upgrade). isPlatformProvidedObjectName answers false for sys_view_definition, so a stack referencing the name is flagged as a probable typo rather than resolved. Neither MetadataPlugin nor the metadata protocol assembly registers the object, a serving boot issues no statement naming it, and os migrate duplicates reports three runtime-index pre-flight entries, none naming it. Existing databases: schema sync is additive and never drops a table, so a database an earlier release provisioned keeps sys_view_definition and any rows a caller wrote through the generic data door, and nothing reads them. os migrate apply --allow-destructive does not drop it either — it reconciles declared objects only (measured: on one database it dropped an orphaned column of a declared table and left this table and its row in place). os migrate plan lists it among the platform-prefixed tables nothing declares when the project has a host config. Export any row worth keeping; dropping the table is the operator's call, by hand. +- **`system-cache-durations-unit-in-key`** — `the two cache durations whose name carried no unit: CacheTier.ttl and CacheAvalanchePrevention.circuitBreaker.resetTimeout (system/cache.zod.ts)` → ttlSeconds and resetTimeoutSeconds — rename each key; both values, the 300 TTL default and the 30 reset default are unchanged + - Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. These two are one entry because they are one file and one authoring session: a cache tier and the avalanche-prevention block that protects it. Each sat beside a number in a DIFFERENT unit with nothing at the authoring site to separate them — CacheTier.ttl (seconds) beside maxSize (megabytes), and circuitBreaker.resetTimeout (seconds) beside lockout.lockTimeoutMs (milliseconds) on the very same schema. That last pair is the sharpest case on this file: one shape already carried both conventions, and the suffixed one was the honest half. Both are retiredKey() tombstones; neither shape is strict, so a bare deletion would strip in silence and the unknown-key error could not carry the rename. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no cache collection, and neither a cache tier nor an avalanche-prevention block is a registered metadata kind stored as a sys_metadata row, so the conversion chain has no seam that would see one. ADR-0087. + - Done when: Every author of a CacheTier spells ttlSeconds and every author of a CacheAvalanchePrevention spells circuitBreaker.resetTimeoutSeconds. Authoring either old spelling fails to compile (input type `never`) and fails to parse with the rename prescription rather than a bare unrecognized-key error. Behaviour is unchanged: a tier given ttlSeconds: 600 expires after ten minutes exactly as ttl: 600 did, an omitted key still defaults to 300, and resetTimeoutSeconds still defaults to 30. One thing this rename deliberately does NOT touch: lockout.lockTimeoutMs keeps its name and its MILLISECOND unit — the two timeouts on this schema were never the same unit and must not be migrated as if they were. +- **`system-collaboration-durations-unit-in-key`** — `the two collaboration-session durations whose name carried no unit: CollaborationSessionConfig.idleTimeout and CollaborationSessionConfig.snapshot.interval (system/collaboration.zod.ts)` → idleTimeoutMs and snapshot.intervalMs — rename each key; both values and the 300000 idle-timeout default are unchanged + - Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. idleTimeout is the collision that got this whole population ruled rather than merely noted: it is MILLISECONDS here, while the tenant surface carried its own idleTimeout in SECONDS at the same time — so the identical bare name meant five minutes on one shape and three and a half days on the other, a 1000x divergence no parse could catch because both readings are positive integers. The tenant half was already renamed, in the same change that landed the duration gate itself; this is the half that remained. snapshot.interval rides in the same entry because it is the same object graph and the same authoring session — leaving one bare beside the other would have preserved exactly the ambiguity the rename removes. Both are retiredKey() tombstones; the shapes are not strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no collaboration collection, and a session config is a runtime call argument rather than a stored sys_metadata row, so the conversion chain has no seam that would see it. ADR-0087. + - Done when: Every caller that opens a collaboration session spells idleTimeoutMs, and every snapshot block spells intervalMs. Authoring either old spelling fails to compile (input type `never`) and fails to parse with the rename prescription. Behaviour is unchanged: idleTimeoutMs: 600000 idles out after ten minutes exactly as idleTimeout: 600000 did, an omitted key still defaults to 300000, and the positive-integer bounds ride along with the renamed keys so a zero or negative interval is still refused. The migration is proved correct when no source in the tree spells a bare idleTimeout on ANY shape — the seconds-valued tenant twin is already gone, so a surviving bare spelling is now unambiguously a missed edit rather than the other key. +- **`system-failover-health-check-interval-unit-in-key`** — `FailoverConfig.healthCheckInterval, the disaster-recovery health-check period whose name carried no unit (system/disaster-recovery.zod.ts)` → healthCheckIntervalSeconds — rename the key; the value and the 30 default are unchanged + - Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. It stands alone because its file has exactly one offender left — and because the key directly beside it is the counter-example that shows where the line falls. FailoverConfig.dns.ttl is also a bare-named duration in seconds, and it is NOT renamed: it carries an externalVocabulary marker because it mirrors the DNS resource-record TTL field (RFC 1035 section 4.1.3), spelled ttl by every provider API the value is forwarded to (Route 53, Cloudflare). healthCheckInterval mirrors nothing outside this repo, so the exemption does not reach it. Tombstoned with retiredKey(); the shape is not strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no disasterRecovery collection and a failover config is host configuration, never a stored sys_metadata row. ADR-0087. + - Done when: Every FailoverConfig author spells healthCheckIntervalSeconds; authoring healthCheckInterval fails to compile (input type `never`) and fails to parse with the rename prescription. Behaviour is unchanged: healthCheckIntervalSeconds: 30 probes every thirty seconds exactly as before, and an omitted key still defaults to 30. The migration is proved correct when dns.ttl is still spelled ttl — a sweep that renamed it too has over-applied the rule and stripped a declared exemption. +- **`system-metrics-jsdoc-durations-unit-in-key`** — `the five remaining metrics durations whose unit lived in a source JSDoc only: MetricDefinition.summary.maxAge, ServiceLevelObjective.errorBudget.burnRateWindows[].window, MetricExportConfig.interval, MetricsConfig.collectionInterval and MetricsConfig.retention.period (system/metrics.zod.ts)` → summary.maxAgeSeconds, errorBudget.burnRateWindows[].durationSeconds, intervalSeconds, collectionIntervalSeconds and retention.durationSeconds — rename each key; every value is unchanged + - Why not automatic: This entry FINISHES what system-metrics-window-durations-unit-in-key started on this file, and the two are meant to be read as a sequence — this one does not amend that record, which stays a true account of what the system-directory duration round did. That round renamed the three metrics window and period lengths whose describe named no unit, and recorded that the error-budget burn-rate window was "outside this rename, not outside the gate population", naming the JSDoc-channel gap as where it would be settled. That gap is now ruled and this is its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer's 「同意」, which keeps the refusal of a duration key whose JSDoc names a unit its describe does not, remediates the 21-row JSDoc-channel population per file, and lands that widened gate last, into a tree already clean. ⚠️ One consequence for readers of the older entry: its acceptanceCriteria says the burn-rate window keeps its name and that a sweep renaming it has over-applied the rule. That sentence was true of that round and is superseded here, by the ruling it itself pointed at; the other key it names, the exporter batch size, is a COUNT of records and still does not move. All five keys here share one defect: the unit (seconds) was stated in the JSDoc above the key, a channel check:duration-unit-keys does not read — it reads .describe() and .meta({ description }) — and four of the five carried no describe at all while the fifth read "Window size". So the reader who most needs the unit, the reader of the published reference page, got a bare integer: 600, 3600, 60, 15 and 604800 are each a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. Each key is renamed and its describe corrected in the same stroke, because under the duration-unit rule (the unit lives in the key name or a unit-carrying value, never in the describe prose alone) moving the unit into the describe alone is itself a violation. Three of the five spellings are not the mechanical suffix, and each departure has a reason this file already supplied: burnRateWindows[].window becomes durationSeconds, not windowSeconds, because the enclosing array is already called burnRateWindows so the key would stutter — the objection the system-directory round recorded against window.windowSeconds — and because on this tree windowSeconds is not an authorable key at all, its only key-position occurrence being an alias-map entry in ServerRateLimitConfigSchema that maps the spelling AWAY to windowMs; retention.period becomes durationSeconds, not periodSeconds, because period is calendar vocabulary elsewhere in this spec (ServiceLevelObjective.period.type selects rolling or calendar, PluginRegistryEntry.pricing.billingPeriod is monthly or yearly) so periodSeconds would keep the ambiguous half of the name; and collectionInterval keeps its qualifier as collectionIntervalSeconds so it stays distinct from the MetricExportConfig.intervalSeconds this same card creates one def over. The two mechanical spellings are attested: maxAgeSeconds is the token AccessControlConfig.maxAgeSeconds already carries after this same rule renamed it on system/object-storage.zod.ts, and it keeps the age stem the sibling ageBuckets counts buckets of; intervalSeconds is the token four seconds-valued cadences already carry. Counted in key position across packages/spec/src at fc28c1d38, the base of this change, the seconds suffixes run Seconds 40, Sec 1 (maxExecutionTimeSec) and S 0 — the two bare S keys on that corpus, maxCommitTimeMS and enableRLS, are a millisecond spelling and a boolean — so Seconds is the family; this change takes Seconds to 45 at 9b62f54671. All five are retiredKey() tombstones; none of the five enclosing shapes is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no metrics collection, and none of a metric definition, an SLO, an export config or a metrics config is a registered metadata kind stored as a sys_metadata row — the same reading the system-directory round recorded for the three keys it renamed. Measured on fc28c1d38: no in-repo code consumer reads any of the five — outside packages/spec the only occurrences of every distinctive key on these shapes (burnRateWindows, errorBudget, downsampling, collectionInterval, cardinalityLimits, maxLabelCombinations, ageBuckets) are in the generated content/docs/references/system/metrics.mdx, which this rename regenerates, against a lit control of 1195 defineStack occurrences on that same corpus at fc28c1d38 (1195 again at 9b62f54671); and the objectui checkout this repo builds against — this is the pin, `.objectui-sha` = `f0268ad784854568aa58a2aa791f6a7502259186`, re-read from this tree — spells all six metrics def names and both distinctive keys 0 times across 8234 tracked files at that sha, against lit controls window 4430, timeout 1658, period 249, interval 213 and metrics 404 on that same corpus and sha (0 across 7754, against 4255 / 1431 / 247 / 200 / 401, at a58626c88, 0 across 7650, against 4194 / 1360 / 238 / 195 / 401, at 0abd4f9f8, 0 across 7632, against 4193 / 1360 / 238 / 195 / 401, at 9dfaca654, 0 across 7579, against 4175 / 1351 / 238 / 195 / 374, at 2e818d0b5, 0 across 10267, against 4044 / 1348 / 231 / 196 / 354, at ab1879721, 0 across 10071, against 4002 / 1331 / 231 / 196 / 355, at 89cad75d5, 0 across 9912, against 3916 / 1303 / 234 / 196 / 354, at 31971ff1e, 0 across 9800, against 3873 / 1293 / 233 / 196 / 352, at e420df310, 0 across 9546, against 3772 / 1197 / 228 / 196 / 341, at db11afd49, 0 across 9283, against 3681 / 1172 / 183 / 176 / 340, at dd3f7e1be, 0 across 8512, against 3581 / 1096 / 171 / 179 / 326, at f8a9d0fb0, and 0 across 8303, against 3526 / 1086 / 170 / 179 / 324, at 62597c588), so no pin bump is owed. ADR-0087. + - Done when: Every metric definition spells summary.maxAgeSeconds, every error-budget burn rate window spells durationSeconds, every metric export config spells intervalSeconds, and every metrics config spells collectionIntervalSeconds and retention.durationSeconds. Authoring any of the five old spellings fails to compile (input type `never`) and fails to parse with the rename prescription naming the suffixed key — not an unrecognized_keys issue. Behaviour is unchanged: collectionIntervalSeconds: 15 collects every fifteen seconds exactly as collectionInterval: 15 did, and every default (600, 60, 15, 604800) and positive-integer bound rides along with its renamed key. Each new describe names the unit, so the reference page carries it. Verify the same-named decoys on this one file apart: MetricAggregationConfig.window and ServiceLevelIndicator.window are objects that already hold a durationSeconds of their own, and ServiceLevelObjective.period is an object holding a durationSeconds and a calendar — none of the three moves, and a sweep that renamed any of them has over-applied this rule. +- **`system-metrics-window-durations-unit-in-key`** — `the three metrics window/period lengths whose name carried no unit: MetricAggregationConfig.window.size, ServiceLevelIndicator.window.size and ServiceLevelObjective.period.duration (system/metrics.zod.ts)` → window.durationSeconds, window.durationSeconds and period.durationSeconds — rename each key; every value is unchanged + - Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. The three are one entry because they are one measurement expressed three times on one file: how long a window or period is. The new name is deliberately NOT the mechanical sizeSeconds the gate prints. size means a byte or row count everywhere else in this spec — CacheTier.maxSize is megabytes, RegistryConfig.cache.maxSize is bytes, and this very file spells a batch row count size — so sizeSeconds would have kept the misleading half of the name and bolted a unit onto it, leaving a reader to decide whether a window is measured in bytes-per-second or in time. windowSeconds was rejected for a plainer reason: the parent key is already window, so it would read window.windowSeconds. durationSeconds names what the number IS, and the file itself supplied the precedent — ServiceLevelObjective.period already called its length a duration, so after the rename all three read alike instead of one borrowing byte vocabulary. All three are retiredKey() tombstones; the shapes are not strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no metrics collection, and none of an aggregation config, an SLI or an SLO is a registered metadata kind stored as a sys_metadata row. ADR-0087. + - Done when: Every aggregation window and SLI window spells durationSeconds, and every SLO period spells durationSeconds. Authoring window.size or period.duration fails to compile (input type `never`) and fails to parse with the rename prescription. Behaviour is unchanged: window.durationSeconds: 300 aggregates over five minutes exactly as size: 300 did, and the positive-integer bounds ride along with the renamed keys. Two keys on this same file deliberately do NOT move, and a sweep that renamed either has over-applied the rule: the error-budget burn-rate window names its unit only in the JSDoc above it ("Window size in seconds"), a channel the gate does not read: it reads `.describe()` and `.meta({ description })`, and that key's describe ("Window size") names none. So the gate lists it among the duration-shaped keys without judging it — neither an offender nor an exemption — and it is outside this rename, not outside the gate population; that JSDoc-channel gap is filed as a finding of its own; and the exporter batch size is a COUNT of records, not a duration, so it has no unit to carry. Both keep their names. One of those two moves after all, in this same protocol step: the error-budget burn-rate window is renamed to durationSeconds by system-metrics-jsdoc-durations-unit-in-key, the remediation of the JSDoc-channel gap named just above (ruled: a duration key whose JSDoc names a unit its describe does not is refused). Read that entry with this one; the exporter batch size is still a COUNT of records and still does not move. +- **`system-object-storage-durations-unit-in-key`** — `the two object-storage durations whose name carried no unit: AccessControlConfig.maxAge and StorageConnection.timeout (system/object-storage.zod.ts)` → maxAgeSeconds and timeoutMs — rename each key; both values are unchanged + - Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. AccessControlConfig.maxAge is the one key in this stack where the two structural exemptions and the rename look alike from a distance, so the reasoning is recorded rather than assumed. It was CONSIDERED for an externalVocabulary marker and demoted on evidence: every bucket-CORS standard the value is forwarded to spells the field WITH its unit — S3 MaxAgeSeconds, GCS maxAgeSeconds, Azure MaxAgeInSeconds — so marking it would have exempted a DEVIATION from the cited standard rather than a mirror of it, which is the opposite of what the marker declares. Its twin shared/CorsConfig.maxAge DID get the marker and keeps its bare name, because the Fetch response header that one mirrors, Access-Control-Max-Age, genuinely carries no unit token. Two maxAge keys on opposite sides of the same line; the asymmetry is the point and must not be harmonised. StorageConnection.timeout rides along as the plain case on the same file. Both are retiredKey() tombstones; the shapes are not strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no objectStorage collection, and neither shape is a registered metadata kind stored as a sys_metadata row. ADR-0087. + - Done when: Every bucket access-control block spells maxAgeSeconds and every storage connection spells timeoutMs. Authoring either old spelling fails to compile (input type `never`) and fails to parse with the rename prescription. Behaviour is unchanged: maxAgeSeconds: 3600 caches a preflight for an hour exactly as maxAge: 3600 did, and the non-negative bounds ride along with the renamed keys. The migration is proved correct when shared/CorsConfig.maxAge is STILL spelled maxAge — a find-and-replace that renamed both has destroyed a declared external-vocabulary mirror, and the gate will not catch it because the marker exempts the key either way. +- **`system-registry-config-durations-unit-in-key`** — `the three package-registry durations whose name carried no unit: RegistryUpstream.syncInterval, RegistryUpstream.timeout and RegistryConfig.cache.ttl (system/registry-config.zod.ts)` → syncIntervalSeconds, timeoutMs and cache.ttlSeconds — rename each key; every value, the 30000 timeout default and the 3600 TTL default are unchanged + - Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. The three are one entry because they are one file and, for the first two, one object: RegistryUpstream declared a SECONDS interval and a MILLISECONDS timeout twenty-five lines apart, both bare. That pair carries the clearest demonstration in this card of why a bound is no substitute for a name — timeout is min(1000), which reads as one second under the right unit and as sixteen minutes under the wrong one, and both readings satisfy the validator. The cache TTL is the same defect one schema over, beside a maxSize measured in bytes. All three are retiredKey() tombstones; the shapes are not strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no registry collection, and a registry config is host configuration read at startup rather than a stored sys_metadata row, so the conversion chain has no seam that would see it. ADR-0087. + - Done when: Every upstream declaration spells syncIntervalSeconds and timeoutMs, and every registry cache block spells ttlSeconds. Authoring any old spelling fails to compile (input type `never`) and fails to parse with the rename prescription. Behaviour is unchanged: syncIntervalSeconds: 300 syncs every five minutes exactly as syncInterval: 300 did, an omitted timeoutMs still defaults to 30000, an omitted ttlSeconds still defaults to 3600, and the min-60 / min-1000 / min-0 bounds ride along with the renamed keys so a too-small interval or timeout is still refused. The pair on RegistryUpstream is the one to check by hand rather than by search-and-replace: after the migration a reader can tell at the authoring site that 300 and 30000 are not the same kind of number. +- **`system-tracing-otel-exporter-durations-unit-in-key`** — `the four tracing-configuration durations whose unit lived in a source JSDoc only: OpenTelemetryCompatibility.exporter.timeout, OpenTelemetryCompatibility.exporter.batch.exportTimeout, OpenTelemetryCompatibility.exporter.batch.scheduledDelay and TracingConfig.performance.exportInterval (system/tracing.zod.ts)` → timeoutMs, exportTimeoutMs, scheduledDelayMs and exportIntervalMs — rename each key; all four values (milliseconds) and their 10000 / 30000 / 5000 / 5000 defaults are unchanged + - Why not automatic: Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the maintainer's 「同意」: a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands — the duration-unit rule (the unit lives in the key name or its value type, never in prose alone) executed file by file. It follows system-tracing-span-duration-unit-in-key on this same file and does not amend it: that entry retired Span.duration under ruling B, whose population was the describe channel, and these four keys were never in it — they are the JSDoc-only channel that finding opened, which is why one file carries two rounds. Each key named milliseconds in its JSDoc — "Timeout in milliseconds", "Export timeout in milliseconds", "Scheduled delay in milliseconds", "Background export interval in milliseconds" — and the JSDoc above a key is NOT what content/docs/references/** renders; .describe() is. Measured on this tree: all four carried NO .describe() at all, so the published reference row for each was a bare integer with no unit anywhere on the page — a strictly worse channel than the unit-in-prose shape the duration-unit rule already refuses, since here the reference reader had no prose to misread. The magnitudes make the guess plausible in both directions: 10000, 30000, 5000 and 5000 are all defensible as seconds and as milliseconds, and an operator who reads seconds sets an exporter deadline 1000x short. The suffix is the family spelling, counted in key position at 98bd7986fe over packages/spec/src *.ts (reproduce with git grep -hoE on that ref): 281 *Ms declarations over 42 distinct names, timeoutMs 65 of them and intervalMs 14, against 0 key-position timeoutSeconds and 77 *Seconds of any name; the Delay-plus-Ms pairing is likewise already attested on that same ref (maxDelayMs 9, initialDelayMs 9, maxRetryDelayMs 5, debounceDelayMs 2, delayMs 2, retryDelayMs 1) with 0 occurrences of any competing exportTimeout, scheduledDelay or exportInterval spelling, suffixed or Seconds. Note this file is milliseconds throughout and its own landed precedent is Span.duration to durationMs, the opposite of the sibling metrics card whose rows were seconds. exporter.timeoutMs and exporter.batch.exportTimeoutMs are deliberately allowed to sit one nesting level apart: the pair pre-exists the rename — the batch sub-object is the OpenTelemetry batch span processor's own four knobs (max batch size, max queue size, scheduled delay, export timeout) beside the exporter's own request deadline — so renaming either to something more distinctive would depart from the vocabulary the shape mirrors, and the nesting already disambiguates every read point (exporter.timeoutMs vs exporter.batch.exportTimeoutMs). All four old spellings are retiredKey() tombstones: neither OpenTelemetryCompatibilitySchema nor TracingConfigSchema nor any object nested inside them is .strict(), so a bare deletion would be a SILENT STRIP (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — and the stripped value lands on an export deadline and a background export period. Why a semantic entry and not a D2 conversion: the conversion chain walks a normalized STACK, and neither def is an authorable surface — stack.zod.ts declares no tracing collection, no metadata-type binding or manifest embed carries either, and a tracing configuration is never a stored sys_metadata row — so a conversion would be a transform with no seam that ever runs. That is the same disposition system-tracing-span-duration-unit-in-key recorded for the other key on this file. Measured at 98bd7986fe: NO in-repo reader exists outside packages/spec — OpenTelemetryCompatibility, TracingConfig and all three batch key names occur 0 times across the whole tree at that ref excluding packages/spec and content/docs/references, against a lit control of 18920 Schema occurrences on exactly that corpus and ref — both counts from one git grep -o over 98bd7986fe with those two pathspec exclusions — and a dark control of 0; inside packages/spec the only occurrences are tracing.zod.ts, its test, and the generated rows in content/docs/references/system/tracing.mdx, which this rename regenerates. And the pinned objectui checkout — `.objectui-sha` = `f0268ad784854568aa58a2aa791f6a7502259186` — names none of it: all 37 exports of tracing.zod.ts and each of the four key names occur 0 times across the 8234 files tracked at that sha (the 517 Span and 57 SpanSchema hits are objectui's own HTML text-span component, TextSpanSchema, an unrelated name, plus colSpan and prose), against two lit controls on that same corpus and sha: 17956 hits for the bare token objectstack, and 7522 for the package specifier @objectstack/spec (at a58626c88: 0 across 7754, Span 509, 17468 and 7246; at 0abd4f9f8: 0 across 7650, Span 508, 17390 and 7209; at 9dfaca654: 0 across 7632, Span 508, 17313 and 7186; at 2e818d0b5: 0 across 7579, Span 505, 17227 and 7134; at ab1879721: 0 across 10267, Span 491, 16377 and 6665; at 89cad75d5: 0 across 10071, Span 489, 16044 and 6461; at 31971ff1e: 0 across 9912, Span 486, 15691 and 6206; at e420df310: 0 across 9800, Span 486, 15352 and 6024; at db11afd49: 0 across 9546, Span 486, 14704 and 5545; at dd3f7e1be: 0 across 9283, Span 485, 13745 and 5466; at f8a9d0fb0: 0 across 8512, Span 488, 13347 and 5123; at 62597c588: 0 across 8303, Span 486, 13125 and 5043). + - Done when: Every author and reader of an OpenTelemetryCompatibility spells exporter.timeoutMs, exporter.batch.exportTimeoutMs and exporter.batch.scheduledDelayMs, and every one of a TracingConfig spells performance.exportIntervalMs. Authoring any old spelling fails to compile (input type `never`) and fails to parse with the rename prescription naming the suffixed key — not with a generic unrecognized_keys issue, which these non-strict shapes could never have raised anyway. Behaviour is unchanged: the same milliseconds, the same 10000 / 30000 / 5000 / 5000 defaults and the same int().positive() bounds, and all four published describes now name milliseconds where before there was no describe at all. The authorable-surface and authorable-defaults ledgers move nothing: every one of the four is NESTED, and those artifacts record top-level keys per def only. +- **`system-tracing-span-duration-unit-in-key`** — `Span.duration, the emitted trace-span length whose name carried no unit (system/tracing.zod.ts)` → durationMs — rename the key; the value is unchanged + - Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. It stands alone because it is the only offender on its file and the only one in this card that is a pure runtime-emitted measurement: a span is written by an exporter and read by a backend, never authored by hand. That is also why it is a rename and not an externalVocabulary mirror, which is the exemption a tracing shape would most plausibly claim: OpenTelemetry, whose model this schema follows, carries span length as a start/end nanosecond PAIR and declares no key named duration at all, so there is no external spelling for the marker to point at. The shape already spells its two instants startTime and endTime, so the bare duration was the one measurement on the span that did not say what it was. Tombstoned with retiredKey(); the shape is not strict, so a bare deletion would strip in silence and an exporter emitting the old spelling would lose the value without an error. Why a semantic entry and not a D2 conversion: an emitted span is never a stack collection member and never a stored sys_metadata row — the same disposition every runtime-emitted measurement in this stack has taken. ADR-0087. + - Done when: Every exporter that BUILDS a Span spells durationMs, and every consumer that reads a span length reads durationMs. Authoring duration fails to compile (input type `never`) and fails to parse with the rename prescription rather than silently dropping the measurement. Behaviour is unchanged: durationMs: 150 is the same 150 milliseconds, and the non-negative bound rides along with the renamed key so a negative span length is still refused. Note the sibling instants startTime and endTime are ISO-8601 strings, not numbers, and are untouched by this rename. +- **`system-worker-queue-rate-limit-duration-unit-in-key`** — `QueueConfig.rateLimit.duration, the worker rate-limit window whose name carried no unit (system/worker.zod.ts)` → durationMs — rename the key; the value is unchanged + - Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. It stands alone because it is the only offender left on its file, and the file itself is what makes it a drift rather than a convention: TaskResult.durationMs, declared ninety lines earlier in the SAME source, already spelled the identical measurement with its unit. One file, one unit, two spellings, and the correct one was already there — so this rename removes an internal inconsistency rather than imposing an external one. Tombstoned with retiredKey(); the shape is not strict, so a bare deletion would strip in silence and a queue would fall back to no rate limit at all without an error. Why a semantic entry and not a D2 conversion: stack.zod.ts declares jobs, not queues, so a QueueConfig is worker host configuration rather than a stack collection member or a stored sys_metadata row, and the conversion chain has no seam that would see it. ADR-0087. + - Done when: Every queue declaration spells rateLimit.durationMs. Authoring rateLimit.duration fails to compile (input type `never`) and fails to parse with the rename prescription rather than silently dropping the window and leaving the queue unthrottled. Behaviour is unchanged: { max: 100, durationMs: 60000 } is a hundred tasks a minute exactly as { max: 100, duration: 60000 } was, and the positive-integer bound rides along with the renamed key. The sibling max is a COUNT and keeps its name — it has no unit to carry. +- **`tenant-schema-cache-ttl-unit-in-key`** — `SchemaLevelIsolationStrategy `performance.schemaCacheTTL` (system/tenant.zod.ts)` → `performance.schemaCacheTtlSeconds` (default 3600) — rename the key; the value (seconds) is unchanged + - Why not automatic: Maintainer ruling A, 2026-09-11: the gate that reads a duration key's JSDoc lands last, after its offenders are fixed file by file — so this entry executes, per file, the rule that a duration number key carries its unit in its name. The key carried its unit (seconds) in a source JSDoc only — "Schema cache TTL in seconds" — while `.describe()`, the text `content/docs/references/**` publishes, said "Schema cache TTL" and named no unit at all. So the reader who most needs the unit, the reader of the published reference page, was the only reader who never saw it: 3600 is a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. Under that rule's gate, moving the unit into the describe alone is itself a violation (unit in prose, none in the name), so the key is renamed and the describe is corrected in the same stroke. Spelled `Ttl` and not `TTL`: counted on this tree, the suffixed family already spells it that way in every member (`cacheTtlSeconds` 11, `ttlSeconds` 3, `defaultCacheTtlSeconds` 1) and no key-position `TtlSeconds` variant spells it otherwise. Tombstoned with `retiredKey()` because the nested `performance` object is not strict, so a bare deletion would silently strip the key. Why a semantic entry and not a D2 conversion: `stack.zod.ts` declares no tenancy collection and a tenant isolation strategy is not a stored metadata row (it describes cloud tenancy configuration), so the chain has no seam that runs on it — the same reading `tenant-timeouts-unit-in-key` recorded for the two sibling keys on this file. Measured on bd25e897dc: no in-repo runtime reads the key — outside `packages/spec/src/system/tenant.zod.ts` and its test the only occurrences are the four generated rows in `content/docs/references/system/tenant.mdx`, which this rename regenerates; and the pinned objectui checkout — `.objectui-sha` = `f0268ad784854568aa58a2aa791f6a7502259186` — spells it 0 times across 8234 tracked files, against lit controls `TTL` 184 and `tenant` 1338 on the same corpus (0 across 7754, against 184 and 1319, at a58626c88; 0 across 7650, against 182 and 1318, at 0abd4f9f8; 0 across 7632, against 182 and 1318, at 9dfaca654; 0 across 7579, against 182 and 1317, at 2e818d0b5; 0 across 10267, against 180 and 1238, at ab1879721; 0 across 10071, against 181 and 1237, at 89cad75d5; 0 across 9912, against 181 and 1237, at 31971ff1e; 0 across 9800, against 181 and 1235, at e420df310; 0 across 9546, against 181 and 1200, at db11afd49; 0 across 9283, against 181 and 1185, at dd3f7e1be; 0 across 8512, against 156 and 1034, at f8a9d0fb0; 0 across 8303, against 156 and 987, at 62597c588). + - Done when: Every schema-level tenant isolation source spells `performance.schemaCacheTtlSeconds`; authoring `performance.schemaCacheTTL` fails to compile and fails to parse with the rename prescription naming the suffixed key; the parsed default is 3600 as before, and the published describe reads "Schema cache TTL in seconds". +- **`tenant-timeouts-unit-in-key`** — `DatabaseLevelIsolationStrategy `connectionPool.idleTimeout` / TenantSecurityPolicy `accessControl.sessionTimeout` (system/tenant.zod.ts)` → `connectionPool.idleTimeoutSeconds` (default 300) and `accessControl.sessionTimeoutSeconds` (default 3600) — rename each key; the values (seconds) are unchanged + - Why not automatic: Maintainer ruling 2026-09-02, B: a duration number key carries its unit in its name, enforced by a gate with no grandfathered baseline — folding in the finding that these two descriptions named no unit. Both keys carried their unit (seconds) in a source JSDoc only; `.describe()` — the text `content/docs/references/**` publishes — said "Idle pool timeout" and "Session timeout" with no unit at all. So the one reader who most needs the unit, the reader of the published reference page, was the only reader who never saw it: 300 is a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. That finding proposed adding the unit to the two descriptions; under the ruled gate that exact fix is a violation (unit in prose, none in the name), so the keys are renamed instead — one breaking change per key, and the tree never passes through a state the gate refuses. Both are retiredKey tombstones (the nested objects are not strict). Why a semantic entry and not a D2 conversion: neither schema is a stack collection member or a stored row (they describe cloud tenancy configuration), so the chain has no seam that runs on them (the `kernel/Manifest:loading` precedent). Measured on ca46f8f12: no in-repo runtime reads either key. + - Done when: Every tenant isolation / security-policy source spells `idleTimeoutSeconds` and `sessionTimeoutSeconds`; authoring `idleTimeout` or `sessionTimeout` fails to compile and fails to parse with the rename prescription naming the suffixed key; the parsed defaults are 300 and 3600 as before. +- **`time-default-zone-refused`** — `a literal `defaultValue` with a `Z` or a UTC offset on a `time` field, or on an action param typed `time`` → the wall clock itself, `HH:MM` or `HH:MM:SS` with no zone, or a `datetime` field when the value is an instant. The conversion drops a `Z` or a zero offset, which names the same wall clock. It does not touch a non-zero offset (`08:00+08:00`): whether that meant 08:00 or the UTC 00:00 only the author knows, so rewrite it by hand + - Why not automatic: A `time` value is a zone-less wall clock (ADR-0053 D-C1), and the record validator already refuses a zone-suffixed time of day on write. The stored form still admitted one, so a field default such as `10:00Z` parsed clean and every insert that fell back to it was then refused `invalid_time` on a field the caller never sent, and an action param default or submitted value passed the dispatcher. The stored form now refuses the zone, so the field and action-param default gates refuse it when it is authored and the dispatcher refuses it at submit. + - Done when: No `time` field or `time` action param declares a literal default with a zone. Zone-less defaults, the `NOW()` token and expression defaults parse as before. A stored `sys_metadata` row whose default carried a `Z` or a zero offset loads with the zone dropped; one with a non-zero offset keeps loading as stored, is listed by `os migrate meta --stored` as a TODO naming the field or param, and fails the schema wherever it is parsed until it is rewritten. +- **`time-update-interval-sub-day-retired`** — ``TimeUpdateInterval` — the `/analytics/query` body's `timeDimensions[].granularity` and an analytics cube dimension's `granularities[]`. The three sub-day members `second`, `minute` and `hour` are retired; `day`, `week`, `month`, `quarter` and `year` are unchanged and parse byte-identically` → the coarsest declared interval that still answers the question — `day` is the finest bucket the platform labels. A caller who wants raw per-instant rows drops `granularity` entirely, which groups on the unbucketed timestamp deliberately rather than by accident. There is no mechanical replacement that preserves a sub-day bucket, because no backend ever produced one + - Why not automatic: ADR-0049 enforce-or-remove — the spec-side narrowing promised by the fix that made driver-memory's analytics face bucket by its declared granularity. The rest of the contract never carried these three: `DateGranularity` (`data/query.zod.ts`) — the vocabulary a `groupBy` entry and every driver's bucket expression are typed by — declares five, `@objectstack/core`'s `BUCKET_GRANULARITIES` labels the same five, and `DriverCapabilitiesSchema.supports.queryDateGranularity` is a `z.record(DateGranularity, boolean)`, so a driver could not advertise sub-day bucketing even if it had one. Measured on the shipped faces before the narrowing: `driver-memory`'s analytics face answered NOT_IMPLEMENTED/501, `driver-mongodb`'s bucket builder answered NOT_IMPLEMENTED/501, and the engine's in-memory aggregation — the fallback every SQL/ObjectQL analytics query carrying a granularity lands on, since `NativeSQLStrategy` declines on a granularity — answered 200 with one group per distinct timestamp, echoing the raw instant back as its own bucket label. Two honest refusals and one silently wrong answer, and no third behaviour anywhere. ⚠️ This retires the NAMES, not the idea: offering sub-day analytics means widening `DateGranularity`, the `queryDateGranularity` record, the canonical bucket-key vocabulary and every driver's bucket expression together — new capability, decided as such + - Done when: No analytics request body carries `timeDimensions[].granularity` of `second`, `minute` or `hour`, and no cube dimension offers one in `granularities[]` (the D2 conversion `cube-sub-day-granularities-removed` strips them from sources, dropping the key entirely when nothing coarser remains). ⚠️ The conversion cannot decide what a dimension that offered ONLY sub-day intervals should offer instead — review each site the run reports and state the granularities that dimension actually serves. +- **`training-deadline-keys-retired`** — `training duration and deadline keys: `TrainingCourse.durationMinutes` / `validityDays`, `TrainingPlan.recertificationIntervalDays` / `gracePeriodDays` / `reminderDaysBefore`` → nothing to re-declare — delete the keys. No training-management engine exists on the platform: nothing schedules or times a course, computes a certification expiry, re-assigns training on an interval, escalates an expired certification or sends a reminder, so there is no live mechanism to declare a duration or deadline to + - Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on the unread deadline keys (ruled A: retire per family). Five minute/day-shaped keys sat on the published authorable surface and in the generated reference docs — an author could write `validityDays: 365` and reasonably expect a certificate to expire — and read by NOTHING: the schemas are exported from `@objectstack/spec/system`, mounted by no stack key, registered as no metadata type, absent from the 2026-06 liveness ledgers, and the reader census over every package outside `packages/spec` (tests and changelogs excluded) and over objectui at the pinned sha returned zero hits for every key. Three of the five carried defaults (365, 30 and 14 days) that were materialized into every parsed plan without ever being consulted. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections; none of these schemas is either, so a conversion would be a transform with no seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent). + - Done when: No `TrainingCourse` or `TrainingPlan` literal — standalone or as a `courses[]` entry — carries `durationMinutes`, `validityDays`, `recertificationIntervalDays`, `gracePeriodDays` or `reminderDaysBefore`. TypeScript authors get the refusal at compile time (each key is typed `never`); a value reaching the parse is refused with the prescription (`invalid_type` at the path of the key). Parsed plans no longer carry the three former defaults. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ever read the keys, so removing them removes no behaviour. +- **`training-family-retired`** — `the training family, retired whole: the five defs system/TrainingCategory, system/TrainingCompletionStatus, system/TrainingCourse, system/TrainingPlan and system/TrainingRecord, and every name system/training.zod.ts exported from @objectstack/spec/system (the five *Schema consts, their z.input aliases and the two *Parsed aliases)` → nothing to re-declare — no training-management engine exists on the platform, so there is no working configuration to migrate to. Nothing assigned a course, tracked a completion, sent a reminder or expired a certification; a training record the organisation keeps is ordinary object data, declared as an object with its own fields and enforced by the object engine. If training management becomes a product capability it re-declares fresh, through the enforce route of ADR-0049 — the engine first, the vocabulary second + - Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 on the families' remaining keys and defs (ruled A: retire the three compliance-shaped families whole via RETIRED_DEFS_BY_MAJOR, the integration/ErrorMappingConfig precedent; not roadmapped). Five defs and roughly twenty-five declared keys sat on the exported surface and in the generated reference docs, and were read by NOTHING: the schemas were exported from `@objectstack/spec/system`, mounted by no `stack.zod.ts` key, registered as no metadata type, absent from the 2026-06 liveness ledgers, and the reader census over every package outside `packages/spec` (tests and changelogs excluded), over `examples/**` and `skills/**`, and over objectui at the pinned sha returned zero hits for every exported name, with a lit control. `TrainingCourse.mandatory`, `TrainingPlan.trackCompletion` and `TrainingPlan.sendReminders` were boolean capability claims of exactly the shape ADR-0049 names: an author could write them, parse clean, and get no behaviour and no diagnostic. Tagging the family `[EXPERIMENTAL — not enforced]` was the fallback the ruling did not take (a human-only signal). The deadline-key tombstones of the 2026-09-02 per-family ruling (five sites, `RETIRED_KEYS_BY_MAJOR[18]`, D3 `training-deadline-keys-retired`) leave with their defs' source; their registry entries stay as history. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections; none of these schemas is either, so a conversion would be a transform with no seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent), and with no carrier key there is no shape on which a tombstone could sit. + - Done when: No code imports TrainingCategorySchema, TrainingCompletionStatusSchema, TrainingCourseSchema, TrainingPlanSchema or TrainingRecordSchema — or any of their type aliases — from @objectstack/spec or @objectstack/spec/system: every such import is TS2305 after upgrade, and no working replacement exists to point at because the vocabulary described nothing real. The five defs are absent from `json-schema.manifest/system.json`, the api-surface / declaration-map / export-origins shards and the generated reference docs. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ever parsed or read these shapes, so removing them removes no behaviour. +- **`translation-component-submit-label-retired`** — `translation.pages..components..submitLabel — the component-copy key of the retired element:form` → The live form surface's submit copy: `submitText` on the `object-form` component, an I18nLabel localized at its own authoring site. + - Why not automatic: The D2 conversion `translation-component-submit-label-removed` deletes `submitLabel` from every translation bundle and stored translation item, and the delete is lossless: the key's only declarer, `element:form`, retired whole, so no resolver has overlaid the string since and it was read by nothing. What the delete drops is translation WORK. A translator who localized a submit button for each locale did so because a user was meant to read it; if the page's form now lives on `object-form`, its submit copy is `submitText`, and that key is not filled by moving the old strings mechanically — the component ids differ, and a retired `element:form` may have no successor on the page at all. Only the author can say which form each string belonged to and whether it still exists. + - Done when: No translation bundle or translation item carries a component `submitLabel`; the parse refuses it. For each form a user still submits, the `object-form` component carries a `submitText` whose localized values cover the locales the dropped strings covered, and switching the UI locale shows the submit button in that locale — or the author has decided the default copy is acceptable. +- **`translation-per-app-settings-platform-only`** — `stack.translations[]..settings and translation.settings — the settings group on the per-app bundle and on the registered `translation` item` → Delete the group from the per-app bundle and from every `translation` item. There is no application-side replacement key: settings copy is not application-authorable at either door. `settings` is keyed by `SettingsManifest.namespace`, and only platform code declares a manifest (`packages/services/service-settings/src/manifests/*.manifest.ts`), so the only namespaces an application could ever address were the platform’s own. Platform settings copy is translated in the PLATFORM bundle — `@objectstack/service-settings`’s `settingsBuiltinTranslations`, typed `PlatformTranslationData` — which is where a correction to a platform string belongs. An application’s own copy goes in the 11 groups the per-app bundle and the `translation` item still declare, in the order they declare them: `objects`, `picklists`, `apps`, `messages`, `globalActions`, `dashboards`, `datasets`, `pages`, `flows`, `metadataForms`, `settingsCommon`. Note `settingsCommon` among them: it IS on both faces, so the Settings UI shell strings an application may translate (the source badges, under `settingsCommon.sourceLabels`) are NOT what is being removed here — only the per-namespace manifest copy under `settings` is. + - Why not automatic: Not losslessly convertible, and NOT because the content was inert: what it did differs by door, and both effects are visible on screen. THE PER-APP BUNDLE — measured on this tree before the split: `AppPlugin.loadTranslations` hands each `stack.translations` bundle entry WHOLE to `II18nService.loadTranslations`, the adapter deep-merges it into the one per-locale tree, and every platform plugin contributes into that same tree — so `settings` from an app bundle and `settings` from `@objectstack/service-settings` land in one place. `resolveSettingsTitle` and the rest of the `resolveSettings*` family read it (`pickSettingsEntry` → `pickData(bundle, locale)?.settings`), and so does the console's `useSettingsLabel`, which scans every namespace carrying a `settings` branch. ORDER decides the rest, and it runs against the application: `AppPlugin` loads the app’s bundles in its own `start()` (kernel Phase 2), `SettingsServicePlugin` contributes the platform’s settings translations from a `kernel:ready` hook (Phase 3), and `deepMerge` gives the LATER source the leaf — `AppPlugin`’s own comment says as much (“the platform bundles have not arrived yet at this point in the lifecycle”). So the platform won every key both bundles defined, and what a per-app bundle actually had was a GAP FILLER on a namespace it does not own: the entry rendered only where the platform bundle carried no string for that key and locale (the platform ships en / zh-CN / ja-JP / es-ES), silently, with no way for the author to tell a filled gap from an ignored override. Dropping it takes those gaps back to the manifest’s own literal — the `?? fallback` every `resolveSettings*` helper ends in, which is English. THE `translation` ITEM went further: a stored item is not loaded into the static tree at all but into the runtime-authored layer (`authored-translation-sync` → `replaceAuthoredTranslations`), and both i18n adapters read that layer OVER the shipped bundles (`deepMerge(static, authored)`), whatever order they loaded in. So an item’s `settings` OVERRODE the platform’s own copy for its locale — a published item could rewrite a platform Settings screen — which is exactly what the ownership ruling says an application must not do. Dropping it takes each overridden key back to the platform bundle’s string, and each key it had filled back to the manifest literal. A mechanical notice reading "(removed)" conveys neither. The two bundles are separate namespaces from this major on (ruling of 2026-09-13, letter ②: a platform bundle schema and a per-app bundle schema, `settings` absent from the per-app one), and the item door follows the file door (ruling of 2026-09-22, letter B: the file door and the item door are two authoring surfaces for ONE app metadata type, so they accept one shape; an admin override of platform copy, if ever wanted, is a platform-level feature, not app metadata). ADR-0049 enforce-or-remove supplied the question, not the answer — `settings` stays a LIVE platform key. No deprecation window: both doors refuse the key by name from this major, with the prescription on the rejection. + - Done when: No application-authored face carries `settings`. `defineTranslationBundle({ : { settings: … } })`, a `defineStack({ translations: [...] })` entry carrying it, `defineTranslation({ locale, settings: … })` and a `translation` item saved through the metadata API carrying it are all refused as an unrecognized key, and each refusal names the group as platform-only rather than suggesting a rename (pinned in `packages/spec/src/system/translation.test.ts`; the metadata door answers `422 INVALID_METADATA`, pinned in `packages/metadata-protocol`). The platform face still accepts it: `PlatformTranslationDataSchema.parse({ settings: … })` succeeds, `settingsBuiltinTranslations` still type-checks, and `GET /api/v1/i18n/translations/:locale` still declares `settings` on its response (`GetTranslationsResponseSchema`), because the served document is the merged tree. A `translation` row ALREADY STORED with `settings` is not refused — a stored row has no author to teach — it is converted: the runtime sync replays this conversion before merging the row, logs the conversion notice once, and loads the rest of the item, so its `settings` stops overriding at the next sync; `os migrate meta --stored --apply` persists the canonical row. For a deployment that WAS authoring settings copy, re-read the Settings screens in each locale it covered: where a `translation` item overrode a platform string, the platform’s string renders again; where either door filled a GAP — a namespace, key or locale the platform bundle does not translate — the manifest’s own literal renders, which is English. If a platform string is wrong or missing for your locale, correct it in the platform bundle (`@objectstack/service-settings`’s `settingsBuiltinTranslations`) — ⛔ do not re-add app-side copy at either door, which is refused. +- **`translation-widget-sub-caption-retired`** — `translation.dashboards..widgets..subCaption — the metric sub-caption overlaid onto a widget's options.description` → The widget's one authored description, `widget.description`, rendered as the card-header subtitle and translated by `dashboards..widgets..description`. + - Why not automatic: The D2 conversion `translation-widget-sub-caption-removed` deletes `subCaption` from every translation bundle and stored translation item, and `translateDashboard` no longer overlays anything onto a widget's `options`. The sub-caption was the string under a metric's value; the dashboard schema never declared `options.description` and no authored widget wrote it, so a translated sub-caption existed only because this key put it there. What the delete drops is translation WORK: a translator who wrote a caption per locale meant a user to read it. The conversion cannot move those strings to `description`, because `description` already translates the card-header subtitle — a different string a widget may also carry — and only the author can say whether the caption's wording belongs in that subtitle or is no longer needed. + - Done when: No translation bundle or translation item carries a widget `subCaption`; the parse refuses it. For each metric widget whose caption a user still needs to read, the copy lives in the widget's `description` and its localized values sit under the widget's `description` entry for every locale the dropped strings covered — or the author has decided the card-header subtitle alone is enough. +- **`try-catch-and-retry-policy-undeclared-keys-refused`** — `a retry policy carrying a key it does not declare (a typo such as maxRetry, a key borrowed from another retry vocabulary such as baseDelayMs or maxAttempts, or a key nothing reads), wherever the policy is written: a job retryPolicy, and the retry block of a try_catch flow node; and a try_catch flow node whose config carries a key beside try, catch, errorVariable and retry that its executor contract does not declare. Never a key on the try or catch region object or on its nodes and edges (the region check at registration owns those). Reachable wherever a job or a flow is authored or stored: defineStack sources, defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved from the Studio flow designer, and a flow row already sitting in sys_metadata` → the key the policy declares, or no key: maxRetries (retries after the first attempt), backoffMs (the base delay), backoffMultiplier, maxRetryDelayMs and jitter. Rename a typo to the declared key the refusal's did-you-mean names; write a delay borrowed from another vocabulary as backoffMs or maxRetryDelayMs; write a count of total attempts as maxRetries one lower (maxAttempts 3 is maxRetries 2); delete a key nothing reads. A retryDelayMs is still answered by its own tombstone: rename it to backoffMs + - Why not automatic: `RetryPolicySchema` was a plain `z.object`, which strips a key it does not declare. Its defaults are opt-in (`maxRetries` 0, `backoffMultiplier` 1), so a stripped key falls back to "no retry" or to a flat delay: a `job.retryPolicy` with `maxRetry: 3` parsed, deployed and never retried, and nothing said so. On a `try_catch` node the strip also kept the flow parse from judging the node's keys: its descriptor closes `retry` to five keys, so `registerFlow`'s undeclared-key walk (`validateNodeConfigKeys`) refused a key the contract would have accepted, and `flow-builtin-node-config-undeclared-keys-refused` left `try_catch` to that walk. A `retry.maxRetry` typo or a `bogusKey` beside `try` therefore passed `objectstack validate` and `objectstack compile` and was refused only when the flow registered. The policy is now a `strictObject`: an undeclared key is refused at parse, naming the key, with a did-you-mean for a near miss. Measured before closing it: every writer of either parser in this repository and in the pinned objectui writes only declared keys, so it is closed on the shared schema. The one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first), `objectstack validate` and the metadata save door share (`flowNodeConfigRefusals`) now judges `try_catch` keys like every other builtin's, as `node-config-refused-by-contract` anchored at the key (`nodes.N.config.retry.maxRetry`), and the descriptor walk stands aside for it, so it keeps plugin node types only. The descriptor's declared key sets equal the contract's at every position the walk descends to, so registration refuses what it refused before. ⚠️ The one key the walk refused that the contract declares is the `retryDelayMs` tombstone. The `retry-policy-converged` conversion renames it before every door that converts first, but keeps it beside a `backoffMs` holding a different value, and leaves it when it is `null`. The key arm refuses what survives at `nodes.N.config.retry.retryDelayMs`, in the tombstone's own words, so registration widens nowhere. A `script` node's retired keys keep the scope they had. ⚠️ No D2 conversion: the platform cannot know what an undeclared key was meant to be. ⚠️ Where such a node already sits, the whole flow is refused, as registration already refused it: from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, while the flows beside it register; a `defineStack` source throws `StackSchemaInvalidError`; a save from Studio answers 422 naming the key. A job whose `retryPolicy` carries such a key is new to refusal (no door judged one before): its `defineStack` source throws `StackSchemaInvalidError` at `jobs.N.retryPolicy`, and an artifact carrying it is refused whole at load. ADR-0087, ADR-0031. + - Done when: Run `objectstack validate` over every stack authored in config files, and boot every deployed stack. Each refusal names the key: a job's at `jobs.N.retryPolicy` with the unrecognized key and its did-you-mean, a flow's at `nodes.N.config.retry.` or `nodes.N.config.` for a `try_catch` node, and `validateStackExpressions` phrases it as `node 'n' (try_catch) config.retry.maxRetry`. For each hit rename or delete the key per the replacement. Two proofs. (1) For a stack authored in config files, `objectstack validate` is clean. (2) Boot the stack and confirm each flow REGISTERS: no `failed to register flow` warn for it. A retry policy and a `try_catch` node whose keys the contract declares parse and register byte-identically to before. +- **`turso-config-forced-local-with-sync-url-refused`** — `data.TursoConfig (a turso / libsql datasource.config) and the TursoDriver constructor of @objectstack/driver-turso — mode local beside a non-empty syncUrl is now refused, on mode at authoring and at construction. The published TursoConfigSchema mirror of @objectstack/driver-turso carries the same text for parity but declares no mode key and strips an authored one, so it cannot see a forced mode and still accepts the config as a replica` → the configuration the author meant. An embedded replica drops mode: keep the file: url and syncUrl, for example url file:./data/replica.db with syncUrl naming the remote, and the url and syncUrl select the replica. A plain local database drops syncUrl and sync: a file: url (or :memory:) with no syncUrl is a local database, with or without mode local + - Why not automatic: A syncUrl names the remote an embedded replica syncs with, and the turso driver syncs whenever it is set on a local engine, whatever mode says. The triage ruling of 2026-09-29 weighed refusing this shape against honouring mode local by skipping the sync, and refused it: honouring it would ignore a declared syncUrl, the same defect with the keys swapped, and a loud contradiction is the author's to resolve. A forced mode local beside a syncUrl parsed clean at authoring, and the driver built it with a local transport label and then ran it as a replica: it synced on connect, started the sync interval and answered true to the sync-enabled check, exactly as the same config with no mode did (measured on the driver source). A declared mode the runtime ignores is the declared-but-not-enforced shape ADR-0049 does not ship, so the datasource contract and the constructor now refuse it together, with one message, which names both ways out. The sibling refusals keep their order: a forced local mode on a remote url or a bare path meets its url refusal first. An empty syncUrl is unset and is not refused. Stored datasource rows are not re-parsed when they load, so a stored row in this shape now fails when its driver is built: the connection service records it as failed-degraded, a test connection answers ok false, and under ADR-0062 D5 the boot fails fast when objects bind to that datasource, unless OS_ALLOW_DRIVER_CONNECT_FAILURE is set. Measured on this tree at the change: no example, template, published skill or hand-written doc authors the shape, and no host default or environment variable sets mode or syncUrl. ADR-0049 / ADR-0087 / ADR-0112. + - Done when: Validate every stack and re-save every turso datasource: os validate or defineStack, and a save or test connection through the datasource admin service, report a forced local mode beside a syncUrl at config.mode with both ways out. Decide per datasource whether it is an embedded replica (drop mode) or a local file (drop syncUrl and sync). Done when every turso datasource parses, the driver builds from it, and no datasource that declares mode local carries a syncUrl. +- **`turso-config-forced-replica-without-sync-url-refused`** — `data.TursoConfig (a turso / libsql datasource.config) and the TursoDriver constructor of @objectstack/driver-turso — mode replica with no syncUrl (or an empty one) is now refused, on mode at authoring and at construction. The published TursoConfigSchema mirror of @objectstack/driver-turso carries the same text for parity but declares no mode key and strips an authored one, so it cannot see a forced mode and still accepts the config as a local file` → the configuration the author meant. An embedded replica names the remote it replicates from: keep the file: url and set syncUrl to the libsql or https Turso endpoint, for example url file:./data/replica.db with syncUrl naming the remote. A plain local database drops mode: a file: url with no syncUrl and no mode is a local database + - Why not automatic: An embedded replica is a local file kept in sync with the remote named in syncUrl, so a replica is defined by its remote. The ruling of 2026-09-28 weighed refusing this shape against documenting a replica with no remote as a local mode, and refused it: with no remote there is no replica mode to document, only a declaration nothing honours. A forced mode replica with no syncUrl parsed clean at authoring, and the turso driver built it as a replica that never synced: no sync client was created, no sync interval started, the sync call did nothing and the sync-enabled check answered false, while every read and write went to the local file (measured on the built driver). A declared mode the runtime never runs is the declared-but-not-enforced shape ADR-0049 does not ship, so the datasource contract and the constructor now refuse it together, with one message, which names both ways out. The sibling refusals keep their order: a forced replica on a remote url, an in-memory url or a bare path meets its url refusal first, and one with sync meets the sync refusal first. Stored datasource rows are not re-parsed when they load, so a stored row in this shape now fails when its driver is built: the connection service records it as failed-degraded, a test connection answers ok false, and under ADR-0062 D5 the boot fails fast when objects bind to that datasource, unless OS_ALLOW_DRIVER_CONNECT_FAILURE is set. Measured on this tree at the change: no example, template, published skill or hand-written doc authors the shape, and no host default or environment variable sets mode. ADR-0049 / ADR-0087 / ADR-0112. + - Done when: Validate every stack and re-save every turso datasource: os validate or defineStack, and a save or test connection through the datasource admin service, report a forced replica with no syncUrl at config.mode with both ways out. Decide per datasource whether it is an embedded replica (set syncUrl) or a local file (drop mode). Done when every turso datasource parses, the driver builds from it, and every datasource that declares mode replica carries a syncUrl. +- **`turso-config-timeout-unit-in-key`** — `datasource.config.timeout on the turso driver — the per-request time limit` → `timeoutMs` — the same limit, in milliseconds, beside the sibling `sync.intervalSeconds` that already spelled its unit. + - Why not automatic: The D2 conversion `turso-config-timeout-to-timeout-ms` renames `config.timeout` to `config.timeoutMs` on every datasource whose driver resolves to turso (a stored `libsql` spelling included) and leaves every other driver's `timeout` alone; the rename is lossless because the key always meant milliseconds. The judgment is whether each value was written in that unit. Two keys above it, `sync.intervalSeconds` spelled SECONDS, so one config block carried both conventions, and the unit of `timeout` lived only in a description and a title no parse reads: `timeout: 30` meant as thirty seconds became a thirty-millisecond limit, short enough to fail a remote request, and the rename keeps 30. Only the author can say which unit they meant. Code that builds a turso driver config in TypeScript is outside the chain's reach. + - Done when: No turso datasource carries `config.timeout`; the spec contract refuses it with the rename. Every `timeoutMs` value is the limit the author intends in milliseconds — a datasource meant to allow thirty seconds reads `timeoutMs: 30000` — and queries against the remote database complete as they did before the upgrade. No code reads or writes `timeout` on a turso driver config. +- **`turso-config-transport-mismatch-refused`** — `data.TursoConfig (a turso / libsql datasource.config) and the published TursoConfigSchema mirror of @objectstack/driver-turso — combinations of url, syncUrl, mode and timeoutMs that are now refused at parse: a remote url (libsql, https, http, wss, ws, any letter case) beside syncUrl or under a forced local or replica mode; in a local or replica mode, a url that is none of a file: url, :memory: or a remote url (a bare path, another scheme, :MEMORY:, a blank url); a replica on an in-memory url; timeoutMs beside a wss or ws url in remote mode; and syncUrl under a forced remote mode. The driver mirror also refuses sync with no syncUrl, as the spec contract already did` → the configuration the author meant, spelled the way the driver runs it. A remote database is the remote url alone (drop syncUrl and sync, and drop a forced local or replica mode or set it to remote). An embedded replica is a local file written as a file: url beside syncUrl, for example url file:./data/replica.db with syncUrl naming the remote. A local database is a file: url (file:./data/app.db, never the bare path ./data/app.db) or :memory: for a throwaway one. A remote database that needs timeoutMs spells its url libsql or https, or drops timeoutMs. Each refusal names the key it sits on (url, syncUrl or timeoutMs) and prints the spellings above + - Why not automatic: Each key parsed on its own, so the contract accepted configurations the turso driver refuses when it is built (VALIDATION_ERROR / 400 from the constructor, since the fixes that stopped a remote url beside a syncUrl from writing to process memory and an unrecognised url scheme from falling through to an in-memory local engine) — a datasource published clean and then failed at boot or at test connection. One more it built and then ignored until the constructor was taught to refuse it as well: syncUrl under a forced remote mode, where the remote client was created without it, no sync ever ran and the sync call failed as not supported while the driver reported sync as enabled (measured on the built driver). Authoring now refuses exactly the constructor's refused set — the same predicates, a scheme matched in any letter case, the url read trimmed as both datasource loaders hand it over — plus that key, refused at authoring first as the declared-but-not-enforced shape ADR-0049 does not ship, and by the constructor too since that later fix. Nothing the constructor accepts is refused (when authoring first refused that key it was the one exception; since the constructor refuses it too there is none): a forced remote mode keeps its url unjudged, as the constructor does. Stored datasource rows are not re-parsed when they load, so a stored row still reaches the constructor as written; the constructor refuses the first four shapes there already and, since that later fix, also refuses syncUrl under a forced remote mode and sync with no syncUrl when the datasource boots. What changes here is that creating, testing or editing its config through the datasource admin service, defineStack or os validate is refused at the key. Measured on this tree at the change: no example, template, published skill or hand-written doc authors a refused combination. ADR-0049 / ADR-0087 / ADR-0112. + - Done when: Validate every stack and re-save every turso datasource: os validate or defineStack, and a save or test connection through the datasource admin service, report each refused combination at config.url, config.syncUrl or config.timeoutMs with the ways out. Decide per datasource whether it is a remote database, an embedded replica on a local file, or a local file, and rewrite it to that spelling. Done when every turso datasource parses, the driver builds from it, and a replica datasource reports a file: url beside its syncUrl. +- **`ui-action-group-menu-member-params-array-only`** — `page action:group and action:menu components — a member of properties.actions whose type is not api, and whose params is not an array` → Write `params` as the list of inputs to collect from the user, an `ActionParam[]` array. To run an action with static parameter values, author it as its own `action:button` node, whose `params` object carries them; for a `type: 'api'` member's request body write `bodyExtra`. A member that needs neither drops the key. + - Why not automatic: An `action:group` or `action:menu` runs each member itself and forwards an array `params` as the input list. It forwards any other `params` value only for a `type: 'api'` member, as its request payload; for every other `type`, an absent one included, it drops the value, with a development-build warning only. The member declared `params` as any value, so an object `params` on such a member passed the component-props gate and then had no effect: no error and no static values. `params` carries one shape, the input list, and no second value-bag key is declared; a member's `properties.params` is already refused, so static parameter values are not part of the inline action vocabulary at all, and the action that needs them is its own `action:button` node. The member now refuses a non-array `params` on a non-`api` type at the gate, at `actions.N.params`, with that prescription. The `api` member's object `params` is unchanged. It is read where every page component's props are: the component-props gate reports the refusal as an advisory `component-props-invalid` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: the static values belong on a different node, and the census found no writer. Deployed metadata NOT MEASURED. + - Done when: Every `action:group` and `action:menu` node validates: `objectstack validate` reports no `component-props-invalid` finding at `properties.actions.N.params`. Each member whose action needs static parameter values is now its own `action:button` node, and pressing it hands the handler those values. Census at the time of the change: no `action:group` / `action:menu` member authors a non-array `params` on a non-`api` type in this repository, in objectui (at the pinned commit and on its main branch) or in the hotcrm application, outside objectui's own tests asserting that the container drops it; the cloud repository was not reachable. +- **`ui-action-group-menu-members-typed`** — `page `action:group` and `action:menu` components — each member of `properties.actions` (whose keys used to pass unjudged)` → an inline action with `action:button`'s keys, its executor spelled `type`: `{ name?, label?, icon?, type?, variant?, visible?, disabled?, tags?, params?, description?, target?, openIn?, method?, bodyExtra?, bodyShape?, operation?, patch?, confirmText?, successMessage?, errorMessage?, refreshAfter?, locations?, toast?, resultDialog?, onSuccess?, objectName? }`, plus `size?` on an `action:group` member. Write `actionType` as `type`, `endpoint` (and `url` / `path` / `href`) as `target`, `enabled` as `disabled` with the condition inverted, and `outcomeMessages` as one `successMessage`; drop a member `className`, `properties`, `autoTrigger`, `undoable`, `recordIdField` and an `action:menu` member's `size`. + - Why not automatic: An `action:group` or `action:menu` draws and runs each member itself: it draws `label` (or `name`), `icon`, `variant`, `tags` and, on a group's inline buttons, `size`; gates the member on `visible` and `disabled`; places it by `locations`; and forwards its `type` and the rest of `action:button`'s keys to the action runner. The page-component rows declared each member an open record, so a misspelled key, a node-style `actionType` or an `endpoint` no `api` handler reads passed the component-props gate, and the container drew and ran the member without it. The rows now take a closed member: `action:button`'s keys by `type`, with the rows' prescriptions; the keys the rows leave undecided — `outcomeMessages`, a member `className`, a member `properties.params` — are refused, and `outcomeMessages` stays undeclared on all four action blocks as one decision. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and the authored census found no working member to respell. Deployed metadata NOT MEASURED. + - Done when: Every `action:group` and `action:menu` node validates: `objectstack validate` reports no `component-props-invalid` / `component-props-unknown-key` finding under `properties.actions`. Each member is drawn with its label, icon and variant, and runs the executor its `type` names. +- **`ui-action-undoable-unfulfillable-refused`** — ``action` documents declaring `undoable: true` on a shape no runtime fulfils — `type: 'script'` (the default route) and `type: 'url'`, plus the dormant `type: 'flow'` / `'modal'` / `'form'`, in each case WITHOUT `operation: 'update'`` → either of the two fulfilled shapes — `operation: 'update'` with a `patch`, where the framework runtime snapshots the prior value of every field in the merged write bag, or `type: 'api'`, where the pinned console builds the undo envelope — or no `undoable` at all. ⛔ NOT mechanically convertible: which of the two the author meant is an intent no artifact records (a `script` action with an inline handler and an api action calling an endpoint are different dispatches, not two spellings of one), and dropping the flag silently would remove an Undo the author asked for. The refusal names both shapes and the drop, and the author chooses + - Why not automatic: The key was a plain optional boolean read by no refinement, so every combination parsed clean while only two of them ever produced an Undo — the declared-but-inert shape ADR-0078 refuses at author time. ⚠️ The obvious repair, requiring `operation: 'update'`, was MEASURED WRONG and is deliberately not what this entry records: the pinned console's two readers gate the undo envelope on `action.undoable` alone with zero reads of `action.operation`, and those same two files are the entire recorded evidence for this package's own liveness verdict `action/undoable: live`. A blanket requirement would therefore have refused the published `ReassignLeadAction` skill example (`type: 'api'` + `undoable: true`, no `operation`) at import time, since `defineAction` IS `ActionSchema.parse`, and every console api action with undo along with it. So the accepted set is closed to the two shapes some runtime fulfils rather than to the one the framework runtime fulfils. Stating "`type: 'api'` is fulfilled by the console" in the contract is the point, not a leak: the spec is the contract for every runtime including the console, and a closed table of fulfillable combinations is what the declared-is-delivered rule asks for. + - Done when: Every `action` document declaring `undoable: true` carries `operation: 'update'` or `type: 'api'`. Both fulfilled shapes parse byte-identically to before — the published `ReassignLeadAction` example included — and an action with `undoable` absent or `false` is untouched on every type. An action declaring `undoable: true` on any other shape is refused with a per-key issue at `undoable` whose message names both fulfilling shapes and the runtime that fulfils each; the author adds the shape they meant or drops the flag. +- **`ui-ai-chat-window-retired`** — `page.component.ai:chat_window — the component node, with every key its props bag declared (`mode`, `agentId`, `context`, `aria`), in regions, named slots and nested containers alike` → Delete the component node and put nothing in its place: AI chat is not a page element, and the floating chat overlay the console mounts on every page is the supported entry point. To choose which platform agent the overlay answers with — what `agentId` reached for — set the app's `defaultAgent` (a platform agent: `ask`, or `build` on an authoring surface). `mode`, `context` and `aria` have no counterpart on the page: none of them was ever read, and the overlay is not configured per page + - Why not automatic: No renderer for `ai:chat_window` ever shipped in objectui, framework or cloud, and none is wanted: the console leaves it unregistered on purpose so that a page naming it fails loudly, and Studio's page palette excludes it. So the element and its four keys were a capability claim nothing kept — a page that placed one validated clean and drew "Unknown component type" in front of an end user. Zero producers were measured in objectstack, cloud and hotcrm (one comment naming it as dropped). The name is now refused at `PageComponentSchema.type`, its `ComponentPropsMap` row refuses every props bag with the same prescription, and the enum no longer lists it; `ai:suggestion` is unchanged. No conversion is registered, because the only edit is deleting the node, and which region closes up, holds something else, or keeps its slot is the author's judgment about a page they composed — this entry is that delegation + - Done when: No `ai:chat_window` component remains in any page — regions, named slots and nested containers alike. `os validate` is clean: a remaining node is reported at its own `type` path with `params.retiredComponentType` naming `ai:chat_window`, so each one is named individually rather than as one page-level failure. An app whose removed node named an `agentId` now names that platform agent in its `defaultAgent` instead, or leaves it unset for the default `ask`. Replaying the 17 → 18 chain over the edited source then reports the migrated stack schema-valid — `schemaValid: true` in `--json` +- **`ui-bulk-action-param-unknown-keys-refused`** — `a list view's `bulkActionDefs[].params[]` entry (`BulkActionParamSchema`) — undeclared keys, which this shape accepted and forwarded while it was `.passthrough()`` → the declared shape, now closed to match its single-record twin `ActionParamSchema`: `{ name, type }` plus `label`, `help`, `required`, `default`, `options`, `object`, `labelField`, `multiple`, `placeholder` and — new in this release — `dependsOn`. Every rejection names the surface, echoes the offending key and carries a rename or a prescription: the action-param spellings rename onto this surface's words (`helpText` → `help`, `defaultValue` → `default`, `reference` → `object`, `displayField` → `labelField`); the keys that belong one layer out are pointed there (`visible` and a capability gate belong on the DEF, `visibleWhen` belongs on an `options[]` entry, `field` / `objectOverride` / `carryOver` / `defaultFromRow` / `requiresFeature` are field-backed ACTION-param contracts the bulk surface does not implement); and the widget-config family (`min`, `max`, `step`, `precision`, `scale`, `rows`, `accept`, `maxSize`, and the picker knobs `lookupFilters` / `lookupColumns` / `lookupPageSize` / `descriptionField` / `picker` / `subtitle` / `avatarField` / `idField` / `allowCreate`) is answered with one prescription naming `FieldSchema` as the shape those keys are real on. `dependsOn` needs NO edit — it is declared, in the same shape the field-level key takes (`['parent']`, or `[{ field, param }]` when the remote filter key differs). + - Why not automatic: The accept was a NULL READING, and that is what makes this a contract fix rather than a preference. Measured against installed spec 17.4.0, three parses per schema in one process: `BulkActionParamSchema` accepted `zzz_nonsense_key_that_no_producer_emits_8755` in the SAME RUN that it accepted `dependsOn`, while `ActionParamSchema` one surface over refused both with `unrecognized_keys`. A shape that examines nothing cannot license anything — so "the bulk schema accepts it" was never evidence a key was authorable, and every misspelling and every invented key shipped silently. Not losslessly convertible for the reason the majors-15/16/17 strictness entries give: an arbitrary unknown key has no mapping target and auto-deleting it would be the silent data loss ADR-0078 bans, so each occurrence needs an author's decision. ⚠️ Two halves of this are worth knowing before you upgrade. (1) `dependsOn` was already LIVE on this surface and is kept: `bulkParamToField` does not destructure it out, so it rides the adapter's spread onto the field bag, where the option widgets read it through `useCascadingOptions` and the reference-bearing pickers lower it into a candidate filter; an ablation removing it from that spread reddened 7 of 12 cases in the consuming repo, so retiring it was measured off the table. (2) the widget-config family rode the same spread and really was honoured by whichever widget read it — those keys are refused now rather than forwarded, which is the accepted cost of closing the shape (maintainer ruling, letter A, 2026-09-17: 「Breaking for authored metadata」, one-shot, no grace window and no dual spelling). ⛔ Do not read their rejection as "the renderer ignores them", and ⛔ do not answer it by declaring the key on the object's FIELD: the bulk surface has no field-backed param route, so that value does not reach this dialog either. A census of authored bulk params taken at registration time over the two repositories reachable from that session found ZERO carrying an undeclared key (objectstack@176b03582e: 7 param literals; objectui@3e4f6324f7: 3), so no in-corpus configuration is known to break. ⚠️ That census did NOT cover hotcrm, which was unreachable from the session that took it — that leg is UNMEASURED, not clean, and an upgrader with their own metadata corpus should run the check below rather than inherit this result. + - Done when: Every `bulkActionDefs[].params[]` entry in your stack parses with declared keys only — `objectstack validate` (and `os lint` / `os build`) reports no `unrecognized_keys` under a `params` path. Each rejection carries its own fix; apply the rename it names, move the key to the layer the prescription points at, or delete metadata that was never read. ⚠️ Parsing clean is the weaker half here, because the widget-config keys were being HONOURED rather than dropped: for every param that carried one, re-open the bulk dialog and confirm the control still behaves as authored (a number param's bounds and step, a file param's accepted types and size cap, a picker's base filters and columns) — where it does not, the configuration is genuinely gone and the remedy is a spec issue asking for the key, not a local workaround. `dependsOn` needs no action: re-open one bulk dialog that declares it and confirm the dependent control is still gated until its parent param is filled, and that picking the parent still narrows the child. +- **`ui-cloud-connection-widgets-unknown-keys-refused`** — `page `cloud-connection:panel` / `marketplace:installed-list` components — `properties` (any key at all: both widgets declare no props)` → an empty `properties` bag (`{}`), or omit `properties` entirely. Neither widget reads any prop: the console registrations discard the schema node (`() => `) and the components take no arguments, so there is no declared key to move to — a key authored on either widget configures nothing and is removed, not renamed. Node-level keys (`visibleWhen`, `id`, `style`, …) stay on the component node, where the page runtime reads them. + - Why not automatic: These were two more instances of the class already closed for `record:reference_rail` and then for `record:alert` / `record:quick_actions` / `record:history`, each by declaring a strict `ComponentPropsMap` row measured from the renderer's read points: console-registered widgets on `@objectstack/cloud-connection`'s published Setup pages, reachable through the component type union's open string arm, with registered renderers but no `ComponentPropsMap` row — so the props gate's dispatch (it parses `properties` against the type's row at publish and lint time, and skips a type with no row because the type union is open) skipped them as unregistered and any authored key rode through every validator in silence. The new rows are strict and EMPTY, measured from the renderers' actual read points at the objectui pin (not from the registrations' declared-input lists): both registrations ignore the component node entirely, so the widgets accept no configuration at all, and an authored key is now a publish-time refusal naming the surface instead of a silent no-op. + - Done when: Every `cloud-connection:panel` / `marketplace:installed-list` node authors an empty (or absent) `properties` bag and validates clean — the two plugin-shipped pages (`cloud_connection_settings`, `marketplace_installed`) already do; `objectstack validate` reports no `component-props-unknown-key` finding for these types. Any remaining authored key on either widget is deleted (it never configured anything), and behaviour that seems to need one is a renderer capability request against objectui, not a metadata key. +- **`ui-form-field-length-malformed-refused`** — `form-view field row `maxLength` / `minLength` declarations (`FormFieldSchema`, the rows inside `FormView.sections[].fields[]`) — `0`, negative or non-integer values` → a positive-integer bound (>= 1), or no declaration at all ("no minimum" is expressed by OMITTING `minLength`, never by `minLength: 0`). The row-level key is a per-form override that can only NARROW what the referenced object field already declares (the object field surface tightened first, to a positive integer, by maintainer rulings) — so a malformed row value is deleted, and a bound that was actually wanted is re-declared as a positive integer, or dropped in favour of the object field's own authoritative declaration + - Why not automatic: The form-field row still carried the object field's old shape — bare `z.number()` — after that surface converged (`maxLength` by the maintainer's 2026-08-24 ruling, `minLength` by the 2026-08-25 one, which refused zero too: both `z.number().int().min(1)`). The row keys are LIVE, measured in objectui: the spec bridge (`packages/react/src/spec-bridge/bridges/form-view.ts` mapField, which maps every spec key or explains why it does not, so none is dropped in silence) and plugin-form (`sectionFields.ts` normalizeSectionField) both copy them onto the runtime field, the console FormPage merges `override.maxLength ?? def.maxLength` onto the rendered input (fixed so that a form's own bound wins over the object's, as its docstring promised), and the fields package builds react-hook-form validation rules from `minLength`/`maxLength` — so `maxLength: 0` on a form row reached the DOM as an input that accepts nothing, and the public-form resolve route (`GET /forms/:slug`) serves the rows verbatim to anonymous renderers. The schema now refuses the malformed values at parse (`z.number().int().min(1)`, ADR-0078 declared=enforced). Unlike the object-field twins there is NO type-conditional gate: a form row references its object field by name and usually omits `type`, so the referenced field's type is invisible at parse time — value shape is checkable on this surface, key placement is the object field's own schema's job. + - Done when: Every form-view field row declaring `maxLength` or `minLength` carries a positive integer. Well-formed rows (a positive-integer bound) parse byte-identically to before; rows declaring neither key are untouched, and absence stays absence — no default materializes. A stored form view carrying a malformed row value is refused on its next authoring-path save with a prescriptive per-key issue; the author deletes the key (the object field's own declaration keeps governing the write seam) or re-declares the intended positive integer. +- **`ui-form-field-precision-scale-integer-refused`** — `form-view field row `precision` / `scale` declarations (`FormFieldSchema`, the rows inside `FormView.sections[].fields[]`) — non-integer or negative values (`scale: 2.5`, `precision: -1`)` → a non-negative integer digit count, or no declaration at all. The row-level key is a per-form override of the referenced object field's own declaration (that surface tightened first, to a non-negative integer) — a malformed row value is deleted, and a count that was actually wanted is re-declared as a non-negative integer (`scale: 2.5` was probably `2` or `3`) + - Why not automatic: The form-field row still carried the object field's old shape — bare `z.number()` — after that surface converged on `z.number().int().min(0)` for both digit counts. The row keys are LIVE, measured in objectui: the spec bridge (`form-view.ts` mapField, which maps every spec key or explains why it does not) and plugin-form (`sectionFields.ts`) copy them onto the runtime field, `ObjectForm` derives the number input's step from `precision`, and the `NumberField` widget reads `scale` — so a malformed count flowed into rendering arithmetic (`Math.pow(10, -precision)`) with no defined meaning. The schema now refuses non-integer and negative values for both keys at parse time (ADR-0078 declared=enforced). Same no-type-gate rationale as the length pair entry (`ui-form-field-length-malformed-refused`): the row usually omits `type`, so only value shape is checkable on this surface. ⚠️ The timeline view's `scale` enum (`TimelineConfigSchema.scale`) is a different surface and is unchanged; the gantt view has no `scale` key at all — its own granularity key is `viewMode`. `CurrencyConfigSchema.precision` was also a different surface — retired in this same protocol major by `currency-config-precision-removed`, not enforced here. + - Done when: Every form-view field row declaring `precision` or `scale` carries a non-negative integer. Well-formed rows (`0`, `2`, any non-negative integer) parse byte-identically to before; rows declaring neither key are untouched, and absence stays absence. A stored form view carrying a malformed row value is refused on its next authoring-path save with a prescriptive per-key issue; the author deletes the key or re-declares the integer they meant. +- **`ui-form-layout-inline-grid-retired`** — `form `layout` — the `object-form` page component (`ObjectFormPropsSchema.layout`) and the form view (`FormViewSchema.layout`: `view.form`, `view.formViews.*`, a form view item's `config`, a flattened form overlay): the `inline` and `grid` arms (REMOVED)` → `layout: 'vertical' | 'horizontal'`, or no `layout` at all ('vertical' is the renderer default). A multi-column form is `columns` (e.g. `columns: 2`), which the renderer honours under either layout — it was never a layout value. 'grid' → 'vertical' and 'inline' → 'vertical', with any `columns` beside them kept as authored. + - Why not automatic: Both surfaces declared `vertical | horizontal | inline | grid`, and no renderer ever gave `inline` or `grid` a behaviour of its own. Measured at the objectui pin `f8a9d0fb0`: the simple `object-form` arm folds both to `vertical` under a comment saying exactly that, the drawer and modal arms pass only `vertical` / `horizontal` through, and the tabbed, split and wizard sub-forms hard-code `vertical` — so both values parsed green at the spec door and rendered as the default. The spec admitted them from two declarations (the designer palette and the registry inputs), never from a read. The maintainer's ADR-0049 family criterion asks whether mainstream platforms have the capability — if they do, build the consumer once, correctly; if they do not, retire the key — and not whether anything in this repository reads it. Multi-column, the capability `grid` names, is one they have, and this spec already carries it under another key, `columns`; `inline` is a toolbar / filter-row pattern, not a record-form layout. So the two arms are redundant vocabulary rather than a missing consumer, and are retired with no alias window. The mechanical rewrite is the ADR-0087 D2 conversion `form-layout-inline-grid-to-vertical` (retired from the load path — both enums refuse the two values at parse with a per-value prescription; stored rows and assembled artifacts replay clean). It is behaviour-preserving: the rewritten form renders exactly as before. What it cannot decide is whether an author who wrote `grid` without `columns` wanted a multi-column form they never got — that form always rendered single-column, and only the author knows whether that was the intent. + - Done when: No authored `object-form` component or form view carries `layout: 'inline' | 'grid'`; `objectstack validate` passes. For every form that was rewritten from `grid`, decide whether it should be multi-column: if so, author `columns` with the count you meant (the rewrite never invents one); if not, the rewritten `vertical` — or deleting `layout` — is already what the form rendered. +- **`ui-form-view-predicate-features-root-refused`** — `form-view predicates naming the `features.*` scope root — section-level `visibleWhen`, field-level `visibleWhen` at any nesting depth, and per-option `visibleWhen` authored inline in the form view (`FormViewSchema`, including the flattened runtime form overlay and the deprecated `visibleOn` alias spellings)` → gate by record state (`record.*` in runtime forms, `data.*` in metadata forms), or move the feature-gated surface onto an app page component or action — the predicate surfaces where `features.*` stays bound and stays legal. No rewrite is mechanical: a feature-flag gate and a record-state gate answer different questions, so the author chooses which surface the gate belongs on. + - Why not automatic: Ruled by the maintainer on 2026-08-27 (option B — vocabulary narrowing: a form view may not name `features.*` in a predicate, and the authoring door refuses it loudly): one authored form view is served on two kinds of route, and a `features.*` predicate got two verdicts from the same text. Inside an app (`/apps/:appName/*`) the root resolves against the real auth-config flags; on the standalone form routes (`/forms/:name`, public `/f/:slug`) no app context exists, the root is UNBOUND, the predicate faults — and `visibleWhen`'s fault fallback is visible, so the field or section a feature flag was meant to hide is shown to everyone (fail-open, on an access-shaped key). Measured before ruling and re-verified at dispatch (2026-08-28): ZERO authored `features.*` form-view predicates exist across objectui apps/examples/content, against an 18-hit positive control on authored `visibleWhen` predicates — so the vocabulary is narrowed at the authoring door instead of building an auth-config fetch plus pre-load semantics on a route with zero consumers. App-context predicate surfaces (page components, actions, bulk-action eligibility) keep `features.*` unchanged. + - Done when: A form view carrying a predicate that names `features` in root position (dotted member access, index access, or the bare root — outside string literals) is refused at parse with a prescriptive issue naming the root, the surface, the fail-open reason and the ruling. Predicates on permitted roots parse unchanged, including member access on a record field that happens to be named `features` (`record.features.x`). Stored form views are unaffected until their next authoring-path save (zero such documents were measured to exist); on refusal the author re-gates by record state or moves the gate to an app surface. +- **`ui-html-page-div-refused`** — `kind:'html' page source (and its deprecated kind:'jsx' alias) in a project with no sdui.manifest.json of its own — the div tag, and any other tag or prop the SDUI component manifest shipped in @objectstack/console does not declare` → `box` for a plain wrapper — the one drop-in swap: the same element, your `className` verbatim, the same children, and no layout of its own. Reach for `card`, `flex`, `container`, `stack` or `grid` only where you want their layout. For any other tag or prop the command names, a component and prop the manifest declares; the file is `dist/sdui.manifest.json` inside `@objectstack/console`. + - Why not automatic: An html page's source is a JSX string, not a keyed document: `objectstack migrate meta` rewrites stored metadata by key and cannot rewrite a tag inside authored source, so the move is by hand, and which wrapper keeps a page's layout is the author's call. `objectstack validate`, `objectstack compile` (which `dev` and `start` run before they boot only when the artifact is missing or `--compile` is passed, and `dev`'s watch mode when a watched file changes) and `objectstack lint` check that source against an SDUI component manifest: the `sdui.manifest.json` in the directory the command runs in, then the copy `@objectstack/console` ships. The second lookup asked for a file the console package does not export, so it always failed, and a project without its own manifest had its html pages checked at parse level only — syntax and structure, never which components and props they use. It now reaches the shipped copy. That manifest declares the html tier's intrinsic tags but not `div`: the maintainer ruled (2026-09-27) that an html page may author the intrinsic tags its renderer registers and that the published manifest declares that set, while `div` stays deprecated in favour of `box`, and the console's own html-page compile refuses `div` the same way. A `div` in such a page, which used to pass unchecked, now fails the command with `jsx-forbidden-tag` and `jsx-unknown-component`. A project that keeps its own `sdui.manifest.json` is checked against that file, as before. The runtime save door now holds pages to the same manifest: a server that `objectstack serve` runs (`dev` and `start` run it too) resolves the deployment's manifest the same way, from the `sdui.manifest.json` beside the served config and then the copy `@objectstack/console` ships, and the metadata save door compiles an html page's source against it on every publish. A `div` page saved from Studio or through the metadata API is refused with a `422` under the same rule ids, and a draft is stored as written and refused at its publish. A server that resolves no manifest says so once at boot and stores html pages unjudged, as before. Pages already stored are not rewritten; each is judged the next time it is saved. + - Done when: `objectstack validate` reports no `jsx-forbidden-tag`, `jsx-unknown-component` or `jsx-unknown-prop` finding on any `kind:'html'` page and prints no `sdui/jsx-parse-level-only` notice — that notice means the component check did not run, so a clean result beside it proves nothing; `objectstack compile` and `objectstack lint` agree. No html page source authors `div`, and in the console each rewritten page renders with no compile error. The platform's own reference is the showcase app's three html pages, which author their wrappers as `box` and pass with zero findings. +- **`ui-list-view-groupbyfield-padded-refused`** — `list-view group-by field names — `kanban.groupByField` (`KanbanConfigSchema`, REQUIRED), `gantt.groupByField` and `timeline.groupByField` (`GanttConfigSchema` / `TimelineConfigSchema`, both optional) — values carrying leading or trailing whitespace` → the field name written with no leading and no trailing whitespace — the same spelling the object declares and the server answers under. A padded value is RE-AUTHORED, never trimmed on the author's behalf: `' stage'` becomes `'stage'`. The refusal names the offending spelling verbatim, so the whitespace an author cannot see in an editor is visible in the message, next to the name to write instead. + - Why not automatic: The same padded-name defect the grouping-level narrowing (`ui-list-view-grouping-field-padded-refused`) refused, on the axis that one scoped out by name, and given the same refusal. All three keys were a bare `z.string()`, so a padded group-by name was valid authored metadata all the way to the renderers. The name is a LOOKUP KEY on every row, measured in objectui at `dda8f3815`: the kanban board resolves its lane as `laneField = groupByField || groupField || detectStatusField(objectDef)` and buckets cards by `card[laneField]`; `ObjectGantt`'s `groupByAccessor` splits the name on `.` and walks the backing record (`resolvePath(task.data, field)`); the timeline groups its rows the same way. The server answers under the unpadded name, so every per-row lookup reads `undefined` and the board collapses into one `Uncategorized` lane — the gantt and the timeline into one ungrouped bucket — holding every record. That is a silent wrong answer that reads as a true statement about the data: one giant bucket is indistinguishable from a dataset where the field genuinely is empty, which is why nothing weaker than a parse refusal is honest here. `packages/lint`'s `validate-list-view-field-refs` already grades this position `error` for the same consequence, but it only runs where an app is validated against its object definitions; the producer accepted the value regardless. ⛔ NOT a `.trim()`: a trimming schema makes `' stage'` and `'stage'` silently equivalent, the consumer-tolerance direction the contract-first rule refuses (fix the metadata, not the renderer: off-spec metadata is refused where it is authored, never coerced into working) — and on the REQUIRED kanban key the author cannot withdraw the value by omitting the key, so a normalising producer would be their only feedback channel and it would say nothing. The narrowing is non-padded ONLY and deliberately not the snake_case machine-name grammar `/^[a-z_][a-z0-9_]*$/` this package spells inline for object/field/tool NAMES: a `groupByField` is authored as a field REFERENCE and a dotted relationship path (`owner.name`) is an in-tree spelling of one. Ships at once, no deprecation window (2026-08-27 maintainer ruling 「短期不考虑渐进」). + - Done when: Measured against the shipped schemas, not restated from the card. Every stored view whose `kanban.groupByField`, `gantt.groupByField` or `timeline.groupByField` carries leading or trailing whitespace is refused on its next authoring-path save, with a `custom` issue at that key's own path (`groupByField`, or `kanban.groupByField` when the view is parsed whole) naming the offending spelling verbatim and the trimmed name to write instead; a value that is nothing but whitespace is refused with the remedy "Name the field to group by" rather than a trimmed name, since there is none. Refused: leading, trailing and both; a tab, a newline and a non-breaking space in those positions; whitespace-only. NOT refused, on purpose: whitespace INSIDE the name (`'Group by field'` parses), and the EMPTY string (unchanged on all three keys, this narrowing covers the silent case only). Nothing is normalised on the way through — an accepted name arrives byte-identical, `'owner.name'` included — so a consumer proves the migration by re-saving each view and seeing either a refusal naming the field or a value it can compare byte-for-byte with what it wrote. Every `groupByField` spelling in the repo at the time of the change parses unchanged: 14 distinct literals harvested across every `.ts` / `.tsx` / `.mdx` / `.json` / `.mjs` outside `node_modules`, zero of them padded, so no fixture had to be rewritten to keep the tree green. +- **`ui-list-view-grouping-field-padded-refused`** — `list-view grouping level names — `grouping.fields[].field` (`GroupingFieldSchema`, the rows inside `ListView.grouping.fields[]`) — values carrying leading or trailing whitespace` → the field name written with no leading and no trailing whitespace — the same spelling the object declares and the server answers under. A padded value is RE-AUTHORED, never trimmed on the author's behalf: `' business_unit '` becomes `'business_unit'`. The refusal names the offending spelling verbatim, so the whitespace an author cannot see in an editor is visible in the message. + - Why not automatic: Ruled by the maintainer on 2026-09-10 (「其他同意」): refuse at the producer. `field` was a bare `z.string()`, so a padded grouping level was valid authored metadata all the way to the renderers. Measured on objectui (M1-M11 with live controls): the projection harvester `collectGroupingFieldRefs` TRIMS the name when it builds `$select`, while THREE renderers bucket rows by the RAW name — plugin-grid `usableGroupingFields`, plugin-list `ObjectGallery.groupedItems`, plugin-kanban `effectiveSwimlaneField`. The server therefore answers under `business_unit` while every per-row lookup asks for `' business_unit '`, reads `undefined`, and the view collapses into ONE `(empty)` group (grid, gallery) or ONE `Uncategorized` lane (kanban) holding every record — a silent wrong answer that reads as a true statement about the data, which is why nothing weaker than a parse refusal is honest here. ⛔ NOT a `.trim()`: a trimming schema makes `' a '` and `'a'` silently equivalent, the consumer-tolerance direction the contract-first rule refuses (fix the metadata, not the renderer: off-spec metadata is refused where it is authored, never coerced into working). objectui's harvester trim stays as defence-in-depth; nothing is removed there. The narrowing is non-padded ONLY and deliberately not the snake_case machine-name grammar `/^[a-z_][a-z0-9_]*$/` this package spells inline for object/field/tool NAMES: a grouping level is authored as a field REFERENCE and a dotted relationship path (`owner.name`) is an in-tree spelling of one. The blank name is unchanged here — it is already refused loudly one layer down by `compileListViewGroupQuery`'s `grouping_field_blank`, and this narrowing exists for the SILENT case. Ships at once, no deprecation window (2026-08-27 maintainer ruling 「短期不考虑渐进」). + - Done when: Every stored list view whose `grouping.fields[].field` carries leading or trailing whitespace is refused on its next authoring-path save, with a per-element issue at `grouping.fields[N].field` naming the offending spelling and the trimmed name to write instead. Names with no padding parse byte-identically to before — nothing is normalised on the way through, and a dotted relationship path stays valid. Views with no `grouping` block are untouched. Every `grouping.fields[].field` spelling in this repo at the time of the change parses unchanged: 50 literal occurrences under a `grouping:` key across 19 files, harvested with the TypeScript parser and cross-checked against 906 shape-exact `{ field, order?, collapsed? }` literals in `packages/**`. The single harvested spelling this refuses — `' '` at `view-grouping-query.test.ts` — is a NEGATIVE fixture handed straight to `compileListViewGroupQuery` with no parse on its path, pinning that same `grouping_field_blank` refusal; the producer now refuses it one layer earlier for the same reason. +- **`ui-mcp-connect-agent-unknown-keys-refused`** — `page `mcp:connect-agent` component — `properties` (any key at all: the widget declares no props)` → an empty `properties` bag (`{}`), or omit `properties` entirely. The widget reads no prop: the console registration discards the schema node (`() => `) and the component function takes no parameters — every value it renders comes from `/discovery`, i18n and its own state — so there is no declared key to move to; a key authored on it configures nothing and is removed, not renamed. Node-level keys (`visibleWhen`, `id`, `style`, …) stay on the component node, where the page runtime reads them. + - Why not automatic: This was a third instance of the class closed for the `record:*` blocks by declaring a strict `ComponentPropsMap` row measured from the renderer's read points (the strict, empty `cloud-connection:panel` / `marketplace:installed-list` rows closed the previous two): a console-registered widget on `@objectstack/mcp`'s plugin-shipped Setup page, reachable through the component type union's open string arm, with a registered renderer but no `ComponentPropsMap` row — so the props gate's dispatch (it parses `properties` against the type's row, and skips a type with no row because the type union is open) skipped it as unregistered, any authored key rode through every validator in silence, and door 3 of the canonical-envelope gate `@objectstack/mcp` was given for its shipped page had to carry a standing exemption for the type. The new row is strict and EMPTY, measured from the renderer's actual read points at the objectui pin (not from the registration's declared-input list): the registration ignores the component node entirely, so the widget accepts no configuration at all, and an authored key is now a publish-time refusal naming the surface instead of a silent no-op. + - Done when: Every `mcp:connect-agent` node authors an empty (or absent) `properties` bag and validates clean — the plugin-shipped page (`connect_agent`) already does; `objectstack validate` reports no `component-props-unknown-key` finding for the type. Any remaining authored key on the widget is deleted (it never configured anything), and behaviour that seems to need one is a renderer capability request against objectui, not a metadata key. +- **`ui-object-block-grouping-config-typed`** — `the grouping property of the object-grid and object-kanban page blocks (ComponentPropsMap['object-grid' | 'object-kanban'].grouping), which was z.unknown and therefore accepted any value: a padded field name such as { fields: [{ field: ' business_unit ' }] }, a number, a bare field-name string, an empty fields list, or keys the grouping config does not declare` → the grouping config a list view carries, `GroupingConfigSchema` from `@objectstack/spec/ui`: `{ fields: [{ field, order?, collapsed? }, ...] }` with at least one entry, each `field` naming the record field exactly as it is stored, with no leading or trailing whitespace, `order` one of `asc` / `desc` and `collapsed` a boolean. A padded name is rewritten unpadded (`' business_unit '` becomes `'business_unit'`); a bare string `'business_unit'` becomes `{ fields: [{ field: 'business_unit' }] }`; an empty `fields` list, a number, or any other value is deleted, since it never grouped anything. On `object-kanban`, `swimlaneField` still wins when both are authored, and deleting `grouping` is the whole migration there when `swimlaneField` is set + - Why not automatic: Both blocks' renderers read the list view's grouping shape and nothing else: the grid groups its rows by every `grouping.fields[i].field` (its server-side group header query and its row projection) and reads `order` and `collapsed` per level, and the kanban board takes `grouping.fields[0].field` as its swimlane field when no `swimlaneField` is authored, looking that raw name up on every card. The list view has refused a padded grouping field name since protocol 17.5, and a list view's `grouping` is a closed shape; these two doors declared the same prop as `z.unknown`, so the same value that list view refuses validated green here and rendered wrong with no error — the grid showed one `(empty)` group holding every row, the board one swimlane holding every card. The prop is kept, not retired: the board's fallback is a live reader of the grouping config. The rewrite is left to the author on purpose: a trimming rule would make a padded and an unpadded name silently equivalent, which is the consumer tolerance the contract refuses, and a bare string, a number or an empty list has no mapping that says what grouping was meant. Metadata AT REST is left exactly as stored — `properties` on a page component is not parsed on the save path, so a stored page keeps loading and renders as it does today; the component-props gate reports such a value as an advisory `component-props-invalid` finding at the offending path on `os validate`, `os build` and `os lint`, a padded name with the received value and the unpadded name to write. ADR-0049 / ADR-0087. + - Done when: Every `object-grid` and `object-kanban` node in your pages either omits `grouping` or carries `{ fields: [...] }` with at least one entry whose `field` is the unpadded stored name. `os validate` reports no `component-props-invalid` finding under `properties.grouping` for these blocks. A well-formed grouping parses byte-identically to before when `order` and `collapsed` are spelled out; a short entry `{ field }` parses clean and gains the list view's defaults (`order: asc`, `collapsed: false`), which is how the grid already read it. After the rewrite, a grid that showed one `(empty)` group shows one group per value of that field, and a board that showed one swimlane shows one swimlane per value — check that this is the grouping you meant. +- **`ui-object-form-custom-fields-typed`** — `page `object-form` components — `properties.customFields` (which used to accept any value)` → a list of closed inline form fields `{ name, label?, type?, required?, options?, … }` — the members the form draws, in camelCase, each option `{ label, value, description?, visibleWhen? }` with `value` a string, a number or a boolean. Write a `visibleOn` (or a legacy `condition`) as `visibleWhen`, move a member's `defaultValue` into the block's `initialValues`, drop `id`, and leave the `grid` widget's snake_case keys (`min_rows`, `allow_add`, …) out until the widget reads a camelCase spelling. + - Why not automatic: The form merges `customFields` over the fields it generates from the object's metadata — a member naming a declared field replaces that field's whole definition, any other is added — and draws each member as it was written, handing it to the field widget as its metadata. The page-component row declared it `z.unknown()`, so `42`, a member with no `name`, or a misspelled member passed the component-props gate, and the form drew the field without it. The row now takes a closed runtime form field of the members the form draws, keyed by `name`, each typed to its read — by reference where this package already declares the member (the object field's metadata members, the evaluated predicates). An option is the runtime option the form's option controls draw — `label`, `value`, `description`, `visibleWhen` — and its `value` is any string, number or boolean, kept as written: an inline field binds no object column, so a stored field's lowercase identifier rule does not apply to it. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and the authored census, with every inline option list evaluated, found no working member to respell. Deployed metadata NOT MEASURED. + - Done when: Every `object-form` node validates: `objectstack validate` reports no `component-props-invalid` / `component-props-unknown-key` finding under `properties.customFields`. Each form draws every inline field with the label, type and rules its member names. +- **`ui-object-form-fields-names-typed`** — `page `object-form` and `object-master-detail-form` components — `properties.fields` (whose entries used to accept any value)` → a list of bare field names, in the order the form draws them. Write a `{ name: 'email' }` entry as `'email'` — the form only ever drew its name — and move a `label` or `required` override onto a `sections[].fields` entry (`type` is always the object field's); write a `{ field: 'email' }` entry as `'email'`, or move it into a section's `fields`, the vocabulary it belongs to. + - Why not automatic: The form reads its top-level `fields` as the names of the fields to draw, in order, selecting from the object's fields and from `customFields`; the master-detail form hands its own to the parent form verbatim. objectui declares the member `string[]`, but the page-component rows declared it `z.array(z.unknown())` while the form drew a `{ name }` entry by that name — the shape objectui's page-builder guide taught, with a `label`, `type` and `required` the form silently dropped. objectui has since retired that entry from every authoring face — the guide and its fixtures name the fields — keeping only a STORED one readable; so both rows now take field names, and refuse an object entry with what to write instead: a `{ name }` entry is its bare name, and a `{ field }` entry — the `sections[].fields` vocabulary, which the form skips at the top level with a console warning — is its bare name or belongs in a section. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, the form already draws a stored `{ name }` entry by its name, and an override written beside it has no rewrite that keeps it — moving it onto a section is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED. + - Done when: Every `object-form` and `object-master-detail-form` node validates: `objectstack validate` reports no `component-props-invalid` finding under `properties.fields`. Each form draws the fields its list names, in that order, with any per-form label or required override taken from its section entry. +- **`ui-object-form-members-typed`** — `page `object-form` components — `properties.contentLayout`, `.submitBehavior`, `.navigateOnSuccess` and `.mobile` (which used to accept any value)` → the shape the form reads: `contentLayout` `'simple'` or `'tabbed'`; `submitBehavior` the form view's own block — `{ kind: 'thank-you', title?, message? }`, `{ kind: 'redirect', url, delayMs? }` with a relative `url`, `{ kind: 'continue' }` or `{ kind: 'next-record' }`; `navigateOnSuccess` a relative path string; `mobile` `{ stickyActions?, stepper?, stepperMinFields?, stepperFieldsPerStep?, fullscreenLongText? }`, with `stepper` `true`, `false` or `'auto'` and the two counts positive integers. Write a `submitBehavior` `kind` as one of the four; move a `redirect` destination to a relative path; write `heading` as `title`. + - Why not automatic: The form reads these members with one shape, and the page-component row declared them `z.unknown()`, so any value passed the component-props gate and the form answered an off-shape one with a silent default: a `submitBehavior` `kind` it does not know fell through to the thank-you panel; a misspelled `contentLayout` such as `'tabs'` stacked the sections; a `navigateOnSuccess` that is not a string threw after the record was written, so the submit reported a failure; and a `mobile` member it does not read, or a `stepper` outside `true` / `false` / `'auto'`, was ignored. The row now takes the form view's own `submitBehavior` by reference — the block the renderers already judge a redirect `url` through — so one value is judged the same way on the form view and the block, and the measured shape for the other three. The form's `fields` and `sections` and the master-detail form's two stay open, because the form draws a `{ name }` field entry and an inline runtime field inside a section, which the typed shapes would refuse; and `customFields` stays open until the spec declares the runtime form field its entries are. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and an off-shape value has no rewrite that both keeps what the form shows today and honours what the author wrote — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED. + - Done when: Every `object-form` node validates: `objectstack validate` reports no `component-props-invalid` / `component-props-unknown-key` finding under the four members' paths. Each form that set one of them now shows it: the post-submit behaviour it names, the modal's tabbed sections, the navigation after a save, and the phone presentation. +- **`ui-object-form-sections-typed`** — `page `object-form` and `object-master-detail-form` components — `properties.sections` (whose entries used to accept any value)` → closed sections `{ name?, label?, description?, collapsible?, collapsed?, visibleWhen?, columns?, pane?, fields }` (or `{ group, columns?, pane? }`), each `fields` entry a field name, the form view's `{ field, … }` entry or an inline form field `{ name, type, … }`. Write a section or field `visibleOn` as `visibleWhen`, a string `columns: '2'` as the number `2`, and a section `label` (or a field entry's `label` / `placeholder` / `helpText`) as a plain string. + - Why not automatic: The form reads a section's heading, collapse pair, `visibleWhen`, `columns`, `pane`, `group` and `fields` — the key set of the form view's section — and draws three kinds of field entry: a name, the form view's `{ field }` entry overriding that object field, and an inline runtime form field drawn as it stands. The page-component rows declared each section `z.unknown()`, so a misspelled key passed the component-props gate and the form drew the section without it; a form view's deprecated `visibleOn` and string `columns`, which a form view folds at parse, reached the form raw — a page block's `properties` is never parsed on the way — and were dropped. Both rows now take one section shape of their own, the stored form view unchanged: the form view's section keys plus the three entry arms, canonical spellings only, a label a plain string because the form draws it as it stands, and the form view's group-reference rule. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and the authored census, with every inline option list evaluated, found no working section to respell. Deployed metadata NOT MEASURED. + - Done when: Every `object-form` and `object-master-detail-form` node validates: `objectstack validate` reports no `component-props-invalid` / `component-props-unknown-key` finding under `properties.sections`. Each form draws every section with the heading, visibility and columns it names, and every entry in it. +- **`ui-object-gantt-markers-typed`** — `page `object-gantt` components — `properties.markers` (whose entries used to accept any value)` → a list of `{ date, label?, color? }`: `date` an ISO date or date-time string (required), `label` the text drawn against the line, `color` any CSS colour. Write a marker `title`, `text` or `name` as `label`, and a `colour` as `color`; give every marker a string `date`. + - Why not automatic: The gantt reads each marker with one shape — `date` places the line, and a date that does not parse or falls outside the drawn range draws none; `label` is drawn against it; `color` paints it, the theme's primary colour when absent — and the page-component row declared the entries `z.unknown()`, because that contract was objectui's alone. So a marker with no `date`, a numeric `date` or a misspelled member passed the component-props gate, and the chart drew no line, or drew it with no label and in the default colour. The spec now declares objectui's own authoring declaration of a marker, `{ date, label?, color? }` with `date` a string (authored metadata is JSON, which cannot carry a `Date`), closed as every element shape on that map is. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and a misspelled member has no rewrite that says which member the author meant — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED. + - Done when: Every `object-gantt` node validates: `objectstack validate` reports no `component-props-invalid` / `component-props-unknown-key` finding under `properties.markers`. Each gantt that sets markers draws one line per marker whose date falls in the drawn range, with the label and colour written. +- **`ui-object-grid-columns-typed`** — `page `object-grid` components — `properties.columns` (whose entries used to accept any value)` → the list view's own `columns`: all field-name strings, or all column entries `{ field, label?, width?, align?, hidden?, sortable?, resizable?, wrap?, type?, pinned?, summary?, prefix?, link?, action? }`. Respell a column keyed `accessorKey` / `header` or `name` as `field` / `label`; write a list as all strings or all entries, never a mix; delete a column key the entry does not declare (`editable`, `options`, `reference`, `currency`, `precision`, …) — inline editing is the grid's own `editable`, and option labels, relational metadata and number formats are the object field's. + - Why not automatic: The grid reads `columns` with one shape — all field-name strings or all column entries, decided by the first entry, drawing only an entry with a string `field` and reading the column entry's own members off it — and the page-component row declared it `z.array(z.unknown())`, so any entry passed the component-props gate and the grid answered an off-shape one in silence: a column keyed `accessorKey` / `header` or `name`, or one with no `field`, drew no column, a mixed list lost every entry the first one did not match, and a key the grid never reads off a column (`editable`, `options`, `reference`) was ignored. The member was held while the grid's group headers drew a column's `options` ahead of the field's; the renderer has since retired that read and takes the labels from the object field only, so the row takes the list view's own `columns` by reference — the column entry a list view already refuses an undeclared key on. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and a refused column has no rewrite that both keeps what the grid draws today and honours what the author wrote — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED. + - Done when: Every `object-grid` node validates: `objectstack validate` reports no `component-props-invalid` / `component-props-unknown-key` finding under `properties.columns`. Each grid draws every authored column: one per entry, headed by its `label` or the field's own, in the order written. +- **`ui-object-grid-export-options-closed`** — `page `object-grid` components — `properties.exportOptions` (which used to accept any value)` → the export options object a list view's `exportOptions` declares: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`, with `formats` drawn from `csv`, `xlsx` and `json`, `maxRecords` a non-negative integer, and `includeHeaders` / `streaming` booleans. Where a bare format array was written, write `{ formats: [...] }` to offer the formats you listed — the grid will now offer exactly those — or `{}` to keep the csv/json default the grid has been offering. Delete `pdf` from `formats`, and any key the object does not declare; delete an `exportOptions: null` (it never enabled the menu). + - Why not automatic: The grid reads one export options block — `exportOptions.formats`, `.maxRecords`, `.includeHeaders`, `.fileNamePrefix` and `.streaming` — the block a list view declares, but the page-component row declared the key `z.unknown()`, so any value passed the component-props gate. The trap was the list view's legacy spelling: a bare format array is legal on a list view, which lifts it to `{ formats }` at parse, and was accepted on the grid, which lifts nothing — the export menu appeared, offering the csv/json default, and the author's list was dropped without a report. The row now takes the list view's export options object itself rather than its union, so a legacy spelling does not spread to a surface that never read it: a bare array is refused with the object form named, a format outside the enum is refused at its index (`pdf` with its retirement text), and a key the object does not declare is named. It is read where every page component's props are: the component-props gate reports these as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape; a bare array has no rewrite that both keeps what the grid shows today and honours what the author wrote, which is the judgment this entry leaves to the upgrader; and the authored census found nothing to respell. Population measured at the change, on origin/main f148852752: zero `object-grid` blocks authoring `exportOptions` in the examples, the package fixtures, the documentation and the published skills, against ten authored `object-grid` blocks through the same matcher (nine in TypeScript, one in a YAML documentation example) and four list-view `exportOptions` authorings as the key's control. Deployed metadata NOT MEASURED. + - Done when: Every `object-grid` node validates: `objectstack validate` reports no `component-props-invalid` / `component-props-unknown-key` finding on a `properties.exportOptions` path. Every `exportOptions` on an `object-grid` is an object carrying only the five declared keys, with every `formats` entry `csv`, `xlsx` or `json`, and the grid's export menu offers the declared formats the active export path delivers (`xlsx` on the server stream only). +- **`ui-object-grid-kanban-calendar-list-members-typed`** — `page `object-grid` components — `properties.fields`, `.selection`, `.selectable`, `.rowActions`, `.bulkActions` and `.batchActions`; page `object-kanban` components — `properties.columns`; page `object-calendar` components — `properties.calendar` (which used to accept any value)` → the shape each block reads, the list view's own where it has one: `object-grid` `fields` field-name strings; `selection` `{ type }` with `none` / `single` / `multiple`; `selectable` `true`, `false`, `'single'` or `'multiple'`; `rowActions`, `bulkActions` and `batchActions` action-name strings. `object-kanban` `columns` all lanes `{ id, title, cards?, limit?, className?, collapsed? }` or all bare value strings (never mixed), a lane `id` a string. `object-calendar` `calendar` `{ startDateField, endDateField?, titleField?, colorField?, allDayField? }`. Move an object entry of `fields` to `columns`; move a `{ name }` entry of `bulkActions` to `bulkActionDefs` or write the bare name; style a lane with `className` instead of `color`; rename `dateField` / `endField` to `startDateField` / `endDateField`. + - Why not automatic: Each renderer reads these members with one shape, and the page-component rows declared them `z.unknown()`, so any value passed the component-props gate and the block answered an off-shape one with a silent default: an object entry of `fields` named no field; a `{ name }` entry of `bulkActions` was skipped; a kanban lane list mixing objects and strings drew a blank lane and swept its records into the trailing lane; and a calendar block without `startDateField` placed no event. The rows now take the list view's own `selection`, `rowActions`, `bulkActions` (for `batchActions` too, the spelling the grid reads first) and `calendar` members by reference, and the measured shape for the grid's `fields` and `selectable` and the kanban lane, so one value is judged the same way on every door that carries it. The grid's `columns` is not narrowed: its group-header labels read an authored column's `options`, which the list view's column entry does not declare, so it stays open until that read is ruled. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and an off-shape value has no rewrite that both keeps what the block shows today and honours what the author wrote — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED. + - Done when: Every `object-grid`, `object-kanban` and `object-calendar` node validates: `objectstack validate` reports no `component-props-invalid` / `component-props-unknown-key` finding under the eight members' paths. Each block that set one of them now shows it: the grid's field fallback, the selection mode, the row and bulk actions, the kanban lanes with their records, and the calendar events placed by `startDateField`. +- **`ui-object-grid-page-size-positive-integer-refused`** — ``object-grid` page-component page sizes (`ComponentPropsMap['object-grid']` — `pagination.pageSize`, each `pagination.pageSizeOptions[]` entry, and the flat `pageSize` shorthand) — zero, negative and non-integer values (`pagination: { pageSize: 0 }`, `pageSize: 25.5`)` → a positive integer, or no declaration at all. A page size of `0` has no defined meaning on this surface and never had one: delete the key to take the renderer's own default, or write the page size that was meant (`pageSize: 0` authored to mean "no paging" is `showPagination: false` with no `pagination` bag, since the bag's PRESENCE is what enables paging) + - Why not automatic: This door still carried the shape it was given when the `object-*` blocks first got `ComponentPropsMap` rows measured from their read points — `pagination: z.unknown()` and `pageSize: z.number()` — after the view arm converged on `z.number().int().positive()`. So the SAME authored member carried two accept sets and renderers read the looser one: `PaginationConfigSchema` (`view.zod.ts`) refuses `pageSize: 0` and pins that refusal by name, and every other `pageSize` the package declares is bounded with its own throwing pin (`kernel/metadata-plugin.zod.ts`, `marketplace/marketplace.zod.ts`) — the component arm was the only one that accepted `0`. The value is LIVE: an objectui grid measurement found that an authored `pagination.pageSize: 0` reached `ObjectGrid`, went out on the wire as `$top: 0` and rendered ZERO ROWS, with no grouping needed to trigger it, and it reached the renderer through this arm. objectui's grid plugin repaired the consumer half — it now refuses a non-positive page size at all three read points (one resolver, fail-soft, one loud diagnostic); this is the declaration half, and it is not a prerequisite for that repair. ⚠️ The `pagination` bag itself stays OPEN (`z.looseObject`): only the two members whose value is a page size are bounded, and sibling keys parse and pass through exactly as before. `PaginationConfigSchema` on the view arm is a closed shape and is unchanged by this entry. + - Done when: Every `object-grid` node declaring a page size — inside `pagination` or through the flat shorthand — carries a positive integer. Well-formed values (`10`, `25`, `50`) parse byte-identically to before, a `pagination` bag carrying sibling keys parses and keeps them, and absence stays absence. A stored page whose `object-grid` node carries `pagination: { pageSize: 0 }` still saves and loads — `properties` on a page component is not parsed on the metadata save path — and the component-props gate reports it as an advisory `component-props-invalid` finding at `pagination.pageSize` on `os validate`, `os build` and `os lint`; a `pageSizeOptions` entry and the flat `pageSize` shorthand are reported the same way at their own paths. The author deletes the key or writes the page size they meant, and `os validate` then reports no `component-props-invalid` finding for that node. +- **`ui-object-grid-row-members-typed`** — `page `object-grid` components — `properties.rowHeight`, `.rowColor`, `.navigation`, `.conditionalFormatting`, `.bulkActionDefs`, `.aggregations` and `.operations` (which used to accept any value)` → the shape the grid reads, the list view's own where it has one: `rowHeight` one of `compact` / `short` / `medium` / `tall` / `extra_tall`; `rowColor` `{ field, colors }`; `navigation` `{ mode?, size?, openNewTab?, preventNavigation? }`; `conditionalFormatting` `[{ condition, style }]` with a CEL `condition` and a CSS `style` map; `bulkActionDefs` the list view's bulk-action defs; `aggregations` `[{ field, type }]` with `type` one of `count`, `sum`, `avg`, `min`, `max`, `count_distinct`; `operations` `{ create?, update?, delete?, export? }` booleans. Rewrite an objectui-native formatting rule `{ field, operator, value, backgroundColor }` as `{ condition: "record.FIELD == VALUE", style: { backgroundColor } }`; delete `operations.read` and `operations.import`, which nothing reads. + - Why not automatic: The grid reads each of these members with one shape, and the page-component row declared them `z.unknown()`, so any value passed the component-props gate and the grid answered an off-shape one with a silent default: an off-preset `rowHeight` such as `42` rendered as `compact`, a `rowColor` of the wrong shape coloured no row, a `navigation` written as a bare mode string opened the record page whatever it named, an aggregation with an unknown function drew a zero nothing computed or no number at all, and an `operations` toggle nothing reads toggled nothing. The row now takes the list view's own schemas for the five members a list view declares, and the measured shape for `aggregations` and `operations`, so one value is judged the same way on both doors. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and an off-shape value has no rewrite that both keeps what the grid shows today and honours what the author wrote — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED. + - Done when: Every `object-grid` node validates: `objectstack validate` reports no `component-props-invalid` / `component-props-unknown-key` finding under the seven members' paths. Each grid that set one of them now shows it: the declared row height, the row colours its `colors` map names, the navigation mode on a row click, the conditional styles, the bulk actions, the group-header numbers and the affordances `operations` names. +- **`ui-object-kanban-conditional-formatting-typed`** — `page `object-kanban` components — `properties.conditionalFormatting` (which used to accept any value)` → the list view's own rules, `[{ condition, style }]`: a non-blank CEL `condition` over the card's `record.*` and a CSS `style` map of string values. Rewrite a native rule `{ field, operator, value, backgroundColor }` as `{ condition: "record.FIELD == VALUE", style: { backgroundColor } }`, an `expression` as `condition`, and move a colour written beside `condition` into `style`. + - Why not automatic: The board reads `conditionalFormatting` as an ordered list of `{ condition, style }` rules, through the evaluator the grid's rows use, and paints a card with the `style` of the first rule whose condition holds; objectui declares exactly the list view's rule as the member's only dialect. The page-component row declared it `z.unknown()`, so `42`, a bare string or a rule with no `style` passed the component-props gate and the board painted no card for it. The row now takes the list view's own member, by reference, as `object-grid` does, so one rule is judged the same way on every door. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and the authored census found no working rule to respell. Deployed metadata NOT MEASURED. + - Done when: Every `object-kanban` node validates: `objectstack validate` reports no `component-props-invalid` / `component-props-unknown-key` finding under `properties.conditionalFormatting`. Each board that sets rules paints the card each rule names with its `style`. +- **`ui-object-map-gantt-tree-navigation-typed`** — `page `object-map`, `object-gantt` and `object-tree` components — `properties.navigation` (which used to accept any value)` → the list view's navigation block `{ mode?, size?, openNewTab?, preventNavigation? }`, `mode` one of `page`, `drawer`, `modal`, `split`, `popover`, `new_window`, `none` — the block `object-grid`, `object-kanban`, `object-calendar` and `object-timeline` already take. Rewrite a bare mode string such as `navigation: 'drawer'` as `navigation: { mode: 'drawer' }`. + - Why not automatic: Each of the three renderers hands `navigation` to the shared navigation hook, which reads `navigation.mode`, falls back to `page` when it finds none, and types its mode union as the list view's `NavigationConfigSchema`. The rows declared the member `z.unknown()`, so any value passed the component-props gate and an off-shape one was answered with a silent default: `navigation: 42` and a bare mode string such as `'drawer'` both opened the record page, whatever they named. The three rows now take the list view's schema by reference, so one value is judged the same way on every door that carries it. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and an off-shape value has no rewrite that both keeps what the block shows today (the record page) and honours what the author wrote — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED. + - Done when: Every `object-map`, `object-gantt` and `object-tree` node validates: `objectstack validate` reports no `component-props-invalid` / `component-props-unknown-key` finding under `properties.navigation`. Each block that set `navigation` opens the mode it names on a marker, task or row click. +- **`ui-object-master-detail-form-details-closed`** — `page `object-master-detail-form` components — `properties.details[]` (each detail entry, which used to accept any value) and `properties.details[].columns[]` (its inline grid columns), including `scale` on a column that declares no `type` and whose `name` is a `currency` field of the entry's `childObject`` → each entry is `{ childObject, relationshipField?, columns?, formFields?, inlineMode?, amountField?, totalField?, title?, minRows?, maxRows?, addLabel? }` — the keys the renderer reads — with `inlineMode` one of `grid` / `form`. Each column is the strict, name-keyed inline grid column a relationship field's `inlineColumns` takes — `{ name, label?, type?, … }`, where `{ name }` alone hydrates the rest from the child object's field. Write `childObject` on every entry; write `name` where a column said `field` (or `fieldName`, `key`) or was a bare field-name string; delete `scale` from a column that renders as a currency column, whether it declares `type: 'currency'` or takes it from a `currency` child field — nothing replaces it, the currency's ISO 4217 minor unit decides; delete any key neither shape declares. + - Why not automatic: The block draws one inline grid per detail entry, hydrating an authored column list with the same rule and into the same grid as the other two carriers of the inline grid column, but nothing judged its entries: a key the renderer does not read was ignored in silence, and a column carrying a key the grid does not read, or `scale` on a currency column — refused on the other carriers under the maintainer's rulings of 2026-09-23 (option B, `scale` retired from the currency type) and 2026-09-24 (option 乙 — a currency's ISO 4217 minor unit decides its display) — went through `objectstack validate` green. The entry is now a strict shape and its `columns` references the column schema, so every rule that schema holds applies here too, with its own prescription. The entry half is read where every page component's props are: the component-props gate reports a failing entry or column as an advisory `component-props-unknown-key` / `component-props-invalid` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. The identity-only half is `defineStack`'s cross-reference check, which already judged the other two carriers: it now reaches the block wherever a page carries it and refuses an identity-only column over a `currency` child field that carries `scale`, with the column schema's own message; reach: the child object must be declared in the same stack, and a column the column schema refuses on its own is left to the component-props gate. No conversion is registered: nothing on the load path refuses the shape, and the authored census found nothing to respell. Population measured at the change, on origin/main ebdb6f2aca: one authored block in the examples (the showcase project workspace, one entry `{ title, childObject, addLabel }`, no columns), one documentation example whose three columns were bare field-name strings (rewritten as `{ name }` columns in the same change), and zero `field`-keyed detail columns, against one authored `inlineColumns` block as the control. Deployed metadata NOT MEASURED. + - Done when: Every `object-master-detail-form` node validates: `objectstack validate` reports no `component-props-unknown-key` / `component-props-invalid` finding on a `properties.details` path and no cross-reference finding on a `details[].columns[].scale` path. Every detail entry carries `childObject` and only keys the entry shape declares; every column is an object carrying `name`, no column carries `field`, `fieldName` or `key`, and no column that renders as a currency column carries `scale`. The block's showcase entry `{ title, childObject, addLabel }` parses unchanged, and the master-detail grid renders a value — not a blank cell — in each authored column for a row that has one. +- **`ui-object-metric-aggregate-trend-typed`** — `page `object-metric` components — `properties.aggregate` and `.trend` (which used to accept any value)` → the shape the tile reads: `aggregate` `{ field?, function, groupBy? }`, with `function` one of the engine's `count`, `sum`, `avg`, `min`, `max` or `count_distinct`, a `field` for every function but `count`, and `groupBy` the chart aggregate's own union — a field name or a `{ field, dateGranularity?, alias? }` date-bucket node — here optional; `trend` `{ value, label?, direction? }`, with `value` a number, `label` a string or an inline locale map and `direction` `up`, `down` or `neutral`. Write a string `aggregate` as an object (`'count'` → `{ function: 'count' }`); move `dateGranularity` inside `groupBy`; write a bare trend direction as `{ value, direction }`. + - Why not automatic: The tile reads these members with one shape, and the page-component row declared them `z.unknown()`, so any value passed the component-props gate and the tile answered an off-shape one in silence: a string `aggregate` or a function the engine does not have asked the server for a measure it does not have, so the tile showed an error or, on the client-side fallback, a sum it was not asked for; `groupby` for `groupBy` drew one ungrouped number; and a `trend` with no `value` painted a lone `%`, with a misspelled member or direction simply not drawn. The row now takes the query AST's own aggregation functions — the six the tile forwards to the engine — and the chart aggregate's `groupBy` union by reference, and the badge's measured shape for `trend`. The aggregate is not the chart's whole: the chart requires `groupBy` and five functions, while a metric paints one number over every row and draws a `count_distinct` wherever the analytics service answers it. `drillDown` and `compareTo` stay open: the chart's drill-down declares a `filter` the tile never reads and refuses a `report` it draws, and the dashboard widget's comparison declares a `dimension` this path never reads, so each waits on a ruling between the reference and the read. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and an off-shape value has no rewrite that both keeps what the tile shows today and honours what the author wrote — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED. + - Done when: Every `object-metric` node validates: `objectstack validate` reports no `component-props-invalid` / `component-props-unknown-key` finding under the two members' paths. Each tile that set one of them now shows it: the number its aggregate names, grouped or bucketed as written, and the trend badge with its value, arrow and caption. +- **`ui-object-metric-compare-to-typed`** — `page `object-metric` components — `properties.compareTo` (which used to accept any value)` → the shape the tile reads: `{ kind }`, with `kind` the dashboard widget comparison's own vocabulary, `previousPeriod` or `previousYear`. Write a bare kind string as an object (`'previousYear'` → `{ kind: 'previousYear' }`), and delete a `dimension`: the tile shifts the date macros in its own `filter`, so state the window there. + - Why not automatic: The tile reads `compareTo` with one shape — `kind` alone, dispatching on `previousYear` and treating every other value as `previousPeriod` — and the page-component row declared it `z.unknown()`, so any value passed the component-props gate and the tile answered an off-shape one in silence: a bare `'previousYear'` or a kind outside the two compared against the previous period, and a `dimension` was carried and never read, because this inline tile shifts the date macros in its own `filter` while only a dashboard widget's dataset path hands `dimension` to the analytics executor. The row now takes `{ kind }`, with `kind` the dashboard widget comparison's own member by reference, and refuses `dimension` by name with that prescription rather than accepting a key the tile ignores. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and a `dimension` has no rewrite that keeps the window the author meant — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED. + - Done when: Every `object-metric` node validates: `objectstack validate` reports no `component-props-invalid` / `component-props-unknown-key` finding under `properties.compareTo`. Each tile that sets a comparison shows its trend labelled for the kind it names, over the window its own `filter` resolves to. +- **`ui-object-metric-drill-down-report-typed`** — `page `object-metric` components — `properties.drillDown.report` (which used to accept any value)` → a report definition, the same shape as `reports[]` (`ReportSchema`): `{ name, label, dataset, values, … }`, or a `joined` report whose every block binds a `dataset`. Write a bare report name, a `{ name }` reference or the retired `objectName` / `columns` form as the dataset-bound report itself. + - Why not automatic: The metric tile hands `drillDown.report` to the shared drill drawer, which draws it as a report — with the metric's filter joined into the report's own `runtimeFilter` — when it is dataset-bound (a non-empty `dataset`, or a `joined` report with a block that binds one), and lists the records for any other value. The page-component row declared it `z.unknown()`, so a report with no `dataset`, a misspelled report key, a bare report name or a `{ name }` reference passed the component-props gate, and the drawer quietly listed the records instead. The row now takes `ReportSchema` by reference — the declaration objectui already names for the member — and, since a joined report refuses a block that binds no `dataset`, every report it admits is one the drawer draws. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and the authored census found no drawn report to respell. Deployed metadata NOT MEASURED. + - Done when: Every `object-metric` node validates: `objectstack validate` reports no `component-props-invalid` / `component-props-unknown-key` finding under `properties.drillDown.report`. Each tile whose drill names a report opens that report, scoped by the metric's filter, instead of the record list. +- **`ui-object-metric-drill-down-typed`** — `page `object-metric` components — `properties.drillDown` (which used to accept any value)` → the shape the tile reads: `{ enabled?, title?, target?, columns?, maxRows?, report? }`, the first five the chart drill-down's own members — `enabled` a boolean, `title` a string, `target` `drawer`, `dialog` or `navigate`, `columns` field names, `maxRows` a positive whole number — and `report` still open. Delete a drill `filter` and scope the metric with its own `filter`, one level up; delete a `mode`, since a metric always lists the records behind its number. + - Why not automatic: The tile reads `drillDown` with one shape — `enabled`, `title`, `target`, `columns`, `maxRows` and `report`, scoping the drilled list by the metric's own `filter` — and the page-component row declared it `z.unknown()`, so any value passed the component-props gate and the tile answered an off-shape one in silence: a drill `filter` or a `mode` was carried and never read, a misspelled member was simply not applied, and a non-numeric page size reached the drilled list. The row now takes the five list members the chart drill-down declares, by reference, and refuses `filter` and `mode` by name: a metric tile has no click event for a drill filter to resolve against, and no row for `mode` to open as a record. The chart's shape is not taken whole, because it declares `filter`. The drill `report` stays open: the tile draws a dataset-bound report through the shared drawer, but no spec drill shape declares a `report` member yet. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and a drill `filter` has no rewrite that keeps the scope the author meant — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED. + - Done when: Every `object-metric` node validates: `objectstack validate` reports no `component-props-invalid` / `component-props-unknown-key` finding under `properties.drillDown`. Each tile that sets a drill-down opens it as written: the panel shape `target` names, the heading `title` names, and the records behind the number, scoped by the metric's own `filter`, in the columns and page size written. +- **`ui-object-timeline-items-typed`** — `page `object-timeline` components — `properties.items` (whose entries used to accept any value)` → the entry kind the block's `variant` selects: on `vertical` (the default) or `horizontal`, a feed entry `{ time?, title, description?, variant?, icon?, content?, className? }`; on `gantt`, a gantt row `{ label, items? }` whose bars are `{ title?, startDate?, endDate?, variant? }`, each date a string or epoch milliseconds. Write a feed entry's `date` as `time` and its `color` as `variant` (`default`, `success`, `warning`, `danger`, `info`); move a gantt row to `variant: 'gantt'`, or a feed entry off it. + - Why not automatic: The timeline rail draws `items` as authored, ahead of every record source, and each branch of its renderer reads only its own kind of entry: the feed branches read `time`, `title`, `description`, `variant`, `icon`, `content` and `className`; the gantt branch reads a row's `label` and its bars' `title`, `startDate`, `endDate` and `variant`. The page-component row declared each entry `z.unknown()`, so a misspelled key, a feed entry with no `title`, or a gantt row on a feed timeline passed the component-props gate, and the rail drew an empty, unlabelled entry. The row now takes objectui's two ruled kinds, closed, and pairs each entry with the kind its `variant` selects; a feed entry's `content` (child components) is held unjudged until a writer appears. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and the authored census found no drawn entry to respell. Deployed metadata NOT MEASURED. + - Done when: Every `object-timeline` node validates: `objectstack validate` reports no `component-props-invalid` / `component-props-unknown-key` finding under `properties.items`. Each timeline with authored entries draws every entry with its title (or row label), date and colour. +- **`ui-object-timeline-mapping-typed`** — `page `object-timeline` components — `properties.mapping` (which used to accept any value)` → the binding record the rail reads: `{ title?, date?, description?, variant? }`, each a field name. Write `titleField`, `dateField` / `startDateField`, `descriptionField` and `variantField` inside `mapping` as `title`, `date`, `description` and `variant`; write a bare field name as the member it binds (`mapping: { title: 'subject' }`). + - Why not automatic: The timeline rail reads `mapping` as four field names — `title` and `date` between the `timeline` block's own member and the flat fallback, `description` ahead of `descriptionField`, and `variant`, the field whose value picks each entry's marker colour and the one binding with no other spelling — and the page-component row declared it `z.unknown()`, because that contract was objectui's alone. So a bare field name, a non-string binding or a misspelled member passed the component-props gate, and the rail bound nothing for it and drew the default field. The spec now declares objectui's own declaration of the binding record, four optional field names, closed as every element shape on that map is. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and the authored census found nothing to respell. Deployed metadata NOT MEASURED. + - Done when: Every `object-timeline` node validates: `objectstack validate` reports no `component-props-invalid` / `component-props-unknown-key` finding under `properties.mapping`. Each timeline that sets a mapping draws its entries' title, date, description and marker colour from the fields it names. +- **`ui-react-list-view-binding-aliases-retired`** — ``kind:'react'` page source — `` and `` (the react-tier overlay aliases published as deprecated when the react tier converged on the metadata-tier vocabulary)` → `` — ListViewSchema's own `data` data source and `type` view kind, the same two keys a metadata list view authors. `objectName="x"` → `data={{ provider: 'object', object: 'x' }}`; `viewType="kanban"` → `type="kanban"`. A `` with no `data` at all is refused too: on a react page no host stamps the object, so the data source is the required binding there. + - Why not automatic: A react page's source is a JSX string, not a keyed document: `objectstack migrate meta` rewrites stored metadata by key and cannot rewrite props inside authored source, so the move is by hand. The contract deprecated both aliases in favour of the metadata-tier spelling (maintainer ruling 2026-08-23: the react tier converges on the metadata-tier vocabulary, deprecating first) while objectui's ListView still read only `objectName`, so the canonical spelling validated green and rendered an empty list. The consumer fold has landed (objectui `normalizeListViewSchema`, console pin a472b071: `data.provider === 'object'` → `objectName`, and the author's `type` read for the view kind), and the maintainer ruled the aliases retired with no deprecation window (2026-09-07). Writing either alias is now a publish-time `react-prop-retired` error carrying this prescription — never a silent pass on a key the renderer happens to still read. + - Done when: `objectstack validate` reports no `react-prop-retired` and no `react-prop-missing-required` finding on any `kind:'react'` page; every `` carries `data={{ provider: 'object', object }}` (or another ViewData provider) and, where a view kind was chosen, `type`; in the console each rewritten list renders the same rows and visualization it rendered under the alias spelling. The platform's own sites are the reference: the showcase `crm-workbench`, `renewals-pipeline` and `task-desk` pages pass with zero findings. +- **`ui-record-blocks-unknown-keys-refused`** — `page `record:alert` / `record:quick_actions` / `record:history` / `record:discussion` components — `properties` (undeclared keys, notably a typo'd `severty`, quick_actions' inline `actions`, history's host-channel `entries` / `loading`, and any key at all on `record:discussion`)` → the declared shapes the renderers read. `record:alert`: `severity?`, `title?` / `body?` (string or inline locale map), `visible?` (boolean | CEL string | `{ dialect, source }`), `icon?`, `action?` `{ actionName, label?, variant? }`, `dismissible?`, `dismissKey?`. `record:quick_actions`: `actionNames?`, `requiredPermissions?`, `location?` (the spec's own action-location vocabulary), `align?`, `inline?`, `variant?` / `size?` (the Button primitive's vocabulary). `record:history`: `limit?`, `emptyText?` / `unknownUserText?` (literal strings). `record:discussion`: `record:chatter`'s own row — one schema for the pair. Every rejection carries the surface, the offending key and a prescription (`actions` → `actionNames`; `entries` / `loading` → omit, the block self-fetches `sys_activity`; `aria` on quick_actions → not declared until the renderer reads the contract spelling; `visibleWhen` / `visibility` on the alert → `visible`; a locale map as history text → a literal string) + - Why not automatic: These were the four `record:*` components the component-props unknown-key gate (an authorable surface refuses a key it does not declare; for a component's `properties`, by parsing them against the type's `ComponentPropsMap` row) could not reach after the rail was given its strict row: each had a registered objectui renderer (and, bar `record:discussion`, a `PageComponentType` entry and a console palette slot) but no `ComponentPropsMap` row, so the props gate's dispatch skipped them as unregistered and every authored key rode through. A typo'd `severty` on the platform's own banner surface parsed, typechecked, validated, built and shipped as a silent no-op while sibling components in the same file drew loud diagnostics. The rows declare the shapes the renderers actually read (measured from read points at the objectui pin, not from the registrations' declared-input lists — quick_actions' registration claims an empty-bar fallback the renderer does not implement, and omits the `aria.label` read that exists but under a spelling the shared ARIA shape refuses), so an undeclared key is now a publish-time refusal instead of a silent no-op. + - Done when: Every `record:alert` / `record:quick_actions` / `record:history` / `record:discussion` node validates with only declared keys, and declared keys parse byte-identically to before — the platform `sys_user` page's banner (inline locale maps, CEL `visible`, CTA) and self-service quick_actions bars, and the showcase task page's banner and bar, all pass with zero findings; `objectstack validate` reports no `component-props-unknown-key` / `component-props-invalid` finding for these types. The one parse-time normalization is `ExpressionInputSchema`'s own: a bare-string `visible` becomes the canonical `{ dialect: 'cel', source }` envelope. +- **`ui-record-line-items-props-closed`** — `page `record:line_items` components — `properties` (which used to accept any key) and `properties.columns[]` (its inline grid columns)` → the declared shape the renderer reads: `{ childObject?, relationshipField, columns, parentObject?, parentId?, recordId?, amountField?, totalField?, title?, readonly?, minRows?, maxRows?, filter?, sort?, limit? }`, with `filter` the ViewFilterRule array, `sort` the SortItem array and `limit` a positive integer; `childObject` may come from the component-level `dataSource` binding instead. `columns` is required and holds at least one column, each the strict, name-keyed inline grid column a relationship field's `inlineColumns` takes — `{ name, label?, type?, options?, … }`. Write `name` where a column said `field` (or `fieldName`, `key`); declare `label`, `type` and `options` on the column, because this block draws a column exactly as declared and hydrates nothing from the child object's field; delete `scale` from a column declaring `type: 'currency'`; delete `addLabel`, `formFields` and `inlineMode`, which belong to an `object-master-detail-form` detail entry and are not read here, `sortField`, which no block takes (the detail entry derives the line-position field from the child object), and any other key the shape does not declare. + - Why not automatic: The block draws one inline grid of the record's child rows, through the same objectui grid as the other three carriers of the inline grid column, but it had no `ComponentPropsMap` row: it was the one entry on the string-arm registration ledger, so the component-props gate skipped it as unregistered and every authored key rode through. The showcase project page keyed all five of its columns `field`, the spelling the grid retired, and published green; the grid binds a column by `name`, so every cell rendered empty. The row is measured from the renderer's read points at the objectui pin, not from the registration's declared-input list, and its `columns` references the column schema, so every rule that schema holds applies here too, with its own prescription. It is read where every page component's props are: the component-props gate reports a failing key or column as an advisory `component-props-unknown-key` / `component-props-invalid` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. `defineStack`'s identity-only column check does not reach this block: the panel hands its columns to the grid as authored, so there is no hydrated type to judge. No conversion is registered: nothing on the load path refuses the shape, and the one `field`-keyed producer was respelled in the same change. Population measured at the change, on origin/main 1ecb871beb: one authored block in the examples (the showcase project detail page, five `field`-keyed columns, respelled `name`), zero in the documentation, against eight authored `record:*` blocks of other types through the same matcher as the control. Deployed metadata NOT MEASURED. + - Done when: Every `record:line_items` node validates: `objectstack validate` reports no `component-props-unknown-key` / `component-props-invalid` finding on its `properties` path. Every node carries `relationshipField` and at least one column, every column is an object carrying `name`, no column carries `field`, `fieldName` or `key`, and no key outside the declared shape is present. The showcase project detail page's block parses with its five `name`-keyed columns, and its grid renders a value — not a blank cell — in each column for a row that has one. +- **`ui-reference-rail-unknown-keys-refused`** — `page `record:reference_rail` components — `properties` and each `entries[]` item: undeclared keys (notably a per-entry `filter`, an entry `icon`, and an inline locale map as `title`)` → the declared shape the renderer reads: `entries[]` of `{ objectName, relationshipField, title?, limit?, displayField? }` plus a component-level `hideEmpty`. Every rejection carries the surface, the offending key and a prescription (`filter` → remove it, or use `record:related_list` whose `filter` is real; `icon` → remove it, no render path reads it; entry-level `hideEmpty` → move it up beside `entries`; `items` / `related` → `entries`; `object` → `objectName`; `label` → `title`; a `title` locale map → a literal string, or omit it to keep the localized object label) + - Why not automatic: The rail was the `record:*` component the component-props unknown-key gate (an authorable surface refuses a key it does not declare; for a component's `properties`, by parsing them against the type's `ComponentPropsMap` row) could not reach: it had a registered renderer and a `PageComponentType` entry but no `ComponentPropsMap` row, so the props gate's dispatch skipped it as unregistered and every authored key rode through. Measured on 17.0.0 GA end to end: a planted entry `filter` passed tsc, `objectstack validate` and `objectstack build`, shipped verbatim in the artifact, and the rendered rail kept counting and listing unfiltered rows — while the same build loudly reported `record:related_list` keys in the same file. The row declares the shape the renderer actually reads (measured from its read points, not its TS interface — the interface's `icon` is read by nothing and is refused, not declared), so an undeclared key is now a publish-time refusal instead of a silent no-op. + - Done when: Every `record:reference_rail` node validates with only declared keys: `properties` carries `entries` (≥ 1) and optionally `hideEmpty`; each entry carries `objectName` and `relationshipField` and optionally `title` (literal string), `limit` (positive int) and `displayField`. Declared keys parse byte-identically to before; `objectstack validate` reports no `component-props-unknown-key` / `component-props-invalid` finding for the rail. +- **`ui-report-joined-block-dataset-required`** — `reports[].blocks[].dataset on a report whose type is joined: a block that binds no dataset (the joined arm of the ReportSchema refinement)` → Bind the block to a dataset: set the block's `dataset` to the dataset whose measures (`values`) and dimensions (`rows`) it shows. A block with nothing to show can be deleted instead, as long as the report keeps at least one block. + - Why not automatic: ADR-0021 single-form, enforced (ADR-0049 enforce-or-remove, the enforce arm). A `joined` report carries its data on `blocks`, each an independent query over that block's own `dataset`, and the container selects nothing: a container `dataset` is refused. `ReportSchema`'s refinement comment and the reports guide both said each block is dataset-bound, but the joined arm required only that `blocks` be non-empty, and a block's `dataset` is optional on its shape, so a block with no `dataset` parsed, passed `objectstack validate` and every save door, and drew nothing. Measured at this repo's `.objectui-sha` pin `ab187972159583b595facdcae3c73b50f6f312e9`: the joined renderer hands each block's `dataset` to its table, whose query hook goes idle on an empty name, so an unbound block draws an empty table and issues no query; a report whose blocks all lack one fails the dataset-report guard and falls through to the pre-9.0 presentation bridge, which issues no query either, and a dashboard drill-down that opens that report lists the records instead of drawing it. Studio's report inspector authors blocks through the spec form's `blocks` repeater, which adds a blank row and requires no column of it, so a block saved with only a name reached the store with no error. The joined arm now refuses each such block at `blocks[i].dataset`, naming the block, with the prescription to bind it to a dataset. `dataset` stays optional on the block shape itself: `blocks` is read only on a `joined` report, and a block on any other report type is ignored, as before. Ships at once, with no deprecation window: there is no window in which an unbound block draws anything, and there is no mechanical rewrite, because only the author knows which dataset the block was meant to show. + - Done when: WHICH DOOR: this is the spec schema's refusal, so it lands wherever a report is parsed through `@objectstack/spec` — `defineReport`, `defineStack`, `os validate` / `os build`, and the metadata save door (the `report` entry of the metadata type registry) — as one `custom` issue per unbound block at `blocks.N.dataset`, naming the block. A stored `sys_metadata` report row is not rewritten: it carries the same issue in its read-side `_diagnostics` and is refused on its next save. Fix each by binding the block to the dataset it is meant to show, or by deleting the block, then check the rendered report: every block queries its dataset and draws its rows. A joined report whose blocks all bind a `dataset` parses byte-identically to before, and every non-joined report is untouched. Census at the time of the change: no joined report with an unbound block in this repository (one example-app report, one docs example and the test fixtures in `packages/lint`, `packages/platform-objects` and `packages/spec` all bind every block; one metadata-door test fixture that left its block unbound on purpose was bound in the same change), in the hotcrm application (one joined report, every block bound) or in the cloud repository (no joined report exists). +- **`ui-report-joined-chart-retired`** — ``report.blocks[].chart` (REMOVED from the joined report block shape) and `report.chart` on a report whose `type` is `joined` (REFUSED by `ReportSchema`'s refinement) — a chart anywhere on a joined report` → nothing on the joined report: a joined report draws each block as a table and has no chart channel at either level. Delete the `chart`. If the chart was wanted, give the slice it was meant to plot a report of its own — `type` `tabular`, `summary` or `matrix`, binding the same `dataset` the block bound, selecting the dimension and measure the chart names in its `rows` and `values` — carry the `chart` over to that report's top level, where `xAxis` names a dataset dimension and `yAxis` a measure exactly as before, and reach it from the app navigation beside the joined report. + - Why not automatic: ADR-0049 enforce-or-remove. Nothing ever drew a chart on a joined report: the renderer's joined branch draws each block as a table and returns before its one read of the report's `chart`, and no renderer reads a block's `chart` at all — measured at this repo's `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52` (`DatasetReportRenderer.tsx`, joined branch at lines 1462-1524, the only chart read at 1557). So both coordinates parsed, passed the `validate-chart-bindings` lint (which resolved their axes as if they would plot), and showed tables only. The D2 conversion `report-joined-chart-removed` already REPAIRS THE DATA: it strips both from authored sources on a chain replay and from stored `sys_metadata` rows at rehydration, a lossless delete because neither value ever rendered. What it cannot repair is intent. Deleting the key leaves the report looking exactly as it always did — which is the problem when the author believed a chart was there: they were reading a chart that never existed, and only they know whether they wanted one. A walker cannot move it anywhere either: a joined report has no chart channel, and creating a new report, choosing its type and placing it in navigation are authoring decisions, not rewrites. The Studio report form offered a block chart input until this change, so a stored row carrying one is a real shape, not a hypothetical. Ships at once, no deprecation window: there is no window in which a key the renderer never reads does anything. `chart` on every non-joined report is unchanged — it is that report's live embedded chart. + - Done when: WHICH DOOR: the refusal is the spec schema's, so it lands wherever a report is parsed through `@objectstack/spec` — `defineReport`, `os validate` / `os build`, and the metadata save door (the `report` entry of the metadata type registry, answered as `INVALID_METADATA` with status 422). A block `chart` is refused as an unrecognized key on that block with the upgrade prescription; a container `chart` on a joined report is one `custom` issue at `chart`. (1) No joined report carries a `chart` at either level: the D2 strip covers existing sources on a chain replay, and the stored-row seams replay it for rows already at rest. (2) For every joined report that carried one, decide whether the chart was wanted; if it was, a non-joined report now binds that slice's dataset and carries the chart, and its `xAxis` / `yAxis` resolve (`validate-chart-bindings` checks them there). (3) Check the rendered joined report: it renders exactly as before, because the chart was never drawn. A joined report with no `chart` parses byte-identically to before, and every non-joined report is untouched. Census at the time of the change: zero joined reports with a chart in this repo's example apps and in the hotcrm reference app, against a lit control (non-joined reports carrying a chart: one and five). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. +- **`ui-report-joined-container-selection-refused`** — `report selection keys on a `joined` container — a top-level `dataset`, or a NON-EMPTY top-level `rows` / `columns` / `values` list, on a report whose `type` is `joined` (`ReportSchema`'s refinement)` → the same key on the `blocks[]` entries that need it — each block binds its own `dataset` and selects its own `rows` / `columns` / `values` — or DELETE it. Deleting changes nothing that renders: the container value was never read. The refusal lands at the key's own path and says both, the way the container `order` refusal beside it always has, and that `order` refusal is unchanged. + - Why not automatic: ADR-0049 enforce-or-remove, the enforce arm: the four keys stay declared (they are the selection of every non-joined report), and the one report type that never reads them now refuses them. A `joined` report selects nothing itself, and the refinement already said so for `order` alone — it refused a container `order` with a pointer onto `blocks[]` while the four selection keys beside it parsed green. Measured at this repo's `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52`: `DatasetReportRenderer`'s joined branch (`DatasetReportRenderer.tsx:1462`) reads `blocks`, plus the container `runtimeFilter` and `drilldown` resolved above it, and returns before the top-level reads of `columns` / `dataset` / `rows` / `values` begin (line 1529 onward) — so each was accepted by the metadata layer and dropped by the renderer without a word. The alias tables made it reachable: `fields` / `measures` / `metrics` route to `values`, `groupings` / `groupBy` / `dimensions` to `rows`, and `objectName` / `object` / `dataSet` / `source` to `dataset`, on a joined report as on any other. Studio's report inspector hides the top-level binding for a joined report (`ReportDefaultInspector.tsx:328`) but its type picker patches only `type`, so a report bound first and switched to `joined` second carries the keys invisibly. An empty list is NOT refused: it selects nothing, which is what a joined container selects — the container `order` refusal's own threshold. Ships at once, no deprecation window: there is no window in which a key the renderer never reads does anything. + - Done when: WHICH DOOR: this is the spec schema's refusal, so it lands wherever a report is parsed through `@objectstack/spec` — `defineReport`, `os validate` / `os build`, and the metadata save door (the `report` entry of the metadata type registry) — as one `custom` issue per key at `dataset` / `rows` / `columns` / `values`. A stored `sys_metadata` report row is not rewritten: it carries the same issue in its read-side `_diagnostics` and is refused on its next save. Fix each by moving the key onto the blocks that need it or deleting it, then check the rendered report: it renders exactly as before, because the container value was never read. A joined report that carries only `blocks`, `runtimeFilter`, `drilldown` and the identity / protection keys parses byte-identically to before, and every non-joined report is untouched. Census at the time of the change: zero joined reports carry any of the four at the container — in this repo one example-app report, one docs example and five test fixtures across `packages/lint` and `packages/platform-objects`; in objectui every joined-report fixture and docs example at the pin above (13 occurrences); in the cloud repo none exist. +- **`view-filter-rule-absent-value-refused`** — `ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, or present and undefined, on equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before or after (an alias spelling of any of them included), on every carrier of ViewFilterRuleSchema` → the value the rule compares against — value: "open" on equals, value: "2026-01-01" on after. A rule that meant "the field has no value" becomes one of the four operators that take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction from their name and still parse with or without a value. A rule that was an unfinished row is deleted. The list operators (in / not_in) and the range operator (between) refused an absent value before this change and still do, in their own words + - Why not automatic: The value key's own published description has declared, since the value was first shaped by its operator, that every operator outside the list, range and unary sets takes a scalar, and that only the unary operators ignore the key; the refinement implementing the coupling returned early on an absent value for every operator, so a rule with no value parsed green on all thirteen scalar operators. The query path refuses the same rule: both lowerings of a stored rule — the console's and the REST lookup-picker route's — emit it as the two-element [field, operator] node, which the filter-AST lowering reads as an undefined comparand and refuses with INVALID_FILTER / 400, measured for all thirteen operators. Nothing between storage and the query drops the rule, so one such rule failed every query that read its view, the view's other rules included. The first-party producer does not write the shape: the console filter builder drops a row whose operator takes a value and whose value is missing before it saves, and the drill-down save-as-view path checks each rule against this schema before persisting it (read at the pinned objectui commit). Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: there is no value to infer, and writing a value, switching to a unary operator and deleting the rule are three different predicates only the author can choose between. The read path does not re-validate stored rows (the reading the sibling entry view-filter-rule-scalar-operator-array-refused records), so a stored view keeps loading — and keeps failing its queries, as it did before this change; what changes is that RE-SAVING it is refused at the value path, naming the operator and the field. ADR-0049 / ADR-0087 / ADR-0112. + - Done when: Grep your authored views, pages and object-* blocks for a filter rule that has no value key and whose operator is none of the four unary operators, then decide per rule which of three things it meant: a comparison (write the value), a test for emptiness (switch to is_empty / is_not_empty / is_null / is_not_null), or an unfinished row (delete it). os validate reports each one by path with the operator and the field, so the sweep is mechanical rather than by eye. A view carrying one of these rules was refusing every query before this change, so re-check what it is supposed to show rather than assuming any earlier result set. +- **`view-filter-rule-operator-input-canonical`** — `ui.ViewFilterRule operator — the TypeScript INPUT type of a view filter rule, on every carrier of ViewFilterRuleSchema (ListView.filter, a view tab filter, Page.filterBy, the related-list, record-picker and object-* block filter doors)` → the canonical operator id, a member of ViewFilterOperator (VIEW_FILTER_OPERATORS). A typed rule written operator: "eq" becomes operator: "equals"; every legacy spelling maps to exactly one canonical id, and VIEW_FILTER_OPERATOR_ALIASES is that map (ne and neq to not_equals, gt to greater_than, gte to greater_than_or_equal, nin and notIn to not_in, isNull to is_null, and the rest). A value that is not yet known to be an operator — read from storage, a URL or user input — is typed unknown and handed to ViewFilterRuleSchema.safeParse, or folded with normalizeFilterOperator first; the schema stays the judge + - Why not automatic: The operator key is a z.preprocess over the alias fold, and zod types a preprocess's INPUT from its function's parameter. That parameter was unknown, so ViewFilterRule (a z.input) typed operator as unknown: a rule with operator: 42, or any string at all, compiled on every carrier and was refused only when the door parsed it. The typed input is now the canonical ViewFilterOperator, the vocabulary the alias table's own contract says new producers emit. The RUNTIME does not move: the door still folds every spelling it folded before to canonical and still refuses a non-string with the enum's own issue at operator, so a stored sys_metadata row, a YAML or JSON body, and a plain-JS producer that carries an alias keep parsing exactly as before, and os validate answers as before. Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: what narrows is only what TypeScript source may write. The exported normalizeFilterOperator keeps its unknown parameter on purpose — it exists to fold untyped stored metadata, and its callers pass raw strings by design. ADR-0087 / ADR-0122. + - Done when: Your TypeScript compiles: tsc reports each typed rule whose operator is an alias or a non-string, naming the canonical vocabulary. Rewrite each alias to the id VIEW_FILTER_OPERATOR_ALIASES maps it to — the rule selects the same rows, because the door already folded it to that id — and for a value typed string that is really unvalidated input, type it unknown and parse it rather than casting it. Stored views need no action: they load and parse as before. +- **`view-filter-rule-scalar-operator-array-refused`** — `ui.ViewFilterRule value on a SCALAR operator — an ARRAY where the operator takes one value (equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before, after), on every carrier of ViewFilterRuleSchema` → one scalar — a string, number, boolean or null. A rule written value: ["won"] on equals becomes value: "won"; a rule that really did mean membership of a list becomes operator: "in" with the array unchanged. The list operators (in / not_in) and the range operator (between) are untouched and still take their arrays. The unary operators (is_empty / is_not_empty / is_null / is_not_null) are untouched too: they take their direction from the operator NAME and their value position is discarded, so whatever sits there still parses, array included. An omitted value is still an omitted value + - Why not automatic: Closing the protocol half of the maintainer's ruling C-prime of 2026-09-20 on objectui's render-time filter converter — the protocol is the only refusal set, so a document it accepts never throws at render time — verbatim, untranslated: 「the differences are the protocol's to close」. The value key's own published description has declared this rule since the value was first shaped by its operator — 「every other operator takes a scalar」 — and the refinement that implements the coupling returned early for every operator that is neither a list operator nor between, so the entire scalar class was declared and, from then until this change, not judged. ⚠️ This REVERSES a reading recorded in the sibling entry view-filter-rule-value-shaped-by-operator, which listed a scalar operator carrying an array as deliberately accepted because it 「lowers to a deep-equality comparand」. The backends a lowered view rule reaches at this release do not agree, so each is named rather than generalised. The SQL family REFUSES: the lowered node reaches driver-sql's bare field-value loop, which asserts the comparand against its own SCALAR_COMPARAND_OPERATORS set; an array is none of the six accepted comparand types the platform declares in ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE, so the comparand is refused with the withheld INVALID_FILTER / 400 envelope — and with it the driver-turso and driver-sqlite-wasm drivers built on driver-sql, and turso's remote transport. driver-memory REFUSES the same shape in the same envelope (its assertFilterConditionShape throws on an array in the implicit-equality position). driver-mongodb ANSWERS: its translateFilter passes the array through unchanged and the engine's shared comparand doors (normalizeFilterComparandTypes, assertListComparandShapes) both pass the shape, so the server applies MongoDB's equality rule for an array operand — a row matches when its stored array equals the value or holds the value as one of its elements, and a row storing the scalar does not match. That MongoDB reading is taken at the driver's compile face, at those engine doors and through mingo 7.2.4, which applies that rule; a live mongod instance was NOT measured. Method: driver-sql on SQLite, driver-memory, driver-mongodb's translateFilter and mingo were each run on the lowered node beside a scalar and an $in control; MySQL and a live Turso server were NOT measured. Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: a SemanticMigration converts nothing by its own type, and the stored-row pass replays D2 conversions only. Coercing at load would be the platform guessing intent — an array of two on equals has no honest single value, and picking the first is a different predicate. The read path does not re-validate stored rows, so a stored view keeps loading; what changes is that RE-SAVING it is refused at the value path. ADR-0049 / ADR-0087 / ADR-0112. + - Done when: Grep your authored views, pages and object-* blocks for a filter rule whose operator is none of in / not_in / between / the four unary operators and whose value is an array, then decide per rule which of the two things it meant: one value, or membership. os validate reports each one by path with the operator, the received shape and both corrected spellings, so the sweep is mechanical rather than by eye. Either way, re-check what the view is supposed to show rather than assuming the old result set was correct. A one-element array is the case to read closest: its two corrected spellings — value: "won" on equals, and operator: "in" with value: ["won"] — select the same rows, so the result set cannot tell you which the metadata meant, and only the author knows. +- **`view-item-options-bag-refused`** — `A top-level `options` bag on a `view` item RECORD (`{ name, object, viewKind, config }`) saved through the metadata write door (`PUT /api/v1/meta/view/:name`, the Studio / MCP save): `options.kanban`, `options.calendar`, `options.timeline` and every other key in it, on either `viewKind`.` → The same per-kind blocks under the record's `config` — `options.kanban` becomes `config.kanban`, `options.timeline` becomes `config.timeline` — where each is judged by its kind's own block schema, and a key that block does not declare is re-spelled or deleted as its refusal says. A key `config` already sets wins; the bag's copy is deleted. The flattened list overlay (no `config`) keeps its legacy `options` bag, judged key by key, as before. + - Why not automatic: The record member re-opens its top level with a strip so the console's round-trip keys survive, and that strip dropped a top-level `options` bag from the parse without looking inside it, while the save stored the request body. The console reads a record's body from `config` on the object page but spreads the whole stored record on the interface page, so the same saved view rendered two ways. Now that the save stores the parsed body, the bag would instead vanish silently on the next save. The maintainer's ruling of 2026-09-30 refuses it by name with the prescription to write `config.KIND`; declaring it would have kept a second spelling of one block on a second member. No console write puts the bag on a record. Not convertible: which of two spellings of one block the author meant, where both are set, is the author's call. + - Done when: Every stored `view` record carrying a top-level `options` saves again after its blocks move under `config`. A record that still carries the bag is refused `422 INVALID_METADATA` on its next save, with an issue at `options` whose message prescribes `config.KIND`. Nothing is rewritten on read and nothing is refused on read: a record that fails is served exactly as stored until it is saved. Verify by re-saving each stored record that carries `options` (a GET then a PUT of the same body) and reading a `200`. +- **`view-item-owner-hidden-retired`** — `view.owner / view.hidden on the view item record — the per-user owner and the switcher-hidden flag` → (removed — no per-user view scope and no switcher filter exists.) A view is listed to everyone who can read its object. A view that must not be listed is deleted; per-user view scoping is a parked direction, not a shipped mechanism. + - Why not automatic: The D2 conversion `view-item-owner-hidden-removed` deletes both keys from every view item RECORD — in `views` (stack sources and stored rows) and in the assembled-manifest view item channel (package export, environment artifacts) — and the delete is lossless: both switcher read paths filter on the view kind and object and sort on `order`, so `hidden: true` hid nothing and no scope ever read `owner`. The judgment is about exposure. A view an author marked as one user's, or hid from the switcher, has always been listed to every user who can read the object — its name, its columns, its filters and its sort. Whether anything in such a view was meant to stay private, and whether it should now be deleted rather than kept, is the author's call. A flattened view overlay's own `owner` and `hidden` are a separate family on a different door, with their own D2 conversion `view-overlay-owner-hidden-removed` and their own D3 entry `view-overlay-owner-hidden-retired`. + - Done when: No view item record in `views` or in an assembled artifact carries `owner` or `hidden`; the parse refuses both by name, and an artifact assembled before the upgrade registers without a refusal over them. For every view that had carried either key, the author has confirmed that everything it shows may be listed to all readers of its object, or has deleted it. The view switcher lists the same views as before the upgrade. +- **`view-overlay-judged-by-viewkind-arm`** — `A flattened `view` overlay saved through the metadata write door (`PUT /api/v1/meta/view/:name`, the Studio / MCP save) whose `viewKind` names one family while the body was judged by the other: a column-less `viewKind: "list"` body, which only the form overlay member used to accept (its list keys `sort`, `searchableFields`, `timeline`, `sharing` and the rest stripped unread), and a `viewKind: "form"` body carrying list `columns`, which only the list overlay member used to accept.` → Each overlay is judged by the member its `viewKind` names. A column-less list overlay is a patch on the view it shadows and carries list keys the list view schema accepts: a `sort` array of `{ field, order }` (the bare string clause was retired in 17.5.0), no `timeline.metaFields` (the timeline block has no such key), an array `searchableFields`, and the list `sharing` block (`{ type, lockedBy }`), not the form public-link block. A column-less list overlay names no `type`; one that does is a full inline config and lists its `columns`. A form overlay's `columns` is its body-column count (an integer); a field list means the body is a list view (`viewKind: "list"`) or belongs in `sections: [{ fields }]`. + - Why not automatic: Both overlay members shared one `viewKind: list | form` enum. The list member required `columns`, so it refused the column-less list patch the console writes on every toolbar save (the ruled patch-only storage shape, maintainer ruling: 「`persistViewPatch` 只存 patch,不存 merged base」); the union then tried the form member, which requires no list key and strips every one, and accepted it — so a retired `sort` string or a `timeline.metaFields` the list schema refuses by name was saved with `success: true` and stored as sent. Measured on `origin/main` @ `4df101c3` and again at `ce70876e` (after the `options`-bag door landed) through the real save. Ruled route C-prime: each member admits one `viewKind`, the list member judges a column-less patch (`columns` optional there only; the authoring list view keeps it required), and a column-less body that names a `type` stays refused at `columns`. Not convertible: whether a refused value was a typo or a stale capability is the author's call. + - Done when: Every stored flattened `view` overlay saves again unchanged. A row that does not is refused `422 INVALID_METADATA` on its next save, with the issue located at the refused key (`sort`, `timeline`, `searchableFields`, `sharing`, `columns`) and carrying that key's own message — for a column-less list overlay naming a `type`, the prescription at `columns`; for a field list on a form overlay, the body-column-count prescription at `columns`. Nothing is rewritten on read and nothing is refused on read: a row that fails is served exactly as stored until it is saved. Verify by re-saving each stored flattened overlay (a GET then a PUT of the same body) and reading a `200`. +- **`view-overlay-options-bag-judged`** — `The legacy `options` bag on a flattened `view` overlay saved through the metadata write door (`PUT /api/v1/meta/view/:name`, the Studio / MCP save): `options.kanban`, `options.calendar`, `options.gantt`, `options.gallery`, `options.timeline`, `options.chart`, `options.map` and `options.tree` on a list overlay, any other key in the bag, and the bag on a form overlay.` → Each `options.KIND` block carrying only keys the top-level `KIND` block declares, with values that block accepts — or, preferred, the same keys moved to the top-level `KIND` block, which wins per key where both set one. A key the block does not declare is deleted or re-spelled to the declared key the refusal names (`options.kanban.groupField` becomes `groupByField`, `options.calendar.dateField` becomes `startDateField`); `options.timeline.metaFields` has no declared successor and is deleted. Any other key in the bag is deleted, and a form overlay carries no bag at all. + - Why not automatic: The list overlay member re-opens its top level with a strip so the console's round-trip keys survive, and that strip dropped the `options` bag from the parse without looking inside it. The save stores the request body, not the parse output, and objectui's interface page forwards a stored view's `options` into the list renderer, which merges `options.KIND` under the top-level block — so a key the strict block refuses by name (`timeline.metaFields`) was saved and rendered when spelled `options.timeline.metaFields`. Measured on `origin/main` @ `8d1f7ab` through the real save. Ruled direction A (the maintainer's ruling of 2026-09-24): judge each `options.KIND` with the kind's strict schema and refuse an out-of-contract key by name, as the direct spelling is; refusing the bag whole was ruled out because the legacy `options.map` path is live and pinned. Judged key by key, because the renderer reads the bag as a per-key underlay of the top-level block: a bag that carries only the keys the top-level block leaves to it is legal and stays accepted. Not convertible: whether a refused key was a typo of a declared one or a retired capability is the author's call. + - Done when: Every stored `view` overlay carrying a top-level `options` saves again unchanged. A row that does not is refused `422 INVALID_METADATA` on its next save, with an `unrecognized_keys` issue at `options.KIND` naming the key and carrying the same message the direct spelling gets at `KIND` — or at `options` for a key that is not a kind, or a form overlay's bag. Nothing is rewritten on read and nothing is refused on read: a row that fails is served exactly as stored until it is saved. Verify by re-saving each stored overlay that carries `options` (a GET then a PUT of the same body) and reading a `200`. +- **`view-overlay-owner-hidden-retired`** — `view.owner / view.hidden on a flattened view overlay — the lean PUT /api/v1/meta/view/:name body with no config that the console saves for a view it personalizes` → (removed — no per-user view scope and no switcher filter exists.) A view is listed to everyone who can read its object. A view that must not be listed is deleted, or no longer shipped from source; per-user view scoping is a parked direction (ADR-0017), not a shipped mechanism. + - Why not automatic: The D2 conversion `view-overlay-owner-hidden-removed` deletes both keys from every flattened overlay (a view body with no `config` and no container slot) — in `views` (stack sources, and every stored row, replayed on each read before it is served or badged) and in the assembled-manifest view item channel — and the delete is lossless: both switcher read paths filter on the view kind and object and sort on `order`, so an overlay saved with `hidden: true` hid nothing and no scope ever read `owner`. The judgment is about exposure, the same one the view item record's retirement leaves. A view someone hid or marked as one user's through its overlay has always been listed to every user who can read the object. The delete is lossless but does not always close the row: an overlay row that held nothing but its identity and these keys is left identity-only, a body the view door refuses, so that row needs its author (see the acceptance criteria). Measured writers in this repository and its sibling UI: zero (no source, example or skill, and objectui at its pinned commit and at main writes neither key on an overlay; the HotCRM app writes neither). NOT MEASURED: clients outside this repository, and production stored rows — the write door accepted and stored both until this release, and no deployment store is reachable from here. + - Done when: No flattened view overlay you save carries `owner` or `hidden`: the write door refuses either with 422 INVALID_METADATA, the issue located at the key and the retirement prescription as its message. A stored overlay row that held either is stripped of it on every read, and what follows depends on what else the row holds. (1) A row with any other view key (a column state, a sort, a default flag, an order) is served and badged valid without the keys, a GET then a PUT of the whole row answers 200 (if it was otherwise valid), and `os migrate meta --stored --apply` rewrites it. (2) A hide-only row — nothing but its identity (name, object, viewKind, label) and `owner` / `hidden`, such as `{ object, viewKind, hidden: true }` — is left with identity only, which the view door refuses ("only identity fields"): it is served badged invalid (it was badged valid before this release), a whole-row re-save or one that adds only identity answers 422 INVALID_METADATA, and `--apply` reports it `failed` and leaves it as stored. A write that adds a real view key, such as a toolbar toggle, saves. Resolve each such row: delete it (it never changed what anyone saw), or add the personalization setting its author meant and save that. For every view whose overlay had carried either key, its author has confirmed that the view may be listed to all readers of its object, or has deleted it. No switcher read path ever read either key, so which views the switcher lists does not change. +- **`view-pagination-page-size-default-50`** — `ui.PaginationConfig.pageSize — an OMITTED page size on a view` → nothing, to take the platform display page size of 50. To keep the old 25 rows per page on a view, write it: `pagination: { pageSize: 25 }` + - Why not automatic: A RULED behaviour change on a default, so there is nothing to rewrite and nothing to refuse: the maintainer's ruling of 2026-09-24 set the platform display page size to 50, declared once in the protocol, and the declared default of `PaginationConfigSchema.pageSize` moved from 25 to 50. A `pagination` block that omits `pageSize` now parses to 50 — 50 rows per page on a paged view, and a fetch ceiling of 50 on a view with no pager (kanban, gallery, timeline). A view with no `pagination` block at all parses with none on either side; its page size reaches it through the renderer, which is ruled to read the spec default rather than keep its own number (an earlier ruling on the grid's page size, which the page-size ruling restated). Not losslessly convertible because the question is intent, not text: a mechanical pass that wrote `pageSize: 25` into every silent view would preserve the old number and defeat the ruling, and one that wrote 50 would add nothing the default does not already do. Only the deployment knows which silent views were relying on 25. The accept set is unchanged — a positive integer — and every authored `pageSize` parses exactly as before. + - Done when: An empty pagination configuration parses to a page size of 50, and a list view carrying `pagination: {}` parses to `pagination.pageSize` 50; an authored `pagination: { pageSize: 25 }` still parses to 25; `pageSize: 0`, a negative and a fraction are still refused. A view that must keep 25 rows per page declares `pagination: { pageSize: 25 }` and shows 25 rows on its first page. +- **`visibility-strict-options-unexported`** — ``VISIBILITY_STRICT_OPTIONS` (const) on `@objectstack/spec/shared` — the shared `strictObject` options of the visibility-carrying view/page shapes (ADR-0089 D3a)` → (removed from the public surface — no replacement export. It was an internal option bag for this package's own schemas; the visibility contract it configures is unchanged and still published through the schemas that use it — `FormFieldSchema`, `FormSectionSchema` and the page component — together with `normalizeVisibleWhen` and `VISIBILITY_ALIAS_KEYS`, which stay exported.) + - Why not automatic: ADR-0049 enforce-or-remove applied to an export. The const was barrel-exported while its type, `StrictObjectOptions`, is deliberately unpublished, so no consumer could annotate it, spread it into a typed option bag or name it in a parameter — a published value with no usable contract and zero measured pull outside this package. Publishing the type instead was weighed and not adopted: no consumer ever asked for it, and it would turn the strict-object template's internals into public API. + - Done when: No code imports `VISIBILITY_STRICT_OPTIONS` from `@objectstack/spec`, `@objectstack/spec/shared` or any other entry (TS2305 after upgrade). Every visibility-carrying shape parses and refuses exactly as before — the options object is unchanged, only where it is exported from moved. No authored metadata document ever carried it, so `os migrate meta` has nothing to visit. +- **`wait-node-event-config-required`** — `The `waitEventConfig` block of every `type: 'wait'` flow node, and the `boundaryConfig` block of every `type: 'boundary_event'` node — the BLOCK, not a key inside it. `eventType` has been required INSIDE each block since protocol 17, so the contract already refused `waitEventConfig: {}`; what it also accepted was the block missing entirely, which is the state a freshly created node is in. Two documents, two verdicts, and the accepted one was the silent one. Also narrowed one level down: under `eventType: 'timer'`, `timerDuration` is now required and may not be blank. ⚠️ That second narrowing sits on the BLOCK and is NOT gated on `type: 'wait'`, so it reaches any node type that carries a `waitEventConfig` at all — a `start` node spelled `waitEventConfig: { eventType: 'timer' }` parsed before and is refused now. Inert in practice, because no executor but the wait one reads the block, but a stack that spells it elsewhere must be edited too, so scan for the KEY and not only for the node type.` → Declare what resumes the node, on the node: `waitEventConfig: { eventType: 'timer', timerDuration: 'PT1H' }` for a delay — QUOTE a bare number, the key is a string and a numeric string is read as milliseconds, so '60000' is the same 60s wait as 'PT1M' — or `{ eventType: 'signal' | 'webhook' | 'manual' | 'condition', signalName: '' }` when an external producer resumes the run. For `boundary_event`, `boundaryConfig: { attachedToNodeId: '', eventType: 'error' | 'timer' | 'signal' | 'cancel' }`. ⛔ There is deliberately NO default for either `eventType`: a required key has no "unset behaves as", and an indefinite park — if one is ever wanted — is its own declared `eventType`, never the absence of configuration. ⚠️ `boundary_event` has no executor in the runtime at all (a flow reaching one fails with NO_EXECUTOR), so a stored boundary node is an authoring-surface repair: the native construct for error handling is a `try_catch` region (ADR-0031). + - Why not automatic: Maintainer ruling of 2026-09-13, the clause of the reply that covers this item, verbatim and untranslated: 「其他同意」 — carrying the presented option: the protocol is the source of truth; a designer never invents a default the protocol does not apply; a default the protocol should have is declared by the protocol; a required key has no "unset behaves as". ⛔ NOT losslessly convertible, and the reason is that the missing value is an INTENT no artifact records: a block-less wait node does not say whether its author meant a delay (and for how long) or a named signal (and which one), and a transform that picked one would be inventing the very default this ruling forbids. What the old runtime picked was 'timer' with no duration, which is not a wait at all: measured through a real `engine.execute()` run, such a node answered `{ success: true, suspend: true }`, scheduled no wake-up job THOUGH A JOB SERVICE WAS ANSWERING, persisted no `waitUntil` for a later boot's re-arm pass, and emitted not one log line at any level — the run parked forever and reported success. So the conversion layer (D2) cannot hide this break and the tombstone channel cannot carry it either (nothing was renamed or retired; a key that was optional became required), which leaves D3: a structured TODO naming each node that must be edited. The alternative considered and NOT taken was to warn and keep parsing — a warning on the authoring path an AI agent drives is read by nobody, and the agent reports "done" over a flow that hangs. + - Done when: Every `type: 'wait'` node in the stack — at the top level AND inside every ADR-0031 region body — carries a `waitEventConfig` with an `eventType`, and every one whose `eventType` is 'timer' carries a non-blank `timerDuration`; every `type: 'boundary_event'` node carries a `boundaryConfig` with an `attachedToNodeId` and an `eventType`. `FlowSchema.parse` (and therefore `registerFlow`, `os validate` and a Studio publish) accepts the stack: a node still missing its block is refused with the key named at `nodes[i].waitEventConfig` / `nodes[i].boundaryConfig` and the remedy in the message. ⚠️ A region body is checked through the REGION contract rather than the flow parse — `parseFlowNodeRegions` leaves a refused region raw — so a nested node is named by `LoopConfigSchema` / `ParallelConfigSchema` / `TryCatchConfigSchema` at `body.nodes[i].waitEventConfig`, and at run time by the container node's own execute-time config parse; check the nested ones by parsing the container config, not only by parsing the flow. Behaviour to re-check after editing, because the fix CHANGES IT deliberately: a run that used to park forever on such a node now either waits the duration you declared or waits for the signal you named — anything that resumed those runs by hand (an operator calling `resume(runId)`, a nightly sweep) has less to do, and anything that COUNTED on the park is now on a timer. +- **`websocket-durations-unit-in-key`** — `the four WebSocket configuration durations whose name carried no unit: WebSocketConfig.reconnectInterval, WebSocketConfig.pingInterval, WebSocketConfig.timeout and WebSocketServerConfig.heartbeatInterval (api/websocket.zod.ts)` → reconnectIntervalMs, pingIntervalMs, timeoutMs and heartbeatIntervalMs — rename each key; every value is unchanged, and so is every default (1000, 30000, 5000, 30000) + - Why not automatic: Maintainer ruling B (2026-09-02, extended on 2026-09-05 to runtime-emitted durations): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. What makes this shape worth one entry rather than four is the neighbour: on both configs a bare duration sits directly beside a bare COUNT — maxReconnectAttempts on the client, reconnectAttempts on the server — so `reconnectInterval: 5` and `maxReconnectAttempts: 5` read as the same kind of number and are not. Suffixing the durations separates the two families at the authoring site; the counts keep their names, because a count has no unit to carry. All four are retiredKey() tombstones (neither shape is strict, so a bare deletion would strip in silence). Why a semantic entry and not a D2 conversion: a WebSocketConfig is a client CONNECTION argument and a WebSocketServerConfig is a server CONSTRUCTION argument — neither is a stack collection member and neither is ever stored as a sys_metadata row, so the conversion chain has no seam that would see one. The same disposition the epoch-instant renames on this file took (epoch-instant-keys-renamed), and what ruling B prescribes for a key that is not authorable metadata. ADR-0087. + - Done when: Every WebSocketConfigSchema.parse(…) / WebSocketServerConfigSchema.parse(…) site and every literal handed to a WebSocket client or server spells the suffixed keys; authoring any old spelling fails to compile (input type `never`) and fails to parse with the rename prescription. Behaviour is unchanged in every case: a client configured with `reconnectIntervalMs: 2000` retries after two seconds exactly as `reconnectInterval: 2000` did, and a config that omits the keys still gets the same defaults. The positive-integer bounds ride along with the renamed keys, so a zero or negative interval is still refused. + --- *Machine-readable equivalents: `spec-changes.json` (shipped in `@objectstack/spec` and attached to each GitHub Release) and the structured output of `objectstack migrate meta --json`.* diff --git a/examples/app-crm/objectstack.config.ts b/examples/app-crm/objectstack.config.ts index 55825762d68..8ff69b242ba 100644 --- a/examples/app-crm/objectstack.config.ts +++ b/examples/app-crm/objectstack.config.ts @@ -45,7 +45,7 @@ export default defineStack({ // Protocol major this package is authored against (ADR-0087). The kernel // checks the range at load time and refuses a major-incompatible runtime // with a structured diagnostic instead of failing deep in a schema parse. - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }, // Auto-resolved by the CLI; `ui` enables the Studio shell, `automation` diff --git a/examples/app-multi-package/src/packages/core/index.ts b/examples/app-multi-package/src/packages/core/index.ts index 0f2815bc18a..67d5ef6fc2d 100644 --- a/examples/app-multi-package/src/packages/core/index.ts +++ b/examples/app-multi-package/src/packages/core/index.ts @@ -19,7 +19,7 @@ export default defineStack({ version: '1.0.0', type: 'app', description: 'The App half of a two-package release artifact (ADR-0130 D4)', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }, objects: [ diff --git a/examples/app-multi-package/src/packages/orders/index.ts b/examples/app-multi-package/src/packages/orders/index.ts index 3b77e138582..0ad7a0b3ea5 100644 --- a/examples/app-multi-package/src/packages/orders/index.ts +++ b/examples/app-multi-package/src/packages/orders/index.ts @@ -58,7 +58,7 @@ export default defineStack({ version: '1.0.0', type: 'module', description: 'The Module half of a two-package release artifact (ADR-0130 D4)', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, // The App package this module extends. `resolveArtifactPackageOrder` reads // it as the topological edge that registers core BEFORE orders (ADR-0130 // D5, ADR-0116's one sorter) — the array order below is not what decides. diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index 7e8a4e5ec08..ce5fefe511b 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -86,7 +86,7 @@ export default defineStack({ // Protocol major this package is authored against (ADR-0087). The kernel // checks the range at load time and refuses a major-incompatible runtime // with a structured diagnostic instead of failing deep in a schema parse. - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }, // Capability tokens the CLI resolves to platform plugins: diff --git a/examples/app-todo/objectstack.config.ts b/examples/app-todo/objectstack.config.ts index 8774026a3c1..6d472e86308 100644 --- a/examples/app-todo/objectstack.config.ts +++ b/examples/app-todo/objectstack.config.ts @@ -45,7 +45,7 @@ export default defineStack({ // Protocol major this package is authored against (ADR-0087). The kernel // checks the range at load time and refuses a major-incompatible runtime // with a structured diagnostic instead of failing deep in a schema parse. - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }, // Platform services this app needs — the closed `PLATFORM_CAPABILITY_TOKENS` diff --git a/packages/cli/src/utils/author-time-rules.test.ts b/packages/cli/src/utils/author-time-rules.test.ts index 7673a63bda6..6021cf439a4 100644 --- a/packages/cli/src/utils/author-time-rules.test.ts +++ b/packages/cli/src/utils/author-time-rules.test.ts @@ -61,7 +61,7 @@ const optionBStack = (reference: string): Record => ({ const perPackageOnlyStack = (): Record => { const coreManifest = { id: 'com.example.obflip.core', name: 'obflip core', namespace: 'ob', - version: '1.0.0', type: 'app', engines: { protocol: '^17' }, + version: '1.0.0', type: 'app', engines: { protocol: '^18' }, }; const coreObjects = [{ name: 'ob_account', label: 'Account', pluralLabel: 'Accounts', sharingModel: 'private', @@ -79,7 +79,7 @@ const perPackageOnlyStack = (): Record => { }]; const ordersManifest = { id: 'com.example.obflip.orders', name: 'obflip orders', namespace: 'ob', - version: '1.0.0', type: 'module', engines: { protocol: '^17' }, + version: '1.0.0', type: 'module', engines: { protocol: '^18' }, dependencies: { 'com.example.obflip.core': '^1.0.0' }, }; const ordersObjects = [{ diff --git a/packages/cli/src/utils/config-named-export-rule.test.ts b/packages/cli/src/utils/config-named-export-rule.test.ts index ac721e22c1a..7169d4d750c 100644 --- a/packages/cli/src/utils/config-named-export-rule.test.ts +++ b/packages/cli/src/utils/config-named-export-rule.test.ts @@ -65,7 +65,7 @@ const MANIFEST = `{ version: '0.1.0', type: 'app', name: 'Probe', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }`; const roots: string[] = []; diff --git a/packages/cli/src/utils/config-shadowed-named-export.test.ts b/packages/cli/src/utils/config-shadowed-named-export.test.ts index 115e587d2e8..b5c4252b594 100644 --- a/packages/cli/src/utils/config-shadowed-named-export.test.ts +++ b/packages/cli/src/utils/config-shadowed-named-export.test.ts @@ -67,7 +67,7 @@ const MANIFEST = `{ version: '0.1.0', type: 'app', name: 'Probe', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }`; const roots: string[] = []; diff --git a/packages/cli/test/authoring-rule-command-parity.test.ts b/packages/cli/test/authoring-rule-command-parity.test.ts index 0517cb4ed23..dd27e50cf03 100644 --- a/packages/cli/test/authoring-rule-command-parity.test.ts +++ b/packages/cli/test/authoring-rule-command-parity.test.ts @@ -36,7 +36,7 @@ const cliBin = join(fileURLToPath(new URL('.', import.meta.url)), '..', 'bin', ' /** A stack that satisfies the security linter, so only the planted defect gates. */ const withBaseline = (stack: Record) => ({ - manifest: { id: 'com.example.parity', namespace: 'parity', version: '1.0.0', name: 'Parity', type: 'app', engines: { protocol: '^17' } }, + manifest: { id: 'com.example.parity', namespace: 'parity', version: '1.0.0', name: 'Parity', type: 'app', engines: { protocol: '^18' } }, ...stack, }); diff --git a/packages/cli/test/build-text-face-advisory-count.test.ts b/packages/cli/test/build-text-face-advisory-count.test.ts index 2b3609667e5..467bac71b08 100644 --- a/packages/cli/test/build-text-face-advisory-count.test.ts +++ b/packages/cli/test/build-text-face-advisory-count.test.ts @@ -157,7 +157,7 @@ const perPackageWarnings = (warnings: unknown[]): string[] => const CONFIG_MULTI = ` const coreManifest = { id: 'com.example.bcount.core', name: 'bcount core', namespace: 'bc', - version: '1.0.0', type: 'app', engines: { protocol: '^17' }, + version: '1.0.0', type: 'app', engines: { protocol: '^18' }, }; const coreObjects = [{ name: 'bc_account', label: 'Account', pluralLabel: 'Accounts', sharingModel: 'private', @@ -176,7 +176,7 @@ const coreApps = [{ const ordersManifest = { id: 'com.example.bcount.orders', name: 'bcount orders', namespace: 'bc', - version: '1.0.0', type: 'module', engines: { protocol: '^17' }, + version: '1.0.0', type: 'module', engines: { protocol: '^18' }, dependencies: { 'com.example.bcount.core': '^1.0.0' }, }; const ordersObjects = [{ @@ -223,7 +223,7 @@ import { defineStack } from '@objectstack/spec'; export default defineStack({ manifest: { id: 'com.example.bcsingle', name: 'bcsingle', namespace: 'bs', - version: '1.0.0', type: 'app', engines: { protocol: '^17' }, + version: '1.0.0', type: 'app', engines: { protocol: '^18' }, }, objects: [{ name: 'bs_thing', label: 'Thing', pluralLabel: 'Things', sharingModel: 'private', diff --git a/packages/cli/test/flow-credential-literal-doors.e2e.test.ts b/packages/cli/test/flow-credential-literal-doors.e2e.test.ts index c8def08333a..00fd078e3d6 100644 --- a/packages/cli/test/flow-credential-literal-doors.e2e.test.ts +++ b/packages/cli/test/flow-credential-literal-doors.e2e.test.ts @@ -42,7 +42,7 @@ const stackOf = (literal: boolean) => ({ version: '1.0.0', type: 'app', name: 'Credential door probe', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }, objects: [ { diff --git a/packages/cli/test/jsx-gate-manifest-notice.e2e.test.ts b/packages/cli/test/jsx-gate-manifest-notice.e2e.test.ts index 537bf79733a..45306ee08e2 100644 --- a/packages/cli/test/jsx-gate-manifest-notice.e2e.test.ts +++ b/packages/cli/test/jsx-gate-manifest-notice.e2e.test.ts @@ -141,7 +141,7 @@ function stack(body: string | null): string { import { defineStack } from '@objectstack/spec'; export default defineStack({ - manifest: { id: 'com.example.jxg', name: 'jxg', version: '1.0.0', type: 'app', namespace: 'jxg', engines: { protocol: '^17' } }, + manifest: { id: 'com.example.jxg', name: 'jxg', version: '1.0.0', type: 'app', namespace: 'jxg', engines: { protocol: '^18' } }, ${pages} apps: [{ name: 'jxg_app', label: 'JXG', navigation: [${nav}] }], objects: [ @@ -161,7 +161,7 @@ function packageCarried(top: string): string { import { defineStack } from '@objectstack/spec'; export default defineStack({ - manifest: { id: 'com.example.jxg', name: 'jxg', version: '1.0.0', type: 'app', namespace: 'jxg', engines: { protocol: '^17' } }, + manifest: { id: 'com.example.jxg', name: 'jxg', version: '1.0.0', type: 'app', namespace: 'jxg', engines: { protocol: '^18' } }, pages: ${top}, packages: [ { diff --git a/packages/cli/test/lint-handwritten-checks-package-fold.test.ts b/packages/cli/test/lint-handwritten-checks-package-fold.test.ts index 269f1a5c7f5..7528e935d4b 100644 --- a/packages/cli/test/lint-handwritten-checks-package-fold.test.ts +++ b/packages/cli/test/lint-handwritten-checks-package-fold.test.ts @@ -76,7 +76,7 @@ const MANIFEST = { version: '1.0.0', type: 'app', namespace: 'probe', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }; /** A module-scope binding, so the hook handler below cannot be lowered. */ @@ -286,7 +286,7 @@ describe('#17528 — os lint judges the stack the author declared, in either ADR }); const manifest = { id: 'com.example.ob', name: 'ob', version: '1.0.0', type: 'app', namespace: 'ob', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }; const fromPackages = lintConfig({ diff --git a/packages/cli/test/lint-namespace-prefix-per-package.test.ts b/packages/cli/test/lint-namespace-prefix-per-package.test.ts index c29c3ce0eaa..df4b61e9fd4 100644 --- a/packages/cli/test/lint-namespace-prefix-per-package.test.ts +++ b/packages/cli/test/lint-namespace-prefix-per-package.test.ts @@ -74,7 +74,7 @@ const B = { id: 'com.example.b', name: 'b', version: '1.0.0', type: 'module', namespace: 'beta', dependencies: { 'com.example.a': '^1.0.0' }, }; -const TOP = { ...A, engines: { protocol: '^17' } }; +const TOP = { ...A, engines: { protocol: '^18' } }; const obj = () => ({ name: 'ob_order', label: 'Order', sharingModel: 'private', nameField: 'number', diff --git a/packages/cli/test/lint-per-package-authoring-parity.test.ts b/packages/cli/test/lint-per-package-authoring-parity.test.ts index 998591200b2..33d089f1624 100644 --- a/packages/cli/test/lint-per-package-authoring-parity.test.ts +++ b/packages/cli/test/lint-per-package-authoring-parity.test.ts @@ -122,7 +122,7 @@ const lintPerPackage = (issues: unknown[]): string[] => const CONFIG_FLIP = ` const coreManifest = { id: 'com.example.ppflip.core', name: 'ppflip core', namespace: 'pp', - version: '1.0.0', type: 'app', engines: { protocol: '^17' }, + version: '1.0.0', type: 'app', engines: { protocol: '^18' }, }; const coreObjects = [{ name: 'pp_account', label: 'Account', pluralLabel: 'Accounts', sharingModel: 'private', @@ -141,7 +141,7 @@ const coreApps = [{ const ordersManifest = { id: 'com.example.ppflip.orders', name: 'ppflip orders', namespace: 'pp', - version: '1.0.0', type: 'module', engines: { protocol: '^17' }, + version: '1.0.0', type: 'module', engines: { protocol: '^18' }, dependencies: { 'com.example.ppflip.core': '^1.0.0' }, }; const ordersObjects = [{ @@ -193,7 +193,7 @@ import { defineStack } from '@objectstack/spec'; export default defineStack({ manifest: { id: 'com.example.ppsingle', name: 'ppsingle', namespace: 'ps', - version: '1.0.0', type: 'app', engines: { protocol: '^17' }, + version: '1.0.0', type: 'app', engines: { protocol: '^18' }, }, objects: [{ name: 'ps_thing', label: 'Thing', pluralLabel: 'Things', sharingModel: 'private', diff --git a/packages/cli/test/lint-per-package-authoring-seam.test.ts b/packages/cli/test/lint-per-package-authoring-seam.test.ts index afdf17ece3c..04764dc7dd7 100644 --- a/packages/cli/test/lint-per-package-authoring-seam.test.ts +++ b/packages/cli/test/lint-per-package-authoring-seam.test.ts @@ -83,7 +83,7 @@ function twoPackageArtifact(): Record { namespace: 'pp', version: '1.0.0', type: 'app', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }, objects: [ { @@ -119,7 +119,7 @@ function twoPackageArtifact(): Record { namespace: 'pp', version: '1.0.0', type: 'module', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, dependencies: { 'com.example.lintseam.core': '^1.0.0' }, }, objects: [ @@ -161,7 +161,7 @@ function singlePackageStack(): Record { namespace: 'ps', version: '1.0.0', type: 'app', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }, objects: [ { diff --git a/packages/cli/test/migrate-meta-default-range-window.test.ts b/packages/cli/test/migrate-meta-default-range-window.test.ts new file mode 100644 index 00000000000..1744992a738 --- /dev/null +++ b/packages/cli/test/migrate-meta-default-range-window.test.ts @@ -0,0 +1,199 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os migrate meta`: the DEFAULT `--to`, pinned in the window #17134 was found + * in, with that window held open on purpose. + * + * ## Why this file exists beside `migrate-meta-default-range.test.ts` + * + * The default under test is `Math.max(PROTOCOL_MAJOR, ...MIGRATION_MAJORS)`. + * The default it replaced is `PROTOCOL_MAJOR`. The two differ only while the + * registry carries a step PAST the runtime major, which is the window each + * major's line spends accumulating the next major's conversions. Measured at + * protocol 18 on #22130's branch: `MIGRATION_MAJORS` is `[17, 18]`, and no + * conversion registers `toMajor: 19`. Both defaults are 18, so no run of the + * installed CLI tells them apart. The spawned file's anti-vacuity line + * (`TERMINUS > PROTOCOL_MAJOR`) would read `18 > 18` there. + * + * So the window is held open here rather than waited for. The real command + * runs in-process over the real registry, and only the runtime's protocol + * major is replaced: by the rename step's AUTHORING major (`toMajor - 1`). + * That is the world `@objectstack/spec@17.4.0` shipped in and the card was + * measured in. In it, the old default composes `N → N` and lists nothing, + * while the default under test reaches the rename's step and lists all five + * rewrites. The anti-vacuity assertion is the spawned file's own line, applied + * to this window, where it holds in every release instead of only while the + * live registry runs ahead. + * + * ⛔ The replacement is the module's two protocol constants and nothing else. + * The registry, the conversions, the schemas and the loader are the installed + * ones, so the five rewrites and the schema verdict are the real chain's. + * + * In-process over `MigrateMeta.run`: no process is spawned and no kernel is + * booted, so this file sits in the `unit` tier by behaviour. + */ + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { stripVTControlCharacters } from 'node:util'; +import { MIGRATIONS_BY_MAJOR, MIGRATION_MAJORS } from '@objectstack/spec/migrations'; +import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; +import MigrateMeta from '../src/commands/migrate/meta.js'; + +const { RENAME_CONVERSION } = vi.hoisted(() => ({ + RENAME_CONVERSION: 'dashboard-refresh-interval-to-refresh-interval-seconds', +})); + +/** + * The window: the runtime still on the major the tombstone's source was + * authored against, with the rename's step already registered. Derived from + * the registry, never written down. A missing step is a loud failure here, + * because the window would otherwise be undefined. + */ +vi.mock('@objectstack/spec/kernel', async (importOriginal) => { + const actual = await importOriginal(); + const { MIGRATIONS_BY_MAJOR: steps } = await import('@objectstack/spec/migrations'); + const step = Object.values(steps).find((s) => s.conversionIds.includes(RENAME_CONVERSION)); + if (!step) throw new Error(`no registered migration step carries ${RENAME_CONVERSION}`); + const major = step.toMajor - 1; + return { ...actual, PROTOCOL_MAJOR: major, PROTOCOL_VERSION: `${major}.0.0` }; +}); + +const CLI_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..'); + +const RENAME_STEP = Object.values(MIGRATIONS_BY_MAJOR).find((s) => s.conversionIds.includes(RENAME_CONVERSION))!; + +/** The tombstone's `N`, and in this window also the runtime's major. */ +const AUTHORED = RENAME_STEP.toMajor - 1; + +/** The default under test, computed as the command computes it, in this window. */ +const TERMINUS = Math.max(PROTOCOL_MAJOR, ...MIGRATION_MAJORS); + +/** The spawned file's reproduction: five dashboards authoring the retired key. */ +const RETIRED_KEY_CONFIG = ` +export default { + manifest: { id: 'com.example.default-range-window', name: 'Default Range Window', version: '1.0.0', type: 'app' }, + objects: [{ name: 'dw_ticket', label: 'Ticket', fields: { title: { type: 'text', label: 'Title' } } }], + dashboards: [ + { name: 'kpi_a', label: 'KPI A', widgets: [], refreshInterval: 300 }, + { name: 'kpi_b', label: 'KPI B', widgets: [], refreshInterval: 60 }, + { name: 'kpi_c', label: 'KPI C', widgets: [], refreshInterval: 120 }, + { name: 'kpi_d', label: 'KPI D', widgets: [], refreshInterval: 900 }, + { name: 'kpi_e', label: 'KPI E', widgets: [], refreshInterval: 30 }, + ], +}; +`; + +interface Run { + stdout: string; + stderr: string; + exitCode: number | undefined; +} + +let dir: string; +const runs = new Map>(); + +/** Run the real command in-process once per distinct invocation, capturing both streams and any exit. */ +function runMeta(flags: string[]): Promise { + const key = flags.join(' '); + const hit = runs.get(key); + if (hit) return hit; + const started = (async (): Promise => { + const out: string[] = []; + const err: string[] = []; + const priorExitCode = process.exitCode; + const write = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, ...rest: unknown[]) => { + out.push(String(chunk)); + const done = rest.find((r) => typeof r === 'function') as (() => void) | undefined; + done?.(); + return true; + }) as never); + const log = vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => { out.push(a.join(' ')); }); + const warn = vi.spyOn(console, 'warn').mockImplementation((...a: unknown[]) => { err.push(a.join(' ')); }); + const error = vi.spyOn(console, 'error').mockImplementation((...a: unknown[]) => { err.push(a.join(' ')); }); + let exitCode: number | undefined; + try { + await MigrateMeta.run([join(dir, 'objectstack.config.ts'), ...flags], { root: CLI_ROOT }); + } catch (e: any) { + if (typeof e?.oclif?.exit !== 'number') throw e; + exitCode = e.oclif.exit; + } finally { + write.mockRestore(); + log.mockRestore(); + warn.mockRestore(); + error.mockRestore(); + if (exitCode === undefined && typeof process.exitCode === 'number' && process.exitCode !== 0) { + exitCode = process.exitCode; + } + process.exitCode = priorExitCode; + } + return { + stdout: stripVTControlCharacters(out.join('\n')), + stderr: stripVTControlCharacters(err.join('\n')), + exitCode, + }; + })(); + runs.set(key, started); + return started; +} + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'os-meta-default-window-')); + writeFileSync(join(dir, 'objectstack.config.ts'), RETIRED_KEY_CONFIG); +}); + +afterAll(() => { + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } +}); + +describe('os migrate meta: the default --to in the window the card was found in (#17134)', () => { + it('runs in that window: the runtime major is the tombstone\'s authoring major', () => { + // The replacement reached the module the command reads. Without this, a + // mock that silently missed would leave the cases below on the live major. + expect(PROTOCOL_MAJOR).toBe(AUTHORED); + expect(MIGRATION_MAJORS).toContain(RENAME_STEP.toMajor); + }); + + it('defaults --to to the highest major this build has a step for, not the runtime major', async () => { + const { stdout, exitCode } = await runMeta(['--from', String(AUTHORED), '--json']); + const parsed = JSON.parse(stdout); + expect(exitCode).toBeUndefined(); + expect(parsed.from).toBe(AUTHORED); + expect(parsed.to).toBe(TERMINUS); + // ⛔ Anti-vacuity. If the terminus ever equalled PROTOCOL_MAJOR the line + // above would hold for the very default this card exists to replace, so the + // premise is asserted rather than assumed: this is the line that speaks up + // when a major ships and the registry has no entry past it yet. + expect(TERMINUS, 'the registry carries a step past the runtime major').toBeGreaterThan(PROTOCOL_MAJOR); + }, 120_000); + + it('lists every retired-key rewrite for the tombstone\'s `--from`, with no --to given', async () => { + const parsed = JSON.parse((await runMeta(['--from', String(AUTHORED), '--json'])).stdout); + const renames = parsed.applied.filter((a: any) => a.conversionId === RENAME_CONVERSION); + expect(renames.map((a: any) => a.path)).toEqual([ + 'dashboards[0].refreshIntervalSeconds', + 'dashboards[1].refreshIntervalSeconds', + 'dashboards[2].refreshIntervalSeconds', + 'dashboards[3].refreshIntervalSeconds', + 'dashboards[4].refreshIntervalSeconds', + ]); + for (const r of renames) { + expect(r.from).toBe('refreshInterval'); + expect(r.to).toBe('refreshIntervalSeconds'); + } + expect(parsed.schemaValid).toBe(true); + }, 120_000); + + it('the human run prints the rewrites instead of the canonical verdict', async () => { + const { stdout, exitCode } = await runMeta(['--from', String(AUTHORED)]); + expect(exitCode).toBeUndefined(); + expect(stdout).toContain('Applied 5 mechanical change(s)'); + expect(stdout).toContain(RENAME_CONVERSION); + // ⛔ The whole sentence, never the phrase: step-18 semantic entries open a + // `replacement` with "Nothing to migrate to, because …". + expect(stdout).not.toContain('Nothing to migrate — the metadata is already canonical'); + }, 120_000); +}); diff --git a/packages/cli/test/migrate-meta-default-range.test.ts b/packages/cli/test/migrate-meta-default-range.test.ts index edf2758a9f2..a2ce5936980 100644 --- a/packages/cli/test/migrate-meta-default-range.test.ts +++ b/packages/cli/test/migrate-meta-default-range.test.ts @@ -42,6 +42,32 @@ * what does not move is that the default terminus is the highest major the * installed build carries a step for, so every expectation is derived from * `MIGRATION_MAJORS` and `PROTOCOL_MAJOR` and stays true one major later. + * + * ## Which `--from`: the tombstone's, not the runtime's (#22130) + * + * The invocation under test is the one the tombstone prints, and its `N` is + * the AUTHORING major: one below the `toMajor` of the step that carries the + * rename conversion. That is {@link AUTHORED}, read off the registry. This + * file used to spell it `String(PROTOCOL_MAJOR)`, which was the same number + * only while the runtime was still on the authoring major. At protocol 18, + * `--from 18` is a range above the rename, so every case here read an empty + * chain and failed for a reason unrelated to the defect. + * + * ## Where the default is told apart from the old one + * + * The old default was `PROTOCOL_MAJOR`, and the new one differs from it only + * while the registry carries a step past the runtime major. Measured at + * protocol 18 on #22130's branch: `MIGRATION_MAJORS` is `[17, 18]`, and no + * conversion registers `toMajor: 19` on that branch or on `main` at + * `dc4a5c6308`. The two defaults are therefore the same number, 18, and no + * run of the installed CLI can tell them apart. The anti-vacuity line this + * file carried (`TERMINUS > PROTOCOL_MAJOR`) cannot hold at protocol 18. It + * moved, unchanged, to `migrate-meta-default-range-window.test.ts`. That file + * runs the real command in-process with the runtime major set to + * {@link AUTHORED}, the window this card was found in, so it distinguishes the + * two defaults in every release and not only while the registry runs ahead. + * The cases below pin what a real terminal prints for the tombstone's + * invocation, and that pin holds whichever default is in place. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -60,9 +86,21 @@ const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); +const RENAME_CONVERSION = 'dashboard-refresh-interval-to-refresh-interval-seconds'; + +/** The step that carries the rename: its `toMajor` is the tombstone's `N + 1`. */ +const RENAME_STEP = Object.values(MIGRATIONS_BY_MAJOR).find((s) => s.conversionIds.includes(RENAME_CONVERSION)); + /** What the command must now default `--to` to — derived, never written down. */ const TERMINUS = Math.max(PROTOCOL_MAJOR, ...MIGRATION_MAJORS); -const INSTALLED = String(PROTOCOL_MAJOR); + +/** + * The `N` the tombstone prints in `--from N`: the major the source was + * authored against, one below the rename step's `toMajor`. A missing step + * yields `NaN`, and the guard case below fails on it before any spawn result + * is read. + */ +const AUTHORED = String((RENAME_STEP?.toMajor ?? Number.NaN) - 1); /** * The card's reproduction: a stack on the installed line authoring the @@ -92,8 +130,6 @@ export default { }; `; -const RENAME_CONVERSION = 'dashboard-refresh-interval-to-refresh-interval-seconds'; - interface Run { stdout: string; code: number } let retiredDir: string; @@ -134,21 +170,26 @@ afterAll(() => { }); describe('os migrate meta — the invocation the tombstones prescribe (#17134)', () => { - it('defaults --to to the highest major this build has a step for, not the runtime major', async () => { - const { stdout, code } = await runMeta(['--from', INSTALLED, '--json'], retiredDir); + it('defaults --to to the highest major this build has a step for, which holds the rename', async () => { + // The subject first: without the step that carries the rename there is no + // tombstone invocation to run, and `AUTHORED` reads `NaN`. + expect(RENAME_STEP, `a registered migration step carries ${RENAME_CONVERSION}`).toBeDefined(); + const { stdout, code } = await runMeta(['--from', AUTHORED, '--json'], retiredDir); const parsed = JSON.parse(stdout); expect(code).toBe(0); - expect(parsed.from).toBe(PROTOCOL_MAJOR); + expect(parsed.from).toBe(Number(AUTHORED)); expect(parsed.to).toBe(TERMINUS); - // ⛔ Anti-vacuity. If the terminus ever equalled PROTOCOL_MAJOR the line - // above would hold for the very default this card exists to replace, so the - // premise is asserted rather than assumed: this is the line that speaks up - // when a major ships and the registry has no entry past it yet. - expect(TERMINUS, 'the registry carries a step past the runtime major').toBeGreaterThan(PROTOCOL_MAJOR); + // The presumption the tombstone's template makes: the default terminus is + // at least the conversion's own `toMajor`. + expect(parsed.to).toBeGreaterThanOrEqual(RENAME_STEP!.toMajor); + // ⛔ This run cannot tell the new default from the old one at protocol 18 + // (both are 18, measured; see the header). The anti-vacuity line that + // asserts the difference lives in migrate-meta-default-range-window.test.ts, + // where the runtime major is held one below the rename's step. }, 120_000); it('lists every retired-key rewrite with no --to given at all', async () => { - const { stdout } = await runMeta(['--from', INSTALLED, '--json'], retiredDir); + const { stdout } = await runMeta(['--from', AUTHORED, '--json'], retiredDir); const parsed = JSON.parse(stdout); const renames = parsed.applied.filter((a: any) => a.conversionId === RENAME_CONVERSION); @@ -170,7 +211,7 @@ describe('os migrate meta — the invocation the tombstones prescribe (#17134)', }, 120_000); it('the human run prints the rewrites instead of `Nothing to migrate`', async () => { - const { stdout, code } = await runMeta(['--from', INSTALLED], retiredDir); + const { stdout, code } = await runMeta(['--from', AUTHORED], retiredDir); expect(code).toBe(0); expect(stdout).toContain('Applied 5 mechanical change(s)'); expect(stdout).toContain(RENAME_CONVERSION); @@ -187,18 +228,19 @@ describe('os migrate meta — the invocation the tombstones prescribe (#17134)', describe('os migrate meta — an empty range answers as an empty range (#17134)', () => { /** - * The pre-fix default, now reachable only by typing it. The command is right - * that this range holds no conversion; what it may not do is turn that into a - * verdict about the metadata. + * The range the pre-fix default composed in the card's world, where the + * runtime was still on the authoring major. It is reachable now only by + * typing it. The command is right that this range holds no conversion; what + * it may not do is turn that into a verdict about the metadata. */ it('refuses to call an un-migrated stack canonical when the range holds no step', async () => { - const { stdout, code } = await runMeta(['--from', INSTALLED, '--to', INSTALLED], retiredDir); + const { stdout, code } = await runMeta(['--from', AUTHORED, '--to', AUTHORED], retiredDir); expect(stdout).not.toContain('already canonical'); - expect(stdout).toContain(`No migration step exists for protocol ${INSTALLED} → ${INSTALLED}`); + expect(stdout).toContain(`No migration step exists for protocol ${AUTHORED} → ${AUTHORED}`); // Triage's requirement: name the range that WOULD list them. expect(stdout).toContain(`--to ${TERMINUS}`); - expect(stdout).toContain(`Protocol ${INSTALLED} → ${TERMINUS} has 5 mechanical`); + expect(stdout).toContain(`Protocol ${AUTHORED} → ${TERMINUS} has 5 mechanical`); // ⛔ The exit code is deliberately unchanged. This command reports findings // rather than exiting on them — its schema-invalid arm beside this one has // always been a warning at exit 0. What changed is that the text no longer @@ -207,7 +249,7 @@ describe('os migrate meta — an empty range answers as an empty range (#17134)' }, 120_000); it('no longer returns past the schema verdict that contradicts it', async () => { - const { stdout } = await runMeta(['--from', INSTALLED, '--to', INSTALLED], retiredDir); + const { stdout } = await runMeta(['--from', AUTHORED, '--to', AUTHORED], retiredDir); // Unreachable before the fix: the zero-change branch returned first, so the // same run could report `schemaValid: false` in `--json` while the human // output claimed the metadata was canonical and stopped. diff --git a/packages/cli/test/non-array-packages-readers.test.ts b/packages/cli/test/non-array-packages-readers.test.ts index 7ea38088381..964ba08f59c 100644 --- a/packages/cli/test/non-array-packages-readers.test.ts +++ b/packages/cli/test/non-array-packages-readers.test.ts @@ -53,7 +53,7 @@ const MANIFEST = { version: '1.0.0', type: 'app' as const, namespace: 'probe', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }; const PROBE_OBJECT = { diff --git a/packages/cli/test/null-packages-follows-resolver.test.ts b/packages/cli/test/null-packages-follows-resolver.test.ts index c6290fed53a..56787e5ca05 100644 --- a/packages/cli/test/null-packages-follows-resolver.test.ts +++ b/packages/cli/test/null-packages-follows-resolver.test.ts @@ -52,7 +52,7 @@ const MANIFEST = { version: '1.0.0', type: 'app' as const, namespace: 'probe', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }; const stackWith = (packages: unknown) => ({ manifest: MANIFEST, packages }); diff --git a/packages/cli/test/package-union-readers.test.ts b/packages/cli/test/package-union-readers.test.ts index c43a78efdfd..1bf26c93133 100644 --- a/packages/cli/test/package-union-readers.test.ts +++ b/packages/cli/test/package-union-readers.test.ts @@ -83,7 +83,7 @@ const PER_PACKAGE = /^package '[^']+' — /; const PIECES = ` import { defineStack, composeStacks } from '@objectstack/spec'; const I18N = { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'], fallbackLocale: 'en' }; -const engines = { protocol: '^17' }; +const engines = { protocol: '^18' }; const svcManifest = { id: '${SVC_ID}', name: 'Union Service', namespace: 'unr', version: '1.0.0', type: 'module', engines }; const appManifest = { id: '${APP_ID}', name: 'Union App', namespace: 'unr', version: '1.0.0', type: 'app', engines }; const ticket = { name: 'unr_ticket', label: 'Ticket', pluralLabel: 'Tickets', sharingModel: 'private', diff --git a/packages/cli/test/per-package-dedup-positional-echo.test.ts b/packages/cli/test/per-package-dedup-positional-echo.test.ts index e4029d910ad..cb6d9ee2231 100644 --- a/packages/cli/test/per-package-dedup-positional-echo.test.ts +++ b/packages/cli/test/per-package-dedup-positional-echo.test.ts @@ -78,7 +78,7 @@ const CORE_MANIFEST = { namespace: 'pp', version: '1.0.0', type: 'app', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }; const CORE_OBJECTS = [ @@ -115,7 +115,7 @@ const ORDERS_MANIFEST = { namespace: 'pp', version: '1.0.0', type: 'module', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, dependencies: { 'com.example.echo.core': '^1.0.0' }, }; diff --git a/packages/cli/test/retry-policy-key-validate-door.test.ts b/packages/cli/test/retry-policy-key-validate-door.test.ts index 1914ad6bb55..4b8880f1a12 100644 --- a/packages/cli/test/retry-policy-key-validate-door.test.ts +++ b/packages/cli/test/retry-policy-key-validate-door.test.ts @@ -48,7 +48,7 @@ const stackOf = (retry: Retry, retryPolicy: Retry) => ({ name: 'Retry key door probe', version: '1.0.0', type: 'app', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }, objects: [ { diff --git a/packages/cli/test/union-fold-command-parity.test.ts b/packages/cli/test/union-fold-command-parity.test.ts index 823ea80e014..3b0990ae5bb 100644 --- a/packages/cli/test/union-fold-command-parity.test.ts +++ b/packages/cli/test/union-fold-command-parity.test.ts @@ -176,7 +176,7 @@ const optionBStack = (reference: string): Record => ({ const perPackageOnlyStack = (): Record => { const coreManifest = { id: 'com.example.obflip.core', name: 'obflip core', namespace: 'ob', - version: '1.0.0', type: 'app', engines: { protocol: '^17' }, + version: '1.0.0', type: 'app', engines: { protocol: '^18' }, }; const coreObjects = [{ name: 'ob_account', label: 'Account', pluralLabel: 'Accounts', sharingModel: 'private', @@ -194,7 +194,7 @@ const perPackageOnlyStack = (): Record => { }]; const ordersManifest = { id: 'com.example.obflip.orders', name: 'obflip orders', namespace: 'ob', - version: '1.0.0', type: 'module', engines: { protocol: '^17' }, + version: '1.0.0', type: 'module', engines: { protocol: '^18' }, dependencies: { 'com.example.obflip.core': '^1.0.0' }, }; const ordersObjects = [{ diff --git a/packages/cli/test/validate-per-package-authoring-parity.test.ts b/packages/cli/test/validate-per-package-authoring-parity.test.ts index 69d58f6c92c..a97687e8732 100644 --- a/packages/cli/test/validate-per-package-authoring-parity.test.ts +++ b/packages/cli/test/validate-per-package-authoring-parity.test.ts @@ -133,7 +133,7 @@ const perPackageWarnings = (warnings: unknown[]): string[] => const CONFIG_MULTI = ` const coreManifest = { id: 'com.example.ppparity.core', name: 'ppparity core', namespace: 'pp', - version: '1.0.0', type: 'app', engines: { protocol: '^17' }, + version: '1.0.0', type: 'app', engines: { protocol: '^18' }, }; const coreObjects = [{ name: 'pp_account', label: 'Account', pluralLabel: 'Accounts', sharingModel: 'private', @@ -152,7 +152,7 @@ const coreApps = [{ const ordersManifest = { id: 'com.example.ppparity.orders', name: 'ppparity orders', namespace: 'pp', - version: '1.0.0', type: 'module', engines: { protocol: '^17' }, + version: '1.0.0', type: 'module', engines: { protocol: '^18' }, dependencies: { 'com.example.ppparity.core': '^1.0.0' }, }; const ordersObjects = [{ @@ -212,7 +212,7 @@ import { defineStack } from '@objectstack/spec'; export default defineStack({ manifest: { id: 'com.example.ppsingle', name: 'ppsingle', namespace: 'ps', - version: '1.0.0', type: 'app', engines: { protocol: '^17' }, + version: '1.0.0', type: 'app', engines: { protocol: '^18' }, }, objects: [{ name: 'ps_thing', label: 'Thing', pluralLabel: 'Things', sharingModel: 'private', diff --git a/packages/cli/test/validate-per-package-authoring-seam.test.ts b/packages/cli/test/validate-per-package-authoring-seam.test.ts index d2df9753eff..063eabe3cbc 100644 --- a/packages/cli/test/validate-per-package-authoring-seam.test.ts +++ b/packages/cli/test/validate-per-package-authoring-seam.test.ts @@ -73,7 +73,7 @@ function twoPackageArtifact(): Record { namespace: 'crm', version: '1.0.0', type: 'app', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }, objects: [ { @@ -118,7 +118,7 @@ function twoPackageArtifact(): Record { namespace: 'crm', version: '1.0.0', type: 'module', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, dependencies: { 'com.example.seam.core': '^1.0.0' }, }, objects: [ diff --git a/packages/cli/test/verify-author-time-stage.test.ts b/packages/cli/test/verify-author-time-stage.test.ts index 4af5411a5c3..175d17182bd 100644 --- a/packages/cli/test/verify-author-time-stage.test.ts +++ b/packages/cli/test/verify-author-time-stage.test.ts @@ -59,7 +59,7 @@ function taskApp(planted: boolean): Record { version: '1.0.0', name: 'Verify Gate', type: 'app', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }, objects: [ { diff --git a/packages/cli/test/verify-host-root.test.ts b/packages/cli/test/verify-host-root.test.ts index 63b4cd0d8ca..67840cf7fad 100644 --- a/packages/cli/test/verify-host-root.test.ts +++ b/packages/cli/test/verify-host-root.test.ts @@ -53,7 +53,7 @@ const STACK = { version: '1.0.0', name: 'Verify Host Root', type: 'app', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }, objects: [ { diff --git a/packages/cli/test/verify-json-stdout.test.ts b/packages/cli/test/verify-json-stdout.test.ts index 6410d3a06c6..e7a9dc7c5f9 100644 --- a/packages/cli/test/verify-json-stdout.test.ts +++ b/packages/cli/test/verify-json-stdout.test.ts @@ -57,7 +57,7 @@ const STACK = { version: '1.0.0', name: 'Verify JSON Stdout', type: 'app', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }, objects: [ { diff --git a/packages/lint/src/authoring-rule-input-tier.test.ts b/packages/lint/src/authoring-rule-input-tier.test.ts index f29e73e9a7c..b3def9e457e 100644 --- a/packages/lint/src/authoring-rule-input-tier.test.ts +++ b/packages/lint/src/authoring-rule-input-tier.test.ts @@ -55,7 +55,7 @@ const manifest = { version: '1.0.0', type: 'app', name: 'Tier Probe', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }; /** `defineStack` warns on the D2 conversion channel; keep test output clean. */ diff --git a/packages/lint/src/validate-form-layout.test.ts b/packages/lint/src/validate-form-layout.test.ts index fee85da2997..73d3f73b5bc 100644 --- a/packages/lint/src/validate-form-layout.test.ts +++ b/packages/lint/src/validate-form-layout.test.ts @@ -398,7 +398,7 @@ describe('#6251 — reachable on a REAL parsed app stack', () => { version: '1.0.0', type: 'app', name: 'Form Layout Probe', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }; const data = { provider: 'object' as const, object: 'fl_contact' }; diff --git a/packages/mcp/README.md b/packages/mcp/README.md index eb37d94b03e..81ee7609189 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -411,7 +411,7 @@ export default defineStack({ version: '0.1.0', type: 'app', name: 'My CRM', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }, // Optional: the CLI already anchors a persistent SQLite database at // `/.objectstack/data/standalone.db`. Declare a datasource only @@ -520,7 +520,7 @@ export default defineStack({ version: '0.1.0', type: 'app', name: 'CRM Assistant', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }, objects: [account, contact, opportunity], // Your actions become MCP tools — the plugin bridges them at start. diff --git a/packages/metadata/src/plugin-artifact-packages-attribution.test.ts b/packages/metadata/src/plugin-artifact-packages-attribution.test.ts index 005822cd193..2b07b37a2fa 100644 --- a/packages/metadata/src/plugin-artifact-packages-attribution.test.ts +++ b/packages/metadata/src/plugin-artifact-packages-attribution.test.ts @@ -74,7 +74,7 @@ const coreStack = { namespace: 'crm', version: '1.0.0', type: 'app', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }, objects: [ { @@ -105,7 +105,7 @@ const ordersStack = { // would let a wrong-body stamp pass unnoticed. version: '2.4.0', type: 'module', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, dependencies: { [CORE_ID]: '^1.0.0' }, }, objects: [ diff --git a/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts b/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts index 5233702ec6e..744085a43b4 100644 --- a/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts +++ b/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts @@ -154,7 +154,7 @@ const probeApp = defineStack({ version: '0.0.1', type: 'app', name: 'Authored Row-Write Scope Probe', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }, objects: [OpenNote, SecretNote], permissions: [WidenerSet, PlainSet], diff --git a/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts b/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts index a4290956779..e85a205aae5 100644 --- a/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts +++ b/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts @@ -121,7 +121,7 @@ const probeApp = defineStack({ version: '0.0.1', type: 'app', name: 'Bulk Widener Probe', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }, objects: [ProbeNote], permissions: [ProbeWidenerSet], diff --git a/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts b/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts index 1f21fbcd8bc..d060d8f9e8c 100644 --- a/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts +++ b/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts @@ -147,7 +147,7 @@ const probeApp = defineStack({ version: '0.0.1', type: 'app', name: 'OWD public_read_write Write Floor Probe', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }, objects: [mk(OPEN, 'public_read_write'), mk(READ, 'public_read'), mk(SECRET, 'private')], permissions: [EditorSet, ViewerSet, ScopedSet], diff --git a/packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts b/packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts index cf988b145c6..65a45a881c3 100644 --- a/packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts +++ b/packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts @@ -69,7 +69,7 @@ const SET = 'coldboot_env_set'; const POSITION = 'coldboot_env_position'; const manifestOf = (id: string, namespace: string) => ({ - id, namespace, version: '0.0.1', type: 'app' as const, name: id, engines: { protocol: '^17' }, + id, namespace, version: '0.0.1', type: 'app' as const, name: id, engines: { protocol: '^18' }, }); const Note = ObjectSchema.create({ diff --git a/packages/qa/dogfood/test/view-container-default-form.dogfood.test.ts b/packages/qa/dogfood/test/view-container-default-form.dogfood.test.ts index 19a68c5e214..798661b9eb1 100644 --- a/packages/qa/dogfood/test/view-container-default-form.dogfood.test.ts +++ b/packages/qa/dogfood/test/view-container-default-form.dogfood.test.ts @@ -94,7 +94,7 @@ const fixture = defineStack({ version: '0.0.1', type: 'app', name: 'Default form fixture', - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }, objects: [object('crm_lead'), object('crm_contact'), object('crm_account')], views: [ diff --git a/packages/runtime/src/standalone-stack-security-registrar.test.ts b/packages/runtime/src/standalone-stack-security-registrar.test.ts index 90ffd169226..d31f573aaf8 100644 --- a/packages/runtime/src/standalone-stack-security-registrar.test.ts +++ b/packages/runtime/src/standalone-stack-security-registrar.test.ts @@ -38,7 +38,9 @@ import '@objectstack/service-datasource'; * Same probe artifact as the two-reader harness: a legacy shape in every * security collection, an `engines.protocol` floor that opens the door's * conversion window. The values asserted below are the door's measured output - * for these bytes (DOOR_COPY in the harness). + * for these bytes (DOOR_COPY in the harness). The range is `>=17.1.0`, not + * `^17.1.0`: this boot runs the ADR-0087 D1 handshake, which refuses a caret on + * 17 from protocol 18, and only the floor opens the window. */ const ARTIFACT = { manifest: { @@ -46,7 +48,7 @@ const ARTIFACT = { name: 'Single Registrar Boot', type: 'app', version: '2.0.0', - engines: { protocol: '^17.1.0' }, + engines: { protocol: '>=17.1.0' }, }, roles: [{ name: 'sales_rep', label: 'Sales Rep' }], permissions: [ diff --git a/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts b/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts index fc91e15fa55..81418d448fe 100644 --- a/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts +++ b/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts @@ -86,9 +86,11 @@ import '@objectstack/objectql'; import '@objectstack/service-datasource'; /** - * The probe artifact. `engines.protocol` sits one minor below the runtime spec + * The probe artifact. `engines.protocol`'s floor sits below the runtime spec * so the door's ADR-0087 forward-conversion window is open — the same lever * commit 317132495's probe uses, and the reason the two copies can differ at all. + * It is a `>=` floor, not `^17.1.0`: this boot runs the ADR-0087 D1 handshake, + * which refuses a caret on 17 from protocol 18, and only the floor opens the window. * * Each declaration isolates one axis of triage's question: * @@ -116,7 +118,7 @@ const ARTIFACT = { name: 'Seeder Declaration Copy Probe', type: 'app', version: '3.0.0', - engines: { protocol: '^17.1.0' }, + engines: { protocol: '>=17.1.0' }, }, permissions: [ { diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 14b08dbbd9c..92f6134c2f1 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -1,11 +1,11 @@ { "$comment": "GENERATED (ADR-0087 D4) — do not edit. Regenerate with: pnpm --filter @objectstack/spec gen:spec-changes. A projection of the D2 conversion table + D3 migration chain; the upgrade guide and the MCP spec_changes tool derive from this same data. A record's `added`/`removed` are NOT at its `from` → `to` MAJOR resolution: they come from an api-surface diff against the previously PUBLISHED artifact, so they span ONE RELEASE. When they are non-empty the record carries `surfaceScope: { fromVersion, toVersion }` naming exactly that pair, and a release whose arrays disagree with the two tarballs — or carry no `surfaceScope` — does not publish. Absent `surfaceScope` means the record carries no export diff at all (`added`/`removed` empty), never \"nothing was added between from and to\". When a `release` section is present, its four ADR-0087 D4 arrays report what that release ADDED: `added`/`removed` are the export-surface diff of the two published tarballs, and `converted`/`migrated` are the D2/D3 ids FIRST REGISTERED in it. An id that LEFT the published chain between the two releases is reported in none of them — `converted: []` means \"this release registered none\", never \"none was withdrawn\"; a withdrawal is visible only by comparing two published manifests.", - "protocolVersion": "17.0.0", + "protocolVersion": "18.0.0", "supportFloor": 16, "migrateCommand": "objectstack migrate meta --from (N >= 16)", "aggregate": { "from": 16, - "to": 17, + "to": 18, "added": [], "converted": [ { @@ -349,6 +349,390 @@ "to": "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: []`)", "conversionId": "action-global-nav-location-removed", "toMajor": 17 + }, + { + "surface": "object.fields.*.scale / object.fields.*.precision", + "to": "malformed field 'scale'/'precision' declarations (non-integer or negative) are removed — they were silently unenforced; the schema now refuses them at authoring", + "conversionId": "field-malformed-scale-precision-removed", + "toMajor": 18 + }, + { + "surface": "page.component.record:chatter.position / page.component.record:discussion.position", + "to": "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)", + "conversionId": "record-chatter-position-vocabulary", + "toMajor": 18 + }, + { + "surface": "page.component.element:text_input.targetVariable / page.component.element:record_picker.targetVariable", + "to": "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)", + "conversionId": "element-input-target-variable-removed", + "toMajor": 18 + }, + { + "surface": "page.component.element:filter.object / page.component.element:filter.fields / page.component.element:filter.targetVariable / page.component.element:filter.layout / page.component.element:filter.showSearch / page.component.element:filter.aria", + "to": "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", + "conversionId": "element-filter-removed", + "toMajor": 18 + }, + { + "surface": "page.component.element:form.object / page.component.element:form.fields / page.component.element:form.mode / page.component.element:form.submitLabel / page.component.element:form.onSubmit / page.component.element:form.aria", + "to": "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", + "conversionId": "element-form-removed", + "toMajor": 18 + }, + { + "surface": "field.inlineColumns[].field / field.relatedListColumns[] object entries", + "to": "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)", + "conversionId": "field-column-lists-canonicalized", + "toMajor": 18 + }, + { + "surface": "analyticsCubes[].measures..filters", + "to": "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)", + "conversionId": "metric-filters-removed", + "toMajor": 18 + }, + { + "surface": "analyticsCubes[].dimensions..granularities", + "to": "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)", + "conversionId": "cube-sub-day-granularities-removed", + "toMajor": 18 + }, + { + "surface": "analyticsCubes[].joins..sql / analyticsCubes[].joins..relationship", + "to": "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..name` alone; the record KEY is the foreign-key field on the base object)", + "conversionId": "cube-join-sql-and-relationship-removed", + "toMajor": 18 + }, + { + "surface": "page.component.record:highlights.fields[].icon", + "to": "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)", + "conversionId": "record-highlights-field-icon-removed", + "toMajor": 18 + }, + { + "surface": "mapping.fieldMapping[].params.object / .fromField / .toField / .autoCreate", + "to": "mapping lookup params 'object'/'fromField'/'toField'/'autoCreate' removed (ADR-0049 — the import path never read them: `lookup` copies the cell through and reference resolution runs off the target field's own metadata. `autoCreate` never created anything — an unresolved reference fails the row either way. Implementing them instead would have added a second reference-resolution dialect to the import path)", + "conversionId": "mapping-lookup-params-removed", + "toMajor": 18 + }, + { + "surface": "translation.pages.components.submitLabel", + "to": "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)", + "conversionId": "translation-component-submit-label-removed", + "toMajor": 18 + }, + { + "surface": "page.components[].responsive", + "to": "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)", + "conversionId": "page-component-responsive-removed", + "toMajor": 18 + }, + { + "surface": "page.component.object-grid.defaultSort", + "to": "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)", + "conversionId": "object-grid-default-sort-removed", + "toMajor": 18 + }, + { + "surface": "page.component.object-kanban.quickAdd", + "to": "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)", + "conversionId": "object-kanban-quick-add-removed", + "toMajor": 18 + }, + { + "surface": "permission.objects..allowRestore / permission.objects..allowPurge", + "to": "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)", + "conversionId": "permission-allow-restore-purge-removed", + "toMajor": 18 + }, + { + "surface": "view.form.sections[].fields[].options[].default", + "to": "form-view per-option 'default' removed from the FormView vocabulary (ADR-0049 declared-but-unenforced — nothing on the form path read it: the insert-path default falls back to the OBJECT definition's option list, and no form renderer seeds a value from a form view's. The object field option's 'default' stays enforced; declare the pre-selected choice there — field-level 'defaultValue', or 'default: true' on that field's own options entry)", + "conversionId": "form-view-option-default-removed", + "toMajor": 18 + }, + { + "surface": "field.reference_to", + "to": "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)", + "conversionId": "field-reference-to-alias", + "toMajor": 18 + }, + { + "surface": "connector.errorMapping", + "to": "connector key 'errorMapping' removed (ADR-0049 — no engine ever mapped an external error through the rules, so the eleven nested keys configured nothing, and the rule-level `userMessage` shared its spelling with the live API-error channel while never being shown; deleting the block resolves that collision without a rename. The whole ErrorMappingConfig / ErrorMappingRule shape and the ConnectorErrorCategory enum went with it)", + "conversionId": "connector-error-mapping-removed", + "toMajor": 18 + }, + { + "surface": "connector.connectionTimeoutMs", + "to": "connector key 'connectionTimeoutMs' removed (ADR-0049 — the platform never applied it as a deadline and cannot at the site it names: a WHATWG `fetch` exposes one `AbortSignal` over the whole operation and never the connect phase. The value only travelled — onto the reported def and the materialization fingerprint. Use `requestTimeoutMs`, which `resilientFetch` applies as each attempt's deadline, and bound the connect phase at a provider or gateway that can separate the phases)", + "conversionId": "connector-connection-timeout-ms-removed", + "toMajor": 18 + }, + { + "surface": "hook.timeout", + "to": "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)", + "conversionId": "hook-timeout-to-timeout-ms", + "toMajor": 18 + }, + { + "surface": "job.timeout", + "to": "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)", + "conversionId": "job-timeout-to-timeout-ms", + "toMajor": 18 + }, + { + "surface": "apis[].cacheTtl", + "to": "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)", + "conversionId": "api-endpoint-cache-ttl-to-cache-ttl-seconds", + "toMajor": 18 + }, + { + "surface": "dashboard.refreshInterval", + "to": "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)", + "conversionId": "dashboard-refresh-interval-to-refresh-interval-seconds", + "toMajor": 18 + }, + { + "surface": "connector.health / connector.status / connector.webhooks", + "to": "connector keys 'health', 'status' and 'webhooks' removed (ADR-0049 — no connector health probe or circuit breaker ever ran, nothing read an authored status (the runtime reports a computed `state`), and a webhook nested in a connector was never registered or delivered. The ConnectorHealth / HealthCheckConfig / CircuitBreakerConfig, ConnectorStatus and WebhookConfig / WebhookEvent / WebhookSignatureAlgorithm shapes went with them)", + "conversionId": "connector-resilience-keys-removed", + "toMajor": 18 + }, + { + "surface": "connector.triggers", + "to": "connector key 'triggers' removed (ADR-0049 — a connector trigger never started anything: the automation engine registered a connector's actions only, no polling loop read an interval and no receiver was driven by a webhook trigger. The ConnectorTrigger shape went with it, including the `interval` spelling renamed to `intervalSeconds` earlier in this step. Start the work from a flow that calls the connector's action instead: an `api` flow for an external event, a `schedule` flow for a scheduled pull)", + "conversionId": "connector-triggers-removed", + "toMajor": 18 + }, + { + "surface": "datasource.config.persistence.autoSaveInterval", + "to": "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)", + "conversionId": "memory-persistence-auto-save-interval-to-ms", + "toMajor": 18 + }, + { + "surface": "datasource.config.timeout (turso)", + "to": "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)", + "conversionId": "turso-config-timeout-to-timeout-ms", + "toMajor": 18 + }, + { + "surface": "view.list / view.listViews.* — the list-view type 'page' and its pageName binding", + "to": "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)", + "conversionId": "view-page-mount-removed", + "toMajor": 18 + }, + { + "surface": "view.list.sort / view.listViews.*.sort — the bare string sort clause", + "to": "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)", + "conversionId": "list-view-sort-string-clause-to-array", + "toMajor": 18 + }, + { + "surface": "page.assignedProfiles", + "to": "page key 'assignedProfiles' removed (ADR-0090 D2 deleted the Profile concept it was named for, and no renderer, route or read door ever enforced it — the page stayed open to everyone; ADR-0049 enforce-or-remove)", + "conversionId": "page-assigned-profiles-removed", + "toMajor": 18 + }, + { + "surface": "dashboard.widgets[].chartConfig.aria / report.chart.aria / report.blocks[].chart.aria", + "to": "chart config key 'aria' removed (ADR-0049 enforce-or-remove — no chart renderer ever applied it on either face, so declared ARIA attributes silently did not reach the DOM; the accessible name that IS applied is the sibling 'description')", + "conversionId": "chart-config-aria-removed", + "toMajor": 18 + }, + { + "surface": "dashboard.widgets[].chartConfig.type / dashboard.widgets[].chartConfig.xAxis / dashboard.widgets[].chartConfig.yAxis / dashboard.widgets[].chartConfig.series", + "to": "dataset-bound dashboard widget chart-config keys 'type'/'xAxis'/'yAxis'/'series' removed (ADR-0021 — the dataset decides which series exist and which column each one reads; the widget's own 'type' is the chart family, and 'dimensions'/'values' are the selection, so an authored axis could only agree with the dataset or silently re-point a series at another column)", + "conversionId": "dashboard-widget-chart-config-structure-removed", + "toMajor": 18 + }, + { + "surface": "stack.translations[]..settings / translation.settings", + "to": "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", + "conversionId": "translation-per-app-settings-removed", + "toMajor": 18 + }, + { + "surface": "object.tenancy.organizationField", + "to": "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)", + "conversionId": "object-tenancy-organization-field-removed", + "toMajor": 18 + }, + { + "surface": "page.component.dataSource.filter / page.component.properties.filter (the object-* blocks, element:number, element:record_picker) / page.component.properties.defaultFilters (object-grid) — the record and single-level AST filter forms", + "to": "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)", + "conversionId": "page-component-filter-record-to-rule-array", + "toMajor": 18 + }, + { + "surface": "view.owner / view.hidden — on the view item record ({ name, object, viewKind, config })", + "to": "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)", + "conversionId": "view-item-owner-hidden-removed", + "toMajor": 18 + }, + { + "surface": "report.blocks[].chart / report.chart on a joined report", + "to": "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')", + "conversionId": "report-joined-chart-removed", + "toMajor": 18 + }, + { + "surface": "view.owner / view.hidden — on a flattened view overlay ({ name, object, viewKind, …, no config })", + "to": "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)", + "conversionId": "view-overlay-owner-hidden-removed", + "toMajor": 18 + }, + { + "surface": "page.component.object-form.layout / view.form.layout / view.formViews.*.layout", + "to": "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)", + "conversionId": "form-layout-inline-grid-to-vertical", + "toMajor": 18 + }, + { + "surface": "object.fields.*.currencyConfig.precision", + "to": "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)", + "conversionId": "currency-config-precision-removed", + "toMajor": 18 + }, + { + "surface": "permission.rowLevelSecurity[].tags", + "to": "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)", + "conversionId": "permission-rls-tags-removed", + "toMajor": 18 + }, + { + "surface": "action.aria / object.actions[].aria", + "to": "action key 'aria' removed (ADR-0049 enforce-or-remove — no action surface ever applied it; every renderer takes the accessible name from the action's required 'label', and the placing node's own 'aria' block names the region)", + "conversionId": "action-aria-removed", + "toMajor": 18 + }, + { + "surface": "analyticsCubes[].measures..name / analyticsCubes[].dimensions..name", + "to": "cube member key 'name' removed from measures and dimensions (ADR-0049 enforce-or-remove — nothing read it: every consumer resolves a member by its record KEY, published and queried as `.`. The record key is the member's name; to rename a member, rename its key)", + "conversionId": "cube-member-inner-name-removed", + "toMajor": 18 + }, + { + "surface": "flow.nodes[].config.mode (decision)", + "to": "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)", + "conversionId": "flow-decision-mode-inclusive-explicit", + "toMajor": 18 + }, + { + "surface": "view.list.tabs / view.listViews.*.tabs — the list view's own tab definitions", + "to": "list-view key 'tabs' removed (ADR-0049 enforce-or-remove — parsed and stored, drawn by nothing: no renderer ever mounted a tab bar for it, and the tab strip above an object's records is the saved-view switcher, which renders one tab per `listViews` entry; move each tab you want to a named list view)", + "conversionId": "view-list-tabs-removed", + "toMajor": 18 + }, + { + "surface": "analyticsCubes[].refreshKey", + "to": "cube key 'refreshKey' removed, with its 'every' and 'sql' (ADR-0049 enforce-or-remove — nothing read it: no analytics result is cached, so a declared refresh cadence refreshed nothing. Delete the key; a refresh cadence is declared again when a result cache exists)", + "conversionId": "cube-refresh-key-removed", + "toMajor": 18 + }, + { + "surface": "object.fields.*.defaultValue / action.params[].defaultValue / page.component.element:button.action.params[].defaultValue (type time)", + "to": "a `time` literal default's `Z` or zero-offset suffix is dropped, which names the same wall clock; a default with a non-zero offset is left as stored and reported as a TODO, because a `time` value carries no zone (ADR-0053 D-C1) and only its author knows which wall clock it meant", + "conversionId": "time-default-utc-suffix-dropped", + "toMajor": 18 + }, + { + "surface": "page.component.page:header.breadcrumb", + "to": "page:header prop 'breadcrumb' removed, whether 'true' or 'false' (no renderer ever drew a trail for it: objectui drew an empty slot and nothing filled it, and the app shell's header draws the navigation trail)", + "conversionId": "page-header-breadcrumb-removed", + "toMajor": 18 + }, + { + "surface": "connector.syncConfig / connector.fieldMappings", + "to": "connector keys 'syncConfig' and 'fieldMappings' removed (ADR-0049 — no engine ever ran a connector-attached sync or moved a value through a connector field mapping, so the `latest_wins` and `soft_delete` defaults resolved and deleted nothing. The DataSyncConfig, SyncStrategy, ConnectorConflictResolution and ConnectorFieldMapping shapes went with them. A sync is defined on its target instead: a `mapping` whose `connectorSource` names the connector it pulls from, with a `job` for the cadence)", + "conversionId": "connector-sync-keys-removed", + "toMajor": 18 + }, + { + "surface": "view.form.subforms[].columns[].field / view.formViews..subforms[].columns[].field", + "to": "form-view subform grid column entries respelled 'field' → 'name', the grid's column identity (the carrier accepted any value until it took the inline grid column contract; a relationship field's inlineColumns get the same respelling from field-column-lists-canonicalized)", + "conversionId": "form-view-subform-columns-canonicalized", + "toMajor": 18 + }, + { + "surface": "page.component.action:button.endpoint / page.component.action:icon.endpoint", + "to": "action:button / action:icon component prop 'endpoint' → 'target' on an `api` action — the rename `ActionSchema` already prescribes; the console's `api` handler reads `target` only. An `endpoint` on a block with no `actionType` or another one is left as stored and reported as a TODO", + "conversionId": "action-block-endpoint-to-target", + "toMajor": 18 + }, + { + "surface": "view.form.sections[].fields[].publicPicker", + "to": "form field 'publicPicker' removed (ADR-0087 D2 — the anonymous public-form record-search picker is retired: an anonymous public form no longer takes lookup, master_detail or user fields, and the anonymous lookup route is gone. Use a select field with static options, or put the form behind sign-in)", + "conversionId": "form-field-public-picker-removed", + "toMajor": 18 + }, + { + "surface": "dataset.measures[].field (aggregate count, empty string)", + "to": "a `count` dataset measure's empty `field` is removed: a count with no `field` counts rows, which is what the empty string compiled to, and a measure's `field` is now a column reference that refuses an empty string", + "conversionId": "dataset-count-measure-empty-field-removed", + "toMajor": 18 + }, + { + "surface": "agent.structuredOutput.format / agent.structuredOutput.fallbackFormat / agent.structuredOutput.transformPipeline", + "to": "agent structured-output formats 'regex' / 'grammar' / 'xml' and the transform step 'coerce_types' removed: the AI runtime refused each before the first turn, because structured output is checked only as JSON and no coercion engine exists. A block whose format was retired is deleted, a retired fallback format is deleted, and the coerce step is dropped from the pipeline", + "conversionId": "agent-structured-output-refused-members-removed", + "toMajor": 18 + }, + { + "surface": "translation.dashboards.widgets.subCaption", + "to": "translation widget key 'subCaption' removed: the metric sub-caption it overlaid onto the widget's 'options.description' is retired at both ends — the dashboard schema never declared that key and no authored widget wrote it, so the overlay was its only writer. A widget keeps one authored description, 'widget.description', translated by the widget node's 'description' key", + "conversionId": "translation-widget-sub-caption-removed", + "toMajor": 18 + }, + { + "surface": "agent.memory.longTerm.store", + "to": "agent memory key 'longTerm.store' removed: the memory store is platform infrastructure, not agent metadata — the AI runtime keeps long-term memory notes in its own database store and refused the 'vector' default and 'redis' before the first turn. The key is deleted; every other memory key stays", + "conversionId": "agent-memory-long-term-store-removed", + "toMajor": 18 + }, + { + "surface": "agent.lifecycle", + "to": "agent key 'lifecycle' removed: the conversation state machine was parsed and never read — no runtime moved an agent through a declared state. The key is deleted; a conversation phase is a skill with triggerConditions, orchestration is a Flow, record transitions are a state_machine validation rule", + "conversionId": "agent-lifecycle-removed", + "toMajor": 18 + }, + { + "surface": "page.component.object-grid.resizableColumns", + "to": "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)", + "conversionId": "object-grid-resizable-columns-removed", + "toMajor": 18 + }, + { + "surface": "page.requires", + "to": "page key 'requires' removed from react, full and slotted pages (a page with no kind is full) — the plugin-namespace list is derived from the source at save only on html / jsx pages; on the other kinds nothing derived or enforced it, and the Studio page editor drops it", + "conversionId": "page-requires-non-compiled-kind-removed", + "toMajor": 18 + }, + { + "surface": "page.component.element:text.variant", + "to": "element:text 'variant' spellings 'heading' → 'h2' and 'subheading' → 'h3' (the vocabulary converged on the nine values ui:text publishes; each old spelling already rendered that heading element, so the outline is unchanged and the heading takes that level's style)", + "conversionId": "element-text-variant-heading-levels", + "toMajor": 18 + }, + { + "surface": "page.component.object-master-detail-form.details[].sortField", + "to": "object-master-detail-form detail entry prop 'sortField' removed (the console reads no authored value: the line grid stamps the field it derives from the child object, so the key was accepted and dropped; delete the key — the child object's own position field keeps the line order)", + "conversionId": "object-master-detail-form-detail-sort-field-removed", + "toMajor": 18 + }, + { + "surface": "object.indexes[].unique / objectExtensions[].indexes[].unique", + "to": "declared-index bare `unique: true` → `unique: 'global'` (ADR-0120 D2 — the scope is stated, never positional; `'global'` is exactly the index bare `true` built, so the physical index is byte-identical; field-level `unique: true` is not converted)", + "conversionId": "declared-index-unique-scope", + "toMajor": 18 + }, + { + "surface": "manifest.permissions", + "to": "manifest 'permissions' as a flat list of permission strings removed (ADR-0049 — no loader ever read the list, so dropping it changes no grant; the structured { services, hooks, network, fs } block is the only form, and a permission string has no mechanical mapping onto it)", + "conversionId": "manifest-permissions-string-list-removed", + "toMajor": 18 } ], "migrated": [ @@ -890,898 +1274,5898 @@ "migrationId": "workflow-service-slot-retired", "toMajor": 17, "rationale": "The workflow slot was declared end to end and implemented nowhere: no code in either repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches were plugin-dev's retired stub probe and the generic discovery walk), no implementation of any WorkflowProtocol method ever existed, and no host ever mounted `/api/v1/workflow` (DEFAULT_DISPATCHER_ROUTES, before it was retired as a stale list, named it among routes that never existed). Every part of it was ADR-0078's silently-inert declaration: a CoreServiceName nothing filled, a contract nothing implemented, a protocol nothing served, a discovery route field no builder could truthfully populate. These are TS/API surfaces and a discovery RESPONSE field — never stored in stack metadata, so there is no source for the chain to rewrite; consumers of the deleted types move their imports themselves. ADR-0049 / ADR-0078." - } - ], - "removed": [] - }, - "perMajor": [ - { - "from": 16, - "to": 17, - "added": [], - "converted": [ - { - "surface": "action.execute", - "to": "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)", - "conversionId": "action-execute-to-target", - "toMajor": 17 - }, - { - "surface": "field.conditionalRequired", - "to": "field key 'conditionalRequired' → 'requiredWhen' (the deprecated predicate alias, folded into the canonical key so no reader picks its own precedence)", - "conversionId": "field-conditionalRequired-to-requiredWhen", - "toMajor": 17 - }, - { - "surface": "agent.tools", - "to": "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)", - "conversionId": "agent-tools-to-skills", - "toMajor": 17 - }, - { - "surface": "sharingRule.accessLevel", - "to": "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)", - "conversionId": "sharing-rule-access-level-full-to-edit", - "toMajor": 17 - }, - { - "surface": "flow.node.config.objectName", - "to": "CRUD flow-node config key 'object' → 'objectName' (the last alias in the executors' `readAliasedConfig` shim graduates into this layer, and the shim is deleted)", - "conversionId": "flow-node-crud-object-alias", - "toMajor": 17 - }, - { - "surface": "flow.node.notify.config", - "to": "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)", - "conversionId": "flow-node-notify-config-aliases", - "toMajor": 17 - }, - { - "surface": "flow.node.wait.waitEventConfig", - "to": "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)", - "conversionId": "flow-node-wait-event-config-lift", - "toMajor": 17 - }, - { - "surface": "flow.node.connector_action.connectorConfig", - "to": "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)", - "conversionId": "flow-node-connector-config-lift", - "toMajor": 17 - }, - { - "surface": "flow.node.map.config.flowName", - "to": "map flow-node config key 'flow' → 'flowName' (an undeclared spelling the executor accepted through a bare fallback; it graduates into this layer)", - "conversionId": "flow-node-map-flow-alias", - "toMajor": 17 - }, - { - "surface": "flow.node.subflow.config.flowName", - "to": "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)", - "conversionId": "flow-node-subflow-flow-alias", - "toMajor": 17 - }, - { - "surface": "flow.node.script.config", - "to": "script flow-node config keys 'functionName' → 'function', 'input' → 'inputs' (executor `??` fallbacks, graduated into this layer)", - "conversionId": "flow-node-script-config-aliases", - "toMajor": 17 - }, - { - "surface": "permission.rowLevelSecurity.priority", - "to": "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)", - "conversionId": "permission-rls-priority-removed", - "toMajor": 17 - }, - { - "surface": "tool.category / tool.permissions / tool.active / tool.builtIn", - "to": "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)", - "conversionId": "tool-inert-authoring-keys-removed", - "toMajor": 17 - }, - { - "surface": "app.version / app.aria / app.objects / app.apis / app.sharing / app.embed / app.mobileNavigation / app.contextSelectors.includeAll / app.contextSelectors.placement / app.homePageId / app.areas.order", - "to": "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)", - "conversionId": "app-dead-authoring-keys-removed", - "toMajor": 17 - }, - { - "surface": "app.areas.visible / app.areas.requiredPermissions", - "to": "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)", - "conversionId": "app-area-fail-open-gates-removed", - "toMajor": 17 - }, - { - "surface": "action.shortcut / action.bulkEnabled", - "to": "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)", - "conversionId": "action-inert-keys-removed", - "toMajor": 17 - }, - { - "surface": "flow.active / flow.template / flow.nodes[].outputSchema / flow.errorHandling.fallbackNodeId", - "to": "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)", - "conversionId": "flow-inert-keys-removed", - "toMajor": 17 - }, - { - "surface": "view.list.responsive / view.list.performance / view.form.defaultSort / view.form.aria", - "to": "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)", - "conversionId": "view-inert-keys-removed", - "toMajor": 17 - }, - { - "surface": "view.list.striped / view.list.bordered / view.list.virtualScroll", - "to": "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)", - "conversionId": "view-list-passthrough-keys-removed", - "toMajor": 17 - }, - { - "surface": "view.list.exportOptions / view.listViews.*.exportOptions", - "to": "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)", - "conversionId": "view-export-options-pdf-removed", + }, + { + "surface": "action.aria / object.actions[].aria — the ARIA block on an action", + "replacement": "The action's required `label`, which every action renderer uses as the accessible name (the visible button or menu-item text, and the `aria-label` of an icon-only action). To name the region that places the actions, the `aria` block of the placing node — `page.components[].aria` or the list view `aria`.", + "migrationId": "action-aria-retired", + "toMajor": 18, + "rationale": "The D2 conversion `action-aria-removed` deletes `aria` from every stack action and every object-nested action, and the delete is lossless: no surface that renders an action ever read the block, so the ARIA attributes it declared never reached the DOM. The residue is accessibility work the author did that no user benefited from. An author who wrote `aria.ariaLabel` believed screen-reader users heard that name; they heard the `label`. The strip deletes the text along with the key, and only the author can say whether it should become the `label` — which sighted users read too — or whether it described the toolbar or list the action sits in, and belongs in that node's `aria` block instead." + }, + { + "surface": "page.component.action:button.endpoint / page.component.action:icon.endpoint — the endpoint an `api` action button calls, on the two page blocks that run an action", + "replacement": "`target` — the one key the action runner dispatches an executor on, and the key `ActionSchema` already renames `endpoint` to.", + "migrationId": "action-block-endpoint-spelling-retired", + "toMajor": 18, + "rationale": "The D2 conversion `action-block-endpoint-to-target` renames `endpoint` to `target` in author sources and on every stored-row rehydration, for a block whose `actionType` is `api` — the one meaning the key declared, and the rename is lossless there. Three things are left. A block that carries `endpoint` with no `actionType` was called through the action runner's legacy API fallback, which a `target` with no type does not reach, so the author has to add `actionType: 'api'` as well. A block with another `actionType` never read `endpoint`, so only the author can say whether its value should become the `target` or be deleted. And a block carrying both spellings with different values is left for the author to keep one. Each is left as stored and reported as a TODO. Code is out of reach: a custom action handler that read `endpoint` off the action it was handed reads nothing once the block carries `target`." + }, + { + "surface": "`action.execution` — the bulk dispatch contract an action’s body is written for", + "replacement": "Declare `execution: 'perRecord' | 'aggregate'` on every action a list view wires into the selection bar, DERIVED from the wiring that action already has: a view naming it in `bulkActions: ['']` (the bare-string form) dispatches it once per selected row with that row's `recordId` ⇒ `execution: 'perRecord'`; a `bulkActionDefs` entry naming it with `execution: 'aggregate'` dispatches it once for the whole selection with every id in `params._selectedIds` ⇒ `execution: 'aggregate'`. The derivation is exact wherever an action is wired ONE way, because the wiring is what the body has been receiving all along — declaring it changes no behaviour, it writes down the behaviour. ⛔ There is no default: an action no view bulk-wires, and an action whose body genuinely serves both contracts (it reads `recordId` AND `_selectedIds` and copes with either), stays UNDECLARED rather than being given a value.", + "migrationId": "action-bulk-dispatch-contract-undeclared", + "toMajor": 18, + "rationale": "Not losslessly convertible, because the fact being written down does not live on the item being rewritten. The declaration belongs to the ACTION and the evidence for it belongs to the VIEWS — potentially several, in other files or other packages — so no per-item transform has both halves in hand, and `objectstack migrate meta` rewrites stored metadata by key. The residue is genuinely a judgement: an action wired BOTH ways has no correct value, because one call and N calls have different side effects and the platform will not silently unify them (the 2026-09-12 ruling that made an action declare its dispatch contract refused exactly that option). Such an action is TWO actions — split the body along the line the two wirings already draw and declare each half — or, if the body was deliberately written to serve both, it stays undeclared and the two wirings stand. The census that is this migration’s input was taken 2026-09-13 over objectstack@a9c64779046 (shipped app metadata, test fixtures excluded: 13 distinct bulk-wired actions — 11 unambiguously per-record, 1 unambiguously aggregate, 1 wired both ways) and hotcrm@c716a2ccb3d31574a1a238a590f3e331ddae0200 (3 distinct bulk-wired actions — 2 per-record, 1 aggregate, 0 wired both ways). So the both-ways residue is real but rare, which is why it is a structured TODO and not a blocking rewrite." + }, + { + "surface": "Action handler body — `ctx.engine.find(object, filter)` (`ActionEngineFacade.find`, `@objectstack/spec/ui`)", + "replacement": "`ctx.engine.find(object, { where: filter })` — the engine's own query envelope (`EngineQueryOptions`), the same options bag `IDataEngine.find` takes. The filter moves under `where` verbatim: `find('task', { status: 'open' })` → `find('task', { where: { status: 'open' } })`. An unfiltered `find(object, {})` is unchanged, and the rest of the envelope — `fields`, `orderBy`, `limit`, `offset`, `expand` — becomes reachable from a handler for the first time. A caller-supplied `context` is ignored: the facade is trusted and stamps its own elevated one.", + "migrationId": "action-engine-facade-find-query-envelope", + "toMajor": 18, + "rationale": "The rewrite itself is lossless and mechanical, but it is not automatable here: an action handler is authored TypeScript, and the chain rewrites stored metadata by key, so no `os migrate meta` step can reach a call expression inside a function body. The change is a WITHDRAWAL of the parameter shape an earlier typing fix chose (the filter alone), ruled by the director seat on 2026-09-12, with the maintainer's agreement, on the long-term axis 「one platform, one query shape」. The facade had been given a shape different from the engine's — the `where` half alone — which made the most natural spelling the wrong one: an author who passed the engine's envelope got `{ where: { where: … } }`, matching no row and resolving to `[]` with no error, while an unfiltered `{}` kept working under either belief so a dead handler looked partially alive. The alternative — refusing `where` at the top level with an intersection — was rejected because it asserts a vocabulary fact the spec declares nowhere, reserving the field name `where` across every customer's data model to buy one parameter's compile-time check." + }, + { + "surface": "stored `address` and `location` field VALUES (`AddressSchema` / `AddressValueSchema`, `LocationValueSchema` — ADR-0104 D1), and the two authoring doors that parse the same contract: a `location` / `address` field's literal `defaultValue` and an action param of those types — undeclared keys", + "replacement": "the declared key the rejection names. An address value accepts exactly `street`, `city`, `state`, `postalCode`, `country`, `countryCode`, `formatted`; a location value exactly `lat`, `lng`, `altitude`, `accuracy`. Every rejection carries the surface, the offending key and a rename (`postal_code` / `zipCode` / `zip` / `postcode` → `postalCode`, `latitude` → `lat`, `longitude` → `lng`). A key that names no declared member is removed at the producer — never tolerated at a consumer: an alias for an off-spec key in a consumer stays forbidden (contract-first — fix the metadata, not the runtime)", + "migrationId": "address-location-value-unknown-keys-refused", + "toMajor": 18, + "rationale": "Maintainer ruling 2026-09-01, option A: both value classes refuse undeclared keys. Both value classes were all-optional STRIPPING `z.object`s, so a value with a completely wrong key set parsed green and the wrong keys vanished from the parse output: the showcase seed wrote `postal_code`, the platform accepted it, dropped it, and rendered an empty ZIP box (found while counting stored address values for objectui's survey of which structured values its field validator checks; an earlier report had named the same stripping on the address widget's round-trip, whose ZIP input bound `zipCode` against a stored `postalCode`), while a stored-value scan over the class could only ever report a clean count it had no way to earn. Closing the two shapes restores declared = enforced and pulls \"loose\" back to the one deliberate exception (`FileValueSchema`, untouched). Where the refusal BITES is the ADR-0104 write path's own evidence-gated posture, deliberately unchanged: a record write carrying an undeclared key is refused only on a deployment that has attested `adr-0104-value-shapes` (or opted in with `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1`); everywhere else it stays warn-first and is reported to the admitted-violation sink, and `os migrate value-shapes` now COUNTS such keys, so a deployment holding them cannot attest until they are cleaned. No read path parses these shapes; a stored value reads back as written." + }, + { + "surface": "the SHIPPED platform admin permission sets `admin_full_access`, `organization_admin` and the derived `organization_admin_no_bypass` — their `objects[\"*\"].allowExport = true` wildcard grant (REMOVED; the rest of the wildcard is unchanged)", + "replacement": "an explicit `allowExport: true` on the object entries of an APP-authored permission set held by the principals meant to keep exporting. Nothing replaces the grant in the platform sets themselves", + "migrationId": "admin-export-wildcard-removed", + "toMajor": 18, + "rationale": "A capability NARROWING of a published set, and — like `export-axis-opt-in`, whose 17.0 story this completes — one no gate can announce: the metadata is unchanged and still parses, the shipped sets are re-seeded on upgrade, and the only observable is that an export which returned 200 now returns 403 `EXPORT_NOT_PERMITTED`. `export-axis-opt-in` told upgraders that \"package-shipped sets are re-seeded on upgrade, so the built-ins are handled — `admin_full_access` and `organization_admin` now carry the grant explicitly\"; from this major they deliberately do NOT, so a deployment that read that sentence and left its admins to the built-ins must now act. What the wildcard did, measured on 17.0.0 GA across 40 export probes: an org owner exported three objects on which NO app permission set granted export, 200 with full rows, and the app had no way to refuse — editing a code-package set answers `403 [not_overridable]`, and the org admin holds no app-authored set in which to write the per-object `false` that would have won. So an application could declare an object exportable by nobody, ship, and be silently wrong on an exfiltration boundary — declared ≠ enforced, on the axis where a silent gap costs the most. This is the 2026-08-07 ruling on the member baseline applied to export: that change removed `member_default`'s CRUD wildcard because a wildcard in a set every principal resolves is not a default but a floor nobody can get under; the export wildcard survived by omission rather than by decision, one tier up. It cannot be mechanically converted, in either direction: re-granting `allowExport` wherever an admin holds a set would restore today's behaviour and defeat the entire point, and leaving it withheld may revoke export an operator legitimately wants. WHICH principals may take a bulk copy is the segregation-of-duties judgement the axis exists to make explicit, and it belongs to the operator. Note the boundary this does NOT move: the export gate itself is unchanged and was never the defect (controls C1–C3 of the same run show it enforcing exactly), specific-over-wildcard precedence is unchanged, `allowExport` on a `\"*\"` entry remains a supported authoring shape in an app's OWN sets, and READ is untouched — an admin still sees every record they saw before. ADR-0087; maintainer ruling 2026-08-15, which removed `allowExport` from the wildcard entry of both shipped admin sets." + }, + { + "surface": "security.permission.adminScope.businessUnit (AdminScopeSchema, ADR-0090 D12) authored or stored BLANK: the empty string, or a value that is nothing but whitespace (spaces, a tab, a newline). The key is the scope's only required one and names the root business unit of the delegated subtree; a blank value satisfied the requirement while naming no unit", + "replacement": "the sys_business_unit.name (machine name) of the business unit at the root of the subtree the delegate administers, written out: `businessUnit: 'north_america'`. If the permission set should not delegate administration at all, remove `adminScope` from it. ⛔ There is no replacement that can be DERIVED from what was written: a blank names no unit, so the root the author meant is not recoverable, and the platform must not pick one.", + "migrationId": "admin-scope-business-unit-blank-refused", + "toMajor": 18, + "rationale": "Maintainer ruling A, 2026-09-23: an empty or whitespace-only `businessUnit` is refused at parse, and stored scopes are not rewritten. `AdminScopeSchema` declared `businessUnit` as a bare string with no minimum, so `{ businessUnit: '' }` and `{ businessUnit: ' ' }` parsed green — measured against the published spec 17.4.0 and re-measured on `main` before the change. This narrows a published face: every other key of the scope is scoped TO this one, and ADR-0090 D12 declares the scope's WHERE as a business-unit subtree, which a blank does not name. The delegated-admin gate resolves the anchor by exact name, so a blank anchor resolves to an empty subtree and approves nothing on the subtree axes — no escalation was measured; the defect is a declaration that does not enforce what it declares, satisfied most readily by an author (an AI author above all) that knew the key was required and did not yet know the unit. The refusal is a NON-TRANSFORMING refinement at the key's own path, deliberately not a trim: the metadata save path persists the submitted body verbatim rather than the parsed value, so a trimming schema would validate one string and store another that the gate's exact lookup cannot resolve. A real name therefore parses byte-identical. Scope is blankness only: a real name with surrounding whitespace is not judged by this entry. ⚠️ STORED ROWS ARE NOT REWRITTEN and there is no D2 conversion (no lossless rewrite exists — the root cannot be inferred, and dropping the scope would silently change who is a delegate). The read path does not re-validate stored rows, so no stored permission set becomes unreadable. A stored blank-anchored scope is refused on its NEXT WRITE instead: a Setup or data-door edit of that permission set answers 422 INVALID_METADATA naming adminScope.businessUnit, and the boot reconciliation backfill of a legacy record with no metadata definition reports it through its existing durability ERROR (ADR-0094 D4), whose own prescription is to make the record body spec-valid; restoring a trashed blank-anchored set brings the record back and reports the missing definition at ERROR the same way. ⛔ No path skips the row. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). ADR-0049 / ADR-0087 / ADR-0090." + }, + { + "surface": "kernel.advancedPluginLifecycle (the authorable config surface of `plugin-lifecycle-advanced.zod.ts` — 3 defs, 9 exported names: `AdvancedPluginLifecycleConfigSchema` / `AdvancedPluginLifecycleConfig` / `AdvancedPluginLifecycleConfigParsed`, `GracefulDegradationSchema` / `GracefulDegradation` / `GracefulDegradationParsed`, `PluginUpdateStrategySchema` / `PluginUpdateStrategy` / `PluginUpdateStrategyParsed`)", + "replacement": "(removed — there is no declarative replacement, because nothing ever read the declaration. The supported lifecycle surface is the HOST-DRIVEN library in `@objectstack/core`: construct `PluginHealthMonitor` and pass a `PluginHealthCheck` per plugin, construct `HotReloadManager` and pass a `HotReloadConfig` — the `content/docs/protocol/kernel/lifecycle.mdx` examples — rewritten to show the plugin exposing a method and the host registering it, never a declarative field — are the supported usage, and those input vocabularies (`PluginHealthStatus` / `PluginHealthCheck` / `PluginHealthReport`, `HotReloadConfig` with its embedded `DistributedStateConfig`, `PluginStateSnapshot`) SURVIVE in the same module as library parameter types. Degradation and update-strategy vocabularies return only via the ENFORCE route of ADR-0049 through a new ADR — the executor first, the vocabulary second)", + "migrationId": "advanced-plugin-lifecycle-config-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-08-25, route 2: retire the config container and keep the classes as a host-driven library. The container aggregated six config groups — `health`, `hotReload`, `degradation`, `updates`, `resources`, `observability` — and NO group had a runtime reader, re-measured per group at the retirement's base commit (8cdd696) with positive controls: the kernel never constructs `PluginHealthMonitor` or `HotReloadManager` (only their own unit tests and `core/examples/phase2-integration.ts` do, passing config DIRECTLY to the classes, never through this container); `degradation` / `updates` / `resources` / `observability` keys have no implementation body at all (controls: `checkMethod` resolves to `core/src/health-monitor.ts` and `debounceDelay` to `core/src/hot-reload.ts`, proving the scan sees real readers; the bare-name collisions — plugin-ordering's `optionalDependencies`, auth-manager's private `degradedFeatures`, plugin-security-advanced's `resourceLimits.maxCpu` read by `sandbox-runtime.ts` — are different surfaces, verified structurally). No manifest, stack collection or metadata-type binding ever embedded the container, so no authored document could carry it: an author declaring `health: {...}` or `rollback: { automatic: true }` got a clean parse and NOTHING — the shape of the plugin sandboxing / integrity / approval config that was never wired to anything, at container scale, sharpened by production-safety vocabulary (auto-restart, zero-downtime rolling updates, automatic rollback) an AI author (ADR-0033) reads as proof the capability exists. With no carrier key and no authored document there is nothing to tombstone and no seam for a D2 conversion: route 3, the shape of the dynamic plugin-loading family's removal and of the retired `ApiKeySchema` — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration." + }, + { + "surface": "agent.lifecycle — the agent conversation state machine left the shape; with it the XState StateMachineSchema family left @objectstack/spec/automation (StateMachineSchema, StateNodeSchema, TransitionSchema, ActionRefSchema, GuardRefSchema and their types), and StateNodeConfig left the root and /ai entries", + "replacement": "no key: delete `lifecycle` from every agent. Put what the machine meant where the platform enforces it — a phase of a conversation is a skill with its own `instructions` and `tools`, selected by its `triggerConditions` and attached through the agent's `skills`; a multi-step process is a Flow; a record's status transitions are a `state_machine` validation rule on the object (a flat table of each state's allowed next states). Code that imported the state machine exports declares the shape it needs itself, or drops it", + "migrationId": "agent-lifecycle-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove: `agent.lifecycle` was parsed and never read. No runtime — not this repository, not the cloud AI runtime that executes agents — moved an agent through a declared state or refused an undeclared transition, so an authored machine changed nothing an agent did. Enforcing it would have meant a statechart interpreter beside Flow, the two-engine shape ADR-0020 rejected, and what it reached for is already served: conversation phases by skills (ADR-0064), orchestration by Flow (ADR-0019), record transitions by the `state_machine` validation rule (ADR-0020). Authoring now refuses the key with that prescription, and TypeScript rejects it. The D2 conversion `agent-lifecycle-removed` deletes it from existing sources and stored agent rows, losslessly. `StateMachineSchema` had kept its file only for this door (ADR-0020 implementation note 1), so the family left with it — which of the three destinations each deleted machine meant is the author's judgement, not a mechanical rewrite" + }, + { + "surface": "agent.memory — longTerm.store left the shape (the memory store is the platform's); longTerm.maxEntries and reflectionInterval are required when longTerm.enabled is true, and reflectionInterval is refused without an enabled longTerm; longTerm.enabled is unchanged", + "replacement": "no storage key: delete `longTerm.store`, whatever it held — where long-term memory notes are kept is the platform's choice. An agent whose `longTerm.enabled` is true declares `longTerm.maxEntries` (how many distilled notes are kept for each user; the newest are recalled before the first round and older ones evicted) and `memory.reflectionInterval` (how many delivered interactions pass between the reflections that write a note). An agent without enabled long-term memory declares no `reflectionInterval`", + "migrationId": "agent-memory-store-retired-and-limits-required", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove: the `agent.memory` contract states exactly what the runtime honours. The cloud AI runtime, the one runtime that executes agents, enforces long-term memory from `enabled`, `maxEntries` and `reflectionInterval`: it recalls the newest `maxEntries` notes before the first round, writes one note every `reflectionInterval` delivered interactions, and evicts notes beyond `maxEntries`. It keeps the notes in its own database store, and before an agent's first turn it refused the `vector` store (the old default, so what an omitted `store` parsed to), `redis`, an enabled `longTerm` missing either number, and a `reflectionInterval` without an enabled `longTerm`. Authoring now refuses the same declarations, each with a prescription. The D2 conversion `agent-memory-long-term-store-removed` deletes `store` from existing sources and stored rows, losslessly: no value of it ever chose a backend. No default is declared for either number, because none has a measured basis — so an agent with long-term memory enabled and either number missing no longer parses, and only its author can choose the numbers it needs" + }, + { + "surface": "agent.structuredOutput — the values 'regex', 'grammar' and 'xml' left StructuredOutputFormat (at format and fallbackFormat) and 'coerce_types' left TransformPipelineStep (at transformPipeline); 'json_object', 'json_schema', 'trim', 'parse_json' and 'validate' are unchanged", + "replacement": "a JSON contract: `format: json_schema` with a JSON Schema in `schema` when the answer must have a shape, or `format: json_object` when any JSON value will do — or no `structuredOutput` block at all when the agent needs no output contract. A fallback format names one of the two JSON formats or is left out. In place of `coerce_types`, declare the exact types in `schema`, so the answer is validated as the model wrote it", + "migrationId": "agent-structured-output-refused-members-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove. The cloud AI runtime, the one runtime that executes agents, enforces `structuredOutput` on every final answer and refuses an agent that declares any of these four members before its first turn: the spec never had a key to carry the pattern or grammar a `regex` or `grammar` answer would be checked against, a final answer is checked only as JSON, and no coercion engine exists. Hosted model APIs constrain a final answer by JSON Schema only; regex and grammar constraints live in inference engines and in tool-input formats, not on an agent's answer. The D2 conversion `agent-structured-output-refused-members-removed` makes each stored or existing source parse: it DELETES a block whose `format` was retired, deletes a retired `fallbackFormat`, and drops `coerce_types` from the pipeline. The deletion of a block is the edit that needs judgement: it removes an output contract the runtime never kept, and only the author can say whether the agent should now carry a `json_schema` contract instead — the conversion cannot write the schema the author meant" + }, + { + "surface": "ConversationAnalytics.duration, the emitted session length whose name carried no unit (ai/conversation.zod.ts)", + "replacement": "durationSeconds — rename the key; the value is unchanged", + "migrationId": "ai-conversation-analytics-duration-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B (2026-09-02, extended on 2026-09-05 to runtime-emitted durations): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. It stands alone because it is the only offender in ai/ and the only one on its file. What makes the bare name worth a registry row rather than a quiet edit is the company it kept: every other number on ConversationAnalytics is a COUNT — totalMessages, totalTokens, peakTokenUsage, pruningEvents, tokensSavedByPruning — so the one field that carried a unit was the one field that did not say so, sitting in a block of twelve unitless integers. The two instants beside it, firstMessageAt and lastMessageAt, already spelled themselves; the measurement between them did not. Tombstoned with retiredKey(); the shape is not strict, so a bare deletion would strip in silence and an emitter writing the old spelling would lose the value with no error anywhere. Why a semantic entry and not a D2 conversion: conversation analytics are computed at runtime and handed to a consumer, never authored by hand and never stored as a sys_metadata row, so the conversion chain has no seam that would ever see one — the same disposition every runtime-emitted measurement in this stack has taken. ADR-0087." + }, + { + "surface": "action.ai.outputSchema (stack actions and object-nested actions) and agent.structuredOutput.schema — a JSON Schema in which an object subschema with no type carries a type-scoped keyword", + "replacement": "the same schema with a `\"type\"` declared on every subschema that carries a type-scoped keyword: `\"object\"` beside `properties`, `required`, `additionalProperties`, `patternProperties`, `propertyNames`, `minProperties` or `maxProperties`; `\"array\"` beside `items`, `prefixItems`, `contains`, `minItems`, `maxItems` or `uniqueItems`; `\"string\"` beside `minLength`, `maxLength`, `pattern` or `format`; `\"number\"` or `\"integer\"` beside `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum` or `multipleOf`. A subschema meant to accept several types declares them as an array (`\"type\": [\"string\", \"null\"]`).", + "migrationId": "ai-json-schema-untyped-subschema-refused", + "toMajor": 18, + "rationale": "Both slots are compiled by the cloud AI runtime — `action.ai.outputSchema` before the action runs, to validate its result, and `agent.structuredOutput.schema` as the agent's structured-output contract — and both readers call one guard whose schema reader does not check a type-scoped keyword on a subschema that declares no `type`. That guard refuses the whole schema before anything runs. The spec declared both slots as open records, so such a schema passed `defineStack`, `objectstack validate` and the metadata save door, and the author learned of it only when the action or agent was invoked. Both slots are now one declaration that mirrors the guard exactly — the same 22 type-scoped keywords (`properties`, `required`, `additionalProperties`, `patternProperties`, `propertyNames`, `minProperties`, `maxProperties`, `items`, `prefixItems`, `contains`, `minItems`, `maxItems`, `uniqueItems`, `minLength`, `maxLength`, `pattern`, `format`, `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`), present with any value on an object node whose `type` is absent; the same descent into every value of `properties`, `patternProperties`, `$defs`, `definitions` and `dependentSchemas` and into the single subschema or each array entry of `items`, `additionalProperties`, `contains`, `propertyNames`, `not`, `if`, `then`, `else`, `unevaluatedProperties`, `unevaluatedItems`, `anyOf`, `oneOf`, `allOf` and `prefixItems`, under typed and untyped parents alike, without following `$ref` — and refuses each offending subschema at its own path with the `type` to declare named. Boolean subschemas, `{}`, a node with any `type` value, and an untyped node carrying only keywords outside the list (`enum`, `const`, `$ref`, `anyOf`, `title`, …) are accepted, as the runtime accepts them. Measured on the built package: the per-type schema the metadata save door validates with refuses an action, an object-nested action or an agent carrying such a schema at the subschema path, and `defineStack` throws with the same path; the same schema with `type` declared is accepted at both. Read from source and not run: a row already stored still loads, because the database loader replays the conversion chain and parses nothing, and its next save is refused until the `type` is declared. No conversion is registered: every refused schema was already refused by the runtime, so nothing that worked stops working; and supplying a `type` is a judgment about what the author meant, not a lossless rewrite, because declaring one also narrows what the schema accepts. Population measured at the change, on origin/main 135daaa06b: zero untyped subschemas in the three authorings of either slot across the package fixtures (one action `ai.outputSchema`, two `structuredOutput.schema`), and zero authorings of either slot in the examples, the documentation and the published skills; the one `outputSchema` the examples carry is a connector action's, a different key. Deployed metadata NOT MEASURED." + }, + { + "surface": "analytics cube definitions (`defineCube` / `defineStack({ analyticsCubes })`: the cube, each metric, each dimension, each join) and the `/analytics/query` body's nested `timeDimensions[]` items — undeclared keys", + "replacement": "the declared key the rejection names. Every rejection carries the surface, the offending key and a rename suggestion (`title` → `label` on a metric/dimension, `label` → `title` on the cube, `granuarity` → `granularity`, `orderBy` → `order`; `filters` on a query gets the `where` prescription). A key that names no supported capability is simply removed", + "migrationId": "analytics-authorable-unknown-keys-refused", + "toMajor": 18, + "rationale": "The unknown-key strictness campaign (the sweep that ended silent stripping of undeclared keys as the default, one schema family at a time), its data/ batch. These shapes parsed `.strip` — an undeclared key on an authored cube was silently dropped, so a join authored with a typo'd `relationship` registered with the `many_to_one` default (a different join than the author declared) and a metric's misspelled key vanished under a successful parse. The subtle half: `/analytics/query`'s top level has been strict since the degraded shim's envelope dialect was retired (one URL, one request body), but top-level strictness does not recurse — `timeDimensions: [{ dimension, granuarity: 'day' }]` rode through the strict wrapper with the typo stripped, bucketing the whole range as one group under an ordinary 200. Undeclared keys on all eight sites are now refused at parse time with a prescriptive message. (Two of the eight were themselves removed later in this major, because nothing ever read them: the nested metric `filters[]` item, by `metric-filters-removed`, and the cube's `refreshKey` block, by `cube-refresh-key-removed`.)" + }, + { + "surface": "data.Cube.public — an analytics cube that declares public: false, and every cube in an artifact built by os compile before this release (the compiler writes the parsed stack, so it carries a materialized public: false on each cube that omitted the key)", + "replacement": "nothing, to keep a cube queryable: cubes are visible by default. Delete an authored `public: false` that only restated the old default, and write it only on a cube that must stay out of the analytics API. Recompile every `os compile` artifact built before this release", + "migrationId": "analytics-cube-public-default-visible-enforced", + "toMajor": 18, + "rationale": "A DECLARED-DEFAULT CORRECTION plus the enforcement that makes the key real. The analytics cube schema declared `public` with a default of `false` under an access-control comment, and nothing read it: `/analytics/meta` listed every cube and every query door answered it. The analytics service now reads it. A cube declared `public: false` is left out of `/analytics/meta`, and `/analytics/query` and `/analytics/sql` refuse it with 404 `CUBE_NOT_FOUND` — the same refusal, byte for byte, that an unknown cube name gets, so the refusal does not confirm a hidden cube exists. Enforcing the old default as declared would have hidden every cube that omits the key, so the default moves to `true` in the same change, and a cube that omits the key stays visible exactly as it was. Two holdings change behaviour on upgrade. An authored `public: false` — including one copied from the example app, which carried it — now hides the cube and refuses its queries. And an artifact built by `os compile` before this release carries a materialized `public: false` on every cube that omitted the key, because the compiler writes the parsed stack with its defaults applied; a host that registers cubes from such an artifact hides all of them until the artifact is recompiled. The key is visibility, not row security: records stay governed by object permissions and row-level security on every door, and the metadata door keeps serving cube definitions." + }, + { + "surface": "data.Cube.dimensions.granularities — an analytics cube time dimension whose granularities list holds exactly one interval, whether an author wrote it that way or the protocol 18 conversion cube-sub-day-granularities-removed reduced a longer list to it", + "replacement": "nothing, when that one interval is the bucket the dimension should be grouped at by default. When it is not, list every interval the dimension serves (two or more state no default) or omit the key. A dashboard or report whose query the engine aggregate path cannot evaluate (a custom-SQL measure, or a member of a cube with `joins` that resolves through one) either stops grouping by such a dimension or groups by one that declares no single interval", + "migrationId": "analytics-cube-single-granularity-default-enforced", + "toMajor": 18, + "rationale": "An inert key made real. A cube time dimension's `granularities` was read only for a cube the dataset compiler minted, where a one-interval list is the dataset's default bucket. A cube authored with `defineCube()` or `defineStack({ analyticsCubes })` never reached that reader, so grouping by its time dimension grouped raw timestamps, one group per distinct instant, whatever the list said. The analytics service now reads every cube by the compiled-dataset rule: on `/analytics/query` and on the `/analytics/sql` dry run, a time dimension the query groups by without stating a granularity is bucketed at the one interval its list declares. A granularity the query states still wins, one the list does not name is not refused, and a list of two or more states no default. Two holdings change on upgrade. A query grouping by such a dimension answers one row per bucket where it answered one row per timestamp. And a bucketed query leaves the raw-SQL path, which declines every bucketed query, for the engine aggregate path, which answers 400 `INVALID_FIELD` for every member it cannot evaluate — the same refusal, byte for byte, that the same query already got with that granularity stated by hand. Those members are: a custom-SQL measure (a `number`, `string` or `boolean` measure whose `sql` is an expression); and, on a cube whose members resolve through its `joins`, a measure or a `where` field over a joined object, a `timeDimensions` entry over a joined object (bucketed or a window, so grouping by a one-interval time dimension over a joined object is refused too), a dimension that traverses more than one relationship, and an `avg` or `count_distinct` measure beside any dimension over a joined object. The raw-SQL path serves every one of these, so each such query grouped by such a dimension goes from answered to refused. On a host whose `queryCapabilities` offers raw SQL with no engine aggregate bridge (a hand override: the analytics plugin wires both), no strategy remains for a bucketed query, so every newly bucketed query, a plain count included, goes from answered to \"No strategy can handle query\". The protocol-18 conversion `cube-sub-day-granularities-removed` strips the retired sub-day intervals from every authored and stored cube, so a dimension that offered one sub-day interval and one coarser interval now holds a one-interval list: a default bucket its author never wrote." + }, + { + "surface": "the ARRAY arm of timeDimensions[].dateRange on an analytics query — AnalyticsQuerySchema / the POST /analytics/query and /analytics/sql bodies, a dataset selection's timeDimensions, and any AnalyticsQuery a host passes to AnalyticsService.query in-process — authored with anything other than EXACTLY two string bounds: a one-element window such as [\"2026-01-01\"], the empty array [], and three or more bounds such as [\"2026-01-01\", \"2026-01-31\", \"2026-02-28\"]", + "replacement": "exactly two string bounds — `[start, end]`. A ONE-ELEMENT window is that day written as BOTH bounds: `['2026-01-01']` becomes `['2026-01-01', '2026-01-01']`, the shape the shipped migration table for the closed preset vocabulary already prescribes for a single day, and the shape all four analytics faces have selected that one day with since the fix that made them read the array arm one way. ⛔ The EMPTY array and THREE-OR-MORE bounds have NO replacement that can be derived from what was written: an empty array names no window at all, and a 3+ array names no pair — decide the window the widget was meant to show and write its two bounds, or drop the dateRange entirely (the field is optional, and absent means the query is not time-bounded). A relative window is a preset name from the closed vocabulary (`'last_7_days'`) or a date-macro pair (`['{7_days_ago}', '{today}']`).", + "migrationId": "analytics-date-range-array-two-bounds-required", + "toMajor": 18, + "rationale": "Maintainer ruling A of 2026-09-12, re-affirmed 2026-09-13, which tightened the array arm to exactly two string bounds: the arm was a bare `z.array(z.string())` with NO length constraint, while the refusal sentence in the same source file said verbatim that \"an explicit window is the two-element array [start, end]\" and the shipped migration table for the closed preset vocabulary told an author to write a single day as `['2026-01-20', '2026-01-20']`. So only the TYPE was weaker than the prose beside it, and a measurement of one authored document on each face found what that bought: one authored `['2026-01-01']` meant a point window on ObjectQLStrategy, NO time clause at all on NativeSQLStrategy (the whole of history), an unbounded-above window in the draft-preview evaluator, and a shifted point window in DatasetExecutor.runCompare — the same document, four backends, four different numbers, no error on any of them. The fix that followed made all four faces refuse it with the ADR-0112 envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`, which left the contract door LOOSER than every reader behind it; this narrowing closes that gap at the door. ⚠️ No D2 conversion and no stored-metadata rewrite, deliberately: rewriting `['2026-01-01']` to the same day twice at load would be the platform deciding, silently, that the author meant one day rather than a window whose end they forgot — and for the empty array and 3+ bounds there is nothing to decide FROM. The blast radius is the WIDGET, not the page: a stored dashboard carrying a now-refused range loses that widget with the accurate refusal shown, and the dashboard still loads. Since that fix every such stored range already failed at QUERY time with the same code and status, so this adds no new class of breakage — it moves the refusal to authoring time and states it accurately. ADR-0049 / ADR-0087 / ADR-0112." + }, + { + "surface": "the row window of an analytics query — limit and offset on AnalyticsQuerySchema, the POST /analytics/query and /analytics/sql bodies, and a dataset selection (POST /analytics/dataset/query) — authored as a negative number (limit: -1, offset: -1), a fraction (limit: 1.5), or an integer above Number.MAX_SAFE_INTEGER", + "replacement": "a non-negative integer, or no key at all: delete `limit` to return every row (a `limit: -1` written to mean \"no limit\" is exactly that), write `limit: 0` only for no rows, and delete `offset` (or write `offset: 0`) to skip nothing; a fraction becomes the integer page size that was meant. An `offset` with no `limit` stays valid and returns every row after the offset, on SQLite and PostgreSQL alike", + "migrationId": "analytics-query-window-non-negative-integer", + "toMajor": 18, + "rationale": "Both members were a bare `z.number()`, and every value outside the non-negative integers answered differently per driver and per face. Measured at POST /api/v1/analytics/query on SQLite and PostgreSQL 16, through the real dispatcher route: `limit: -1` returned every row on the native SQLite face, a 500 on native PostgreSQL and all but the last row on the ObjectQL face; `limit: 1.5` answered a 500, two rows and one row; `offset: -1` a 500 on both native drivers and every row on the ObjectQL face. No value had one answer, so the contract refuses them instead of any engine guessing (contract first, ADR-0049): the two members are `z.number().int().nonnegative()` on `AnalyticsQuerySchema`, the dataset selection reads the same two declarations off its shape, and the runtime doors answer the ADR-0112 envelope `400 VALIDATION_FAILED` naming `limit` or `offset` before any engine runs. ⚠️ No D2 conversion and no stored-metadata rewrite: the window is a QUERY-time request field, not a `sys_metadata` shape, and the refused values meant different windows on different backends, so coercing one would be the platform guessing which the author meant. The one stored producer that lowers into a selection, a dashboard widget's `limit`, is already declared a positive integer. Measured in this repository at the change: no example, fixture, document or published skill authors a negative or fractional analytics window. ADR-0049 / ADR-0112." + }, + { + "surface": "analyticsCubes[].measures..sql, analyticsCubes[].dimensions..sql and datasets[].measures[].field (data.MetricSchema.sql / data.DimensionSchema.sql / ui.DatasetMeasureSchema.field) authored as the row wildcard * where no count consumes it — a cube measure whose type is anything but count, any cube dimension, and a dataset measure whose aggregate is anything but count or that declares none (a derived measure)", + "replacement": "what the member meant. A row count: `type: 'count'` on a cube measure or `aggregate: 'count'` on a dataset measure, keeping `'*'` (a dataset count may also omit `field`). An aggregate of values: the column it aggregates — a field of the object (`amount`) or a relationship path ending in one (`account.amount`). A cube dimension: the column it groups by; to count rows, declare a `count` measure instead. A `derived` measure: delete the `field` key, which nothing read — a derived measure combines other measures by name", + "migrationId": "analytics-row-wildcard-outside-count-refused", + "toMajor": 18, + "rationale": "`'*'` is the row wildcard a `count` aggregates (`COUNT(*)`): it reads no field value, so no other aggregate has a column to read over it, and a dimension has no aggregate at all. The contract nevertheless admitted it in a cube member's `sql` on any measure and on a dimension, and in a dataset measure's `field` under any aggregate, and the analytics strategies passed it to the database as written. Measured at POST /api/v1/analytics/dataset/query over a real SQLite driver, on the native-SQL and the ObjectQL strategy alike: a dataset measure aggregating `'*'` under `sum`, `avg`, `min`, `max` or `count_distinct` answered 500 DATABASE_ERROR — a server fault for an authoring mistake the contract had admitted. A dataset measure compiles to the cube measure it names verbatim, so the same reading covers an authored cube measure; a dimension over `'*'` (GROUP BY *) was measured the same way when the dataset dimension was narrowed. Such a member never produced an answer, so no working document changes meaning: the failure moves from the query to the authoring parse, which names the slot and the aggregate and prescribes a `count` or a column. There is no D2 conversion: rewriting to `count` would change the figure the author asked for, and only the author knows which column a sum over `'*'` was meant to read. A STORED document is not rewritten: a metadata read still serves it as stored, with the refusal on its read diagnostics, and a re-save through the metadata write door is refused at the slot. The dataset query door parses every dataset it is handed, inline or saved, so a stored dataset carrying such a measure is refused 400 VALIDATION_FAILED on EVERY query — including a query that selects only its other measures, which used to answer: it fails closed until the member is fixed. An authored cube reaches the analytics runtime through the stack definition, whose parse refuses it when the stack is built. In-repo census before the change: no example, platform object, doc, skill or fixture authored one, and neither did objectui at the pinned commit; deployed metadata was NOT measured. ADR-0021 / ADR-0049 / ADR-0087" + }, + { + "surface": "the bare-STRING arm of timeDimensions[].dateRange on an analytics query — AnalyticsQuerySchema / the POST /analytics/query and /analytics/sql bodies, a dataset selection's timeDimensions, and any AnalyticsQuery a host passes to AnalyticsService.query in-process — authored as anything other than one of the thirteen declared date-range preset names (today, yesterday, this_week, last_week, this_month, last_month, this_quarter, last_quarter, this_year, last_year, last_7_days, last_30_days, last_90_days): the display spelling \"Last 7 days\" the schema comment used to show, the driver-memory dialect \"last N days\" / \"last 3 months\", or a bare ISO date such as \"2026-01-20\" (the SQL strategies' single-day dialect)", + "replacement": "a preset name from the closed vocabulary — `'last_7_days'` for \"Last 7 days\" / \"last 7 days\", `'last_30_days'`, `'this_month'`, and so on (`DATE_RANGE_PRESETS` in `@objectstack/spec/data` is the list; the rejection prints it) — or, for an explicit window, the two-element array the array arm always accepted: `['2026-01-20', '2026-01-20']` for the single day a bare ISO string used to mean on SQL, `['2026-01-01', '2026-01-31']`, or `['{7_days_ago}', '{today}']` in date-macro tokens", + "migrationId": "analytics-time-dimension-date-range-vocabulary-closed", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-09-06 on the analytics date-range string (option A — contract first): the protocol is the baseline, so the vocabulary is declared once in the schema and the drivers align to it, in a driver change of their own, instead of each guessing. The arm was a bare `z.string()` whose only documented example, `\"Last 7 days\"`, no driver could parse: driver-memory recognised exactly `today` and a case-sensitive `last N ` and fell every other string through to a `[range, range]` pseudo-window that — measured through mingo on 2026-09-05 — matched EVERY `Date`-typed row, 2099 included, because a `Date` compares above a `String` under BSON cross-type ordering; the SQL strategies read the same string as a single ISO day. A dashboard asking for one week silently got all of history on one backend and one day on the other, with no error on either. The string arm is now `z.enum(DATE_RANGE_PRESETS)` — derived from `data/date-range-presets.ts`, the vocabulary's single source of truth since the dashboard date filter's three copies of the list were folded into it, so the two cannot drift — and any other string is refused at parse time with one prescriptive issue at the field's own path; the runtime door answers the ADR-0112 envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` (`api/error-code-ledger.zod.ts`). ⚠️ No D2 conversion and no stored-metadata rewrite: this value is a QUERY-time request field, not a `sys_metadata` shape, and the two retired dialects meant different windows on different backends, so coercing one would be the platform guessing which the author meant. Measured in this repository at the ruling: three authored `'Last 7 days'`, all in spec tests, and no published dashboard authors the string arm at all (the shipped console lowers presets to the array arm). ADR-0049 / ADR-0112." + }, + { + "surface": "api.AssembledInstalledPackageSchema / api.InstalledPackageAtEitherStageSchema / api.ListInstalledPackagesResponseSchema / api.GetInstalledPackageResponseSchema / api.PackageApiContracts, with the types AssembledInstalledPackage, InstalledPackageAtEitherStage, ListInstalledPackagesResponse, GetInstalledPackageResponse and their Parsed twins — imported from @objectstack/spec/api", + "replacement": "the same names, unchanged, imported from `@objectstack/spec/api-assembled` — change the import path and nothing else. Every schema parses and refuses exactly what it did, the route map has the same four entries, and the JSON Schema ids are unchanged (`json-schema/api/AssembledInstalledPackage.json` and its three siblings are still published under `api/`). Every OTHER Package API declaration — the request schemas of both read doors, the install / uninstall / upgrade / rollback shapes, `PackageApiErrorCode` — stays on `@objectstack/spec/api`.", + "migrationId": "api-assembled-entry-split", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-09-17, option B (narrow the entry, rather than add a bundle-weight rule to the browser-reachability ledger or accept the weight as it stood): split the API entry so its browser-facing half no longer carries the assembled-package declarations. Those five embed the ASSEMBLED package body, which reaches the whole metadata vocabulary and, behind it, the datasource declaration and the driver-config validators; declared inside `@objectstack/spec/api`, that tree was part of every bundle of the entry, and a browser module importing two string constants from it paid for all of it. Measured on the splitting PR: that module (objectui `@object-ui/core` column-sortability) bundles to 166,529 bytes gzipped instead of 311,124. The split moves an import path, which is TypeScript source rather than metadata — nothing authors, stores or parses it — so there is no source a D2 conversion could rewrite, and the move is recorded here." + }, + { + "surface": "apis[].cacheTtl — the response-cache lifetime of a declared API endpoint", + "replacement": "`cacheTtlSeconds` — the same lifetime, in seconds, with the unit in the key name. It still applies to GET endpoints only.", + "migrationId": "api-endpoint-cache-ttl-unit-in-key", + "toMajor": 18, + "rationale": "The D2 conversion `api-endpoint-cache-ttl-to-cache-ttl-seconds` renames `cacheTtl` to `cacheTtlSeconds` in the `apis` collection and on stored endpoint rows, keeping the value, and the rename is lossless: the key always meant seconds. The judgment is whether the author knew that. The unit lived only in the description, on the same endpoint surface where `rateLimit.windowMs` spells its unit in milliseconds, so a value written in milliseconds — `cacheTtl: 60000` meant as one minute — cached responses for almost seventeen hours, and the rename carries 60000 over unchanged. A cache that lives a thousand times longer than intended serves stale data long after the underlying records change, with no error anywhere. Only the author can say which unit each value was written in." + }, + { + "surface": "EnhancedApiError.retryAfter (api/errors.zod.ts) — the ADR-0112 error envelope on the wire", + "replacement": "retryAfterSeconds — rename the key; the value (seconds) is unchanged", + "migrationId": "api-error-retry-after-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B of 2026-09-02 on duration-shaped keys: the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. BREAKING ON THE WIRE, and ruled in deliberately: its 2026-09-05 population ruling puts the ~16 runtime-emitted measurements in scope because they are read by humans and agents even if nobody authors them, and names ApiError.retryAfter explicitly, with its own BREAKING note. The ambiguity here is sharper than the usual bare duration. A consumer meets TWO retry-after values on the same 429 response: this envelope field, which has always been delta-seconds, and the HTTP Retry-After header, which per RFC 9110 section 10.2.3 may carry EITHER delta-seconds OR an HTTP-date. Spelled identically, they read as one value in two places; spelled retryAfterSeconds, the envelope states its own unit and the header keeps its own rules. THE HTTP HEADER IS A SEPARATE, UNCHANGED SURFACE — its name is fixed outside this repo and nothing in this rename touches it. Do not \"fix\" the header to match, and do not read a green grep for `retry-after` in transport code as leftover work. A SEMANTIC entry rather than a D2 conversion because an error envelope is emitted, never stored: it is not a stack collection member and never a sys_metadata row, so the conversion chain has no seam that would see one. ADR-0087, ADR-0112." + }, + { + "surface": "two api-layer runtime configuration durations whose name carried no unit: DataLoaderConfig.cacheTtl (api/contract.zod.ts) and RouteDefinition.timeout (api/router.zod.ts)", + "replacement": "cacheTtlSeconds (seconds) and timeoutMs (milliseconds) — rename each key; both values are unchanged", + "migrationId": "api-runtime-config-durations-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B of 2026-09-02 on duration-shaped keys: the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. Two keys on two shapes, in one entry because they share a disposition and an audience: both are api-layer runtime configuration a host or plugin builds in code, and neither is part of a published metadata document. DataLoaderConfig.cacheTtl named seconds only in its describe on a batching config whose other numbers are counts (maxBatchSize, maxConcurrency); RouteDefinition.timeout said \"Execution timeout in ms\" in prose and nothing else. Both are retiredKey() tombstones — neither shape is strict, so a bare deletion would strip the old key in silence and an unknown-key error could not carry the rename. Why a semantic entry and not a D2 conversion: a DataLoaderConfig is a per-request batch-loader construction argument and a RouteDefinition is a router registration built by a plugin at start — neither is a stack collection member and neither is ever stored, so the chain has no seam that would run on them (the kernel/Manifest:loading precedent). Worth knowing while grepping: packages/runtime declares its OWN local RouteDefinition interface for the ai:routes hook payload — a different type with no duration key at all, untouched by this rename. ADR-0087." + }, + { + "surface": "approvals position address role: — the approverId filter of the approvals request list, the actorId of every decision, and a stored pending_approvers slot", + "replacement": "`position:`, the one spelling of a position address; a flow approver authored as `{ type: 'role', value: }` becomes `{ type: 'position', value: }`", + "migrationId": "approval-position-address-role-retired", + "toMajor": 18, + "rationale": "The fourth face of the ADR-0090 D3 `role` retirement, beside `actor-user-roles-to-positions` and `action-session-roles-to-positions`, and like them a runtime face with no spec schema. The approvals service read `role:` as a second spelling of `position:` wherever it compares a slot with the caller (the \"My Pending\" filter, the participant gate, `viewer.can_act`, and the slot test of every decision), because 15.x-era slots and the stock console's identity list carried it. ADR-0090 D3 retires the word with no alias window, so once the pinned console sent `position:` the arm came out in one edit (maintainer ruling, 2026-10-04). `position:` is now the only position address: a `role:` ask matches only a slot stored under that exact spelling, and a `role:` actor is refused with 403 `FORBIDDEN`. The same ruling closed the one WRITER of the spelling. The deprecated `role` approver TYPE already resolved as `org_membership_level` (the org-membership tier: owner, admin, member), but when that lookup found no one the fallback slot kept the AUTHORED spelling, `role:`, and a holder of a same-named position decided it through the arm, so the runtime silently honoured a membership-tier declaration as a position. The fallback now writes the canonical `org_membership_level:`, and no path writes a `role:` slot. Two classes of pending request are therefore decided only by the privileged override, or by a reassign to a real approver: a request a 15.x-era release stored as `role:`, and a new request from a flow that still authors `{ type: 'role', value: }` and whose tier lookup finds no one. No stored slot is rewritten: the ruling refused a one-time rewrite as the permanent migration debt ADR-0090's first forcing fact names. Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds. FIRST, no metadata key moves: the address is runtime DATA (a request slot, a query parameter, a decision body's actor), never a `sys_metadata` row, so there is no source for a declarative transform to rewrite. SECOND, the one authored shape that leads here, `{ type: 'role', value: }`, is ambiguous by construction: the deprecated alias means the membership TIER, and whether its author meant a position instead is a judgment only that author can make, so a mechanical rewrite to either type would guess. The `ApproverType` `role` alias itself is a separate retirement and is unchanged here. ADR-0090 D3, ADR-0087." + }, + { + "surface": "artifact `packages[].manifest.plugins` and `packages[].manifest.devPlugins` — the two keys inside an ASSEMBLED package body (`AssembledPackageBodySchema`, ADR-0130 D4)", + "replacement": "Declare `plugins` / `devPlugins` at the stack TOP LEVEL only — the artifact envelope, where `os serve` / `os migrate` / `os dev` read them and where `composeStacks` still concatenates them (`concat` is unchanged for in-memory composition). Delete both keys from every `packages[i].manifest` body: a multi-package artifact that carried them is rebuilt from source (`os build` / `composeStacks(…, { manifest: 'preserve' })` no longer folds them into a body), and a hand-written `packages[]` entry drops them.", + "migrationId": "assembled-package-body-plugins-envelope", + "toMajor": 18, + "rationale": "A classification error, not a new special case (maintainer ruling A, 2026-09-04: both keys are artifact envelope keys, top level only, never inside `packages[]` — decided while one artifact was being taught to carry several co-owning packages). `plugins` and `devPlugins` were the only members of the assembled-body key set whose values are runtime ASSEMBLY instructions rather than serialisable metadata: `plugins` holds what a host hands to `kernel.use()` — live plugin instances, manifests or package names — and `devPlugins` is the `os dev` load list. Inside an artifact a package body is inert JSON, so a plugin written under `packages[i].manifest` could never be constructed by any loader; every reader (`serve.ts`, `schema-migration-plugins.ts`) reads the top level, and the \"resolve `packages[]` when the top level is absent\" repair every other reader took would have turned a silent skip into a boot that registers garbage. Options B (readers resolve JSON descriptions into live plugins) and C (the emitter special-cases the two keys) were refused. After the ruling, \"an artifact carries metadata, a host assembles plugins\" is one sentence every reader inherits. Not losslessly convertible: hoisting a body-level plugin to the envelope changes who loads it, and a live instance has no JSON form to move." + }, + { + "surface": "system.AuthConfig.audience", + "replacement": "explicit `auth: { audience: { posture: 'open' | 'email_domain', selfRegistrationPermissionSet: '' } }` (deployments that intend open self-registration only)", + "migrationId": "audience-posture-default-invite-only", + "toMajor": 18, + "rationale": "The default audience posture flipped when one declared posture replaced the emergent self-registration default: an UNDECLARED `audience` now means `invite_only` — email/password self-registration (and social-provider JIT sign-up) is refused with 403 SELF_REGISTRATION_CLOSED unless the address holds a pending invitation. Previously the emergent default was open self-registration with no email verification. Whether a deployment truly means to admit strangers (public portal) or was open only by accident is a security judgment no transform can make — and a posture that opens self-registration must also DECLARE the permission set a self-registrant receives and accepts forced email verification, neither of which can be invented mechanically." + }, + { + "surface": "GET /api/v1/automation — the flow-list route of the automation door, together with its request and response schemas ListFlowsRequestSchema and ListFlowsResponseSchema (and their ListFlowsRequest, ListFlowsRequestParsed, ListFlowsResponse and ListFlowsResponseParsed types), FlowSummarySchema and its FlowSummary type, the listFlows entry of AutomationApiContracts, and the automation.list method of @objectstack/client. Every other automation route is unchanged, including POST /api/v1/automation (create a flow) at the same path", + "replacement": "GET /api/v1/meta/flow — flows are metadata (ADR-0106), and this is the governed read of them; from the SDK it is `client.meta.getItems` with the type `flow`. It answers the full flow definitions rather than bare names, so a caller that only needs the names maps each item to its `name`. The runtime enablement and trigger binding of every flow — the one piece of engine state a definition does not carry — is `GET /api/v1/automation/_status` (`client.automation.getRuntimeStatus`), which is unchanged", + "migrationId": "automation-flow-list-route-retired", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-09-25 on the list doors found declaring `limit` / `cursor` and never reading them (this route is door ④; verbatim 「退役,统一走 /meta/flow」, given when asked why the flow list does not use the standard API), under ADR-0049 enforce-or-remove. The route's contract described a capability nobody built: ListFlowsRequestSchema declared `status`, `type`, `limit` (default 50) and `cursor`, and the handler read none of them — it asked the automation service for its flow names with no arguments at all. ListFlowsResponseSchema declared a page of FlowSummary rows with `total`, `nextCursor` and `hasMore`, and the handler answered a bare array of names beside a literal `hasMore: false`. So a caller filtering by status received every flow, a caller paging with a cursor re-read the only page forever, and a caller reading FlowSummary fields read undefined — each with a 200 and no error. Measured before removal, on the main branch of this repository and cloud and on objectui at both its pinned commit and main: zero callers of the route or of the SDK method outside their own tests, while both real flow lists in the product — the Console flow-runs page and the Setup packaged-automation page — already read GET /api/v1/meta/flow. Implementing the declared contract instead would have built a second, weaker metadata list beside the governed one; retiring it leaves one read. There is no alias and no transition window: GET simply stops being mounted there. There is no D2 conversion and no tombstone, because the shape is HTTP-only — nobody authors a ListFlowsRequest and nothing persists one — so the three schemas are whole-def removals in RETIRED_DEFS_BY_MAJOR and this entry carries the record. ADR-0049 / ADR-0087 / ADR-0106." + }, + { + "surface": "api.listRuns cursor — the pagination query parameter of GET /api/v1/automation/:name/runs declared by ListRunsRequestSchema, its slot on IAutomationService.listRuns, and its option on all three @objectstack/client run-list surfaces (automation.runs.list, automation.listRuns, environment().automation.listRuns). The limit parameter of the same door is NOT part of this retirement and is unchanged, default(20) included", + "replacement": "a wider `limit` — this door does read it, bounded to 1..100, and it is spent as the run store's history window. There is no replacement for `cursor` itself, deliberately: nothing ever minted one, so no caller holds a value to carry over, and the response `nextCursor` it would have paired with has never been emitted. Read the response `hasMore` to learn whether the window was short — it is now computed from the engine rather than the constant `false` it used to be, so for the first time it answers the question a caller reaching for a cursor was actually asking", + "migrationId": "automation-runs-cursor-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove (maintainer ruling 2026-09-21 on the list doors found declaring `limit` / `cursor` and never reading them — this door is door ①, and the ruling took letter C of three for it; letter A — build a cursor protocol for a 100-row window — and letter B — retire the key and leave the `hasMore` lie standing — were both considered and refused). `cursor` was declared on the request, VALIDATED at the boundary, forwarded into a `cursor?: string` slot on the service contract, and read by no implementation: the engine never looked at the option, and no emit site has ever written the response half `nextCursor`, so a caller looping until the cursor ran out re-read the first and only window forever with no error. ⭐ The `limit` half of this door was NOT retired, and the distinction is the ruling, not an oversight. The sibling `/packages` door retired its `limit` with its `cursor` (the 2026-09-13 ruling aligning that door's declaration with its reads: pagination is no part of a small bounded list) because nothing read it; the parent ruling explicitly does not transfer here. On this door `limit` is read end to end — the boundary enforces the declared 1..100 range off the schema itself, the service takes it as an option, and the engine spends it as `RunStore.listHistory`'s window — and the Console's flow-runs page sends it today. Retiring it would have been a regression, and its `.default(20)` stays with it. The same card computes `hasMore`, which is the half a bare retirement would have left lying. `GET /api/v1/automation/:name/runs` shipped a literal `hasMore: false` beside a list the engine had already truncated with `.slice(0, limit)`, so a caller asking for one row of a thousand was handed one row and told that was all of them. The engine now reports truncation to the door through a new optional contract member, `IAutomationService.listRunsPage`, which returns `{ runs, hasMore }`: it over-reads its history source by exactly one row and compares the merged, filtered, ordered set to the caller's window. The over-read is what makes the answer sound — `runs.length === limit` cannot tell a flow with exactly `limit` runs from one with ten thousand, and `RunStore.listHistory`'s signature is deliberately unchanged because over-reading is expressible in the `limit` it already takes. There IS a tombstone: the request schema is non-strict, so a bare deletion would have made Zod SILENTLY STRIP whatever a generated client kept sending — a clean parse and a parameter that never takes effect, which is this card's own defect re-created one layer down (ADR-0104). `cursor` is therefore a `retiredKey()`, typed `never` for tsc and raising the prescription at any parse, and is registered in RETIRED_KEYS_BY_MAJOR[18]. There is NO D2 conversion: a conversion rewrites an authored source or a stored `sys_metadata` row, and this shape is HTTP-only — nobody authors a `ListRunsRequest` and nothing persists one. The `os migrate meta` house sentence is therefore correctly absent from the prescription. There is no `acceptRetiredDefaultResidue` stage either: `cursor` carried no default, so it materialized into no artifact and there is no residue to accept. The SDK half is part of the retirement rather than a follow-up: `@objectstack/client` declared `cursor` and appended it on all three run-list surfaces, so retiring the key in the schema alone would have left the one generated client this repo ships typing it `string` and sending it into a route that silently drops it — the ADR-0104 shape the tombstone exists to prevent, re-created one layer down. The same call was made when the notifications `cursor` was retired: the client dropped the option and recorded the removal in its docblock. ADR-0049 / ADR-0087." + }, + { + "surface": "`fields..unique` on a `type: 'autonumber'` field when the author OMITS the key — the contract default moves from `false` (no index) to `'organization'` (one holder per organization; the NULL-safe tenant-composite unique index `(COALESCE(organization_id, '__global__'), )` on an organization-scoped object, a plain unique index where the object has no organization key)", + "replacement": "keep the omission to take the default — an auto-number is a business identifier and is unique per organization from now on with zero application-side declaration; write `unique: false` EXPLICITLY on the one autonumber field that is a display-only sequence and is never used to identify the record. Every other field type keeps `unique: false` as its default, and every authored spelling (`true` / `'organization'` / `'global'` / `false`) parses exactly as before", + "migrationId": "autonumber-default-unique-organization", + "toMajor": 18, + "rationale": "Not losslessly convertible because the change is data-dependent, not textual: a table that already holds duplicate auto-numbers (a counter that re-issued a burned number, or the seed/API tenancy split running two counters for one object) cannot take the index the default now declares. The SQL driver refuses to silently degrade — it logs at `error` naming the index, the columns and the remedy, the same boot's drift pass names the conflicting key groups with row counts, and `os migrate plan` reports the blocked `create_index` with the same groups (ADR-0120 D4) — but which of the duplicate rows keeps the number is a business decision no migration entry can make. Maintainer ruling 2026-08-31, on a downstream CRM's measurement that eight of its nine auto-numbered business identifiers could be issued twice: an auto-number that may repeat is not an identifier, so unique is the platform default and opting out is the declaration, not the other way round." + }, + { + "surface": "the six branded identifier schemas of `@objectstack/spec/shared` (`shared/branded-types.zod.ts`, removed whole): `ObjectNameSchema`, `FieldNameSchema`, `ViewNameSchema`, `AppNameSchema`, `FlowNameSchema`, `RoleNameSchema`, and their type exports (`ObjectName`/`ObjectNameParsed` through `RoleName`/`RoleNameParsed`).", + "replacement": "(removed — no replacement brand layer. Parse an identifier through the schema of the surface that stores it: object and field names through `ObjectSchema`/`FieldSchema` (inline snake_case regex), flow names through `FlowSchema`, app names through `AppSchema` (`SnakeCaseIdentifierSchema`), position/role names through `PositionSchema`. A caller that wants a standalone identifier check uses `SnakeCaseIdentifierSchema` or `SystemIdentifierSchema` from `@objectstack/spec/shared` directly — both stay published.)", + "migrationId": "branded-identifier-schemas-retired", + "toMajor": 18, + "rationale": "Maintainer ruling 2026-09-01 (director decision batch C, verbatim 「同意」: retire) — ADR-0049 enforce-or-remove. The brands promised compile-time safety (\"you cannot pass an ObjectName where a FieldName is expected\") that no consumer could obtain: no schema in either repository ever composed a brand, so nothing produced or accepted a branded value, while the surfaces the brands were named for are validated by inline regexes or bare `SnakeCaseIdentifierSchema` three files away. Binding was weighed and not adopted: zero consumers exist, binding would silently change five surfaces' accept sets (the inline regexes admit a leading underscore the brand base does not), and a future real need for centralized identifier grammar re-opens freely against actual pull." + }, + { + "surface": "the data write doors — a by-id update or delete of a row the caller cannot read, on every object and for every principal", + "replacement": "read 404 `RECORD_NOT_FOUND` on a by-id update or delete as \"no row you can see has this id\" — the read door's meaning — and keep a 403 for a row the caller can read but may not write", + "migrationId": "by-id-write-unreadable-row-not-found", + "toMajor": 18, + "rationale": "A WRITE-DOOR ANSWER, made one with the read door's. A by-id update or delete of a row the caller cannot read used to answer a 403 — `PERMISSION_DENIED` where a write-class row filter binds the caller, otherwise a later gate's own 403, such as `FORBIDDEN` from record sharing or a parent-derived gate's code on attachments and comments — while an id that names no row answered 404, so the write door told a hidden row apart from a missing one. The by-id write pre-image check now asks every principal whether it can read the row it addressed, through a by-id read in its own context that every data middleware's visibility applies to, and answers a row that read does not return with the read door's not-found: the same code, status and body a nonexistent id gets. It also refuses a by-id write a principal no row filter binds could previously land on a row hidden from it, such as an attachment's uploader or a comment's author whose parent record they can no longer read. A caller who can read the row but may not write it keeps its 403. Writes the platform issues under the caller's context — the engine's cascade delete, a hook's write, the referential clear of a lookup — keep their previous answer, and writes not routed by id are unchanged." + }, + { + "surface": "CacheWarmup.strategy — the value 'scheduled' left the warmup-strategy enum (packages/spec/src/system/cache.zod.ts), and the enum describe stopped promising \"scheduled (cron)\". The key itself, DistributedCacheConfig.warmup.strategy, is unchanged and still authorable", + "replacement": "'eager' to warm at startup or 'lazy' to warm on first access — the two strategies the vocabulary ever described without pointing outside itself. There is no replacement for the cadence: a warmup on a schedule is a job. Declare a `job` with schedule.expression (system/job.zod.ts) whose handler does the warming — that is the one cron slot this platform evaluates, and it is the slot deliberately kept when the seven cron-typed positions nothing read were deleted", + "migrationId": "cache-warmup-scheduled-strategy-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove, closing the residue the retirement of the seven cron-typed positions nothing reads left inside the schema it had just edited. That retirement deleted CacheWarmup.schedule — the cron key this enum member selected — and declined the member itself on the reading that it is \"a value, not a position this ruling names\". That is a statement about the ruling's SCOPE, not a finding that the value was sound: after the deletion the member declared a warmup cadence with no key left to configure it, no engine that has ever run one, and a .describe() still promising \"(cron)\" — ADR-0049 declared-not-enforced in the form Prime Directive 10 names outright, a capability advertised that the runtime does not deliver. Re-measured on main at 690f083f83 with a lit control rather than inherited from the card: CacheWarmupSchema has zero runtime consumers outside its declaring file (six files reference it — the generated reference page import, the declaration-map and export-origins catalogues, the ADR-0058 D7 ledger comment and two spec test files — while the control, ConnectorSchema, resolves to 46 files), and no cache-warmup engine exists anywhere on the platform. Bookkeeping follows the hot-reload-inert-state-strategies-retired and crypto.hash precedents: an enum-VALUE narrowing puts nothing in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) and leaves the four surface ratchets byte-identical (no def changed, and they key on positions and names, never on a def's value set), so the prescription hangs on the enum's own error map dispatched by issue.input — telling the author of a TYPO that their value \"was removed\" would misinform. It is a SEMANTIC entry rather than a D2 conversion because there is no source to rewrite: CacheWarmup is bound to no metadata type and embedded in no stack collection, so no authored document and no stored row has ever carried this value, and os migrate meta has nothing to list. Route 3 of the retirement playbook, the shape of the dynamic plugin-loading family's removal and the advanced plugin-lifecycle config's retirement: this entry IS the declaration. ADR-0049, ADR-0087." + }, + { + "surface": "object.fields..required on a `master_detail` reference under `sharingModel: 'controlled_by_parent'` — authored via `ObjectSchema.create()`", + "replacement": "`required: true` on the master reference (or nothing at all — the builder now forces `required: true` when the key is omitted). An explicit `required: false` on that shape is refused at `ObjectSchema.create()` with a located error carrying this same prescription. Metadata at rest is untouched: raw `.parse()`/`.safeParse()` still accept the old shape, the security gate's derived enforcement stays, and the lint rule `relationship/master-detail-required` stays `warning` until its own v18 promotion (Direction 1 of the 2026-08-16 maintainer ruling whose Direction 2 this is)", + "migrationId": "cbp-master-detail-required-forced", + "toMajor": 18, + "rationale": "A `controlled_by_parent` detail derives ALL of its record access from the master that its `master_detail` reference names (ADR-0055). With the reference not `required`, an insert may omit the master FK: the row lands with a null FK that the derived read filter `masterFK IN (accessible master ids)` can never match — unreadable by everyone — and every later by-id write answers `422 MISSING_REQUIRED_FIELD`. The finding behind the ruling measured that only the security gate closed this shape while the declaration surface still accepted it. The maintainer ruling (2026-08-16, Direction 2) makes the unsafe shape impossible to NEWLY declare at the builder; whether to keep `required: false` was never a real choice (the value contradicts the sharing model), so the flip is forced rather than convertible — and an explicitly authored `false` is refused rather than silently rewritten (ADR-0032 \"no silent failure\")." + }, + { + "surface": "object.fields.MASTER.required / .readonly / .system, where MASTER is a `master_detail` reference on an object declaring `sharingModel: 'controlled_by_parent'` — as judged by `os lint` under `relationship/master-detail-required`", + "replacement": "`required: true` on every `master_detail` reference of a `controlled_by_parent` object, with neither `readonly: true` nor `system: true` on it: declare the master reference as an ordinary required field. `os lint` now reports each of the three unsafe shapes there — `required` absent or `false`; `required: true` + `readonly: true`; `required: true` + `system: true` — at `error` under `relationship/master-detail-required`, so `os lint` exits non-zero and the metadata-generation rubric marks the stack invalid. On every other object the rule is unchanged: a `warning` for a `master_detail` without `required: true`, and no finding for the two flagged shapes.", + "migrationId": "cbp-master-detail-required-lint-error", + "toMajor": 18, + "rationale": "A `controlled_by_parent` detail derives ALL of its record access from the master that its `master_detail` reference names (ADR-0055). Record validation never checks a field that is not `required`, and skips `readonly` and `system` fields before its required check is reached, so on these three shapes nothing but the security gate refuses an insert that omits the master FK. A record that lands without it anyway is readable by nobody — the derived read filter `masterFK IN (accessible master ids)` never matches null — and every later by-id write is refused. Before this step the lint predicate was `required !== true` at `warning` on every object: the two flagged shapes drew no finding at any severity, and the third drew a warning that an author or a generator could ignore. The maintainer ruling of 2026-08-16 (Direction 1) scheduled the promotion for the v18 boundary as a deliberate narrowing of the authoring contract. Its builder half (the `cbp-master-detail-required-forced` entry) forces `required: true` at `ObjectSchema.create` but never inspects `readonly` or `system`, so two of the three shapes still pass the builder and meet their first authoring-time refusal here, and the third still reaches it from any object not authored through the builder. Runtime tolerance is unchanged on purpose: the security gate keeps refusing these inserts, and keeps resolving the master for metadata already at rest." + }, + { + "surface": "security.PermissionSet rowLevelSecurity[].using and .check, and sharingRules[].condition — a CEL predicate comparing a field with != or == against a list, either a list literal or a current_user membership set the runtime resolves to an array (org_user_ids, positions, accessible_org_ids, or a key staged into rlsMembership), and the negation of such a comparison. On driver-mongodb, also a query filter carrying $ne with an array comparand, at any depth under $and / $or / $not", + "replacement": "the list operator the comparison was standing in for. \"One of these values\" is in: record.status in [\"open\", \"pending\"], or record.reviewer_id in current_user.org_user_ids. \"None of these values\" is the negated in: !(record.status in [\"closed\", \"archived\"]). In a query filter, $in and $nin. Scalar != and ==, null, in, and field-to-field comparisons lower exactly as before", + "migrationId": "cel-predicate-list-comparand-refused", + "toMajor": 18, + "rationale": "The @objectstack/formula pushdown compiler lowered such a comparison to a $ne carrying the array, to a bare-array equality, or to a $not around one. A row-level using clause is composed into the query after the engine's comparand-shape check, and driver-mongodb passed the shape to the server: measured through mingo, the named proxy for MongoDB query semantics, $ne against an array and the $nor that a negated equality becomes selected every row storing a scalar, so the read returned the rows the policy was written to hide. A check written != against a membership set admitted and stored every write, on driver-sql as on driver-mongodb. The compiler now refuses the comparison with reason unsupported, so the RLS compiler drops the policy and fails closed when no other policy applies: reads under it return no rows and check writes are refused 403. A declared sharing rule with such a condition is skipped at bootstrap and never seeded. The authoring lint reports a list literal as rls-predicate-unenforceable; a membership set holds its value only per request, so that form is refused at request time. driver-mongodb refuses $ne with an array comparand with INVALID_FILTER / 400, as driver-sql and driver-memory already do. Metadata AT REST is not rewritten and this entry adds no D2 conversion: the platform cannot tell which list operator a list comparison was standing in for, and a policy rewritten on the author's behalf would change which rows it admits, which is the policy author's decision. ADR-0058 D4 / ADR-0087." + }, + { + "surface": "security.PermissionSet rowLevelSecurity[].using and .check, and sharingRules[].condition — a CEL predicate whose comparison is handed something other than one value: an ordering operator (>, >=, <, <=) against a list literal or a current_user membership set; an in list with a member that is itself a list; a comparison with no field at all against a membership set (current_user.org_user_ids != \"x\"); an ordering operator against the current_user root or a key that resolves to an object; and a field compared with another field (==, !=, or an ordering operator) where either column holds a list or an object on the record, as a json column or a multiple lookup does. In a filter passed to matchesFilterCondition, also $gt / $gte / $lt / $lte with an array, and $in / $nin with an array member", + "replacement": "the comparison the predicate was standing in for. \"One of these values\" is in: record.status in [\"open\", \"pending\"], or record.reviewer_id in current_user.org_user_ids; \"none of these values\" is !(record.status in [\"closed\", \"archived\"]), with the list flat. An ordering takes one bound: record.status > \"m\", and a range is two comparisons joined by &&. A comparison against the caller names one key: record.reviewer_id > current_user.id. A field compared with a json or multiple field has no pushdown form: compare with a single-valued column, or move the condition into a validation rule or hook. One-value comparisons, flat in lists, and field-to-field comparisons between single-valued columns lower and evaluate exactly as before", + "migrationId": "cel-predicate-one-value-comparand-refused", + "toMajor": 18, + "rationale": "Ruling A of 2026-09-24 refused a list under != and in the equality slot, holding both to the declared comparand — a literal or a `{ $field }` reference; stage 2d closes the same fault one position over, measured through the real plugin-security on driver-sql and driver-memory. !(record.status in [[\"closed\", \"archived\"]]) lowered to a negated $in whose only member was a list, which the strictly comparing write-check evaluator matched on no record, so the negation admitted and stored every write, and driver-memory returned every row on a read. record.status > [\"m\"] compared the list as the string \"m\". current_user.org_user_ids != \"x\" and current_user.org_user_ids > \"a\" folded to \"no restriction\": every write admitted and every row read. record.reviewer_id > current_user compared the whole caller object as a string. record.status != record.tags, with tags a json or multiple field, matched every post-image, so the check admitted and stored every write. The CEL compiler now refuses the first four with reason unsupported, so the RLS compiler drops the policy and fails closed when no other policy applies (reads return no rows, check writes are refused 403, the analytics read scope is the deny scope) and a declared sharing rule is skipped at bootstrap; the authoring lint reports what the source shows (a list literal, the current_user root). The compiler cannot see a column's type, so the last is refused by the write-check evaluator on the record whose compared column holds a list or an object: INVALID_FILTER / 400, nothing stored. Metadata AT REST is not rewritten and this entry adds no D2 conversion: the platform cannot tell which comparison a predicate was standing in for, and rewriting it on the author's behalf would change which writes and rows it admits, which is the policy author's decision. ADR-0058 D4 / ADR-0087 / ADR-0112." + }, + { + "surface": "security.PermissionSet rowLevelSecurity[].using and .check, and sharingRules[].condition — a CEL predicate comparing with != or == against the bare current_user root, the variable with no key named after it, whether the other side is a field or a literal, and the negation of such a comparison. For a caller of the published compiler that binds its own variables, also a variable that resolves to an object", + "replacement": "the key of current_user the comparison means: record.owner_id == current_user.id, or current_user.organization_id, or current_user.email. A membership test is in: record.owner_id in current_user.org_user_ids. Scalar keys, membership sets under in, literals, null and field-to-field comparisons lower exactly as before", + "migrationId": "cel-predicate-variable-root-comparand-refused", + "toMajor": 18, + "rationale": "The @objectstack/formula pushdown compiler resolved the bare root to the whole caller context object, every kernel-resolved key at once with the membership arrays included, and lowered the comparison to a $ne carrying that object, to a bare-object equality, or to a $not around one; a constant comparison such as current_user != \"guest\" folded to no restriction. A strict compare never equals an object, so through the real SecurityPlugin on driver-sql a check written != against the root, or its negated ==, admitted and stored every insert and by-id update it was written to refuse, a USING-only such policy admitted every insert on the write pass, and explain reported the read as narrowed with the caller membership sets echoed in its readFilter. ADR-0058 D2 declares the operand opposite a field as a literal, a current_user scalar or a pre-resolved current_user set, and the published $eq / $ne contract declares a literal or a { $field } reference; the root is none of them. The compiler now refuses it with reason unsupported in both of its modes, so the authoring lint reports it (rls-predicate-unenforceable on either clause, sharing-rule-unlowerable-condition on a sharing condition), and the RLS compiler drops the policy and fails closed when no other policy applies: reads under it return no rows, check writes are refused 403, and explain answers denies. A declared sharing rule with such a condition is skipped at bootstrap as it already was, now with reason unsupported instead of unresolved-variable. Metadata AT REST is not rewritten and this entry adds no D2 conversion: the platform cannot tell which key the author meant, and a policy rewritten on the author's behalf would change which rows it admits. ADR-0058 D2 / ADR-0087." + }, + { + "surface": "change-management duration keys: `ChangeImpact.downtime.durationMinutes`, `RollbackPlan.steps[].estimatedMinutes`, `ChangeRequest.implementation.steps[].estimatedMinutes`", + "replacement": "nothing to re-declare — delete the keys. No change-management engine exists on the platform: nothing schedules a maintenance window, executes or times an implementation or rollback step, or compares an estimate with what happened, so there is no live mechanism to declare a duration to", + "migrationId": "change-management-duration-keys-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on the unread deadline keys (ruled A: retire per family). Three minute-shaped keys, at three nested sites, sat in the exported change-management schemas and in the generated reference docs — an author could write `estimatedMinutes: 15` on a rollback step and reasonably expect it to feed a schedule — and read by NOTHING: the schemas are exported from `@objectstack/spec/system`, mounted by no stack key, registered as no metadata type, absent from the 2026-06 liveness ledgers, and the reader census over every package outside `packages/spec` (tests and changelogs excluded) and over objectui at the pinned sha returned zero hits for every key. All three sites are NESTED (`downtime.durationMinutes`, `steps[].estimatedMinutes` twice), so the authorable-surface ratchet — which walks top-level def properties — never listed them; their `RETIRED_KEYS_BY_MAJOR[18]` entries carry the nested spelling for the spec-changes / upgrade-guide projection. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections; none of these schemas is either, so a conversion would be a transform with no seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent)." + }, + { + "surface": "the change-management family, retired whole: the six defs system/ChangeImpact, system/ChangePriority, system/ChangeRequest, system/ChangeStatus, system/ChangeType and system/RollbackPlan, and every name system/change-management.zod.ts exported from @objectstack/spec/system (the six *Schema consts, their z.input aliases and the ChangeRequestParsed alias)", + "replacement": "nothing to re-declare — no change-management engine exists on the platform, so there is no working configuration to migrate to. Nothing routed a change request for approval, walked its implementation steps, honoured a rollback plan or gated on `securityImpact.requiresSecurityApproval` / `approval.required`; a change record the organisation keeps is ordinary object data, declared as an object with its own fields, and an approval that must actually gate something is a flow (ADR-0018) with an approval node. Metadata change tracking on the platform is `sys_metadata` history and the package model (ADR-0126), unrelated to this vocabulary. If ITIL change management becomes a product capability it re-declares fresh, through the enforce route of ADR-0049 — the engine first, the vocabulary second", + "migrationId": "change-management-family-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 on the families' remaining keys and defs (ruled A: retire the three compliance-shaped families whole via RETIRED_DEFS_BY_MAJOR, the integration/ErrorMappingConfig precedent; not roadmapped). Six defs and roughly fifty declared keys sat on the exported surface and in the generated reference docs, and were read by NOTHING: the schemas were exported from `@objectstack/spec/system`, mounted by no `stack.zod.ts` key, registered as no metadata type, absent from the 2026-06 liveness ledgers, and the reader census over every package outside `packages/spec` (tests and changelogs excluded), over `examples/**` and `skills/**`, and over objectui at the pinned sha returned zero hits for every exported name, with a lit control. `ChangeRequest.approval.required` and `ChangeRequest.securityImpact.requiresSecurityApproval` read as gates the platform enforced, and neither ever did — the worst form of the declared-but-unenforced shape, on a security-adjacent surface. Tagging the family `[EXPERIMENTAL — not enforced]` was the fallback the ruling did not take (a human-only signal). The duration-key tombstones of the 2026-09-02 per-family ruling (three nested sites, `RETIRED_KEYS_BY_MAJOR[18]`, D3 `change-management-duration-keys-retired`) leave with their defs' source; their registry entries stay as history. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections; none of these schemas is either, so a conversion would be a transform with no seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent), and with no carrier key there is no shape on which a tombstone could sit." + }, + { + "surface": "dashboard.widgets[].chartConfig.aria / report.chart.aria / report.blocks[].chart.aria — the ARIA block on a chart config", + "replacement": "The sibling `description`, which the chart renderer lowers onto the chart graphic as its accessible name (`role=\"img\"` with an aria-label). One accessibility vocabulary per chart node.", + "migrationId": "chart-config-aria-retired", + "toMajor": 18, + "rationale": "The D2 conversion `chart-config-aria-removed` deletes `aria` from every dashboard widget chart config, report chart and report block chart, and the delete is lossless: no chart renderer on either face ever applied the block, so the ARIA attributes it declared never reached the DOM. The residue is accessibility work the author did that no user benefited from. An author who wrote `aria.label` for a chart believed screen-reader users heard that name; they heard the `description` if one was set, and nothing specific if not. The strip deletes the label text along with the key, and only the author can say whether that text should become the chart's `description` — a field that other readers of the chart may also show — or whether the existing description already says it." + }, + { + "surface": "kernel.cliCommandContribution (the orphan exported schema of `cli-extension.zod.ts` — 1 def, 2 exported names: `CLICommandContributionSchema` / `CLICommandContribution`)", + "replacement": "(removed — there is no declarative replacement, because no declarative surface ever carried it. CLI commands are registered through oclif's native plugin discovery: the plugin package declares an `oclif` section in its own `package.json` — `OclifPluginConfigSchema` in the same module describes that live surface and SURVIVES, as does the module docblock's Commander.js migration record, which the `manifest.contributes.commands` tombstone cites)", + "migrationId": "cli-command-contribution-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove, applied to the exported orphan-value-schema class: an exported schema with no consumer reads as a capability, the lesson of the plugin sandboxing / integrity / approval config that was never wired to anything. The schema described a \"CLI Command Contribution declaration in the manifest\" and claimed retention \"for describing command metadata in plugin manifests\" — but after the retirement of the plugin manifest's nine dead `contributes` members tombstoned `manifest.contributes.commands` (protocol 18), no manifest surface could legally carry these entries: the export advertised a shape whose only declared carrier rejects it. The manifest never referenced this schema even before the tombstone — its inline `commands` item schema was an independent duplicate. Zero consumers outside spec's own test and generated artifacts, measured at the retirement's base commit (146f448a5) with positive controls in objectstack, objectui (pinned sha) and cloud. With no carrier key and no authored document there is nothing to tombstone and no seam for a D2 conversion: route 3, the shape of the advanced plugin-lifecycle config's retirement and of the retired `ApiKeySchema` — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration." + }, + { + "surface": "client.analytics.query / client.analytics.meta / client.analytics.explain / client.automation.trigger — the resolved value of four published `@objectstack/client` methods: the runtime dispatcher's `{ success, data }` envelope before, its `data` member after", + "replacement": "the payload — `r.data.X` → `r.X` on all four. `client.analytics.query`, called with a query, now resolves to `AnalyticsResult`: `r.data.rows` → `r.rows`. `client.analytics.meta`, with or without a cube name, resolves to `AnalyticsMetadataResponse['data']`, the bare cube list: `r.data[0].name` → `r[0].name`. `client.analytics.explain` resolves to `AnalyticsSqlResponse['data']`, `{ sql, params }`: `r.data.sql` → `r.sql`. `client.automation.trigger`, given a trigger name and a payload, resolves to `AutomationResult`: `r.data.status` → `r.status` and `r.data.runId` → `r.runId` — the same value `client.automation.execute` already answered for the same handler. Same call, same wire body, one SDK calling convention", + "migrationId": "client-envelope-convergence-analytics-automation", + "toMajor": 18, + "rationale": "`ObjectStackClient` had two response readers. `unwrapResponse` strips the runtime dispatcher's `{ success, data }` envelope and hands back `data`; every other dispatcher-served method already used it, and these four alone ended `return res.json()`, so their callers alone had to read `.data`. All four now end `return this.unwrapResponse(res)` and their return declarations are the payload types. THE WIRE IS BYTE-IDENTICAL: every route answers exactly the body it answered before, no Zod schema moves, no `packages/spec` declaration moves, no authorable key and no stored representation is involved — the landing diff touches no `packages/spec` path at all — so a raw-HTTP caller is unaffected and `objectstack migrate meta` has nothing to rewrite. This is registered rather than exempted because the change is NOT wholly compiler-delivered, and the gap is exact rather than theoretical. For the three analytics methods it is: every old read is `error TS2339: Property 'data' does not exist on type …`, so tsc names each site. `client.automation.trigger` is the exception — `AutomationResult` itself declares `success: boolean` and `error?: string` (`AutomationResult` in `packages/spec/src/contracts/automation-service.ts`, byte-identical at the merge base and at this landing), so `r.success` and `r.error` COMPILE ON BOTH SIDES while their meaning moves: before, `r.success` was the envelope's flag — always `true` on a resolved call — and `r.error` was never set on a 2xx; now they are the run's own, and a refusal the door does not classify as 400 / 409 / 422 is answered 200 carrying `success: false` with `error` set. A consumer branching on either reads a DIFFERENT QUESTION at the same spelling, with no diagnostic anywhere. And there is no authored source for the conversion chain to rewrite: this is a published TypeScript surface whose enforced channel is tsc at the call site, and for an untyped JS caller there is no constrained channel at all — `.data` simply reads `undefined` — which is why the ledger entry is the only notification that reaches them. That is the same argument the two sibling entries on this package make (`client-delete-result-success`, `client-meta-reset-result-reset`), and this one is the stronger case of the three: those corrected declarations that were UNINHABITED, revealing a defect rather than breaking working code, whereas this moves reads that work today. ⛔ Do not write `r.rows ?? r.data.rows`: there is one producer shape, and a consumer accepting two spellings is what contract-first exists to prevent. The failure path is unchanged and deliberately so — `ObjectStackClient.fetch` rejects on every non-2xx BEFORE either reader runs, carrying the ADR-0112 error envelope, and `unwrapResponse` itself never throws. ADR-0087 D3." + }, + { + "surface": "client.meta.deleteItem(...).deleted / .type / .name (the return of `client.meta.deleteItem()` and the environment-scoped `client.environment(id).meta.deleteItem()`)", + "replacement": "`reset` — `r.deleted` → `r.reset`. Same call, same wire body, declared shape. Both twins now declare `DeleteMetaItemResponse` (`@objectstack/spec/api`); `type` and `name` have no replacement because the reset door never echoed them — the caller already holds both, it passed them in", + "migrationId": "client-meta-reset-result-reset", + "toMajor": 18, + "rationale": "Both `deleteItem` declarations on `@objectstack/client` declared `Promise<{ type: string; name: string; deleted: boolean }>` while `DeleteMetaItemResponseSchema` declares `{ success, reset?, message? }`. The declaration was not merely imprecise, it was UNINHABITED: `DELETE /meta/:type/:name` ends in `res.json(result)` with `deleteMetaItem`'s return, and not one of that method's four return branches carries `type`, `name` or `deleted`. Both surfaces are pure `unwrapResponse` / `_unwrap` passthroughs — and the reset body carries no `data` key, so nothing is stripped — which makes the declaration a CLAIM about the wire, never a rewrite of it, and the claim was false in the one direction that matters: the compiler endorsed a spelling no server has ever sent. `if (r.deleted)` compiled and read `undefined` on EVERY reset, including the ones that really removed an overlay row; `if (r.reset)` was rejected by the compiler and correct on the wire. So this REVEALS a defect rather than breaking working code — every reader of the old key was already reading `undefined`, on every deployment and not just some. The truthful flag also carries the distinction the phantom one could not express at all: `reset: true` means an overlay row was deleted, `reset: false` means none existed and the item was already at its artifact default. Registered as a semantic entry rather than a mechanical conversion for the reason the rewrite does not capture: a call site that branched on `r.deleted` has been taking the FALSE branch unconditionally since it was written, and whatever that branch did — or skipped — is what has to be re-read. There is no authored source for the chain to rewrite either; this is a published TypeScript surface whose enforced channel is tsc at the call site, and for an untyped JS caller there is no constrained channel at all, which is why this entry is the only notification that reaches them. ⛔ Do not write `r.reset ?? r.deleted`: there is one producer shape, and a consumer accepting two spellings is what contract-first exists to prevent. No deprecated `deleted?: boolean` transition key ships, for the same reason — a transition period is for keys that WORKED, and this one never did. The identical correction one door over is `client-delete-result-success`; the wire is deliberately untouched here, per the 2026-08-29 ruling that reality is the contract. ADR-0087." + }, + { + "surface": "client.oauth.applications.delete(clientId) — both halves of what a caller of this published `@objectstack/client` method observes: the DECLARED return, `Promise` before and `Promise` after, and the SETTLE BEHAVIOUR, which rejected with `SyntaxError: Unexpected end of JSON input` on every successful delete before and resolves after", + "replacement": "no value — `void`. There is nothing to move a read TO, because the promise never resolved for a caller to read anything off it. The migration is on the settle path instead: `try { await client.oauth.applications.delete(id); } catch { /* it probably worked */ }` → drop the workaround, the `catch` was executing on EVERY successful delete and now executes only on a real failure. A read off the resolved value — `(await client.oauth.applications.delete(id)).deleted` — was unreachable code that has never executed and now stops compiling (TS2339). Same call, same request, same wire body", + "migrationId": "client-oauth-applications-delete-void", + "toMajor": 18, + "rationale": "The route answers HTTP 200 with a ZERO-BYTE body: `POST {auth}/oauth2/delete-client` returns nothing from its handler, the vendor declares the endpoint `void`, and the response carries `content-type: application/json` with NO `content-length` header at all. The method ended `return res.json()`, so it rejected `SyntaxError: Unexpected end of JSON input` on every successful delete — after the row had already been removed server-side. There was no success path a caller could observe, and the obvious recovery made it worse: the retry failed DIFFERENTLY, with the route's 404 `not_found`, because the client was already gone. The method now reads the body as text, returns on the empty case, and still parses (and still throws on) a non-empty one — so the ONLY behaviour that moved is the zero-byte case, which is the defect itself. THE WIRE IS BYTE-IDENTICAL: same route, same request body, same status codes, same error bodies — which on this route are better-auth's FLAT `{ error, error_description }`, NOT ObjectStack's nested ADR-0112 envelope; no Zod schema and no `packages/spec` declaration moves, no authorable key and no stored representation is involved, so a raw-HTTP caller is unaffected and `objectstack migrate meta` has nothing to rewrite. This is registered rather than exempted because the change is NOT compiler-delivered where it matters, and the gap is exact rather than theoretical. The change has two halves and only one of them has a diagnostic. (1) The declared return moves from a ledgered `any` to `void`, so a typed caller that read a property off the resolved value now gets `error TS2339` — but that read was UNREACHABLE, since the promise never resolved, so the compiler names only code that has never run. (2) The half that DID run on every call — a `try`/`catch` wrapped around the delete — compiles identically before and after, with no diagnostic anywhere, while its `catch` block stops executing. So for the only behaviour that was ever observable, `tsc` names ZERO sites; and for an untyped JS caller there is no constrained channel at all. That is why the ledger entry is the only notification that reaches an upgrader — the same argument the three sibling entries on this package make (`client-delete-result-success`, `client-meta-reset-result-reset`, `client-envelope-convergence-analytics-automation`). ⚠️ Note the DIRECTION, which is the inverse of the usual break: this does not stop working code from working, it makes a method that could never succeed succeed. The hazard is therefore inverted too — code written to survive a permanent failure is now inert, and any alerting or error budget fed by this method's rejections goes quiet. ⛔ Do not keep the old behaviour behind a flag or a wrapper that re-throws: there is one producer shape, and the rejection was never a contract, it was a parse of an empty string. ⛔ Do not synthesise `{ deleted: true }` either: the 200 carries zero bytes and therefore zero information, and \"it was already gone\" is distinguished on the ERROR channel — a client that is not there answers 404 `{ error: 'not_found' }`, which `ObjectStackClient.fetch` raises as a throw before the body reader runs — so a synthesised success value would be a shape the wire never sends and strictly less informative than the 404 the caller already receives. ADR-0087 D3." + }, + { + "surface": "`@objectstack/spec/cloud` — the whole published subpath (`packages/spec/src/cloud/`, 11 modules, 94 JSON-Schema defs): the cloud control plane's own contracts (`environment.zod`, `environment-package.zod`, `tenant.zod`, `developer-portal.zod`, `marketplace-admin.zod`, `app-store.zod` — 62 defs) and the package & marketplace format (`package.zod`, `package-version.zod`, `marketplace.zod`, `package-l10n`, `template-manifest.zod` — 30 defs)", + "replacement": "Two answers, by owner. (1) The package & marketplace FORMAT moved unchanged to `@objectstack/spec/marketplace` (`packages/spec/src/marketplace/`): rewrite the import path — `import { PackageSchema } from '@objectstack/spec/cloud'` becomes `from '@objectstack/spec/marketplace'` — and nothing else; every def, key and JSON Schema is byte-identical under its new `$id` category (`RENAMED_DEFS`, 32 entries). `EnvironmentType(Schema)` — the 7-member taxonomy the discovery fold table is total over — is re-declared in `@objectstack/spec/api` (`api/discovery.zod.ts`); the environment-artifact envelope was only ever a re-export and is imported from `@objectstack/spec/system`. (2) The cloud control plane's contracts have NO open-source replacement: `environment.zod` and `tenant.zod` are re-declared in the cloud repo beside their producer, and `developer-portal.zod`, `marketplace-admin.zod`, `app-store.zod`, `environment-package.zod` are deleted outright — zero consumers in any repo (maintainer ruling 2026-09-07, option A: cloud does not host the four consumer-less files, the move deletes them). Recoverable from git history at `d5d8d50db` if a declaration is ever wanted again; that is a new card in the cloud repo, not a re-import.", + "migrationId": "cloud-subpath-retired", + "toMajor": 18, + "rationale": "Maintainer direction (2026-09-06, verbatim, untranslated): 「我一直觉得 cloud 的协议应该放在云端,没必要开源」; ruled option B \"cut by owner\" on 2026-09-07: the control-plane half leaves the open-source spec, the package & marketplace format half stays. The control-plane schemas' producer and every consumer live in the closed cloud repo — the open-source tree read exactly one type from them (`EnvironmentType`, for the discovery fold table). Leaving them published made the obvious-looking binding of `client.environments.*` to a camelCase `Environment` row compile and read `undefined` at runtime against a snake_case wire (the client SDK's cloud methods carried no return annotation and were typed from `any`, and `@objectstack/spec/cloud` declared camelCase rows for a control plane that speaks snake_case); with the declarations gone the mis-binding is structurally impossible rather than warned about in a docblock. No alias and no deprecation window, per the standing 2026-08-27 ruling 「项目在创业阶段,用户也很少,短期不考虑渐进。」. Not losslessly convertible: an import path is TypeScript source, not a metadata document `objectstack migrate meta` can rewrite." + }, + { + "surface": "kernel.cluster.driver (ClusterDriverSchema, kernel/cluster.zod.ts) - the `postgres` and `nats` enum values", + "replacement": "the drivers that actually ship - `memory` (single-process default), `redis` (@objectstack/service-cluster-redis, the production recommendation), or `custom` + registerClusterDriver(name, factory) for a self-provided transport. A config naming `postgres` or `nats` never worked: pick `redis`, or register the transport yourself under `custom`", + "migrationId": "cluster-driver-dangling-values-removed", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-08-24 on the cluster driver line-up (option B adopted): single-node is the ObjectOS EE boundary, multi-node is Cloud differentiation, and a DB-first postgres cluster driver is not built absent concrete customer pull. The ruling's principle rider decides this entry: a schema-valid value must not be an unconditional runtime throw. Both removed values were dangling by the same measurement - the only non-test registerClusterDriver() caller is service-cluster-redis, so `driver: 'postgres'` or `driver: 'nats'` passed schema validation and then reached defineCluster()'s unconditional `Cluster driver \"\" is not registered` throw. It is a SEMANTIC entry rather than a mechanical conversion because the right replacement is a deployment decision (which transport actually backs this cluster), not a rename a codemod could apply; nothing at rest breaks, because a stored config naming either value never survived boot in the first place. The ruling records its own reversal condition: a value returns to the enum only in the release that ships an implementation behind it. No authorable KEY was retired (the `useExistingPool` field stays, reworded), so nothing lands in RETIRED_KEYS_BY_MAJOR." + }, + { + "surface": "The connectorConfig block of every type: 'connector_action' flow node — the BLOCK, and its connectorId and actionId once it is written. The block was optional on the node and both ids were any string inside it, so a node with no block, or with connectorId or actionId empty or only whitespace, parsed. That is the state of a node authored without its configuration, and of a new connector node from the Studio flow designer, which seeds both ids empty. At any depth, including an ADR-0031 region body. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate, a flow saved from the Studio flow designer (a connector node added and saved before it is configured), and a flow row already sitting in sys_metadata. Also reached: connectorId, actionId or input written under the node's config instead of the block, where the load-time conversion cannot complete the pair and leaves them there", + "replacement": "Declare what the node dispatches, on the node: `connectorConfig: { connectorId: 'slack', actionId: 'chat.postMessage', input: { channel: 'C0WINS000', text: 'Done' } }` — `connectorId` the registered connector's `name`, `actionId` one of the action keys that connector declares, `input` optional. Keys written under the node's `config` move into the block. A node you cannot configure yet is deleted until you can: there is no placeholder connector, and a block with empty ids names nothing to dispatch to", + "migrationId": "connector-action-config-required", + "toMajor": 18, + "rationale": "The block is the node's whole contract: the connector_action executor reads nothing else and refuses the node when `connectorId` or `actionId` is empty. The build doors checked only the block's shape once it was written, so `FlowSchema.parse`, `AutomationEngine.registerFlow` and `objectstack validate` all admitted a node with no block, or with an empty id, and every run that reached the node then failed at the executor's guard — a guard refusal, never routed to a `fault` edge, and no rerun could succeed because the config is metadata. The flow parse now refuses what that read refuses, in the walk that reaches every region body, so all three doors answer alike. A whitespace-only id is refused with the empty one: a connector `name` is a snake_case identifier, so whitespace names nothing a dispatch can reach. ⚠️ No D2 conversion: the platform cannot know the connector or the action the author left out, and no value it could write would dispatch anything. ⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0031." + }, + { + "surface": "connector.errorMapping — the rules / defaultCategory / unmappedBehavior / logUnmapped block and its per-rule keys, on a connector and on a stack connectors[] entry", + "replacement": "(removed — no connector engine maps an external error through authored rules.) Retry behaviour is `retryConfig`, which the outbound fetch applies. No connector-level channel shows an end user a message: an error users must read is surfaced by whatever handles the connector call's failure.", + "migrationId": "connector-error-mapping-retired", + "toMajor": 18, + "rationale": "The D2 conversion `connector-error-mapping-removed` deletes the whole block from every connector, stack entry and stored connector row, with one notice per connector, and the delete is lossless: no provider, dispatcher or materializer ever mapped an external error through the rules, so the eleven nested keys configured nothing. The judgment is about what the rules were written to achieve. A rule marking an upstream code `retryable` never changed a retry — if that retry matters, it belongs in `retryConfig`. A rule with a `userMessage` never showed that message to anyone, although the spelling matches the live API-error channel and read as a user-facing refusal; if users need that text, whatever handles the failed call has to surface it. `unmappedBehavior` and `logUnmapped` suppressed or logged nothing. Which of these intents still matters is known only to the connector's author." + }, + { + "surface": "ConnectorProviderContext.connectionTimeoutMs, the declared connect deadline handed to every ConnectorProviderFactory (integration/connector-provider.ts)", + "replacement": "requestTimeoutMs for the deadline the platform keeps; for a connect-only bound, the provider's own providerConfig, where the provider owns the vocabulary", + "migrationId": "connector-provider-context-connection-timeout-ms-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove, maintainer ruling 2026-09-22 letter A: retire connector.connectionTimeoutMs. The spec key is tombstoned and its authored sources are rewritten by the D2 conversion connector-connection-timeout-ms-removed; this entry carries the half a conversion cannot reach. The key was placed on this context by the round that made the connector resilience policy live, explicitly as a CARRY — handed over so that a custom provider on a transport able to separate the phases could honour it. Measured before removal, none did, and the carry itself was the last thing keeping the key alive in argument: the built-in rest and openapi factories read ctx.connectionTimeoutMs only to deposit it back onto the def that GET /connectors echoes, and connectorFetchOptions — the one mapping from authored policy onto the platform's outbound fetch — was never handed it. Being handed a value is not honouring it, so the carry is the same parsed-unmarked-unenforced state on one more surface, and it leaves with the key rather than outliving it as an orphan a factory could still read. Why a semantic entry and not a D2 conversion: a provider factory is CODE. There is no authored source and no sys_metadata row holding a read of ctx.connectionTimeoutMs, so the chain has no seam to rewrite — the removal reaches a factory author as a tsc error and as this entry, never as a mechanical edit. The declaration cannot be made honest by implementing it either: a WHATWG fetch exposes one AbortSignal over the whole operation and never the connect phase, so bounding time-to-response with this key would kill a slow-but-connected upstream the author meant to allow with a large requestTimeoutMs. ADR-0087, ADR-0097." + }, + { + "surface": "connector.health (healthCheck / circuitBreaker), connector.status and connector.webhooks — on a connector and on a stack connectors[] entry", + "replacement": "(removed — nothing replaces the probe, the breaker or an authored status.) Participation is `enabled` (and `provider` on a declarative instance); whether a registered connector can be dispatched is the computed `state` (`ready` / `degraded`) on `GET /api/v1/automation/connectors`; a webhook that is actually delivered is declared in the top-level `webhooks:` collection; probes and circuit breaking belong in the connector provider or an upstream gateway.", + "migrationId": "connector-resilience-keys-retired", + "toMajor": 18, + "rationale": "The D2 conversion `connector-resilience-keys-removed` deletes `health`, `status` and `webhooks` from every connector, stack entry and stored connector row, one notice per key, and the delete is lossless: no loop ever polled a connector endpoint, counted failures or tripped a breaker, no code read an authored status, and a webhook nested in a connector was never registered, materialized or delivered. Three judgements remain. First, a probe or breaker the author believed was protecting a flaky upstream never was — if that protection matters, it has to be built where calls are made (the connector provider) or in front of the upstream (a gateway). Second, `status` values like `active` or `error` gated nothing; an author who used `status` to switch a connector off needs `enabled: false` on the declarative entry instead. Third, the nested webhooks are STRIPPED, not moved: redeclaring one in the top-level `webhooks:` collection STARTS deliveries that never happened before, so which of them should exist is the author's call — and their `events` (`sync.completed`, `auth.expired` and the rest) and `signatureAlgorithm` have no counterpart there. The chain: in this same protocol step, `connector-health-and-trigger-durations-unit-in-key` no longer renames `health.circuitBreaker.monitoringWindow` to `monitoringWindowMs` — the whole block that key lived in is removed, so an author holding either spelling ends with no key at all. That conversion's other half, `triggers[].interval` to `intervalSeconds`, was absorbed the same way by the removal of the whole `triggers` array (`connector-triggers-removed`), so the rename itself is no longer in the step." + }, + { + "surface": "connector.syncConfig (strategy / direction / realtimeSync / timestampField / conflictResolution / batchSize / deleteMode / filters) and connector.fieldMappings[] (source / target / defaultValue / dataType / required / syncMode), on a connector and on a stack connectors[] entry", + "replacement": "A sync is defined on its TARGET: a `mapping` (`targetObject`, `fieldMapping`, `mode`, `upsertKey`) whose `connectorSource` names the `rest` or `openapi` connector instance it pulls from (`connector`), the action that reads the records (`action`, with a fixed `input` and a `recordsPath`) and, for a timestamp-incremental pull, a `watermark` (`field` on the record, `param` on the request); a `job` sets the cadence. The pull executor reads the binding when a `job` drives it — a `job` whose `pull: { mapping }` names the mapping, on the job's schedule; the binding alone moves no rows.", + "migrationId": "connector-sync-keys-retired", + "toMajor": 18, + "rationale": "The D2 conversion `connector-sync-keys-removed` deletes `syncConfig` and `fieldMappings` from every connector, stack entry and stored connector row, one notice per key, and the delete is lossless: no engine ever ran a connector-attached sync or moved a value through a connector field mapping, so nothing the upgrade removes was ever happening. Three judgements remain. First, any part of the deployment designed around a connector sync running has never been running, so the author decides which syncs should now exist as target-side mappings; the conversion STRIPS the keys and never writes a `mapping`, because a mapping that is pulled STARTS writes into a table that never received them — its target object, match key and cadence are the author's. Second, the retired block named a direction, a conflict policy and a delete policy that no runtime applied — every delete is a hard delete, and `latest_wins` resolved nothing — and the pull that replaces it is one-way (external to local) and writes only through the mapping's `mode` and `upsertKey`: an author who relied on `export`, `bidirectional`, `soft_delete` or a conflict policy decides what to do without them. Third, a connector field map moved values nowhere; carrying its `source` → `target` pairs into `mapping.fieldMapping` makes them real for the first time, including any `defaultValue`, which the import mapping spells as a `constant` transform, and `required`, which the target field declares." + }, + { + "surface": "connector.triggers — the ConnectorTrigger array (key / label / description / type / intervalSeconds, and the interval spelling it was renamed from), on a connector and on a stack connectors[] entry", + "replacement": "(removed — nothing replaces a connector trigger.) Start the work from a flow that calls the connector's action in a `connector_action` node: an external event starts an `api` flow that the event's sender calls, and a scheduled pull is a `schedule` flow.", + "migrationId": "connector-triggers-retired", + "toMajor": 18, + "rationale": "The D2 conversion `connector-triggers-removed` deletes `triggers` from every connector, stack entry and stored connector row, one notice per connector, and the delete is lossless: the automation engine registered a connector's actions only, no polling loop read an interval, no receiver was driven by a `webhook` trigger, and no provider derived one — so a declared trigger never started a flow, before or after the upgrade. Three judgements remain. First, any part of the deployment designed around a connector trigger firing has never been running, so the author decides which of those triggers should now exist as flows: a `polling` trigger becomes a `schedule` flow whose `connector_action` node calls the connector's read action, and a `webhook` trigger becomes an `api` flow that the external sender calls. The conversion STRIPS the array and never writes a flow, because a flow that runs STARTS work that never happened before — its cadence, its action and what it does with the result are the author's. Second, a polling cadence is in SECONDS: the key was renamed from `interval` to `intervalSeconds` earlier in this same protocol step because the bare `interval` means milliseconds elsewhere in this spec, so a trigger written `interval: 60000` for one minute asked for once every sixteen hours or so — carry the intended cadence, not the stored number, into the schedule. Third, turning a `webhook` trigger into an `api` flow opens an inbound endpoint that never existed before (the trigger declared no receiver and no verification), and the platform refuses an `api` flow with no per-flow secret and verifies a signature on every call — so whether the external sender can sign its calls decides whether that flow can receive them directly. The chain: in this same protocol step, `connector-health-and-trigger-durations-unit-in-key` no longer renames `triggers[].interval` — the whole array that key lived in is removed, so an author holding either spelling ends with no key at all." + }, + { + "surface": "analyticsCubes[].joins..sql / analyticsCubes[].joins..relationship — the authored ON clause and the declared cardinality on a cube join", + "replacement": "analyticsCubes[].joins..name alone. The ON clause is DERIVED from the declared relationship between the two cubes' objects, as a foreign-key equality: NativeSQLStrategy emits the LEFT JOIN and its ON from the dotted member path, and ObjectQLStrategy lowers the same alias to a relationship traversal with no ON clause at all. The record KEY is the foreign-key FIELD on the base object, never a second spelling of the object the join reaches.", + "migrationId": "cube-join-sql-and-relationship-retired", + "toMajor": 18, + "rationale": "The KEYS convert mechanically and do: the paired D2 conversion `cube-join-sql-and-relationship-removed` deletes both from every join, which is lossless because neither ever had an effect to lose, and names the cube in each notice. What does NOT convert is the INTENT. `sql` was REQUIRED and documented as the ON clause, and no reader ever consulted it: an authored condition was REPLACED by the synthesised foreign-key equality and the aggregate came back under a 200, joined on something the author had not asked for. `relationship` carried a `.default('many_to_one')` that nothing dispatched on, so `one_to_many` parsed, changed no SQL, and kept the many-to-one arithmetic. Deleting the keys restores honesty but does not give an author who wanted a non-FK join the thing they wanted, and it does not re-check the numbers the replaced join already produced. That is why this entry is a TODO addressed to them rather than a claim that the strip finished the job. A custom join condition is a capability card with its injection / allow-list boundary decided first, which the ruling deferred deliberately." + }, + { + "surface": "analyticsCubes[].measures..name / analyticsCubes[].dimensions..name — the inner name a cube member used to require", + "replacement": "The record key. `measures` and `dimensions` are records, and the key a member is declared under IS its name: the analytics API publishes it as `.` and a query names it that way. To rename a member, rename its key.", + "migrationId": "cube-member-inner-name-retired", + "toMajor": 18, + "rationale": "The D2 conversion `cube-member-inner-name-removed` deletes the inner `name` from every metric and dimension of every cube, and the delete is lossless in behaviour: every consumer — discovery, both query strategies, the in-memory driver — resolves a member by its record key, so the inner value was never read. Where it EQUALED its key there is nothing left to decide. Where it DISAGREED, the key was already the name every query, dashboard and report used, and the inner value was a spelling nothing read; the conversion notice prints both. Only the author can say whether the disagreeing spelling was the one they meant — in which case the member must be re-keyed, and every consumer that names `.` changes with it — or a stale copy to drop." + }, + { + "surface": "analyticsCubes[].measures..sql and analyticsCubes[].dimensions..sql (data.MetricSchema.sql / data.DimensionSchema.sql) authored as a SQL expression — a CASE expression, an aggregate or a ratio of aggregates, a quoted or $-prefixed spelling, or any other value that is not a column reference", + "replacement": "a column reference: a field of the cube's object (`amount`), a relationship path ending in one (`account.amount`), or `'*'` for a count. A derived value moves to an ADR-0021 dataset over the same object: a conditional count or sum is a dataset measure with its own structured `filter` (`{ name: 'done_count', aggregate: 'count', filter: { status: 'done' } }`), and a ratio, sum, difference or product of measures is `derived: { op, of: [...] }` over measures named in the same dataset (`{ name: 'done_rate', derived: { op: 'ratio', of: ['done_count', 'task_count'] }, format: '0.0%' }`). A dimension that bucketed a column with a CASE expression has no expression form in either layer: group by the column itself, or keep the bucket as a field of the object and name that field", + "migrationId": "cube-member-sql-expression-retired", + "toMajor": 18, + "rationale": "Maintainer ruling D (2026-09-30), from the analytics field-level read gate: a member whose `sql` is an expression names no single field, so no platform check can judge which fields it reads, and the analytics strategies never agreed on it — the raw-SQL path emitted it verbatim and the ObjectQL path refused it. ADR-0021 already set the direction for the author surface (\"zero raw SQL / zero raw expressions\"); it governed the dataset layer and left the cube members it compiles to open, which is the gap this closes. The dataset form is the declared home of a derived value because every field it reads is named: a measure filter names its fields, and a derived measure references other measures by name only. There is no D2 conversion: the rewrite moves a member to a different metadata type and cannot be derived from the expression text in general, so only the author can say which dataset measures express what the expression meant. A ratio also changes SCALE on the way: a `derived` ratio is a 0–1 fraction, while an expression that multiplied by 100 returned percentage points — pair the ratio with a `%` numeral pattern (the server marks a ratio column's percent scale as a fraction) and re-check any consumer that read the old number raw. ADR-0021 / ADR-0049 / ADR-0087" + }, + { + "surface": "analyticsCubes[].measures..type (data.AggregationMetricType) authored as number, string or boolean — the custom-SQL-expression metric types", + "replacement": "the aggregate the measure means: `sum`, `avg`, `min` or `max` over the column, `count` (over `'*'` for a row count, or over a column for its non-null values), or `count_distinct`. A value computed per row becomes a field of the object (a stored or formula field) that the measure aggregates; a ratio or other value derived from measures is `derived: { op, of: [...] }` on an ADR-0021 dataset", + "migrationId": "cube-metric-expression-types-retired", + "toMajor": 18, + "rationale": "The three types existed to mark a measure whose `sql` was the whole computation — a ratio, a CASE, a window function — and named only what it returned. Since `cube-member-sql-expression-retired` a member's `sql` is a column reference, so the types had nothing left to declare: measured before this retirement, the raw-SQL strategy emitted the referenced column unaggregated (a bare column in a grouped statement, by SQL's own rules an error on PostgreSQL and an arbitrary row's value on SQLite) and the ObjectQL strategy refused the measure. There is no D2 conversion: the column alone does not say which aggregate the author wanted — a `number` over `amount` may have meant its sum, its average or its largest value — so only the author can choose, and a measure whose old expression computed something per row needs that value stored on the object before any aggregate can read it. Nothing is rewritten or dropped at rest: a stored or built cube that still carries one of the three is refused, with the prescription, at the boot and write doors, and a cube that reaches the analytics service without meeting the parse is refused at query time with the same text. ADR-0049 / ADR-0087" + }, + { + "surface": "analyticsCubes[].measures..filters — the per-metric raw-SQL filter list", + "replacement": "One of the two filters that ARE applied: a `where` condition at query time, or an ADR-0021 dataset measure with a structured `filter`. (A third channel — folding the condition into the metric's own `sql` expression — left with `cube-member-sql-expression-retired`: a member's `sql` is a column reference.)", + "migrationId": "cube-metric-filters-retired", + "toMajor": 18, + "rationale": "The D2 conversion `metric-filters-removed` deletes `filters` from every cube metric, and the delete is lossless in the narrow sense: neither SQL strategy ever read the key, so a metric authored with `filters: [{ sql: \"stage = 'closed_won'\" }]` already returned the UNFILTERED aggregate under the author's metric name, and still does. That is exactly why the strip does not finish the job. The author wrote a condition because they wanted a filtered number; every dashboard, report and export reading that metric has been showing a larger one. Only the author can say which of the two live mechanisms expresses the condition they meant — a query-time `where` changes every query, a dataset measure moves the metric to the governed layer — and whether numbers already published from the unfiltered metric need to be revisited." + }, + { + "surface": "analyticsCubes[].refreshKey (every, sql) — a cube's declared refresh cadence and data-change probe", + "replacement": "Nothing: delete the key. No analytics result is cached, so every query against a cube is computed when it is asked. A refresh cadence is declared again when a result cache exists.", + "migrationId": "cube-refresh-key-retired", + "toMajor": 18, + "rationale": "The D2 conversion `cube-refresh-key-removed` deletes `refreshKey` from every cube, and the delete is lossless: nothing read `every` or `sql`, and no analytics result was ever cached for them to refresh, so no query answers differently. What the conversion cannot check is whether anything the author built assumed that cube results were cached or refreshed on a schedule. They never were." + }, + { + "surface": "object.fields.*.currencyConfig.precision — the decimal-places key of a currency field's configuration, and its never-accepted `decimals` / `scale` spellings", + "replacement": "(removed — nothing replaces it.) A currency amount's decimal places are its currency's ISO 4217 minor unit (2 for USD, 0 for JPY, 3 for KWD), derived from the currency itself and declared nowhere. Delete the key. Do not move the number to the field-level `precision`: that key is the amount's total digit count, not its decimal places, and it is unchanged.", + "migrationId": "currency-config-precision-retired", + "toMajor": 18, + "rationale": "The D2 conversion `currency-config-precision-removed` deletes the key from every field's `currencyConfig` on objects and object extensions — in author sources, in stored object rows and in built artifacts, which can carry a `2` the old schema wrote into parse output without anyone authoring it — and the delete is lossless: no renderer or runtime ever read the key. Every display face derives the width from the currency. Two judgments remain, and neither is a rewrite. First, a width that never applied: the old contradiction check judged an authored value only on a `fixed` field whose code has a known ISO 4217 minor unit, so on a `dynamic` field, and on a `fixed` field whose code has none (a crypto or custom code), an author could declare a width other than the one the field displays — and read amounts as if it applied. Whether the displayed width is acceptable for that field is the author's call. Second, code the chain cannot reach: a plugin, integration or export of your own that read `currencyConfig.precision` from served object metadata now finds no key, and must derive the width from the field's currency the way the platform's renderers always did." + }, + { + "surface": "dashboard `header.actions[]` entries with `actionType: 'modal'` — an `actionUrl` naming a defined action, a bare object name, or the `_` prefix form (`create_`/`new_`/`add_`/`edit_`/`update_` + object). The keys themselves are unchanged and still parse; what changed is what the string RESOLVES to", + "replacement": "a declared page name (`stack.pages`, by `name`) — a modal target names a PAGE, only. To open an object's create/edit form from a dashboard header, use `actionType: 'form'` with an `.` form-view target (`actionType` accepts the full action-type enum, so that shape reaches this surface too)", + "migrationId": "dashboard-header-modal-target-page-only", + "toMajor": 18, + "rationale": "Maintainer ruling A on modal targets (2026-08-09): a `type: 'modal'` string target names a PAGE, only — the spec TSDoc, the published docs and `defineStack`'s cross-reference walk already agreed, and the renderer's page-then-object leniency (self-labelled Back-compat) was retired rather than codified. One objectui change deleted the object fallback in the shared `useActionModal`; a second deleted `DashboardView`'s own second copy of the prefix convention (which had no page resolution at all), after enumerating both repos' corpora and finding zero producers of the prefix form. The `os validate` lint rule (`validateDashboardActionRefs`) then still pointed the other way: it accepted the retired shapes — blessing buttons that dispatch to a named refusal at runtime — and ERRORED on a page-named target, the one shape the runtime serves. The rule now resolves a modal target against declared pages, only. The ruling explicitly declined the middle shape (keep the prefix, reject bare object names): `create_opportunity` names the page `create_opportunity`, or it names nothing." + }, + { + "surface": "dashboard.refreshInterval — the auto-refresh cadence of a dashboard", + "replacement": "`refreshIntervalSeconds` — the same cadence, in seconds, with the unit in the key name. The old rename hints (`refresh`, `autoRefresh`, `pollInterval`) now point at it.", + "migrationId": "dashboard-refresh-interval-unit-in-key", + "toMajor": 18, + "rationale": "The D2 conversion `dashboard-refresh-interval-to-refresh-interval-seconds` renames `refreshInterval` to `refreshIntervalSeconds` in the `dashboards` collection and on stored dashboard rows, keeping the value, and the rename is lossless: the key always meant seconds. Two judgments remain. First, the unit: nothing in the old name said seconds, and three other spellings authors reached for named no unit either, so a value written in milliseconds — `refreshInterval: 30000` meant as thirty seconds — asked for a refresh about every eight hours, and the rename keeps 30000. Second, the reader: the dashboard renderer ships in the separately released console, and when this rename landed it still read the old key, so a console that has not yet moved sees no cadence and starts no timer. A dashboard that stops refreshing after the upgrade is that lag, not a wrong value — which only a look at the running console can tell apart." + }, + { + "surface": "`dashboard.widgets[].chartConfig.type` / `.xAxis` / `.yAxis` / `.series` — the four keys that said which chart family to draw, which series exist and which column each one reads on a DATASET-BOUND widget (REMOVED)", + "replacement": "the widget’s own `type` and its ADR-0021 dataset selection. `chartConfig.type` becomes the widget’s `type` (the chart family has always been the widget’s — the dashboard renderer maps the widget type to the chart family and never read the chart config’s). `chartConfig.xAxis.field` becomes an entry in the widget’s `dimensions`: the dataset dimension the category axis plots. Each `chartConfig.yAxis[].field` becomes an entry in the widget’s `values`: the dataset measure that axis plots, one entry per mark, and a second axis is a second measure rather than a second axis declaration. Each `chartConfig.series[].name` is the same measure name, so a series list that matched `values` needs nothing and one that did not was already being ignored. What has NO replacement, and is the reason this is a TODO rather than a rewrite: the PRESENTATION those objects carried alongside the binding — `ChartAxis.title` / `format` / `min` / `max` / `stepSize` / `showGridLines` / `position` / `logarithmic`, and `ChartSeries.label` / `color` / `type` / `yAxis` / `stack` / `dashArray` / `opacity`. The dataset’s own dimension and measure declarations are what label and format a dataset-bound chart now; `colors` on the same chart config remains the palette channel, and a per-series mark type (the combo chart a widget could author through `series[].type`) has no authoring channel on this face at all.", + "migrationId": "dashboard-widget-chart-config-structure-refused", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-09-12 on the dataset-bound chart config, taking options C+D together: the protocol states the ownership split AND refuses the structural keys by name, because stating it without refusing them leaves the declared-but-inert shape ADR-0049 exists to end, and refusing them without stating it leaves an author with no reason. The defect being closed is not cosmetic: an authored `yAxis[].field` was a LIVE MEMBERSHIP CHANNEL — the renderer synthesised a series from the authored axes when the chart declared none — so one authored axis could silently re-point a dataset-bound series at a different column while the chart still drew, which reads as a true statement about the data. ⛔ Not mechanically convertible: the D2 conversion can delete the keys from a stored widget, but moving what they MEANT into the dataset selection needs facts the item does not carry — whether the dataset declares a dimension by that name, whether the measure is in the dataset at all, and whether the author wanted the axis they wrote or the one the selection derives. An authored field naming a column outside the selection is exactly the case where a walker guessing would produce a different chart rather than a refused one. The keys are NOT retired from the chart config itself: `ReportChartSchema` keeps its own `xAxis`/`yAxis` (narrowed to its bound dataset’s dimension and measure names), and the react `` tier keeps all four, because an inline-data chart has no dataset to derive structure from and the author’s axes are the only ones there are." + }, + { + "surface": "dashboard widget measure arity WITHOUT a dimension — `dashboard.widgets[].values` (`DashboardWidgetSchema.values`) on a widget whose `dimensions` is absent or empty and whose `type` is one of the seven chart types that declare no rendering for several measures: `pie` / `donut` / `funnel` / `scatter` / `radar` / `treemap` / `sankey`", + "replacement": "Pick a visual that renders several measures, or split the widget. With no dimension, `type: 'table'` renders a row of measures and a bar-family type (`bar` / `column` / `horizontal-bar`) renders one bar per measure; both keep the unbounded `values` they have always had. The full set of types that render several measures on a dimensionless widget is the exported constant `DASHBOARD_WIDGET_MULTI_MEASURE_TYPES` — at this release `table`, `pivot`, `bar`, `column`, `horizontal-bar`, `line`, `area` and `combo` — and the refusal prints it from that constant. Or keep the type and give each measure its OWN widget: a new `id`, the same `dataset`, that one measure in `values`, and its own `layout` if the dashboard pins grid positions. ⛔ The migration does not do this for you and no conversion could: whether a dimensionless two-measure pie meant a table, a bar chart or two pies is an authoring choice, and N widgets need N ids and N boxes on a 12-column grid, which is a LAYOUT decision about a dashboard the registry has never seen.", + "migrationId": "dashboard-widget-dimensionless-multi-measure-refused", + "toMajor": 18, + "rationale": "Maintainer ruling D, on objectui's finding that a widget silently drops every measure after the first, applying the maintainer's standing rule 「协议不正确的应该先修改协议」 — the protocol is fixed where it admits measures a widget type cannot render. Its first application bounded the metric FAMILY to one measure (`dashboard-widget-metric-family-multi-measure-refused`); this entry applies the same principle to the chart types. Measured in objectui by the dev who delivered the multi-measure renderings for `table` / `pivot` and the bar, line, area and combo families: the other seven `ChartTypeSchema` members, given no dimension and two or more measures, render `values[0]` and drop the rest — the dataset query selects and computes every measure, and all but the first are thrown away. Every door accepted the document, because `values` is `z.array(z.string()).min(1)` with no upper bound outside the metric family. That is the declared≠delivered shape ADR-0049 exists to end. The census before the change found zero authored dimensionless multi-measure widgets of any type in the platform's examples or in objectui's example apps, so this ships at once with no deprecation window: there is no window in which a queried-and-discarded measure does anything. Relaxing later is free and needs no second migration — if one of the seven gains a declared multi-measure rendering (a radar of measures, a funnel of measure stages) it joins the constant, while leaving the key unbounded costs an author a widget that silently drops what they declared. ⛔ This change does not invent those renderings." + }, + { + "surface": "dashboard widget measure arity — `dashboard.widgets[].values` (`DashboardWidgetSchema.values`) on a widget whose `type` is one of the metric FAMILY (`metric` / `kpi` / `gauge` / `solid-gauge` / `bullet`), INCLUDING a widget that declares no `type` at all and so resolves to the `metric` default", + "replacement": "ONE measure per tile. Keep the measure the tile is actually for — in practice `values[0]`, which is the only one that has ever rendered — and give each of the others its OWN widget: a new `id`, the same `dataset`, that one measure in `values`, and its own `layout` if the dashboard pins grid positions. ⛔ The migration does not do this for you and no conversion could: N tiles need N ids and N boxes on a 12-column grid, which is a LAYOUT decision about a dashboard the registry has never seen. If several numbers in ONE widget is what was meant, that is a different visual and the arity rule is not in its way: `type: 'table'` renders a row of measures, and the chart families (`bar` / `line` / `area` / `combo`) render one mark per measure — all of them keep the unbounded `values` they have always had.", + "migrationId": "dashboard-widget-metric-family-multi-measure-refused", + "toMajor": 18, + "rationale": "Maintainer ruling D of 2026-09-12, on objectui's finding that a metric tile silently drops every measure after the first, applying the maintainer's standing rule 「协议不正确的应该先修改协议。」 — judge the protocol wrong rather than invent display semantics for `values[1..]`. Measured in objectui's first report of the defect: `values` was `z.array(z.string()).min(1)` with NO upper bound on every widget type, so a `metric` tile could declare three measures; the dataset query selected and computed all three, and the tile rendered `values[0]`. The other two were queried and dropped on the floor — the declared≠delivered shape ADR-0049 exists to end, kept alive by a runtime warning rather than closed. An objectui fix (merged) added the declared sub-caption, and objectui's interim half made the tile SAY that the extra measures are not rendered: that makes the tile honest about dropping them, it does not make the document legal. A metric tile answers ONE number — that is what the family means on every mainstream dashboard product, and `ChartTypeSchema` groups these five under \"Performance (single value)\" in its own words. Several numbers is a DIFFERENT visual, not a variant of this one, so the repair is an accept-set narrowing and not a renderer feature. ⛔ NOT the other arm (the wish, from a downstream application's manager dashboard, for several numbers on one tile): under this ruling that is a request for a different widget type, and it stays reachable through `table` / the chart families, which this narrowing does not touch. Ships at once, no deprecation window: there is no window in which a queried-and-discarded measure does anything. Widening later (a real gauge renderer that draws a target band, say) costs an author nothing and needs no second migration — a narrowing that is later relaxed is free, while leaving the key unbounded costs them a tile that silently drops what they declared." + }, + { + "surface": "dashboard widget measure arity WITH a dimension on a single-series chart type — `dashboard.widgets[].values` (`DashboardWidgetSchema.values`) on a widget whose `dimensions` declares one or more dimensions and whose `type` is `pie`, `donut`, `funnel`, `treemap` or `sankey`; and the check export `checkDashboardWidgetDimensionlessMeasureArity` (from `@objectstack/spec/ui`), renamed `checkDashboardWidgetChartMeasureArity`", + "replacement": "Keep ONE measure on the widget, or pick a visual that renders several. With a dimension, `type: 'table'` renders a column per measure and a bar-family type (`bar` / `column` / `horizontal-bar`) renders one bar per measure in each category; both keep the unbounded `values` they have always had. Or keep the type and give each measure its OWN widget: a new `id`, the same `dataset` and `dimensions`, that one measure in `values`, and its own `layout` if the dashboard pins grid positions. ⛔ The migration does not do this for you and no conversion could: whether a two-measure pie by stage meant a table, a grouped bar chart or two pies is an authoring choice, and N widgets need N ids and N boxes on a 12-column grid, which is a LAYOUT decision about a dashboard the registry has never seen. A mirror that chained the old check export by name imports `checkDashboardWidgetChartMeasureArity` instead: same signature, same attachment point, and it refuses everything the old name refused.", + "migrationId": "dashboard-widget-single-series-multi-measure-refused", + "toMajor": 18, + "rationale": "Triage's ruling on objectui's finding that a dimensioned pie draws only its first measure: the spec refuses, the renderer does not invent. It extends `dashboard-widget-dimensionless-multi-measure-refused`, which applied maintainer ruling D (「协议不正确的应该先修改协议」) to a widget with NO dimension, to the dimensioned arm for the five types that draw one series whatever the dimension. Measured in objectui's shared chart renderer: the `pie` / `donut`, `funnel`, `treemap` and `sankey` arms each bind the first series and read no other, so `{ type: 'pie', dimensions: ['stage'], values: ['revenue', 'cost'] }` drew one slice per stage for `revenue` and no trace of `cost` — the dataset query selects and computes every measure, and all but the first are thrown away. Every door accepted the document, because the dimensionless rule stepped aside for any widget that declared a dimension. That is the declared≠delivered shape ADR-0049 exists to end. A pie of several measures has zero measured pull, so no rendering is invented for it. The census before the change found zero authored dimensioned multi-measure widgets of the five types in the platform's examples, its first-party dashboards or objectui's example apps, so this ships at once with no deprecation window. Relaxing later is free and needs no second migration — a type whose renderer gains a declared rendering for several measures with a dimension leaves the single-series set — while leaving the shape accepted costs an author a widget that silently drops what they declared. The check export is renamed in the same change because its old name said a widget with a dimension was outside it, which stopped being true; no first-party consumer chained it under that name." + }, + { + "surface": "dashboard widget stage order — `dashboard.widgets[].options.stageOrder` (`DashboardWidgetOptionsSchema.stageOrder`) on a widget whose `type` is anything other than `funnel`, INCLUDING a widget that declares no `type` at all and so resolves to the `metric` default", + "replacement": "either `type: 'funnel'` on the widget that meant to declare a stage order, or — for every other widget type — DELETE `stageOrder` and order the widget with `options.sortBy` + `options.sortOrder`, which lower into the dataset query as `order: { : 'asc' | 'desc' }` instead of re-sorting what it returned. There is no third spelling: no other widget type has ever read the key, so nothing is lost by removing it that was not already absent from what rendered. The refusal lands at `options.stageOrder` and names the type the widget carries, the one type that reads the key, and the two keys to reach for instead.", + "migrationId": "dashboard-widget-stage-order-non-funnel-refused", + "toMajor": 18, + "rationale": "The first finding of the report that `options.stageOrder` is honoured by the funnel branch only, ADR-0049 enforce-or-remove, and the enforce arm of a defect whose whole content was SILENCE. `options` is the open renderer-extras bag, so `stageOrder` was an ungated member of it: a `horizontal-bar` (or `line`, `pie`, `table`, `metric`) widget carrying an authored lifecycle order PARSED, booted, and forwarded the array to the renderer, which never consulted it. Measured at this repo's `.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694`: the forwarded `categoryOrder` prop has exactly one read in the charts plugin (`buildCategoryRank(categoryOrder)`, `AdvancedChartImpl.tsx:1514`) and it sits inside the `chartType === 'funnel'` guard opened at line 1473; the prop's other two occurrences in that file are its declaration and its destructure. The producer side has no gate either — `DatasetWidget.tsx:1468` builds the explicit order for ANY widget and forwards it whenever non-empty. So the authored order was accepted by the metadata layer, carried all the way to the chart, and dropped there, with nothing anywhere to say so: the widget rendered in whatever order the analytics query returned and looked deliberate. The reporter measured exactly that in a live app — a `horizontal-bar` carrying a seven-stage contract lifecycle rendered alphabetically by display label. The four SIBLING members of the same bag are not in this narrowing and were measured not to share the defect: `dateGranularity`, `sortBy`, `sortOrder` and `limit` are read unconditionally at the top of `DatasetWidget` (lines 443-455, outside every type branch) and lower into the `DatasetSelection` the server compiles, so they act on every widget type. `stageOrder` was the only member whose effect was confined to one branch. ⛔ NOT the other arm of the card (\"or ordered marks honour it\"): teaching `bar` / `line` / `area` to sort by a category order is a renderer change in the objectui repo, and widening the set of types that read the key can be done later WITHOUT a second migration — a narrowing that is later relaxed costs an author nothing, while leaving the key accepted-and-inert costs them a chart that silently lies. Ships at once, no deprecation window: there is no window in which an inert key does anything." + }, + { + "surface": "FileValue.duration, the media length on the expanded file/image/avatar/video/audio read shape, whose name carried no unit (data/field-value.zod.ts)", + "replacement": "durationSeconds — rename the key; the value is unchanged, and a fractional second is still legal", + "migrationId": "data-file-value-duration-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling A of 2026-09-17 on the last two duration keys no closed duration type could express: rename the key and record an ADR-0087 conversion-layer entry, with no new closed type and no narrowing of anything already stored. This key declared its unit in NO channel at all — no `.describe()`, no JSDoc, no unit token in the name — so the published reference page printed a bare number and the authoring site printed nothing. What makes the bare name worth a registry row is the company it kept: the only other number on FileValue is `size`, a BYTE count, so the one member that measured time was indistinguishable from a count at the very site an author (very often a model, ADR-0033) writes it. `durationSeconds` rather than a mechanical `durationSec` or `lengthSeconds`: this spec already spells a length of time `durationSeconds` in SIX places, and they are ENUMERATED rather than counted because a bare number in shipped prose cannot be re-checked against the tree — ai/conversation.zod.ts ConversationAnalytics.durationSeconds; and on system/metrics.zod.ts MetricAggregationConfig.window.durationSeconds, ServiceLevelIndicator.window.durationSeconds, ServiceLevelObjective.period.durationSeconds, ServiceLevelObjective.errorBudget.burnRateWindows[].durationSeconds and MetricsConfig.retention.durationSeconds. The media length is therefore the SEVENTH spelling of one vocabulary, not the first of a second one. The retired-key tombstone entry data/FileValue:duration carries the same six keys in the same order, so the two surfaces that state one fact cannot drift apart. The value type is deliberately UNCHANGED at `z.number().optional()`: a fractional second is the ordinary shape of a media length, so the closed `DurationSeconds` type (`.int().nonnegative()`, published beside `EpochMs` as a closed duration type) was considered and REFUSED by the ruling, and so was an `.int()` floor. That refusal is the load-bearing half — this row is one of the six genuine durations the closed types' unit set was derived from, and it is the one that takes a NAME instead of a TYPE. Tombstoned with retiredKey(); FileValueSchema is the one deliberate z.looseObject in this file, so a bare deletion would wave the old spelling through as an unrecognised extra key and the prescription would never be spoken. Why a semantic entry and not a D2 conversion: FileValueSchema is the ADR-0104 D3 wave-2 EXPANDED READ form, derived at read time from a sys_file id — the STORED form is FileReferenceIdValueSchema, an opaque string — so a file value is never authored as this shape and never persisted as a sys_metadata row, and the conversion chain has no seam that would ever see one. ADR-0104, ADR-0087." + }, + { + "surface": "NoSQLQueryOptions.timeout, the per-query driver deadline whose name carried no unit (data/driver-nosql.zod.ts)", + "replacement": "timeoutMs — rename the key; the value is unchanged", + "migrationId": "data-nosql-query-options-timeout-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. It stands alone because it is the only offender on its file. The neighbour is what makes it a real hazard rather than a naming preference: batchSize sits directly beside it, a plain row COUNT with the same z.number().int().positive() shape and the same order of magnitude, so two adjacent bare integers meant milliseconds and documents respectively with nothing at the call site to separate them. Tombstoned with retiredKey(); the shape is not strict, so a bare deletion would strip in silence and the query would run with no deadline at all while its author believed one was set — the failure a driver timeout exists to prevent. Why a semantic entry and not a D2 conversion: these options are a per-call driver argument, reached only through AggregationPipeline.options, which no stack.zod.ts collection declares and no sys_metadata row stores, so the chain has no seam. ADR-0087." + }, + { + "surface": "ui.Dataset filter and ui.DatasetMeasure filter — an ARRAY as an EQUALITY comparand on a field INSIDE a nested-relation condition, now refused when the dataset is PARSED: the implicit form { account: { region: [...] } } and the explicit form { account: { region: { $eq: [...] } } }, the empty array included, at any relation depth and under $and / $or / $not. Every other schema that carries a FilterCondition keeps the shared schema's reach", + "replacement": "the operator the list was standing in for, on the same field inside the same relation, exactly as in filter-equality-array-comparand-refused-at-save. \"One of these values\" is $in: { account: { region: { $in: [\"a\", \"b\"] } } } (authoring spelling \"in\"). \"The stored multi-value field holds this value\" is $contains with ONE member (authoring spelling \"contains\"), and an $or of those for any-of. A filter that meant a single value writes that value: { account: { region: \"a\" } }. The list operators keep their arrays, empty lists included; every scalar, null above all, is untouched; and $ne is NOT judged by this entry", + "migrationId": "dataset-filter-nested-relation-equality-array-refused-at-save", + "toMajor": 18, + "rationale": "Measured on origin/main 9e7824a445 before the change: DatasetSchema parsed a dataset whose filter was { account: { region: [\"a\"] } }, and one whose measure filter was { account: { region: { $eq: [\"a\"] } } }, GREEN — while the analytics where door, which charts both carriers on every path (the native-SQL and ObjectQL strategies and the draft preview), flattens the relation to the dotted member account.region and hands the list to the shared comparand-shape face, which refuses it with INVALID_FILTER / 400. So such a dataset saved clean and every chart built on it failed, for a different person, later. The shared FilterConditionSchema does not descend a field spec with no $ key, because the engine reads one as a deep-equality comparand; ruling A of 2026-09-24, which made the schema door refuse what the compile face refuses, drew the line there and it stays there. Triage on 2026-09-25 routed the fix to the two analytics carriers instead, rather than stop the analytics door descending, which would change what a nested list means: they refine their filter with the analytics door's own walk ($and / $or arrays and $not descended, other $ keys skipped, a plain object with no $ key descended as a nested relation at any depth) and refuse, inside a nested relation only, exactly what that door refuses there, in the face's words from the one builder both doors import. The one difference is that the door appends the location (at where.account.region) and the carrier does not, because its issue carries the location as its path (filter.account.region, measures.0.filter.account.region.$eq). A list outside a nested relation is the shared schema's refusal and is reported once. No filter changes meaning: the refusal moves from chart time to save. Metadata AT REST is not rewritten and this entry adds no D2 conversion, for the reason the runtime entry gives: an array on equality has no single honest value. The read path does not re-validate stored rows, so a stored dataset keeps loading; what changes is that re-saving it through the metadata protocol (422 INVALID_METADATA), defineStack or os validate is refused at the filter's path. Such a filter has failed every chart since the analytics door began refusing it, so the refusal is a repair and not a loss. In-repo census at 9e7824a445: no dataset or measure filter in examples, platform objects, docs or skills carries the shape; deployed datasets were NOT measured. ADR-0021 / ADR-0087." + }, + { + "surface": "dataset measure `aggregate` × `field` pairs (`DatasetMeasureSchema`, the rows inside `Dataset.measures[]`) over a TEMPORAL field — `date`, `datetime`, `time` — whose aggregate that declared `FieldType` cannot carry: `avg` and `sum` over any of the three. ⚠️ This entry is ONE OF TWO on this leg, and its scope sentence is kept as written: it covered the temporal class and nothing else when it was registered. The non-temporal `sum` / `avg` rows followed in a later change, which registered NO entry of its own — it declared `not-required (already-registered dataset-measure-aggregate-field-type-refused)` against THIS id — so its widening rides this entry's prescription rather than a separate one. The `count_distinct` row's JSON-stored types (`json`, `composite`, `repeater`, `record`, `location`, `address`, `vector`, `multiselect`, `checkboxes`, `tags`) left the table in a third change, which likewise registered no entry and rides this one: no two backends compare those values for equality alike, so `count` is the aggregate that stays. The `min` / `max` rows over every class the table refuses are the second entry, `dataset-measure-selecting-aggregate-field-type-refused`. ⇒ Read BOTH when migrating; there is no third", + "replacement": "an aggregate the field's type accepts, per `AGGREGATE_FIELD_TYPE_COMPATIBILITY` (`@objectstack/spec/data`): `min` / `max` for a temporal field — both return a real instant of the field's own type — or `count` / `count_distinct`, which read no arithmetic off the value. A DURATION is not recoverable from an aggregate over instants: store it as a number (a computed \"days open\" field) and aggregate that. A `derived` measure whose `of` names a refused measure is fixed by fixing that measure, not the `derived` one", + "migrationId": "dataset-measure-aggregate-field-type-refused", + "toMajor": 18, + "rationale": "Nothing between the author and the driver correlated a measure's aggregate with its field type, so `avg` over a `Field.datetime` compiled to `AVG(col)` and reached the backend — where the ANSWER was decided by the dialect rather than by the data. Measured on both halves: SQLite coerces the column's canonical UTC text to a number by reading its leading digits, so `avg(submitted_at)` over 2026-05 and 2025-01 returns `2025.5` — the average YEAR, no error, no log; PostgreSQL 16 answers `function avg(timestamp with time zone) does not exist` (SQLSTATE 42883). ⚠️ The two halves are not evidenced alike: the SQLite half is PINNED by a live `sql.js` suite in `__tests__/aggregate-datetime-measure-refusal.test.ts`, while the Postgres half was MEASURED IN-SESSION on PostgreSQL 16.13 and is not pinned by any test — the live PG conformance job carries no cell for it. Nothing depends on it: the refusal is decided from declared metadata before a driver is reached. ⭐ The silent half is the dangerous one, and it is the DEV default: `derived: { op: 'difference', of: [avg_a, avg_b] }` over two such averages rendered `-0.85` on a tile labelled \"average cycle time delta\" — indistinguishable from a correct answer, which is the shape Prime Directive #12 exists to remove. Which pairs are accepted is therefore a contract, declared once in `@objectstack/spec` under the director's ruling of 2026-09-06 (\"both legs, table in spec\") and executed by the consumer legs; the compile-time leg (`dataset-compiler`, `service-analytics`) refuses the pair with `DATASET_INVALID` / 400 before any query is built, using the declared type the host already supplies through `AnalyticsServiceConfig.sourceFieldMeta`. ⚠️ A `date` / `datetime` used as a DIMENSION — grouping, bucketing, date-range filtering — is untouched: this is about aggregation only." + }, + { + "surface": "dataset measure `aggregate` × `field` pairs (`DatasetMeasureSchema`, the rows inside `Dataset.measures[]`) pairing `min` or `max` with a field whose declared `FieldType` that aggregate cannot carry — every type outside the numeric, temporal and boolean classes. Named in full so an author can grep their own metadata: the string family (`text`, `textarea`, `email`, `url`, `phone`, `password`, `secret`, `markdown`, `html`, `richtext`, `code`, `color`, `signature`, `qrcode`), the option types (`select`, `radio`), the references (`lookup`, `master_detail`, `tree`, `user`), `autonumber`, the multi-option types (`multiselect`, `checkboxes`, `tags`), the file family (`image`, `file`, `avatar`, `video`, `audio`), the structured-JSON types (`json`, `composite`, `repeater`, `record`, `location`, `address`, `vector`) and `formula` — 37 field types × 2 aggregates = 74 pairs", + "replacement": "an aggregate the field's type accepts, per `AGGREGATE_FIELD_TYPE_COMPATIBILITY` (`@objectstack/spec/data`), or a different way of asking the question. ⚠️ There is no lossless rewrite, which is why this is a semantic TODO and not a D2 conversion: nothing can compute \"the smallest text value\" in a way every backend agrees on, so no transform can preserve the answer. The three routes an author actually has, per intent: ① the measure was COUNTING in disguise (\"how many distinct owners\") ⇒ `count`, which accepts every type because it reads no value, or `count_distinct`, which accepts every type but the JSON-stored ones (the structured-JSON types and `multiselect` / `checkboxes` / `tags`, whose values no two backends compare for equality alike); ② the measure wanted a FIRST or LAST RECORD (\"the earliest-titled task\") ⇒ that is a SORT on a list or report, which orders once in a declared direction, not an aggregate that asks each backend for its own smallest value; ③ the measure wanted a QUANTITY that happens to be stored as text or JSON ⇒ store it as a numeric or temporal field (a computed column) and aggregate that. A `derived` measure whose `of` names a refused measure is fixed by fixing that measure, not the `derived` one", + "migrationId": "dataset-measure-selecting-aggregate-field-type-refused", + "toMajor": 18, + "rationale": "Director ruling B of 2026-09-13: the compile door enforces the table for every aggregate. The table refused these 74 pairs from the day it was declared and NOTHING executed the refusal: the compile leg (`dataset-compiler`, `service-analytics`) carried an explicit scope condition — `if (!DERIVING_AGGREGATES.has(aggregate)) return;` — so `min` / `max` were never judged whatever the field type, and `service-analytics`' `measureResultType` went further and typed `min` / `max` over the string classes as a supported `'string'` result and over a `formula` field from its declared `returnType`. Four declarations, three answers, one pair — the worst shape of declared≠enforced, because nobody could tell which sentence was the contract. ⭐ The divergence is real and it is the ORDER rather than the arithmetic: string order is collation-dependent, so two backends answer two different \"smallest\" values for one metadata document, and `min(jsonb)` does not exist on PostgreSQL at all — the same shape Prime Directive #12 exists to remove. The ruling settled all three sub-questions together rather than per field class, because one shared fixture drove members of both halves: the string classes stay REFUSED as the director's 2026-09-06 ruling put them (「`min`/`max` numeric plus `date`/`datetime`; everything else refused」) and the table is NOT amended; the non-string classes are refused AND enforced; and `formula` is refused on the table's own storage ground — it is VIRTUAL in SQL storage, no column is emitted, so no aggregate can be lowered to it whatever `returnType` says. ⚠️ The \"ruled C — the table is to be AMENDED to accept the string rows\" note the tree carried in two test files had no ruling behind it: the card it cited is closed as a duplicate with zero rulings on it, and the earlier recorded ruling on this table says the opposite. Business pull was measured and is zero — the shipped `min` / `max` cases were in-tree fixtures pinning a result TYPE, not customer datasets reading one. ⚠️ Confidence gap, recorded rather than hidden: customer datasets in the `cloud` repository were not readable when this was decided." + }, + { + "surface": "datasets[].dimensions[].field and datasets[].measures[].field (ui.DatasetDimensionSchema.field / ui.DatasetMeasureSchema.field) authored as anything but a column reference — a SQL expression (an arithmetic, an aggregate, a CASE, a subquery, a function call), a quoted or $-prefixed spelling, a padded or empty string, a broken path, or * on a dimension", + "replacement": "a column reference: a field of the dataset's object (`amount`), or a relationship path ending in one (`account.amount`) whose relationships are declared in `include`; on a measure also `'*'` for a count, and a count may omit `field` altogether (never `field: ''`). A derived value takes its ADR-0021 form: a conditional count or sum is a measure with its own structured `filter` (`{ name: 'done_count', aggregate: 'count', filter: { status: 'done' } }`), and a ratio, sum, difference or product of measures is `derived: { op, of: [...] }` over measures named in the same dataset (`{ name: 'done_rate', derived: { op: 'ratio', of: ['done_count', 'task_count'] }, format: '0.0%' }`). A dimension that bucketed a column with an expression has no expression form: group by the column itself, or keep the bucket as a field of the object and name that field", + "migrationId": "dataset-member-field-expression-refused", + "toMajor": 18, + "rationale": "The dataset layer was declared to take no raw SQL (ADR-0021 \"zero raw SQL / zero raw expressions\"), and its `field` was documented as a field or a relationship path, but the slot was a bare string and parsed anything — declared, never enforced (ADR-0049). The runtime had already closed the other end for an expression: the analytics dataset door refuses one with a 403 refusal, inline or saved, because an expression names no single field and no platform check can judge which fields it reads. So an expression could be saved and never answered. That door never judged an empty `field` — it skips one. The cube members a dataset compiles to were narrowed to the same accept set earlier (`cube-member-sql-expression-retired`); the dataset compiler copies `field` into the member's `sql` verbatim, so the two slots now share one declaration. A dimension additionally refuses `'*'`: grouping by every column is no axis, and both analytics strategies answered such a dimension with a 500 database fault. An empty string is refused on both: a dimension groups by nothing, and a count spells \"no field\" by omitting the key. One empty string had a working row and has a lossless repair: a `count` measure with `field: ''` (the shape a blank Field box in Studio's dataset inspector stores) compiled to the row count on SQLite's native-SQL path, and without the key it compiles to `COUNT(*)` — the D2 conversion `dataset-count-measure-empty-field-removed` drops it from stored rows and sources. Everything else has no mechanical rewrite into a column: an expression becomes a measure filter, a derived measure or a field of the object, and a ratio changes scale on the way (a `derived` ratio is a 0–1 fraction, so an expression that multiplied by 100 returned percentage points). ADR-0021 / ADR-0049 / ADR-0087" + }, + { + "surface": "datasource.config.options.auth.password (mongodb) — a login credential written into the MongoClient options passthrough", + "replacement": "remove the `auth` block from `options` (its other keys — `replicaSet`, `tls`, timeouts — stay legal) and bind the secret: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` secrets-store reference, with the username kept in the URL (`mongodb://user@host/db`)", + "migrationId": "datasource-config-mongo-options-credential-refused", + "toMajor": 18, + "rationale": "The FOURTH spelling of the same inline secret: earlier publish refusals closed the top-level `password` key, the URL userinfo password and the credential-bearing URL query parameters — and the `options` passthrough stayed open one syntax over. `options: { auth: { username, password } }` parsed green, persisted the password cleartext into `sys_metadata` (served back by the ordinary data API), and genuinely authenticated: mongodb@7.5.0 transforms the block into `MongoCredentials` (measured), so the workaround was live, not inert. A non-empty string `auth.password` is now refused at publish with the binder prescription; `auth.username` alone stays writable (the asymmetry the URL grammar keeps between its two userinfo halves — a username is not credential material), as do all non-credential passthrough options. The bound secret wins over a passthrough `auth` block at connect (measured when the bound secret was made to reach the mongo client on its URL branch), so the replacement changes which store holds the secret, never which credential connects. There is no mechanical rewrite, for the same reason as the sibling entries `datasource-config-inline-credential-refused`, `datasource-config-url-userinfo-refused` and `datasource-config-url-query-credential-refused`: moving the value requires ENCRYPTING it into a `sys_secret` row through a running secret binder, which a source-file transform cannot do — and auto-dropping only the nested password would leave an `auth` block the client refuses at construction (measured: `credentials must be an object with 'username' and 'password' properties`). Runtime-environment DSNs (`OS_DATABASE_URL` and friends) never pass through the publish door and are unaffected by construction. The read path now also redacts the stored passthrough secrets (`options.auth.password`, `options.proxyPassword`, TLS key material, `AWS_SESSION_TOKEN`) instead of serving them back in cleartext." + }, + { + "surface": "datasource.config.options.**: any credential-SPELLED key (`password`, `authToken`, or a former alias — `passwd`/`pwd`/`token`/`jwt`/`auth_token`/`authtoken`) holding a non-empty string at any object depth of the mongodb options passthrough", + "replacement": "remove the nested key (no measured client behaviour reads any such position other than `auth.password`, which has its own refusal); if a real secret must reach the connection, bind it — the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`) or a direct `external.credentialsRef` reference", + "migrationId": "datasource-config-options-nested-credential-spelling-refused", + "toMajor": 18, + "rationale": "The nested-position closure of the `datasource-config-mongo-options-credential-refused` family: that entry refused the one MEASURED login position (`options.auth.password`) and left every other nested spelling of the same secret accepted — `options.auth.token`, `options.pool.password`, any credential-spelled key one object level down parsed green, persisted cleartext into `sys_metadata` (served back by the ordinary data API), and was served on the datasource read doors with `redactedConfigKeys: []` because the read-side nested judgment was a hand-enumerated path table. Publish now refuses a non-empty string under any credential SPELLING at any object depth of the passthrough — the same one spelling list the top level refuses and the read path redacts, so a nested position is treated identically to the top-level key it mirrors. Arrays are off the walk (row-shaped data is not config). The read path now also redacts these spellings at every depth, for every driver, and carries them forward on an untouched Save. There is no mechanical rewrite, for the same reason as the sibling credential entries: moving a value into `sys_secret` requires a running secret binder, which a source transform cannot do — and unlike `auth.password`, a nested spelling at an unmeasured position buys nothing at connect, so the usual outcome is deletion, which only the author can confirm." + }, + { + "surface": "datasource.config.url (postgres) — connection URLs the `pg` client cannot parse (libpq's multi-host `h1:5432,h2:5433` form, a non-numeric port, a scheme-less non-URL, a malformed percent-escape), plus the filesystem-reading query parameters `?sslcert=` / `?sslkey=` / `?sslrootcert=`", + "replacement": "a single-host URL `pg` itself parses — `postgresql://[user@][host][:port][/dbname][?params]` (unix-socket forms stay accepted: a leading-`/` path, `socket:`, or a percent-encoded socket host). For a multi-host cluster, point the URL at one node or at a proxy/pooler in front of the cluster — `pg` does not implement libpq's multi-host DSN, so no spelling of it can connect. For certificate material, use the datasource-level `ssl` block (`ssl: { ca: …, cert: …, key: … }` next to `driver`) instead of file-path query parameters", + "migrationId": "datasource-config-postgres-url-unparseable-refused", + "toMajor": 18, + "rationale": "`PostgresConfigSchema.url`'s own describe text documents the postgres URL grammar, but until protocol 18 the value was only string-scanned for credentials (the URL userinfo password and credential query parameters) and `${…}` placeholders — deliberately so at the SHARED helper, whose refusal to parse is load-bearing for mongo's multi-host/`+srv` forms (`new URL()` rejects the multi-host form outright, and the mongo arm hands the authored URL to its client untouched). For postgres that leniency was no check at all: `pg@8.22.0` does not implement libpq's multi-host DSN — both `pg-connection-string`'s `parse` and `pg`'s `ConnectionParameters` throw `TypeError [ERR_INVALID_URL]` on `postgresql://app@h1:5432,h2:5433/app` (measured) — so an operator could publish exactly that URL, see it saved, and discover only at connect time that it can never open a connection, via a bare `Invalid URL` whose `input` field `pg` redacts. The refusal now asks the same grammar one door up: `parse` from `pg-connection-string` (the parser `pg` itself uses) runs at publish, per-driver, and what it throws on is refused with the value's path named. Two adjacent shapes are refused as structurally unusable rather than parse-refused, both measured: a scheme-less value \"parses\" only by resolving against the parser's placeholder base (`postgres://base`), i.e. `pg` would connect to the literal host `base` with the authored text as the database name; and `?sslcert=`/`?sslkey=`/`?sslrootcert=` make `parse` itself call `fs.readFileSync`, so the verdict would depend on the validating host's filesystem — certificate material already has its declared home in the datasource-level `ssl` block. There is no mechanical rewrite: a URL `pg` cannot parse does not carry enough structure to say which single host the author meant (a multi-host DSN names several on purpose), so the choice of target is the author's. Runtime-environment DSNs (`OS_DATABASE_URL` and friends) never pass through the publish door and are unaffected by construction." + }, + { + "surface": "datasource.config.url / datasource.config.syncUrl (turso) and datasource.config.url (postgres) — credential-bearing URL query parameters (`?authToken=` on turso, `?password=` on postgres)", + "replacement": "the same URL with the credential query parameter removed (non-credential parameters such as `?tls=` / `?sslmode=` stay legal), plus the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` secrets-store reference", + "migrationId": "datasource-config-url-query-credential-refused", + "toMajor": 18, + "rationale": "The inline-credential closure refused the credential KEYS and the next refusal the URL userinfo spelling; the query string was the third spelling of the identical secret, one syntax over. `libsql://x.turso.io?authToken=eyJ…` landed in `sys_metadata` cleartext exactly as `config.authToken` did — and at connect `@libsql/core` assigns the URL token OVER the binder-injected one (measured), so the workaround also silently defeated the bound secret; `pg-connection-string` likewise honours `?password=` over userinfo (measured). Only parameters a measured client actually reads are refused: mysql and mongo ignore `?password=` (measured), so their URLs are unaffected. Runtime-environment DSNs (`OS_DATABASE_URL`, `OS_DATABASE_AUTH_TOKEN` and friends) never pass through the publish door and are unaffected by construction. There is no mechanical rewrite, for the same reason as the sibling entries `datasource-config-inline-credential-refused` and `datasource-config-url-userinfo-refused`: moving the value requires ENCRYPTING it into a `sys_secret` row through a running secret binder and stripping the cleartext, which a source-file transform cannot do — auto-stripping the parameter alone would silently drop a live credential instead. Do not substitute a `${…}` placeholder into the URL: placeholders in authored metadata are resolved by nothing and reach the database client verbatim (measured when the inline-credential refusal was built)." + }, + { + "surface": "datasource (mongodb) — `external.credentialsRef` bound while `config` authors no `url` and names no `username`", + "replacement": "decide what the datasource is meant to do, then make the two halves agree: add `username` to `config` so the bound secret is interpolated beside it into the composed connection URI at connect — or, for a datasource genuinely meant to connect unauthenticated, remove the `external.credentialsRef` binding (and unbind the orphaned `sys_secret` row via the Setup → Datasources form). Authoring a `config.url` that names a user is a third valid shape, judged by the prescription of the sibling URL-branch entry, `datasource-credentialsref-mongo-url-no-user-refused`.", + "migrationId": "datasource-credentialsref-mongo-composed-no-username-refused", + "toMajor": 18, + "rationale": "The pair cannot work as written, and until protocol 18 it was accepted in silence at every door it passed. With no `config.url` the driver factory COMPOSES the connection URI from the discrete fields, and the bound secret has exactly one route into it — the userinfo written beside a username (`buildMongoUrl`: `const auth = user ? … : ''`). A falsy `username` closes that route, and the branch has no second one: `buildMongoAuth` returns early when there is no `url`, because the composed branch injects THROUGH the URI it builds rather than beside it. So `credentialsRef` bound with no `url` and no `username` composed `mongodb://host:port/db`, connected ANONYMOUSLY, and told the operator nothing. Nothing can be fabricated to rescue it: a MongoDB handshake cannot authenticate from a password alone — the same measured asymmetry behind the sibling URL-branch refusal. Both branches had always agreed on this input, so this inherits that ruling rather than re-opening it, and lands at the same authoring/publish door — the one place both halves are visible at once — as the \"absence must be loud\" half of the family of driver-factory arms, closed one driver at a time, that each dropped something declared without a word: the optional-driver arms that answered a missing package with no remedy, the turso arm that never read its bound secret, and the mysql and mongo DSN branches that discarded one. Deliberately NOT refused, each measured: a discrete `username` that is present and non-empty (the secret is live there — the composed branch has always interpolated it), an empty-string `credentialsRef` (not a binding — the connect path resolves under a truthy check), a non-string `username` (the driver config gate already reports the type error), and every other driver arm (the postgres equivalent is judged on its own client's measurement, never inherited — `pg` receives the bound password regardless of the DSN naming a user). An EMPTY-STRING `username` IS refused, unlike the sibling entry's present-but-empty userinfo carve-out: there MongoClient itself throws (`URI contained empty userinfo section`) so the shape is already loud, while here `username: ''` composes the same userinfo-free URI and connects — silently. There is no mechanical rewrite because the valid fixes are CONTRADICTORY intents — authenticate (name the user) versus anonymous (drop the binding) — and choosing between them requires knowing what the datasource is for." + }, + { + "surface": "datasource (mongodb) — `external.credentialsRef` bound while `config.url` names no user in its userinfo", + "replacement": "decide what the datasource is meant to do, then make the two halves agree: add the username to the URL's userinfo (`mongodb://user@host/db`) so the bound secret is injected at connect — or, for a datasource genuinely meant to connect unauthenticated, remove the `external.credentialsRef` binding (and unbind the orphaned `sys_secret` row via the Setup → Datasources form)", + "migrationId": "datasource-credentialsref-mongo-url-no-user-refused", + "toMajor": 18, + "rationale": "The pair cannot work as written, and until protocol 18 it was accepted in silence at every door it passed. MongoClient credentials need a username as well as a password, and with `url` present the discrete `username` field is superseded — the only place the username can come from is the URL's own userinfo. So the connect-time injection of the bound secret on the URL branch is conditional on the URL naming a user: `mongodb://app@host/db` + bound secret authenticates, while `mongodb://host/db` + bound secret connects ANONYMOUSLY with the secret unused and the operator told nothing. Injecting anyway was measured worse (mongodb@7.5.0): fabricating an empty username turns a connection that works anonymously today into a guaranteed handshake failure, and refusing at connect would contradict `MongoConfigSchema.url`'s published contract (\"bind the secret … and it is injected at connect time\") while planting a per-branch asymmetry inside the driver factory — the defect class closed when each DSN branch was made to inject the bound secret its composed branch already used. The refusal therefore lands at the authoring/publish door, the one place both halves are visible at once, as the \"absence must be loud\" half of the family of driver-factory arms, closed one driver at a time, that each dropped something declared without a word: the optional-driver arms that answered a missing package with no remedy, the turso arm that never read its bound secret, and the mysql and mongo DSN branches that discarded one. Deliberately NOT refused, each measured: the present-but-empty userinfo forms (`mongodb://@h/db`, `mongodb://:p@h/db` — MongoClient itself throws `MongoParseError: URI contained empty userinfo section`), an empty-string `credentialsRef` (not a binding — the connect path resolves under a truthy check), the composed branch (no `url`, where the discrete `username` is live), and every other driver arm (the postgres equivalent is judged on its own client's measurement, never inherited — `pg` injects on a user-less DSN by its own measured mechanism). There is no mechanical rewrite because the two valid fixes are CONTRADICTORY intents — authenticate (add the username) versus anonymous (drop the binding) — and choosing between them requires knowing what the datasource is for." + }, + { + "surface": "`indexes[].unique: true` on a declared index (`objects[]` and `objectExtensions[]`) — the bare boolean, the one `unique` spelling whose scope was positional", + "replacement": "a stated scope: `unique: 'global'` (one holder across the whole installation — exactly the index bare `true` built, which is what the chain writes) or `unique: 'organization'` (one holder per organization — the driver prepends the NULL-safe organization key part `COALESCE(organization_id, '__global__')` to `fields` at registration). `unique: false` / omitted is unchanged, and field-level `unique: true` is unchanged and stays valid (it means per organization there)", + "migrationId": "declared-index-bare-unique-true-retired", + "toMajor": 18, + "rationale": "The mechanical rewrite keeps every index exactly as it was built — `'global'` IS the verbatim column list bare `true` materialized, so nothing on disk changes. What the chain cannot know is what the author MEANT. On a declared index bare `true` read like \"unique per organization\" to anyone who knew the field-level meaning, and silently built an installation-wide constraint instead: an index meant per organization has been refusing a second organization's value all along, and its refusal told that organization somebody else holds it. Each respelled index is therefore a decision the owner makes once: keep `'global'` for a genuinely installation-wide key (a hostname, an external provider id, an engine dedup key), or move it to `'organization'` so each organization may hold the value once — a change to the physical index that `os migrate plan` shows before anything is applied." + }, + { + "surface": "DeviceRequestResponse.interval (api/auth-endpoints.zod.ts) — the polling cadence in the device-flow response body", + "replacement": "intervalSeconds — rename the key; the value (seconds, default 2) is unchanged", + "migrationId": "device-request-response-interval-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B (2026-09-02, extended on 2026-09-05 to runtime-emitted durations): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. This key was ATTRIBUTED to RFC 8628 by the campaign card and reached this card only after the attribution failed verification, so the evidence is recorded here rather than left in a PR body. Ruling B exempts a key that mirrors a name fixed outside this repo, declared on the schema as .meta({ externalVocabulary }) — and DeviceRequestResponseSchema does not mirror RFC 8628 as a SET: `code` is not `device_code`, `verificationUrl` is not `verification_uri`, and `expiresAt` is not `expires_in` — a different name AND a different type, an ISO-8601 instant where the RFC carries a relative lifetime. A schema that has already renamed every RFC field it carries into house style cannot claim the standard fixes the one name it left bare. So it is a rename, and deliberately NOT a marker: a wrongly marked key is exempted permanently and silently, while a wrongly renamed one is visible. A SEMANTIC entry rather than a D2 conversion because the shape is RUNTIME-EMITTED — the body of POST /api/v1/auth/device/request, never a stack collection member and never a sys_metadata row, so the conversion chain has no seam that would see one. ADR-0087." + }, + { + "surface": "the document family, retired whole: the four defs data/DocumentTemplate, data/Document, data/ESignatureConfig and data/DocumentVersion, and every name data/document.zod.ts exported from @objectstack/spec/data (DocumentTemplateSchema, DocumentSchema, ESignatureConfigSchema, DocumentVersionSchema, their z.input aliases and their Parsed aliases)", + "replacement": "a printable document is a PAGE that declares `print` — no separate template type. Author the document (an invoice, a delivery order, a letter, a report) as an ordinary `page` with `kind: 'full'`, its blocks in `regions` drawn from the printable block subset (`record:details`, `record:highlights`, `record:line_items`, `element:text`, `element:image` and the rest of PRINTABLE_PAGE_COMPONENT_TYPES), and a `print` block for the paper, margins, running header and footer, page numbers and page-break hints. A docx-with-placeholders template, a stored document with versions, and an e-signature workflow have no replacement, because nothing on the platform ever merged, stored or sent any of them; a document record the organisation keeps is ordinary object data, and its files are `sys_file` attachments", + "migrationId": "document-schemas-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove, by the ruling of record on the PDF / print document card (letter B′, 2026-10-08): \"A document is a page with a print declaration; no new template type\", and \"The zero-reader DocumentTemplateSchema, DocumentSchema and ESignatureConfigSchema retire in v18 under ADR-0049 with ADR-0087 entries, so that 'template' means one thing.\" Four defs sat on the exported surface and in the generated reference docs — a docx template with typed placeholders, a document with versioning, access control and an e-signature block, and the signer workflow — and were read by NOTHING: they were exported from `@objectstack/spec/data`, mounted by no `stack.zod.ts` key, registered as no metadata type and absent from every liveness ledger, and the reader census over every package, app and example outside `packages/spec` (generated reference docs, release notes and changelogs aside), over objectui at its pin and its main, and over hotcrm returned zero hits for every exported name, against lit controls. Keeping them would have given an author two meanings of \"template\" — the dead docx one and the print page — and an AI that imports DocumentTemplateSchema a schema no runtime reads. DocumentVersionSchema had one carrier, DocumentSchema.versioning, and leaves with it. The ESignatureConfig deadline-key tombstones (RETIRED_KEYS_BY_MAJOR[18], D3 `esignature-config-deadline-keys-retired`) leave with their def's source; their registry entries stay as history. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections; none of these schemas is either, so a conversion would be a transform with no seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent), and with no carrier key there is no shape on which a tombstone could sit. `cloud` and real customer code are UNMEASURED." + }, + { + "surface": "`DriverOptions.timeout` (data/driver.zod.ts) — the per-call options argument of every `IDataDriver` method", + "replacement": "`DriverOptions.timeoutMs` (milliseconds) — rename the key; the value is unchanged", + "migrationId": "driver-options-timeout-to-timeout-ms", + "toMajor": 18, + "rationale": "Maintainer ruling 2026-09-02 on duration units (ruled B — no grandfathered baseline): the unit of a duration-shaped `z.number()` key lives in the key NAME, never only in the description. `timeout` said \"Timeout in ms\" in prose and nothing else. Tombstoned with retiredKey (`DriverOptionsSchema` is not strict, so a bare deletion would strip the old key in silence) and registered as `data/DriverOptions:timeout`. Why a semantic entry and not a D2 conversion: a `DriverOptions` object is built at a call site and handed to a driver method — it is not a stack collection member and is never stored, so the chain has no seam that runs on it. Measured on ca46f8f12: no in-repo driver reads the key (the engine's own per-call budget is a separate `timeoutMs` on its options), so callers move their spelling with no behaviour change." + }, + { + "surface": "IDataDriver find, findOne, count, aggregate, update, delete, bulkUpdate, bulkDelete, updateMany, deleteMany, create and bulkCreate on TursoDriver's remote (libSQL) face, called with a tenant context", + "replacement": "a tenant-scoped call on the remote face reaches the rows the local face reaches for the same options: the caller's organization, rows with no organization, and under the group posture the caller's membership set. A by-id `update` outside that scope answers `null`, a by-id `delete` answers `false`, and a predicate write counts only the rows in scope. `create` stamps the caller's organization on a row that names none. To reach rows of every organization, call without `tenantId`, as on the local face", + "migrationId": "driver-remote-doors-tenant-scoped", + "toMajor": 18, + "rationale": "The engine hands every driver the caller's organization as `DriverOptions.tenantId`, and the group posture's membership set as `tenantIds` (ADR-0131 D8, ADR-0105 D2). TursoDriver's local face applies them through `SqlDriver.applyTenantScope` on every read and on every update and delete predicate, and stamps the organization on insert. Its remote face compiles its own statements, and its doors received no driver options: their statements carried the caller's filter and nothing else, and a remote `create` wrote no organization. Where the engine's Layer 0 wall composes a predicate above the driver, that wall held other organizations' rows back. Where it composes none (the posture in which Layer 0 is inert, or an elevated caller that carries its organization), the driver scope is the only fence, and on the remote face there was none. The remote doors now compile the local face's own predicate, by asking the same chokepoint, and AND it onto each statement, so the two faces answer the same rows by construction. The remote `create` stamps the organization as the local `create` does. `distinct` still refuses a tenant-scoped call on the remote face. The call signatures are unchanged, so nothing reaches the compiler. Code that relied on a tenant-scoped remote call reaching another organization's rows now gets the miss answer each door already declares, and a remote `create` that relied on landing a row with no organization now finds it under the caller's. ADR-0131 D8 / ADR-0087." + }, + { + "surface": "SqlDriver protected methods calendarDayExclusiveUpperBound, calendarDayUpperBoundRewrite and calendarDayBetweenRewrite (inherited by SqliteWasmDriver and TursoDriver)", + "replacement": "lower the filter before the driver compiles it — `lowerFilterCondition(where, { isDatetimeColumn })` from `@objectstack/spec/data` — instead of calling or overriding a driver method; leave `isDatetimeColumn` out and the whole-day rule applies to every column", + "migrationId": "driver-sql-calendar-day-methods-removed", + "toMajor": 18, + "rationale": "The exported `SqlDriver` class of `@objectstack/driver-sql` declared three `protected` methods that were its own copy of the whole-day rule of ADR-0053 D-D1: on a `datetime` column, a bare-day inclusive upper bound (`$lte '2026-01-05'`, or the maximum of a `$between`) compiled as `$lt` the next day, and the last supported day (`9999-12-31`) compiled as no upper bound. `calendarDayExclusiveUpperBound` computed that bound, `calendarDayUpperBoundRewrite` rewrote a `$lte` with it, and `calendarDayBetweenRewrite` rewrote a `$between` with it. The shared filter lowering in `@objectstack/spec/data` (`lowerFilterCondition`) now applies the rule once, at the engine's `where` seam and at the RLS compile seam, before any driver sees the filter, so the driver's copy was deleted, and the three methods with it (ADR-0053 D-D1 items 5 and 9, as amended). Two consequences reach a subclass, and only one of them reaches the compiler. A subclass that CALLS one of the three, or declares one with `override`, stops compiling: TS2339 and TS4113, measured with tsc 6.0.3 against the published declaration. A subclass that re-declares one WITHOUT `override` compiles cleanly, with `noImplicitOverride` off and also with it on, because the base class no longer has a member to override. That declaration is never called: the driver calls none of the three any more, so the override goes silently dead and the rule it carried stops applying. An untyped JS subclass gets a `TypeError` at a call and the same silent death for an override. A driver subclass is CODE, never stack metadata, so there is no authored source for the chain to rewrite and no schema tombstone. For the silent half, this entry is the only notice there is: the same disposition as `driver-sql-distinct-bare-filter-typed` and `runtime-httpserver-wrapper-retired`. In this repo the one caller was `TursoDriver`'s remote face, changed in the same PR. A read through the engine or the RLS compile seam answers as before, because the seam lowers first; a filter handed to the driver directly is now compared as written. ADR-0053 / ADR-0087." + }, + { + "surface": "a `where` naming a column the table does not have, on `driver-sql` (and its `TursoDriver` / `SqliteWasmDriver` subclasses) — `find()` / `findOne()` answered `[]` and `count()` threw the dialect's own error; both now refuse with `INVALID_FILTER` / 400. The `aggregate()` door of `TursoDriver`'s remote face answered `[]` for a missing column or a missing table, and now refuses as the local face does: `INVALID_FILTER` / 400 for a `where` column the table lacks, `INVALID_FIELD` / 400 for a `groupBy` or aggregation column the table lacks, and `DATABASE_ERROR` / 500 for an object whose table is absent", + "replacement": "name a column the object actually has, or run schema sync so a recently declared field exists as a column before filtering, grouping or aggregating on it, and so the object's table exists. A caller that legitimately wants \"no rows unless this matches\" gets that from a predicate over a real column; there is no spelling of an unresolvable column that means \"match nothing\", which is exactly what the old empty list was mistaken for", + "migrationId": "driver-sql-unresolvable-where-column-refused", + "toMajor": 18, + "rationale": "One predicate had two answers. `SqlDriver.findRows()` carries the unknown-column recovery ladder (an unsortable query loses its ORDER BY, not its rows), whose rungs are all built from `buildBase()` — and `buildBase()` always re-applies `query.where`. So the ladder can drop a projection and can drop an ORDER BY, but it can never drop the clause that failed when the unresolvable column is in the WHERE: both rungs raise the same error and the method fell to `return []`. `SqlDriver.count()` runs a separate statement with no ladder at all, so the identical predicate threw. Measured on better-sqlite3, one seeded row: `where { 'title.x': 'y' }` gave `find()` 0 rows and NO error, while `count()` threw `code: 'SQLITE_ERROR'`, `status: undefined`, message `select count(*) as \\`count\\` from \\`task\\` where \\`title\\`.\\`x\\` = 'y' - no such column: title.x`.\n\nA list view calls both halves, so one query produced an empty page from the rows half and a 500-shaped failure from the total half — and a caller reading only the rows got a silent empty page saying \"no records exist\" for what was really \"your predicate never ran\". That is the single most AI-legible failure to get wrong: an agent reads \"no matching records\" and writes its next query on that belief. The thrown half was no better — the dialect's own `code`, no `status` (an unclassified 5xx at the REST boundary rather than a caller mistake), and the statement's bound literals inlined in the message, the same predicate-text disclosure shape the driver's field-reference filter refusals had already been made to stop echoing (the full diagnostic goes to the server log, never the response).\n\nRuled by the maintainer on 2026-08-15: refuse BOTH halves with `INVALID_FILTER` / 400, naming the column. The envelope is not minted here — it is what every sibling refusal on this path already answers, required on both SQL drivers by `cross-field-conformance-cases.ts` and pinned by `sql-driver-boolean-identity.test.ts` and `sql-driver-cross-field-conformance.test.ts` — so what closes is a declared-vs-enforced gap, not a new posture. Recover-both was excluded by the ruling's own argument: dropping a WHERE returns rows the caller explicitly excluded, and the ladder's own premise — rows matter more than their order — is an argument about how rows are PRESENTED, which does not transfer to a predicate. The ladder KEEPS both of its recoveries — only the WHERE-failure terminal became a refusal.\n\nReach, stated rather than assumed: the refusal fires on the wordings the ladder has always recognised — SQLite (`no such column: x`) and Postgres (`column \"x\" does not exist`). MySQL spells it `Unknown column 'x' in 'where clause'`, which neither arm matches, so on MySQL this condition still travels out as the raw dialect error; widening that predicate would also hand MySQL the ladder's recoveries it has never had, which is an accept-set change in the opposite direction and is filed separately.\n\nAddendum 2026-08-16. The paragraph above is kept as the state at registration; this amends it. MySQL joined the one shared predicate, so the reach is now all three dialects this driver speaks, and a MySQL reader must NOT conclude the migration does not apply — it applies exactly as it does on SQLite and Postgres. Because that predicate serves both consumers at once, the accept set moved in BOTH directions on MySQL in one line, and both halves were ruled together (option A, maintainer, 2026-08-16; a split predicate — the envelope while withholding the recoveries — was considered and refused). (1) THE ENVELOPE: an unresolvable WHERE column now refuses with the same `INVALID_FILTER` / 400 naming the column, instead of travelling out as the raw `ER_BAD_FIELD_ERROR` with the statement's bound literals inlined — that disclosure shape closed on the last dialect that still had it. (2) THE RECOVERIES: MySQL also gained the ladder's projection and ORDER-BY recoveries it had never had, so an unresolvable column in a projection or an ORDER BY now returns recovered rows where it used to throw. The two halves arrive together because `ER_BAD_FIELD_ERROR` spells every clause position with one sentence — `Unknown column 'x' in 'where clause'` / `'field list'` / `'order clause'` — so all three ride one arm of the predicate; that is pinned as the ruled direction by the widened predicate sweep in `sql-driver-unresolvable-where-column-refusal.test.ts`. The widening can never drop a predicate: every ladder rung is rebuilt from `buildBase()`, which unconditionally re-applies `query.where`. Unchanged by the ruling: a DOTTED filter key is still classified per dialect (Postgres raises undefined_table, which neither arm matches), the axis owned by the dotted-filter verdict, which refuses a dotted key whose head is a relation, a formula or a plain column at the protocol and engine doors. The entry id, surface and prescription are unchanged — this is a text amendment, not a new migration.\n\nThis is a CODE-path API, not stored metadata, so — like `engine-dotted-projection-refused` and `engine-find-formula-filter-refused` — there is no `sys_metadata` row for the D2 chain to rewrite and this entry is the notification channel. No mechanical rewrite exists: the platform cannot know which real column a mistyped filter key meant, and guessing one would answer with rows the caller never asked for. ADR-0112." + }, + { + "surface": "an `upsert` with no `conflictKeys` — or naming the primary key — on a MySQL table that carries a non-primary UNIQUE key, in `driver-sql` (and its `TursoDriver` / `SqliteWasmDriver` subclasses). It merged onto whichever UNIQUE key the row collided with, silently rewriting a DIFFERENT row; when that happens the write is now rolled back and the call refuses with `VALIDATION_ERROR` / 400", + "replacement": "name the business key you meant to merge on (`conflictKeys`), so the intent is checkable and the pre-flight can answer for it; or drop/rename the extra UNIQUE key so the primary key is the only thing a row can collide on; or run the object on SQLite / PostgreSQL, which compile `ON CONFLICT (...)` and honour the named arbiter. There is no spelling of \"merge onto whatever key happens to collide\" that was ever correct — the old behaviour rewrote a row the caller never identified", + "migrationId": "driver-sql-upsert-cross-row-identity-merge-refused", + "toMajor": 18, + "rationale": "MySQL's only merge statement is `ON DUPLICATE KEY UPDATE`, which carries NO conflict target: knex drops the named keys before the statement leaves the process, so the merge lands on whichever UNIQUE index the row collides with first. Two earlier pre-flight refusals closed the half where no unique index backed a caller-named target and the half where a rival unique key could absorb a caller-named one. This entry closes the residue those two left by construction: the `conflictKeys`-less call and the `['id']` call, which compile byte-identically and which no pre-flight can judge, because neither names anything.\n\nMeasured on live MySQL 8.0.46 through the same knex + `mysql2` path `upsert` takes, `email` and `tax_id` both `unique: true`, NO `conflictKeys` at all: seeding `{email:'d@b.com', tax_id:'T-9', title:'first'}` inserted id `iVvD35rMk4BIayYc`, and `{email:'e@b.com', tax_id:'T-9', title:'second'}` then RESOLVED with no error — one row, the SEEDED one, its `email` rewritten `d@b.com` -> `e@b.com`. The id the caller was handed back was in no row at all. The identical pair on SQLite raises `UNIQUE constraint failed: ….tax_id` and leaves the seeded row untouched.\n\nRuled by the maintainer on 2026-08-15, as a contract principle rather than a MySQL detail: *an `upsert` must never modify a row whose identity the caller did not supply and whose conflict key it did not name.* Enforcement was delegated to the drivers lane with blanket refusal excluded by name — refusing every `conflictKeys`-less upsert on any table with a business unique key would refuse the platform's own lifecycle archiver. Measured before choosing: on this path the merge target is always the primary key, so EVERY non-primary UNIQUE key is a rival and \"narrowed to tables carrying a rival key\" and \"every table with a business unique key\" are the same set — the narrowing that made a pre-flight refusal proportionate for a caller-named target does not exist here.\n\nSo the enforcement is a post-hoc identity check instead, and it is exact rather than heuristic: `id` is insert-only on the merge path (made so once a merge on a non-primary conflict key was measured rewriting the existing row's primary key), so a row merged on the primary key always still carries the id the call supplied, and a row merged on any other key never does. Absence of that row after the statement is therefore a biconditional for \"this landed on a row the caller never identified\", which is why the refusal has no false positives. It runs inside a transaction with the statement — \"never modify\" is not satisfied by noticing afterwards — and only on MySQL tables that carry a rival UNIQUE key, so a table whose only key is its primary key keeps its single autocommitted round trip unchanged.\n\nThis is a CODE-path API, not stored metadata, so — like `driver-sql-unresolvable-where-column-refused` — there is no `sys_metadata` row for the D2 chain to rewrite and this entry is the notification channel. No mechanical rewrite exists: the platform cannot know which business key an unnamed merge meant, and guessing one would merge onto a row the caller never named, which is the defect. ADR-0112." + }, + { + "surface": "`@objectstack/driver-turso`'s published `TursoConfigSchema` — the Spec / Studio mirror of the turso connection config a host may render configuration UI from — keys `localPath` and `wasm`", + "replacement": "delete both keys. The embedded replica's local file is named by `url` (`file:./replica.db`) with `syncUrl` pointing at the remote primary, which is what the driver has always read; nothing selects a WASM build of libSQL, and a runtime that cannot load native bindings uses the remote arm (`libsql://` / `https://`), which needs none", + "migrationId": "driver-turso-config-local-path-wasm-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove, ruled per key by the maintainer on 2026-09-06, once all three of this package's unread config keys had been measured: both keys were declared on the package schema with a describe promising behaviour (\"Local file path for embedded replica\", \"Use WASM build for edge/browser environments\") and were read by no code — the driver names the replica file via `url`, and no mechanism picks a WASM build. Forwarding `localPath` would have created a second way to say what `url` says; forwarding `wasm` would have meant building a WASM selection that does not exist. Why a semantic entry and not a D2 conversion: `@objectstack/spec`'s own turso contract (`data/TursoConfig`, strict) never declared either key, so no stack source or stored datasource row that passed the spec door can carry them, and a value that never did anything has no lossless rewrite — the key is deleted by hand. Both stay declared on the package schema as `z.never()` tombstones (the shape is a plain z.object, so a bare deletion would strip in silence) carrying this prescription. The third key the same measurement found, `TursoDriverConfig.timeout`, was forwarded rather than removed and needs no entry. ADR-0049, ADR-0087." + }, + { + "surface": "IDataDriver upsert on SqlDriver, SqliteWasmDriver and TursoDriver (both faces): a tenant-scoped call whose conflict lands on a row of another organization, and the tenant column on the merge leg", + "replacement": "a tenant-scoped upsert merges only into a row of the organization it writes under; a conflict anywhere else answers `UNIQUE_VIOLATION` / 409 and writes nothing, so handle it as the colliding insert it is from the caller's organization. To move a row between organizations, call the driver's `update` door on that row: an upsert keeps the stored row's organization on merge", + "migrationId": "driver-upsert-cross-organization-conflict-refused", + "toMajor": 18, + "rationale": "`upsert` resolves its conflict against the whole table, and the primary key and a `unique: 'global'` column are installation-wide (ADR-0120 D1). So the row a tenant-scoped call (`options.tenantId` on an object with a tenant column) collided with could belong to an organization the caller cannot read. The merge leg wrote every payload column except the insert-only ones onto that row, and the tenant column was not insert-only: the other organization's columns were overwritten and the row was re-parented to the caller's organization, with no error. That was the one driver door the tenant predicate did not reach (ADR-0131 D8). The merge leg is now fenced to rows whose stored tenant column equals the written one, for any conflict target, the primary key included. A conflict anywhere else, including a row with no organization, is refused with `UNIQUE_VIOLATION` / 409, the registered code a colliding insert gets, and the refusal names no organization. The fence is a predicate inside the merge statement on SQLite, PostgreSQL and the remote libSQL face. MySQL's merge statement takes no predicate, so there the statement and a read of the landed row run in one transaction (a savepoint inside a caller's transaction) and the read's failure rolls the write back. The tenant column also joined `insertOnlyUpsertColumns`, so an upsert with no tenant context keeps the organization of the row it merges into. Two things can break, and neither reaches the compiler, since the call signature is unchanged. Code that let a tenant-scoped upsert land on another organization's row now gets a refusal where it got a silent merge. Code that relied on a payload's tenant value to move a row on merge now finds the row where it was. ADR-0131 D8 / ADR-0087." + }, + { + "surface": "Page-component `dataSource.filter` (`ElementDataSourceSchema`, the binding every data-bound element carries) and the `filter` prop of the four `object-*` blocks in `ComponentPropsMap` — `object-grid`, `object-metric`, `object-kanban`, `object-calendar` (the FORM: the MongoDB-style `FilterConditionSchema` record at the binding, and the accept-anything `z.unknown()` at the four block doors, vs the `ViewFilterRule` array)", + "replacement": "`z.array(ViewFilterRuleSchema)` at all five doors — the rule array `[{ field, operator, value }, ...]` every other `filter` door in the map already carries (`record:related_list`, its Add-affordance picker, `element:number`, `element:record_picker`). A record-form filter `{ status: 'active' }` becomes `[{ field: 'status', operator: 'equals', value: 'active' }]`; an operator object `{ status: { $ne: 'done' } }` becomes `[{ field: 'status', operator: 'not_equals', value: 'done' }]`; several keys become several rules (they AND). An ObjectQL AST tuple array `[['owner_id', '=', '{current_user_id}']]` — which the `z.unknown()` block doors also took — becomes `[{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }]`; the value placeholders and date macros are unchanged. Legacy operator shorthands (`eq`, `ne`, `gt`, `notIn`, …) are accepted and normalized on parse. The dashboard widget `filter` (`dashboard.zod.ts`) is a different family, judged on its own, and is not moved by this entry; `object-grid.defaultFilters` is a different key and is not named by the ruling this entry records.", + "migrationId": "element-data-source-and-object-block-filter-rule-array", + "toMajor": 18, + "rationale": "One filter orthography platform-wide (the maintainer's 2026-08-25 ruling, option B: a filter door takes the `ViewFilterRule` array rather than keeping a record-shaped exception every author and AI would have to remember) reached two more locations the ComponentPropsMap census could not see (ruled 2026-09-06, option A: converge the binding and the four block doors family-wide, under one entry, rather than record an exception). The binding-level `dataSource.filter` alone still said `FilterConditionSchema`: it refused the array the consumer's own pins author at that key, and `element:record_picker` carried two orthographies at two keys (`properties.filter` the rule array, `dataSource.filter` the record) resolved through one `??` in the renderer — the shape in which a dropped or misread filter returns the wrong rows without an error. The four `object-*` doors said `z.unknown()`: a read-point record derived from the renderers on 2026-08-13, when the `object-*` blocks first got props schemas in the map, twelve days before the ruling, not an exception to it — so an author following the showcase wrote the record and an author following the manifest wrote an array, and each got a silent success receipt while the html tier already declared `array` for the grid and the metric. The record's `$and` / `$or` / `$not` keys were misread by every gate block anyway (the console's filter converter had no branch for them), so the exception would have preserved a capability the consumer does not honour. Sequenced measurement-first, as the family had to be: at the objectui pin `a472b07` the `object-metric` aggregate path posted an array `where` that `POST /analytics/query` refused with 400 on every array form, so the converge was parked behind a bump of that pin; at the pin this repo builds against (`53ded82b`) the adapter lowers an authored array through `translateFilterArray` and the spec's own `parseFilterAST` sink before the wire, `ObjectGrid.tsx` lowers a rule array through `toFilterNode`, `ObjectKanban.tsx` / `ObjectCalendar.tsx` hand it verbatim to `$filter` where `convertQueryParams` lowers it, and the binding's composition seam AND-combines it with the named view's rules through `mergeFilterNodes`. The ruled migration check ran with the change: the in-repo sweep found four spec test fixtures at the binding (`page.test.ts`, all record form), five showcase authors at the block doors (`my-work.page.ts`, `index.ts`: four records on `object-metric`, one AST tuple array on `object-grid`) and three lint fixtures — every one rewritten to the rule array in the same change, and zero outside those files; this entry carries the prescription for authors outside the repo. Metadata AT REST: the mappable part of the table above is a D2 conversion, `page-component-filter-record-to-rule-array` (ruled 2026-09-12, option B: convert what maps losslessly and name what does not, rather than leave every stored row to its next save or flatten combinators), so `os migrate meta --stored` (the pass over a deployment's `sys_metadata` rows) rewrites a stored page whose `filter` is a flat record, an operator object whose operators the rule vocabulary spells, several such keys, or a single-level AST tuple array, and every stored-row read replays the same rewrite until it does. It is retired from the load path: an author writing the record form is still refused at the `filter` door. ⚠️ A filter carrying `$and` / `$or` / `$not` is left exactly as stored — the rule array only ANDs, and flattening a combinator changes which rows the page selects — and so is any filter with a part that has no lossless rule spelling: a `null` value (where a block queries an object the renderer skips that key, so it constrains nothing, and where its rows are inline it selects the rows whose value is null — no one rule keeps both, so the TODO names the `is_null` rule for the rows with no value and leaves which rows to select to the author), an operator such as `$null` / `$exists` or an AST `like`, an array or object comparand in equality position, or an AST `and` / `or` group. None of this depends on where a block's rows come from: a filter on a component whose rows are inline (`data: { provider: 'value' }` or `staticData`) — the binding's included — is rewritten or left exactly as it would be on a block that queries an object, because the `object-map`, `object-tree`, `object-calendar` and `object-gantt` blocks of the objectui version this release pins match a rule array against those rows and select the rows the stored form selected. A row left as stored keeps loading unchanged (`applyConversionsToStoredItem` replays the chain without validating, by its own contract), and its `filter` door refuses the form: at `dataSource.filter` on the page's next save; at a block's `properties.filter` — like `properties.defaultFilters`, a key of the open `properties` bag — only as the component-props gate's advisory finding (os validate, os build, os lint), since a re-save through the metadata API is not refused there. For a combinator record that refusal names the combinator and says why no rule spells it. `os migrate meta --stored` lists each such filter as a TODO under its row, naming the block and what blocks the rewrite (a value that is not a record or AST form at all — a bare string or number one of the former `z.unknown()` doors took — is neither converted nor reported, and a row carrying nothing else reads as already on protocol); a row whose only finding is such a TODO is reported `skipped`, and the run's exit code does not change for it." + }, + { + "surface": "page.component.element:filter / page.component.element:form — the bare component node itself, left standing by the `element-filter-removed` and `element-form-removed` conversions after they strip its properties", + "replacement": "Delete the component node. `element:filter` → a list surface owns its own filtering: use a view's `userFilters` quick-filter bar or the list toolbar's filter builder. `element:form` → the object-bound `object-form` block, which is rendered, designer-publishable and carries the same intent (`objectName`, `fields`, `mode`, `submitText`). Nothing is placed where the node was unless the page needs it — which region keeps its layout is the judgment this step delegates", + "migrationId": "element-filter-and-form-node-refused", + "toMajor": 18, + "rationale": "Both elements were retired whole at element grain (ADR-0049 enforce-or-remove): no renderer for either ever shipped in objectui, framework or cloud, so every authorable key was a capability claim nothing kept. The conversions are mechanical where they can be — they strip all twelve keys losslessly — and stop at the node, because removing an authored page node changes the LAYOUT of a page the author composed, and a conversion cannot know whether the region should close up, hold a replacement, or keep its slot. That residue is no longer inert: both names are members of `RETIRED_PAGE_COMPONENT_TYPES`, so `PageComponentSchema.type` refuses them by name, and a stack that replays the chain and stops there is schema-INVALID. Mechanical where it can be, delegated where it cannot — this entry is the delegation, in writing" + }, + { + "surface": "page.component.element:text_input.targetVariable / page.component.element:record_picker.targetVariable — the declarative binding hint on the two input elements", + "replacement": "Declare the binding on the page variable instead: a `variables[]` entry whose `source` is the input component `id`. That reverse lookup is the one binding the renderer has ever honoured; the variable name is the author's choice, and `targetVariable` named it from the wrong end.", + "migrationId": "element-input-target-variable-retired", + "toMajor": 18, + "rationale": "The D2 conversion `element-input-target-variable-removed` deletes `targetVariable` from every text-input and record-picker component, and the delete is lossless: no renderer, hook or runtime ever read the key, so an input authored with it and without a matching `variables[].source` wrote nothing, with a success receipt and no diagnostic. What the delete cannot do is restore the intent. An author who wrote `targetVariable: 'contact_email'` meant that input to feed that variable, and after the strip the page is exactly as unbound as it always was — now without even the hint that says so. Whether the variable exists, whether its `source` already names this component, and whether anything downstream (a flow input, a filter, a visibility predicate) reads it are facts about the author's page that no conversion can see, so the binding is delegated rather than invented." + }, + { + "surface": "`element:number` component props — `filter` (the FORM: the MongoDB-style `FilterConditionSchema` record vs the `ViewFilterRule` array)", + "replacement": "`z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` every other `filter` input in `ComponentPropsMap` already declares (`record:related_list` and its Add-affordance picker). A record-form filter `{ status: 'won' }` becomes `[{ field: 'status', operator: 'equals', value: 'won' }]`; an operator object `{ amount: { $gt: 100 } }` becomes `[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys become several rules (they AND). Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are accepted and normalized on parse", + "migrationId": "element-number-filter-rule-array", + "toMajor": 18, + "rationale": "One filter orthography platform-wide (the maintainer's 2026-08-25 ruling, option B: align the element to the `ViewFilterRule` array rather than keep it the record-shaped exception). `ComponentPropsMap['element:number'].filter` was the one `filter` input in the map declared as the MongoDB-style record (`FilterConditionSchema`) while its siblings declared the `ViewFilterRule` array, so the filter a list view stores and renders was refused by the KPI element beside it, and the objectui parity gate had to carry a reasoned exemption to look away. The convergence was sequenced consumer-first (ruling recorded 2026-08-25, Option A): the console adapter was changed so `ObjectStackAdapter.aggregate()` runs the same `translateFilterArray` its `find()` path runs, and the objectui pin carrying it was re-measured before this entry moved — but that measurement named the wrong hop, and the runtime route's refusal of the array corrects it here. `translateFilterArray` yields AST tuples, which are still a `FilterArray` — input-only sugar — so the real path is: authored array → `translateFilterArray` → lowered by `parseFilterAST` (`@objectstack/spec/data`, the single sink the `FilterArray` docblock names, since the maintainer's 2026-08-04 ruling C declared the array input-only sugar with one lowering seam) in the adapter, BEFORE the wire → a `FilterCondition` on the body. The hop that decides it is the runtime route `POST /analytics/query`, which parses `where` with `AnalyticsQueryRequestSchema` — a `FilterCondition` and nothing else — so an un-lowered array is refused there before any service code runs. `lowerAnalyticsWhere` (`service-analytics`), where that earlier measurement stopped, is the IN-PROCESS door (added when an array `where` was found silently dropped on the analytics path) for callers reaching `analyticsService.query` directly, not the wire's; it too still refuses a RAW rule-object array by design. The adapter-side lowering lands in the console's own repository. The ruled migration check ran with the change: the sweep of first-party corpora (examples/, skills/, create-objectstack, content/docs/, packages/apps/, spec fixtures) found ONE `element:number` author writing a record-form `filter` — a spec test fixture, rewritten to the array form in the same change — and zero outside the spec package; this entry carries the prescription for authors outside the repo." + }, + { + "surface": "`element:record_picker` component props — `filter` (the FORM: the MongoDB-style `FilterConditionSchema` record vs the `ViewFilterRule` array)", + "replacement": "`z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` the map's array-declared `filter` doors already carry (`record:related_list`, its nested Add-affordance picker, `element:number`; the four `object-*` blocks declare `filter` as `z.unknown()`, a gap measured on its own). A record-form filter `{ status: 'active' }` becomes `[{ field: 'status', operator: 'equals', value: 'active' }]`; an operator object `{ amount: { $gt: 100 } }` becomes `[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys become several rules (they AND). Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are accepted and normalized on parse. The binding-level `dataSource.filter` on the same node is a different key (`ElementDataSourceSchema`) and is not moved by this entry", + "migrationId": "element-record-picker-filter-rule-array", + "toMajor": 18, + "rationale": "One filter orthography platform-wide (the maintainer's 2026-08-25 ruling, option B: every `filter` door takes the `ViewFilterRule` array rather than keeping record-shaped exceptions). `ComponentPropsMap['element:record_picker'].filter` was the LAST `filter` input in the map still declared as the MongoDB-style record (`FilterConditionSchema`) after `element:number` converged: the three array-declared doors (`record:related_list`, its nested Add-affordance picker, `element:number`) carried the `ViewFilterRule` array and the four `object-*` doors declare `z.unknown()`, so the filter a list view stores and renders was refused by the picker beside them, and a lone holdout is the state where the next author copies the wrong form. Sequenced measurement-first, as that convergence had to be (the 2026-08-25 Option-A ordering ruling: measure the consumer's read path before the contract moves): at the objectui pin `00d3f09c` the renderer hands `filter` to `query.$filter` and calls `adapter.find()` (`components/src/renderers/basic/record-picker.tsx`); `ObjectStackAdapter.convertQueryParams` lowers an ARRAY `$filter` through `translateFilterArray` into filter AST tuples (`data-objectstack/src/index.ts`), the same door every list view's stored rule array already takes, and the engine lowers the tuples before the driver (`engine-filter-array-lowering.test.ts`); nothing on that path parses `properties` against the installed spec. The pin and objectui `main` (`f7cf7e8`) are byte-identical on every read-path file. The ruled migration check ran with the change: the sweep of first-party corpora (examples/, skills/, content/docs/, docs/, packages/**, .changeset/) found ONE `element:record_picker` author writing a record-form `filter` — a spec test fixture, rewritten to the array form in the same change — and zero outside the spec package; this entry carries the prescription for authors outside the repo." + }, + { + "surface": "page components of type element:text — properties.variant authored as heading or subheading (ElementTextPropsSchema.variant)", + "replacement": "one of the nine values `ui:text` publishes: `h1`-`h6`, `body`, `caption` or `overline`. 'heading' → 'h2' and 'subheading' → 'h3' (the heading element each one always rendered), or the level the page outline means", + "migrationId": "element-text-variant-heading-subheading-retired", + "toMajor": 18, + "rationale": "The ruling converged `element:text` on the vocabulary `ui:text` already publishes, because a heading is a document level, not a text style: `heading` and `subheading` named a style and left the renderer to pick a level. It landed in two releases so authors outside this repository could move first — 17.5.0 added the nine and refused nothing, and 17.6.0 was a full release in which both vocabularies parsed. The D2 conversion `element-text-variant-heading-levels` makes the ruled edit: `heading` → `h2`, `subheading` → `h3`. That keeps the heading element (the renderer drew `heading` as an h2 element and `subheading` as an h3 element), so the document outline a screen reader walks is unchanged, but not the size: `heading` drew in the `h3` style and `subheading` in a medium-weight small heading style, and `h2` / `h3` draw their own, larger styles. Whether the page wanted that level is the author's call — a heading placed for its size rather than its place in the outline may want a deeper level. Nothing is dropped at rest: a stored page replays the rewrite at rehydration; a page component's `properties` is not parsed on the save path, and the component-props gate reports an old spelling as an advisory `component-props-invalid` finding, carrying the prescription, on `os validate`, `os build` and `os lint`. ADR-0087" + }, + { + "surface": "a `where` / filter whose KEY is a dotted path with a relation, virtual-`formula` or plain-scalar head (`{\"project_id.name\": …}`, `{\"is_open.x\": …}`, `{\"title.x\": …}`) — at BOTH doors: the REST ingress (`assertFilterFieldsExist`, covering everything that reaches `findData`) and the engine seam itself (`engine.find` / `findOne` / `count` / `aggregate` / `update` / `delete`), which saved reports, flows and dashboard widgets reach directly", + "replacement": "denormalise the value onto the queried object (a stored field, written when the source changes) and filter that — the same remedy, in the same words, the SORT axis prescribes when it refuses the dotted spelling. To read a related column, `$expand` is unchanged; to CONDITION on one, the stored denormalised field is the supported shape. A dotted path into a structured/JSON field (`{\"address.city\": …}`) is NOT refused and keeps its current per-driver behaviour", + "migrationId": "engine-dotted-filter-refused", + "toMajor": 18, + "rationale": "FILTER was the last of the four query axes with no verdict for a dotted name: SORT refuses it, PROJECTION refuses it at both doors, and the FILTER gates judged a key on its HEAD SEGMENT only — so `where {\"project_id.name\": \"Apollo\"}` cleared the unknown-name check (which refuses a key naming no field of the object) because `project_id` is a real field, reached a driver that cannot serve the path, and answered 200 with zero rows. The formula verdict deliberately skipped dotted keys, so the axis answered one unserviceable intent two ways by spelling: `{is_open: true}` was refused while `{\"is_open.x\": true}` rode through.\n\nMeasured across all THREE drivers before ruling: relation-head, formula-head, system-column-head and plain-scalar-head dotted filters return ZERO rows on `driver-memory`, `driver-sql` AND `driver-mongodb`, each under an ordinary 200 indistinguishable from an empty table. There is no working capability for this refusal to remove: an ObjectStack lookup stores the related record's SCALAR id (SQL: a string column plus FK; Mongo: a single-key index on a scalar), while Mongo's dotted paths traverse EMBEDDED DOCUMENTS — so the spelling is well-formed Mongo that matches nothing. On driver-sql, knex reads the dot as a table qualifier and emits a column no dialect can resolve; `find()` falls into the unknown-column recovery ladder and returns `[]` silently (the same measurement caught the list and count halves answering that query two different ways, a divergence with its own entry, `driver-sql-unresolvable-where-column-refused`, that now refuses it on both).\n\nBoth doors now refuse the three measured-dead head classes with `400 INVALID_FIELD`, naming the whole offending key exactly as the caller wrote it and carrying the remedy sentence — no new mechanism, no new error class, per the maintainer's ruling. Both judge the head by the SAME `@objectstack/spec/data` classification (`classifyDottedFilterHead`), the one-source move the formula verdict made with `isVirtualSearchField`, so the doors cannot drift into answering one spelling two ways. Precedence mirrors the sort axis, verdict for verdict: `unknown` > `dotted` > unmaterializable.\n\nDELIBERATELY UNJUDGED, per the same ruling: a dotted path whose head is a structured/JSON field (`address.city`) — the one spelling the drivers genuinely disagree on (live on memory and mongodb, 2 rows in the measurement; silently empty on sql). Refusing it for symmetry would delete a working capability on two of three backends; declaring JSON-path filtering a capability (`supports`) waits for a real consumer. Array-valued heads (`multiple: true`, tag types) and file heads are unjudged for the same measured reason. The nested-relation OBJECT form `{ owner: { region: \"NA\" } }` is untouched: the refusal targets the dotted-STRING spelling alone.\n\nThis is a CODE-path API, not stored metadata, so — like `engine-find-formula-filter-refused` and `engine-dotted-projection-refused` one step down — there is no `sys_metadata` row for the D2 chain to rewrite and this ledger entry is the notification channel. No mechanical rewrite exists: the platform cannot invent the stored column the remedy prescribes, and it must not join or post-filter instead — the drivers have already applied `limit`/`offset`, so any post-hoc predicate would filter an arbitrary page.\n\nAUTHOR-REACHABLE SURFACES: a saved report's `query.filter` (`sys_saved_report`) is forwarded VERBATIM into `engine.find` by `plugin-reports`, bypassing the ingress; flow node `config.filter` and dashboard widget filters are author-written the same way. A dotted filter path is exactly what an AI author writes by analogy with `$expand`, projection spellings and SQL joins — and it used to answer an empty list indistinguishable from \"no matching records\", often with the related value reading correctly in the very same response. It now fails loudly, with the remedy in the message. Registered on the same inherited ruling as its siblings — the SORT-axis engine refusal was registered in this ledger although no stored row needs rewriting, re-affirmed for the FILTER axis on 2026-08-13. ADR-0112." + }, + { + "surface": "four epoch-instant keys whose name carried no unit: WebSocketEvent.timestamp, SimplePresenceState.lastSeen, KernelContext.startTime (inherited by TenantRuntimeContext) and HealthStatus.timestamp", + "replacement": "the same instants named for what they mark and typed with the new shared EpochMs schema (shared/epoch.zod.ts): occurredAt, lastSeenAt, startedAt and checkedAt. The VALUE is unchanged in every case — still milliseconds since the Unix epoch, still Date.now(). Only the key name and the declared schema move", + "migrationId": "epoch-instant-keys-renamed", + "toMajor": 18, + "rationale": "Maintainer ruling B (2026-09-05, on the population the 2026-09-02 rule reaches): a duration-shaped z.number() carries its unit in the key NAME, minus two structural classes declared ON THE SCHEMA rather than in a gate ledger. Epoch instants are the first class. They read to the rule exactly like an offending duration — a bare name plus a describe that says \"milliseconds\" — but renaming them the way the rule prescribes would resolve the wrong confusion: measured on this package own authorable surface, all 51 distinct keys ending in Ms are durations (timeoutMs, backoffMs, latencyMs, uptimeMs) and all 51 distinct keys ending in At are instants (createdAt, expiresAt, lastUsedAt). Spelling an instant with the Ms suffix would move it INTO the duration family. So the exemption is a declaration on the contract: the value becomes EpochMs, which states the epoch-millisecond unit once, and the key takes this package established At convention. Two of the six instants ruling B names (ServiceMetadata.registeredAt and ScopeInfo.createdAt) were already correctly named and only changed schema, so they are not retirements and appear in no table. A SEMANTIC entry rather than a D2 conversion because all four keys are RUNTIME-EMITTED — a WebSocket event and a presence payload are wire messages, a kernel context is constructed by host code at boot, a health report is emitted by the startup orchestrator — so none is ever stored as a sys_metadata row and the conversion chain has no seam that would see one. That is the same disposition kernel/KernelContext:previewMode already carries on one of these very defs, and ruling B prescribes it explicitly: an ADR-0087 conversion where the key is authorable, a semantic entry where it is runtime-emitted. ADR-0087." + }, + { + "surface": "e-signature deadline keys: `ESignatureConfig.expirationDays` / `reminderDays` (`document.eSignature.expirationDays` / `document.eSignature.reminderDays`)", + "replacement": "nothing to re-declare — delete the keys. No e-signature engine exists on the platform: no signature request is sent, expired or reminded by any layer, so there is no live mechanism to declare an expiry window or a reminder interval to. `ESignatureConfig` itself stays (`provider` / `enabled` / `signers`), unchanged", + "migrationId": "esignature-config-deadline-keys-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; the 2026-09-02 ruling on the unread deadline keys held this pair on one condition — \"no roadmap ⇒ they retire with the other three families\" — and the maintainer answered it on 2026-09-05 (no roadmapped e-signature consumer), so the ruling's own branch resolves to retirement. Two day-shaped keys sat on the published authorable surface (`authorable-surface/data.json`) and in the generated reference docs — an author could write `expirationDays: 30` and reasonably expect a signature request to lapse after thirty days — and were read by NOTHING: the reader census over every package outside `packages/spec` (tests and changelogs excluded), over `examples/**` and `skills/**`, and over objectui at the pinned sha returned zero hits for `expirationDays`, `reminderDays`, `eSignature` and the `ESignatureConfig` names, with a lit control inside `packages/spec`. Both carried defaults (30 days, 7 days) that were materialized into every parsed configuration without ever being consulted. `cloud` and real customer configurations are UNMEASURED. Why D3 semantic and not a D2 conversion: `DocumentSchema` is not a stack collection member and `document` is no metadata type, so the chain has no seam that would ever see one (the `kernel/MetadataPluginConfig:additionalTypes` precedent); the prescription reaches authors through the `retiredKey()` tombstones (`tsc` + the parse) and this entry." + }, + { + "surface": "every EVALUATED expression slot in the spec — the 34 declaring positions of the census of engine-evaluated slots outside the flow ledger that survive into this major, enumerated by identity and not by a name scan: the formula Field.expression; the predicate keys visibleWhen / visibleOn / readonlyWhen / requiredWhen / visibility / disabledWhen / visible / disabled / condition / when, on Field, SelectOption, InlineGridColumn, ScriptValidation, CrossFieldValidation, ConditionalValidation, Hook, ObjectFieldGroup, RowCrudActionOverride, CriteriaSharingRule, PluginPermission.filter, MultiVersionSupport routing, Action and ActionParam (including each param option), BaseNavItem, BulkActionDef, PageComponent, PageTabs items, RecordAlert, ListViewShape, FormFieldBase, FormSection and the settings-manifest Specifier and manifest visible — authored either as an expression envelope carrying only ast ({ dialect: 'cel', ast: … } with no source), or with a source that is blank after trimming, through the envelope key ({ dialect: 'cel', source: ' ' }) or the bare-string shorthand for it. ⚠️ The census this entry was written against counted 36, and the two that are deliberately absent here are the ServiceLevelIndicator successCriteria and TraceSamplingConfig composite condition expression arms. They are not lost: they were RETIRED OUTRIGHT in this same unpublished major by the observability-cel-predicates-retired entry of this step, under ADR-0049 enforce-or-remove, because nothing evaluated either. Both entries first ship together, so an upgrader never meets those two slots under THIS rule — the composite of the two changes is the retirement alone, and stating the narrowing for a slot that no longer accepts an expression at all would send the upgrader to author one. That absorption is the only reason the count here is not the census figure of 36. The published TypeScript interface RowCrudPredicates narrows with the two slots it mirrors. Reachable wherever metadata is authored or stored: defineStack sources, an exported stack passed to objectstack validate, a POST body on any of these metadata types, and a row already sitting in sys_metadata", + "replacement": "a non-blank `source`. ⭐ For an `ast`-only envelope the recovery is MECHANICAL and lossless for `cel`, which is the one dialect in this population that has an AST at all: `printCelAst(ast)` from `@objectstack/formula` (shipped with this narrowing, the inverse of `parseCelToAst`) prints the AST back to surface syntax, and the recovered string is the new `source` — keep the `ast` beside it if you want, an `ast` BESIDE a string `source` is untouched and stays admitted everywhere. ⚠️ Lossless is about MEANING, not bytes: the printer re-renders from the parse tree, so single-quoted literals come back double-quoted (`record.p == 'x'` → `record.p == \"x\"`) and parentheses the parser dropped do not come back. It answers `null` — never a guess — for an `ast` it cannot round-trip through the platform's own bounded parser; that `null` is the hand-migration case. For a BLANK `source` there is nothing to print from, so this entry delegates the judgment, and it is the same fork the flow-edge `condition` narrowing named: author the predicate the slot was meant to carry, or REMOVE the key entirely. ⚠️ Those two are not interchangeable and the choice is per slot, not per file. A refused predicate reached its evaluator and faulted, and what the fault DID differs by slot: on the fail-closed ones (`ObjectFieldGroup.visibleWhen`, `RowCrudActionOverride.visibleWhen`, `BulkActionDef.visible`, the two settings-manifest `visible` slots) it HID or EXCLUDED, so removing the key REVEALS what was hidden; on the fail-soft ones (the rest) it left the gate open, so removing the key preserves what was happening. Removing to clear the refusal is therefore safe on one half of the population and a silent disclosure on the other", + "migrationId": "evaluated-expression-slots-source-required", + "toMajor": 18, + "rationale": "Maintainer ruling 2026-09-12, option A: every engine-evaluated expression slot requires a non-blank `source`, with an ADR-0087 migration path, while the persistence contract stays wide. The rule first set for the flow-node ledger, and then carried to `FlowEdgeSchema.condition`, generalises to every other slot an engine evaluates. Each of those slots now composes `EvaluatedExpressionInputSchema` instead of `ExpressionInputSchema`, so an evaluated slot is held to what the engine can actually run. The engine reads `source` alone (`cel-engine.ts` `evaluate`: \"AST-only evaluation not yet supported; persist `source`\"), so both refused spellings landed in its fault arm on every release that carried them — and measured at the chokepoint, the engine never silently SUCCEEDS on either: it returns a `parse` fault, and what happened next was decided entirely by the slot's fail policy. Nothing between the author's keystroke and that fault said a word — the authoring lint `validateVisibilityPredicates` measured 0 findings on an `ast`-only envelope and 0 on a blank `source`, against two control legs that each measured 1. The refusal is one rule with one sentence, `EVALUATED_EXPRESSION_SOURCE_REQUIRED`. ⚠️ `ExpressionSchema` / `ExpressionInputSchema` are deliberately NOT narrowed and neither is their alias `PredicateInputSchema`: they are the PERSISTENCE contract (`source` OR `ast`) and stay wide by the same ruling's item 2. The narrowing is at the evaluated slots only. ⚠️ Why this is a D3 entry and not a D2 conversion, even though a printer now exists. The conversion layer lives in `packages/spec`, which is dependency-free by Prime Directive #2 and carries no engine — `packages/formula`'s own `normalize.ts` header states the same boundary from the other side (\"Spec layer cannot do step 2 because it must remain dependency-free; this package owns the engine import\"). A conversion that had to call the CEL printer could not live where conversions live, and a conversion that guessed without one would be the platform inventing a predicate. So the printer ships as a named, tested export the migration PRESCRIBES, and the judgment the printer cannot make — a blank `source`, an opaque `ast` that does not round-trip, any future dialect with no printer — stays here as the structured TODO, naming the object, field and slot. ⚠️ And for a row ALREADY STORED the consequence is wider than the key. `applyConversionsToStoredItem` replays the conversion chain on rehydration, but no conversion can supply a `source` that was never written, so a stored row carrying either spelling now fails its schema parse at the seam that loads it rather than parsing and faulting later. That is the intended direction — the refusal moves from run time, where it was invisible on the fail-soft slots and destructive on the fail-closed ones, to load time, where it names the row. ADR-0087, ADR-0058, ADR-0049." + }, + { + "surface": "`EventNameSchema` and its `EventName` type (`@objectstack/spec/shared`, `shared/identifiers.zod.ts`), and the dot-notation grammar it imposed on its only three binding fields: `EventTypeDefinitionSchema.name` and `EventSchema.name` (`kernel/events/core.zod.ts`) and `EventMessageSchema.eventName` (`api/websocket.zod.ts`).", + "replacement": "(removed — no replacement grammar layer. The three binding fields stay and widen to plain `z.string()`; the event vocabulary the platform actually checks is the closed literal enums `DataEventType` / `BulkDataEventType` (`@objectstack/spec/api`, `api/events.zod.ts`), which stand as the only event-name contract. A caller that imported `EventNameSchema` for standalone validation deletes the import; if it was validating platform event names, it parses through the enums instead.)", + "migrationId": "event-name-schema-retired", + "toMajor": 18, + "rationale": "Maintainer ruling 2026-09-01 (director decision batch C, verbatim 「同意」: retire) — ADR-0049 enforce-or-remove. The schema presented itself as the platform's event-name grammar while nothing that runs consumed its three binding schemas, and the closed enums that do the real checking never referenced it. The event surface is platform-defined, not author-extensible, so a grammar layer for a hypothetical extension surface is a trap, not a reserve: a generator satisfying `EventNameSchema` has satisfied nothing the platform will check, while one emitting outside the closed enums is refused by a rule the identifier file never mentioned." + }, + { + "surface": "`ExecutionStepLog.iteration` on a step whose `regionKind` is `parallel-branch` — the per-step records under `ExecutionLog.steps`, as the automation run endpoints return them — and the new optional `ExecutionStepLog.branch` key", + "replacement": "Read the parallel branch index from `branch`. `iteration` is now single-valued: the zero-based iteration of the enclosing `loop`, carried through any nesting, so a branch step of a `parallel` node that sits inside a loop body carries BOTH keys — `iteration` for the row and `branch` for the branch. A consumer that grouped or labelled steps by `iteration` under `regionKind: parallel-branch` moves that read to `branch`; a consumer reading `iteration` on `loop-body`, `try` or `catch` steps changes nothing.", + "migrationId": "execution-step-iteration-single-valued", + "toMajor": 18, + "rationale": "The key was declared as the zero-based loop iteration OR the parallel branch index of the enclosing region — one field, two meanings, told apart only by reading `regionKind` first. The engine tagged each step with its innermost region only, so for a `parallel` node inside a `loop` body every branch step recorded the branch index and no step of that branch recorded the loop iteration: a per-row failure inside a branch was attributable to a branch, never to the row the sweep was processing. The sibling try/catch rule had already settled the containment case — a try/catch region has no index of its own, so it carries the loop iteration — and deliberately left `parallel` open, because there the two indexes genuinely compete for one field. The maintainer ruling of 2026-09-03 took option A: `iteration` always means the enclosing loop iteration and the branch index moves to its own optional key, so a reader no longer has to branch on `regionKind` to know which number it holds, and getting that wrong no longer silently books a failure against the wrong row. Option B — keep the overload and add a second index whose presence depends on nesting shape — was not taken. This is not a mechanical conversion: a step record written before this change carries `iteration` under `parallel-branch` with the branch-index meaning, and only its producer knows whether the parallel node sat inside a loop. The measured corpus held zero `loop { parallel }` nestings and one consumer reading the key — a grouping key in the objectui flow-runs panel — so the migration is a consumer-side read move, not a data rewrite. The engine tagger that writes both keys follows this contract change as its own card; until it lands, `branch` is declared and unwritten, and `iteration` on a `parallel-branch` step written by an older engine still holds the branch index." + }, + { + "surface": "the export-job API family, retired whole: the twelve defs api/ExportJobStatus, api/CreateExportJobRequest, api/CreateExportJobResponse, api/ExportJobProgress, api/ScheduledExport, api/GetExportJobDownloadRequest, api/GetExportJobDownloadResponse, api/ListExportJobsRequest, api/ExportJobSummary, api/ListExportJobsResponse, api/ScheduleExportRequest and api/ScheduleExportResponse with every name api/export.zod.ts exported for them from @objectstack/spec/api (the Schema consts, their z.input aliases and their Parsed aliases) and the ExportApiContracts route map; the IExportService contract with its six types (CreateExportJobInput, CreateExportJobResult, ExportJobDownload, ListExportJobsOptions, ExportJobListResult, ScheduleExportInput) from @objectstack/spec/contracts; and automation/ScheduleState (ScheduleStateSchema, ScheduleState, ScheduleStateParsed) from @objectstack/spec/automation", + "replacement": "nothing to re-declare for the job family — no route ever served it, so no caller holds a job id, a progress body or a download link to carry over. The export the platform DOES serve is the synchronous streaming door GET /api/v1/data/:object/export (@objectstack/rest, the SDK method data.export): it answers the file itself as CSV, JSON or XLSX. ExportFormat stays published (ExportImportTemplate still references it). A recurring export is a Job (system/job.zod.ts) whose handler you write, with its cadence on Job.schedule.expression — the one cron slot the platform evaluates. A scheduled flow declares its cadence on its start node (config.schedule), and its run history is ExecutionLog / FlowRunSummary; ScheduleState had no counterpart to point at because no scheduler ever kept one. The import-job family in the same module (ImportJob…, ListImportJobs…, ImportJobApiContracts) is served and is NOT part of this retirement", + "migrationId": "export-job-family-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling A of 2026-09-12 (retire the family, IExportService and ScheduleExportInput; ScheduleState retired with it unless a live consumer is measured), the landing route the maintainer ruled on 2026-09-24 (route A: objectui retires its own side of the unimplemented async-export path first, then this retirement), and a scope note the maintainer agreed on 2026-09-25 that folds in the declared limit / cursor of the export-job list — one of three sibling list doors found declaring them and never reading them. The family declared an asynchronous export API, create / progress / download / list / schedule / cancel under /api/v1/data/export and a POST on /api/v1/data/:object/export, that NOTHING served: @objectstack/rest mounts no /api/v1/data/export route and only the GET on /api/v1/data/:object/export, IExportService recorded no evidenced provider binding, and the reader census over objectstack outside packages/spec, over objectui at the pinned sha (which carries objectui's own retirement) and over cloud main returned zero code files naming any of the forty-three exported names, each beside a lit control. An AI reading the contract found a complete, well-typed export-job API and wrote calls that answer 404 — and once the retirement of the cron-typed positions nothing read had deleted theirs, ScheduledExport / ScheduleExportRequest kept a REQUIRED schedule block that could hold no schedule, so an author who filled in its timezone believed they had scheduled something. ScheduleState described the runtime state of a scheduled flow that no scheduler wrote or read. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and applyConversionsToStoredItem maps a metadata type onto one of its collections; none of these shapes is either — they are HTTP bodies, a route map, a service interface and an unpersisted runtime record — so a conversion would be a transform with no seam that ever runs, and with no carrier key there is no shape on which a tombstone could sit. Those earlier cron-position deletions on three of these defs registered nothing and stay unregistered; the defs themselves are now the RETIRED_DEFS_BY_MAJOR[18] entries." + }, + { + "surface": "object.fields..scale on a field whose `type` is `currency` — any declared value, `scale: 0` included; the `Field.currency` helper passes it through unchanged. `scale` on `number`, `percent`, `rating`, `slider` and `formula` is untouched", + "replacement": "no `scale` on a currency field. DELETE the key — that is the whole migration: a currency amount's decimal places are its currency's, not a field setting. The currency's ISO 4217 minor unit decides how the amount displays, and the field's write allowance stays unconstrained — a currency write is accepted with the decimals it carries, as it always was on a currency field that declared no `scale`. ⛔ Nothing replaces the key: do not re-declare its value under any other key.", + "migrationId": "field-currency-scale-refused", + "toMajor": 18, + "rationale": "The maintainer's ruling of 2026-09-23 (option B) retires `scale` from the `currency` field type, and the ruling of 2026-09-24 (option 乙 — a currency's decimal places are the currency's, not a setting) words the remedy. On a currency field the key was three-faced: the metadata-admin field designer offered it as stored metadata, the amount's cell never read it (fraction digits come from the currency's ISO 4217 minor unit), and the record validator's `max_scale` branch still refused writes carrying more decimals — so an author who set it bought a narrower write contract and no visible change. `FieldSchema` now refuses the key on `currency` at parse, and the validator stops reading it for the type in the same release, so a stored declaration narrows nothing either. ⛔ No alias and no grace window, per the ruling. NOT mechanically converted, deliberately: a conversion that dropped the key would accept it on every load, which is the grace window the ruling refused; the refusal names the key and its one-line fix instead. Two behaviour changes ride along and are part of what an upgrade means: (1) a currency write with more decimals than a former `scale` is now ACCEPTED — the write allowance stays unconstrained, the contract every currency field without `scale` already had; (2) at the console pin measured when this was written, the grid summary footer and the dashboard metric widget read a currency column's `scale ?? 0`, and the ruling lands this change only after the console derives both faces from the currency, the way the cell does, and the pin has moved past that change. Population measured at the change, on origin/main 1f89ba0d70 by AST sweep: 15 `Field.currency` declarations in `examples/` (app-crm 4, app-showcase 11) and 13 documentation examples carried `scale`, every one of them `scale: 2`; all were deleted in the same change." + }, + { + "surface": "field.inlineColumns[] and field.relatedListColumns[] on lookup and master_detail fields — the two column lists that used to accept any object", + "replacement": "`inlineColumns` entries are strict, name-keyed columns — `{ name, label?, type?, … }`, where `{ name }` alone hydrates the rest from the child object field. `relatedListColumns` entries are child field-name strings.", + "migrationId": "field-inline-and-related-list-columns-closed", + "toMajor": 18, + "rationale": "The D2 conversion `field-column-lists-canonicalized` rewrites what it can resolve without guessing: an inline column spelled `{ field: 'x' }` becomes `{ name: 'x' }` with every other key kept, and a related-list column object folds to its identity string. Two things remain the author's. First, the conversion leaves alone, on purpose, an inline entry that carries BOTH `field` and `name` (rewriting a live key on the strength of a stale one would guess) and a related-list object with no resolvable identity (a conversion must not invent data) — those now fail the parse and only the author knows which column was meant. Second, the fold DROPS a related-list object's decoration keys — a label, a width — because no object spelling rendered reliably on that list; the author decides whether a label they wrote there belongs on the child field itself instead. Both lists used to accept any object, so a mis-keyed column published clean and drew blank cells: a column that was blank before this release was usually one of these, and the author should confirm it now names a real child field." + }, + { + "surface": "object field `deleteBehavior: 'set_null'` authored on a `master_detail` field", + "replacement": "an explicit `deleteBehavior: 'restrict'` or `'cascade'` (or no declaration, which is the cascade default) — re-declared deliberately, because only the author knows which they meant. There is deliberately NO automatic conversion: `'set_null'` here asked for the child rows to be KEPT, and both mechanical rewrites betray that intent in a different direction — stripping the key silently ratifies the cascade the author did not ask for (the same collapse of intent that produced the defect), while `'restrict'` is the only rewrite that cannot lose data (the parent delete is refused while children exist — the closest honest reading of \"keep my children\") but turns a delete that silently succeeded into a loud refusal. If the children genuinely must survive the parent, the field wants to be a `lookup`, not a `master_detail`", + "migrationId": "field-master-detail-set-null-refused", + "toMajor": 18, + "rationale": "`FieldSchema` accepted `deleteBehavior: 'set_null'` on a `master_detail` while the engine's `cascadeDeleteRelations` resolves every value except `restrict` on that type to `cascade` — so the declaration asked for the children to be kept and the engine DELETED them, silently, at the moment the parent went away: data loss relative to the declared intent, the ADR-0049 declared-but-unenforced shape on a delete path. Honoring the value is ruled out (maintainer, 2026-08-19): a detail row whose master reference is nulled becomes an unreachable orphan, which is precisely what the orphan-detail work exists to prevent. The schema now refuses the authored combination at parse time (declared = enforced), and the engine logs loudly if a raw registration or a pre-tightening stored row still carries it to the coercion site. A BARE `master_detail` is untouched: the default still materializes as `'set_null'` in parse output (byte-identical to before) and still resolves to cascade." + }, + { + "surface": "object field `maxLength` declarations — `maxLength: 0`, negative or non-integer values on any type, and the key with any value on field types outside `BOUNDED_STRING_FIELD_TYPES` (`boolean`, `lookup`, `autonumber`, `formula`, `select`, `json`, `secret`, …)", + "replacement": "a positive-integer `maxLength` (>= 1) on a bounded-string field type — `text`, `textarea`, `email`, `url`, `phone`, `password`, `markdown`, `html`, `richtext`, `code`, plus `signature`/`qrcode`, which joined once the write seam enforced a declared bound on them (the set is `BOUNDED_STRING_FIELD_TYPES`; the narrowing itself landed on the ten-member set of its day) — or no declaration at all. Deleting the key is mechanical and behaviour-preserving for a MISPLACED declaration: the write-time validator only ever applied `max_length` inside its bounded-string branch, so the key was inert by construction on every other type. A MALFORMED value on a bounded-string type is the judgment case — the validator's raw `>` comparison did consume it (`maxLength: 0` accepted only the empty string, a negative value refused every write, `maxLength: 12.5` behaved as \"at most 12\"), and the SQL schema-drift planner consumed `maxLength: 0` as `varchar(0)` DDL until it was taught to stop reading a malformed bound as authoritative — so only the author knows the bound they MEANT: re-declare it as a positive integer, or delete it deliberately accepting the unbounding", + "migrationId": "field-max-length-malformed-or-misplaced-refused", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-08-24, tightening both halves — the value's shape and the types the key applies to (enforcement shipped on the 17.x line — accept-set narrowings ride minors, and this entry tells `migrate meta` users at the major boundary; registration was deferred to a follow-up because the registry file was serialized behind an in-flight change when the enforcement landed). Shape: a character length is a positive integer, so the key tightened from `z.number()` to `z.number().int().min(1)` — `maxLength: 0` measurably sent schema-drift planning `varchar(0)` DDL no server accepts, at severity error/destructive, before that consumer was taught to stop reading a malformed bound as authoritative (the house pattern the `precision`/`scale` integer refusal set). Applicability: the key sat on the BASE field schema — authorable on `boolean` / `lookup` / `autonumber`, types where nothing bounded is stored — while the write-time validator (objectql `record-validator.ts`) only ever enforced it on its ten bounded-string types, the one list of the three that had a measured reader; that list is promoted to the protocol as `BOUNDED_STRING_FIELD_TYPES`, the schema refuses the key outside it (ADR-0078 declared=enforced), and both authoring forms (`field.form.ts`, previously 3 types; `object.form.ts`, previously 9) show the key for exactly that set." + }, + { + "surface": "object field `minLength` declarations — `minLength: 0`, negative or non-integer values on any type, and the key with any value on field types outside `BOUNDED_STRING_FIELD_TYPES` (`boolean`, `lookup`, `autonumber`, `formula`, `select`, `json`, `secret`, …)", + "replacement": "a positive-integer `minLength` (>= 1) on a bounded-string field type — `text`, `textarea`, `email`, `url`, `phone`, `password`, `markdown`, `html`, `richtext`, `code`, `signature`, `qrcode` (the twelve-member `BOUNDED_STRING_FIELD_TYPES` set; `signature`/`qrcode` joined once the write seam enforced a declared bound on them) — or no declaration at all (\"no minimum\" is expressed by OMITTING the key, never by `minLength: 0`). Deleting the key is mechanical and behaviour-preserving for a MISPLACED declaration (the write-time validator only ever applied `min_length` inside its bounded-string branch, so the key was inert by construction elsewhere) and for `minLength: 0` / negative values anywhere (a string length is never below zero, so the check could not fire). A FRACTIONAL value on a bounded-string type is the judgment case: the validator's raw `<` comparison did consume it (`minLength: 2.5` behaved as \"at least 3\"), so only the author knows the integer they MEANT — re-declare it deliberately if the constraint was wanted", + "migrationId": "field-min-length-malformed-or-misplaced-refused", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-08-25 (option B, the lower bound at 1): `minLength` carried the exact defect pair the 2026-08-24 ruling closed for `maxLength` (`field-max-length-malformed-or-misplaced-refused`), and converges on the same template. Shape: the key was `z.number()`, so `minLength: -5` and `minLength: 2.5` parsed cleanly while describing no character length; it is now `z.number().int().min(1)`. The lower bound is 1 by ruling: `minLength: 0` is refused loudly — a vacuous always-true declaration is exactly the noise an AI metadata author mass-produces, and the refusal surfaces it at authoring time. Applicability: the key sat on the BASE field schema — authorable on `boolean` / `lookup` / `autonumber`, types where nothing bounded is stored — while the write-time validator (objectql `record-validator.ts`) only ever enforced it on the bounded-string set; the schema now refuses it outside `BOUNDED_STRING_FIELD_TYPES` (ADR-0078 declared=enforced), and both authoring forms (`field.form.ts`, previously 3 types; `object.form.ts`, previously 9) show the key for exactly that set." + }, + { + "surface": "object.fields..multiple — an authored `multiple: true` on a field whose `type` is outside MULTI_CAPABLE_TYPES (`select` / `radio` / `lookup` / `user` / `file` / `image`) union MULTI_OPTION_TYPES (`multiselect` / `checkboxes` / `tags`) — e.g. `master_detail`, `tree`, `text`, `boolean`, `datetime`, `avatar`", + "replacement": "a multi-capable type that actually holds several values: `multiselect` / `checkboxes` / `tags` for several option codes, a `lookup` with `multiple: true` for several related records (the replacement for a multi-valued `master_detail` / `tree`), `file` / `image` with `multiple: true` for several attachments — or, where the field really does hold one value, dropping the `multiple` key. `MULTI_CAPABLE_TYPES` and `isMultiValueField` are unchanged, so every field that was ALREADY multi-valued by that predicate keeps its declaration, its storage and its read path verbatim.", + "migrationId": "field-multiple-non-capable-type-refused", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-09-13, option 1′ (the earlier rule refusing an authored `radio` with `multiple: true`, generalised): two definitions of \"multi-valued\" disagreed. `FieldSchema` accepted `multiple: true` on ANY type; driver-sql's `isJsonField` read it raw (`|| !!field.multiple`) and built a JSON ARRAY column; `isMultiValueField` — the spec predicate consumers shape queries from — answered \"not multi-value\" for the same field. A related list therefore composed `=` against a JSON array column and the driver answered the user a 400 (the console's related list pinned the divergence on the consumer side when it began shaping that filter from the spec predicate, and its follow-up recorded the driver half as owed and not filed). There is NO lossless conversion: the column was physically built as a JSON array, so the stored value is an array while the replacement type may want one scalar, several ids, or several option codes — which of those the author meant is a business judgment the chain cannot make. Hence a structured TODO rather than an auto-rewrite (ADR-0087 D3 \"never silence\", ADR-0032 \"no silent failure\"). Population measured at ruling time: 0 in-tree and 0 in HotCRM (shallow clone c716a2c) — every `multiple: true` there is on `lookup` / `select`; re-measured on origin/main 689d606f by AST sweep, still 0. WIDER THAN THE JSON-COLUMN DECISION ALONE: every site in driver-sql that asked `field.multiple` \"is this value multi-valued\" now asks `isMultiValueField` — the DDL writer, the read-side deserializer, the varchar-width mirror, the cross-field comparison class, the four scalar read-coercion registries on both of their fills, the two MySQL temporal-widening candidate sets, and the schema differ. So a stored field in the retired shape also LEAVES the JSON read path and ENTERS the scalar one: its column is no longer deserialized as JSON, the declared-type text-operator gate applies to it, and a `$contains` against it answers the declared no-match instead of a membership test." + }, + { + "surface": "the field-level predicates objects[].fields[].requiredWhen and objects[].fields[].readonlyWhen, and a select option's objects[].fields[].options[].visibleWhen, whose CEL reads THROUGH a reference field (a lookup, master_detail, user or tree field): record.account.tier where account is such a field; likewise previous.account.tier, and parent.account.tier on a master-detail line item whose master declares account. Refused wherever objects are validated as authored: objectstack validate, build and lint over defineStack({ objects }) sources and exported stacks", + "replacement": "the check as a `validations[]` rule of `type: 'script'` — the one predicate the server reads one hop through a reference (the related record is loaded before it runs) — whose `condition` states the FAILURE. For `requiredWhen: P` on field F: P and F empty, e.g. `record.account.tier == 'enterprise' && (record.po_number == null || record.po_number == '')`. For `readonlyWhen: P` on F: P and F changed, on updates only (`events: ['update']`), e.g. `record.account.tier == 'gold' && record.discount != previous.discount`. For an option gated by P: that option picked while P does not hold — the option is then offered to everyone and refused on save. Or read a column the object itself declares (denormalise the related value onto it). A read through `previous` or `parent` has no hydrated seam at all, a validation rule included: read a column the bound record declares instead", + "migrationId": "field-predicate-reference-traversal-refused", + "toMajor": 18, + "rationale": "Triage routed this on 2026-09-25 to remedy A: refuse the traversal at authoring, with a prescription. The field level is never hydrated: `rule-validator.ts` evaluates `requiredWhen` / `readonlyWhen` / an option's `visibleWhen` against the record alone, so a reference there holds the related record's bare id and every read through it faults, on every row. Measured on the engine before this change: a traversing `requiredWhen` refused every insert and every update that reached it, a traversing `readonlyWhen` refused every update that wrote its field (an insert is exempt), and an option gated through a reference was admitted whatever the related record said (option visibility is fail-open) — while `objectstack validate` passed a stack carrying all three, exit 0. ADR-0137 D2 made the runtime fail closed; the defect was that authoring did not say so first (NORTH-STAR priority rule 4). The same traversal inside a `validations[]` `script` rule is served, one hop deep, and stays accepted. ⚠️ No D2 conversion, and the reason is the judgment this entry delegates: moving a field predicate into a validation rule turns a condition into a FAILURE condition, moves an option from hidden to offered-then-refused, and the right `events` scope depends on what the author meant — none of it mechanical. Hydrating the field level instead is a capability of its own and is not done here. ADR-0087, ADR-0137." + }, + { + "surface": "field.reference_to — the legacy runtime spelling of a lookup or master_detail target, on object fields and object-extension fields", + "replacement": "`reference` — the one spelling the field schema has ever accepted, and the one the wire serves.", + "migrationId": "field-reference-to-spelling-retired", + "toMajor": 18, + "rationale": "The D2 conversion `field-reference-to-alias` renames `reference_to` to `reference` in author sources and on every stored-row rehydration, so the wire only ever carries `reference`; for a row with only the legacy spelling the rename is lossless. Two things are left. First, a row carrying BOTH spellings with DIFFERENT targets is left untouched, on purpose: the loader will not pick a target for the author, so that field keeps failing its parse until someone decides which object it points at. Second, code is out of reach: a plugin, script, custom renderer or external client that read `reference_to` off served field metadata worked only because a stored row happened to carry the legacy spelling, and it now reads nothing — the frontend fallback that tolerated the spelling is scheduled to go, after which a missed reader degrades a lookup to a picker with no target. The camelCase `referenceTo` is a different surface (resolved action params) and is not part of this family." + }, + { + "surface": "object field `scale` / `precision` declarations (`Field.number` and friends) — non-integer or negative values (`scale: 2.5`, `precision: -1`)", + "replacement": "a non-negative integer digit count, or no declaration at all. The mechanical conversion (`field-malformed-scale-precision-removed`) deletes a malformed value — behaviour-preserving, because the write-time `scale` enforcement deliberately skipped malformed declarations, so they enforced nothing — but only the author knows the count they MEANT (`scale: 2.5` was probably `2` or `3`): re-declare it deliberately if the constraint was wanted", + "migrationId": "field-scale-precision-integer-refused", + "toMajor": 18, + "rationale": "Both keys are digit COUNTS (\"Total digits\" / \"Decimal places\"), and `z.number()` admitted values with no defined meaning as a count. That looseness became load-bearing when `scale` was made enforced at write time (an over-scale write refused, never rounded): the runtime branch deliberately guards on `Number.isInteger(def.scale) && def.scale >= 0` — inventing floor/round semantics in a consumer would be PD #12 guessing — so a typo'd declaration (`scale: 2.5`) silently got no enforcement at all: exactly the declared-but-inert shape that hides AI-authored metadata errors. The schema now refuses non-integer and negative values for both keys at parse time (`z.number().int().min(0)`, ADR-0078 declared=enforced). `CurrencyConfigSchema.precision` (under `currencyConfig`) was a different surface with its own bounds and alias table — retired in this same protocol major by `currency-config-precision-removed`, not enforced here." + }, + { + "surface": "either endpoint of a $between range, authored BLANK — the empty string, or an absent (undefined) bound — in any filter this platform stores or executes. The carriers split in two, because they are DETECTED differently and only one half answers on save. (a) REFUSED AT SAVE — the enforced FieldOperatorsSchema / RangeOperatorSchema copy itself, reached by a caller that validates a filter against it directly, and the NormalizedFilter AST. (b) NOT JUDGED AT SAVE — every stored metadata carrier, and that is BOTH authoring dialects, not only the loose one. A view, page or component filter RULE (ViewFilterRuleSchema) admits it: the rule value accepts a string and the operator-shape check judges ARITY alone, so a two-element range with a blank element is a well-formed rule. A dashboard widget filter, a dashboard options-source filter, a dataset filter, a dataset measure filter, a report runtimeFilter, a rollup summaryOperations filter and a relatedListFilter are typed FilterConditionSchema, a loose record intersected with the $and / $or / $not shape and carrying one refinement. ⚠️ That refinement DOES judge an operator map, so the slot is not unjudged: a bare date-range PRESET name in an ordering position — a $gt / $gte / $lt / $lte value, or a $between endpoint — is refused at that position's own path, and it is refused through an otherwise GREEN document; the sibling entry filter-preset-ordering-comparand-refused is that rule, on this same carrier set. What these carriers never judge is a $between endpoint for BLANKNESS or for ARITY. Measured: a dashboard widget whose filter reads close_date $between 2026-01-01 and an empty string parses GREEN, as do a one-element, a three-element and an empty $between, while the same widget carrying a preset endpoint is refused at filter.close_date.$between.0. So for THIS shape every one of those documents parses GREEN and the endpoint is refused only when the filter is EXECUTED, at the engine comparand-shape door. ARITY is not what changed: a blank bound is a well-formed TWO-element range one of whose elements means nothing", + "replacement": "two endpoints that are present and non-empty — the bound the author meant, written out. If only ONE side is genuinely bounded, that is not a range at all: drop `$between` and write the side you have as a scalar comparison, `{\"$gte\": min}` for a lower bound and `{\"$lte\": max}` for an upper one, which every backend already answers. ⛔ There is no replacement that can be DERIVED from what was written: the bound the author did not type is not recoverable from the one they did, and picking either reading (drop the operator, or treat the blank side as unbounded) would be the platform inventing a filter. `null` bounds are a different entry: they were already refused by the 2026-08-31 ruling, whose message prescribes the null predicate because a `null` author was reaching for absence, not for a bound", + "migrationId": "filter-between-blank-endpoint-refused", + "toMajor": 18, + "rationale": "Maintainer ruling A of 2026-09-17: a blank `$between` endpoint is refused at the authoring door, and the refusal names the blank side. `FieldOperatorsSchema.safeParse({ $between: [1, ''] })` answered `success: true` — measured on the card against the installed spec 17.4.0 and re-measured on `origin/main` before the change. This is a NEW RULE narrowing a published face, ⛔ not a pull-back to a declared one: the endpoint contract shared by both bounds says verbatim that \"Each endpoint is a number, a Date, or a string\", and the empty string is a string, so the acceptance was conformant. What made it wrong is the other half of the same contract — \"Closed interval [min, max]\" — which no backend can honour against a blank: driver-sql binds it into `whereBetween`, the JS matchers compare it as a value, and the range stops bounding on that side while still reading as a complete range. The reference matcher had already been taught to survive the null-bound form of exactly this (a bounded range answered EVERY valued row, because both of the arm's comparisons are false against a missing bound); the door that admitted it was never addressed. The only producer ever measured is a UI builder padding a HALF-TYPED pair with `''` so that a length-based completeness check passes it — nobody WANTS a blank bound, which is why it is refused rather than given a published meaning (option B was declined: a semantics nobody asked for, to be honoured per driver). The refusal names the blank SIDE (MIN / MAX plus the index) because with a padded pair both bounds are present and the author is the one person who cannot see which is empty. Scope is the empty string and `undefined` and nothing wider: whitespace-only endpoints are deliberately NOT judged, since narrowing a published face further than the ruling is the seat call this card's whole history refuses to make. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). ⚠️ No D2 conversion and no stored-metadata rewrite, and the load path was MEASURED rather than assumed: `applyConversionsToStoredItem` — the one primitive every stored-row rehydration seam calls — never throws and never validates, and replays only the positively-recognised lossless transforms in the conversion registry; measured on `origin/main`, a stored view carrying `{ close_date: { $between: ['2026-01-01', ''] } }` comes back as the SAME object reference. So the load path today neither drops a refused operator nor refuses the row, and no conversion in the registry drops a filter OPERATOR (the three filter-adjacent entries are key strips and a key rename). That is also the precedent the two nearest narrowings of this same surface set — `filter-preset-ordering-comparand-refused` and `analytics-date-range-array-two-bounds-required` — both of which decline a D2 conversion on the ground that rewriting would be the platform guessing which bound was meant. Dropping the operator would be worse than guessing: it deletes a constraint the author wrote and WIDENS the result set silently, the failure mode `$nin` carries in the same file. The read path does not re-validate stored rows, so no stored view becomes unreadable — and, because every stored carrier is typed loosely or judged by arity alone (see `surface`), re-saving one is not refused either. What changes is the enforced operator schema itself, which answers at the endpoint's own path with the blank side named, and the engine comparand-shape door, which refuses an executed filter carrying one. The objectui half — the builder stops padding a half-typed pair, so the console never meets this refusal mid-typing — is a change to the console's own filter builder and lands on its own schedule, either side of this one. ADR-0049 / ADR-0078 / ADR-0087." + }, + { + "surface": "either endpoint of a $between range, authored as a { $field } column reference, in any filter this platform stores or executes. The carriers split in two, because they are DETECTED differently and only one half answers on save. (a) REFUSED AT SAVE — a view, page or component filter RULE (ViewFilterRuleSchema, whose value is shaped by the operator) and the NormalizedFilter AST the query faces validate against, plus the enforced FieldOperatorsSchema copy itself. (b) NOT JUDGED AT SAVE — a dashboard widget filter, a dataset filter, a report runtimeFilter, a rollup filter and a relatedListFilter: every one of those slots is typed FilterConditionSchema, a loose record intersected with the $and / $or / $not shape and carrying one refinement. ⚠️ That refinement DOES judge an operator map, so the slot is not unjudged: a bare date-range PRESET name in an ordering position — a $gt / $gte / $lt / $lte value, or a $between endpoint — is refused at that position's own path, and it is refused through an otherwise GREEN document; the sibling entry filter-preset-ordering-comparand-refused is that rule, on this same carrier set. What these carriers never judge is a $between endpoint for a COLUMN REFERENCE or for ARITY. Measured: a dashboard widget whose filter reads close_date $between { $field: \"contract.start\" } and 2026-12-31 parses GREEN, as do a one-element, a three-element and an empty $between, while the same widget carrying a preset endpoint is refused at filter.close_date.$between.0. So for THIS shape every one of those documents parses GREEN and the endpoint is refused only when the filter is EXECUTED, at the runtime lowering door this change closes. ARITY is not what changed: a reference endpoint is a well-formed TWO-element range one of whose elements no backend resolves", + "replacement": "a literal bound — the value the range was meant to stop at, written out. If the range was genuinely meant to be COLUMN-TO-COLUMN, that is not a $between at all: write the two bounds separately as scalar comparisons, {\"$gte\": {\"$field\": \"a\"}} for the lower bound and {\"$lte\": {\"$field\": \"b\"}} for the upper one, which is the position the column-to-column comparison compiles on every face. ⛔ There is no replacement that can be DERIVED from what was written: the literal a reference stood for is not recoverable, and dropping the operator would delete a constraint the author wrote and WIDEN the result set silently. A reference remains legal, unchanged, as the WHOLE comparand of $eq / $ne / $gt / $gte / $lt / $lte", + "migrationId": "filter-between-field-reference-endpoint-refused", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-08-11 on column-reference range endpoints, ADR-0049 enforce-or-remove: REMOVE. Both $between endpoint unions carried FieldReferenceSchema and no backend ever resolved one in a list position — matches-filter.ts leaves the list unresolved and orders against the raw reference OBJECT, so the range silently matches nothing, and both SQL faces refuse the position with INVALID_FILTER / 400. The published endpoint contract has stated the rule verbatim since that day: \"A { $field } reference is NOT an endpoint shape\" (RANGE_ENDPOINT_DESCRIPTION, packages/spec/src/data/filter.zod.ts). ⚠️ That ruling shipped at the AUTHORING SCHEMA door alone, and no ledger entry was written for it — measured before this change: no semantic entry, no retired key, no spec-changes row and no upgrade-guide line named the shape. That was not an omission, and this entry SUPERSEDES a recorded answer rather than filling a silence: the 2026-08-11 changeset carried the disposition not-required (no-migration-prescription), reviewed and accepted with that ruling and shipped in the published CHANGELOG. What changed is the fact that disposition rested on. It was claimed for a removal whose reach was believed to be the authoring schema alone; the runtime half now ships with a migration prescription of its own (below), and a body carrying a prescription is exactly what that category refuses. So the transition is registered here, covering BOTH doors, and the earlier not-required reading is retired by this record. The runtime lowering door disagreed with the declaration for the whole of that window: parseFilterAST({ f: { $between: [{ $field: \"a\" }, \"M\"] } }) returned the filter unchanged, same object reference, measured on origin/main immediately before the change and re-measured after. One published sentence, two truth values, decided by which door a caller came through — and the door that passed it is the one an embedder reaches by handing a lowered filter straight to a driver. This entry therefore registers the transition for BOTH doors, not only the second, which is why it is filed as an entry of its own rather than as an already-registered rider. ⚠️ No D2 conversion and no stored-metadata rewrite, and the load path was MEASURED rather than assumed: applyConversionsToStoredItem — the one primitive every stored-row rehydration seam calls — never throws and never validates, and replays only the positively-recognised lossless transforms in the conversion registry; measured on this branch, a stored view carrying { close_date: { $between: [{ $field: \"contract.start\" }, \"2026-12-31\" ] } } comes back as the SAME object reference. Rewriting is not available in principle here, not merely declined: the literal the author meant is not recoverable from a reference, and the column-to-column reading has a different OPERATOR SHAPE (two scalar bounds), so producing it would be the platform rewriting one filter into another. That is the same ground the two nearest narrowings of this surface set stand on — filter-between-blank-endpoint-refused and filter-preset-ordering-comparand-refused. The read path does not re-validate stored rows, so no stored view becomes unreadable; what changes is that RE-SAVING one is refused, at the endpoint's own path, with the side named. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). ADR-0049 / ADR-0087 / ADR-0112." + }, + { + "surface": "data.FilterCondition — a comparand the comparand-type face refuses, now refused when the document is PARSED: a plain object where a single value belongs (an $eq, $ne, ordering, text or flag comparand such as { a: 1 }, including a { $field } whose name is not a string), a Map, a class instance, a function, a Symbol, undefined, or a bigint beyond plus or minus 2^53, whether it is the comparand itself, an implicit-equality comparand or an $in / $nin / $between list member. On every schema that carries a FilterCondition, at the reach the save door already had (the field entries of a condition and of every $and / $or / $not member); and, on a dataset filter, a dataset measure filter and now a dashboard widget filter, a report runtimeFilter and a joined report block runtimeFilter, INSIDE a nested-relation condition as well, for these shapes and for every shape the earlier entry names — so ui.DashboardWidget.filter, ui.Report.runtimeFilter and ui.JoinedReportBlock.runtimeFilter gain the nested-relation reach of the two dataset carriers", + "replacement": "a value of one of the six accepted comparand types — a string, number, bigint within plus or minus 2^53, boolean, null or Date — or a { $field: \"column\" } reference where a column is meant. A value set belongs in $in; an absent value is the null predicate ($eq null / $ne null) or an omitted key, never undefined; a bigint beyond 2^53 is compared as a string or within range. Inside a nested relation on a widget filter or a report runtimeFilter, write the same spelling the top-level refusal prescribes. A Date, a { $field } reference, a {placeholder} string resolved at request time (such as {current_user_id} or {today}) and a bigint within 2^53 are untouched, and the save door keeps a bigint as written", + "migrationId": "filter-comparand-types-and-widget-nested-slots-refused-at-save", + "toMajor": 18, + "rationale": "The save door narrows to exactly what the query faces already refuse (the second stage of closing the family of comparand shapes the save door accepted and the query faces refused). The comparand-type face (normalizeFilterComparandTypes, the accepted set the maintainer ruled on 2026-08-12: string, number, bigint, boolean, null and Date) refuses these values on every query: parseFilterAST, the engine seam, the analytics where door and the read-scope compiler all run it. Measured on origin/main 17bd3187 before the change: FilterConditionSchema, a dataset filter, a dataset measure filter, a dashboard widget filter, a report runtimeFilter and a joined report block runtimeFilter each parsed GREEN for { stage: { $eq: { a: 1 } } }, { stage: { $in: [{ a: 1 }] } } and a Map comparand, top level and nested, while the type face and the analytics where door refused each with INVALID_FILTER / 400. And a dashboard widget filter, a report runtimeFilter and a joined report block runtimeFilter parsed GREEN for { acct: { stage: { $in: [\"won\", null] } } } and for a list in a nested equality slot, which the analytics where door refuses when they are charted, because only the two dataset carriers had the nested-relation walk. The save door now asks the type face itself, read-only, after the shape face, so it refuses exactly what the face refuses and passes what it passes; one slot raises one refusal, in the query doors' order (shape, then type, then the flag rule), in the face's own words less its location clause. At the top level of a filter and in its $and / $or / $not members, a second issue on a slot a face already refused (the schema door's own $icontains and date-preset arms) is no longer raised; inside a nested relation on an analytics carrier those two arms still judge the slot beside the faces, so a nested $icontains with a refused comparand, or a nested one-bound $between of a preset name, can carry two issues. Neither moves a verdict. The widget filter and both report runtimeFilters declare the same analytics-carrier filter as the dataset carriers, so their nested-relation slots are judged by the same walk: every stored filter the analytics where door charts now refuses on save what that door refuses on chart. Metadata AT REST is not rewritten and this entry adds no D2 conversion: none of these values has a single honest meaning as a comparand, which is why each was refused. The read path does not re-validate stored rows, so a stored document keeps loading; re-saving it through the metadata protocol (422 INVALID_METADATA), defineStack or os validate is refused at the filter's path. A JSON document can carry only the plain-object cells; the others arrive only from TypeScript authoring. Such a filter has failed every query since the type face's ruling, so the refusal is a repair and not a loss. ADR-0049 / ADR-0087 / ADR-0112." + }, + { + "surface": "data.FilterCondition — an ARRAY as an EQUALITY comparand, at the runtime filter doors (the shared comparand-shape face that parseFilterAST and the engine lowering seam both run): the implicit form { field: [...] } — which the FilterArray sugar [\"field\", \"equals\", [...]] lowers to, and likewise \"=\", \"==\" and \"eq\" — and the explicit form { field: { $eq: [...] } }, at any depth under $and / $or / $not, the empty array included", + "replacement": "the operator the list was standing in for. \"One of these values\" is $in: { field: { $in: [\"a\", \"b\"] } } (authoring spelling \"in\"). \"The stored multi-value field holds this value\" is $contains with ONE member: { field: { $contains: \"a\" } } (authoring spelling \"contains\"), and an $or of those for any-of. A filter that meant a single value writes that value: { field: \"a\" }. The list operators ($in / $nin / $between) keep their arrays, empty lists included; every scalar equality comparand, null above all (the has-no-value predicate), is untouched; and $ne is NOT judged by this entry", + "migrationId": "filter-equality-array-comparand-refused", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-09-23 (option 乙 — rather than declaring an array equality the SQL-family backends would have to invent, or documenting a divergence that stays silent on one backend): an array in the implicit-equality slot is refused at the shared face, for every driver at once — no alias, no grace window. The comparand-shape face declared that moving a rule to it 「closes that door for every driver at once」, and before this change it judged only the list-operator slot; the equality slot passed both shared doors and each backend answered it alone. Measured on the lowered node { tags: [\"a\"] } at this release, beside a scalar and an $in control. driver-sql on SQLite REFUSED it with INVALID_FILTER / 400 at the top level, and nested under $and / $or / $not answered 500 DATABASE_ERROR instead (driver-turso and driver-sqlite-wasm are built on driver-sql and were not run separately). driver-memory REFUSED it with INVALID_FILTER / 400 at every depth. The formula matcher returned no row, including a row storing exactly [\"a\"]. driver-mongodb ANSWERED it: its translateFilter emits the array unchanged, and MongoDB equality on an array operand selects a stored array equal to [\"a\"] or holding [\"a\"] as an element — mingo 7.2.4, the named proxy, over [\"a\"], \"a\", [\"a\",\"b\"], [\"b\",\"a\"], [[\"a\"],\"x\"], [[\"a\"]], \"b\" and [] selected [\"a\"], [[\"a\"],\"x\"] and [[\"a\"]]. The service-analytics filter normalizer read the FilterArray form as MEMBERSHIP: [\"stage\", \"=\", [\"won\", \"lost\"]] charted as stage IN (won, lost), and its OBJECT form read the same list four ways: { stage: [...] } as IN, $eq with a list as its first member alone, $eq with an empty list as no predicate, and an empty implicit list as the FALSE constant. A live mongod, MySQL, PostgreSQL and a live Turso server were NOT measured. So one stored filter was a 400 on most backends and a silent, differently-shaped row set on one. The shared face now refuses it with INVALID_FILTER / 400 before any driver runs, naming the field, the path and both remedies. Which doors refuse it at this release, and with what: the shared face, inside parseFilterAST and at the engine lowering seam, with INVALID_FILTER / 400; the analytics where door in BOTH spellings, the FilterArray form through parseFilterAST and the OBJECT form because that door hands each equality-slot list to the shared face before it builds a node, with the same INVALID_FILTER / 400 and the same sentence (that door alone, among the runtime doors, also refuses a list inside a nested-relation condition, which it flattens to a dotted member); and, on SAVE, the schema door (FilterConditionSchema and the $eq operator slot), with the same sentence as a parse issue at the filter's own path, which is the sibling entry filter-equality-array-comparand-refused-at-save, plus the two carriers that analytics door charts (a dataset filter and a measure filter) inside a nested relation too, which is dataset-filter-nested-relation-equality-array-refused-at-save. The ruling records the hosted product as running on the SQL family, where the top-level shape was already a 400, so the population that can observe a change is self-hosted driver-mongodb, plus any filter nested under a combinator on the SQL family (a 500 becomes a 400). $ne carrying an array measured the same split and is deliberately left to its own ruling. Metadata AT REST is not rewritten and this entry adds no D2 conversion: an array on equality has no single honest value, and choosing between $in and $contains is the author's call, not the platform's. ADR-0049 / ADR-0087 / ADR-0112." + }, + { + "surface": "data.FilterCondition and the $eq slot of data.FieldOperators — an ARRAY as an EQUALITY comparand, now refused when the document is PARSED: the implicit form { field: [...] } and the explicit form { field: { $eq: [...] } }, the empty array included, on every schema that carries a FilterCondition — a dataset filter and a dataset measure filter, a dashboard widget filter and an options-source filter, a report and joined-report-block runtimeFilter, a field relatedListFilter and a rollup summaryOperations filter, a solution-blueprint summary filter, an analytics query where, a dataset selection runtimeFilter, a query where and having, the data-engine aggregate call's having, an aggregation filter and a query-filter where — plus FieldOperatorsSchema.$eq, its documentation copy EqualityOperatorSchema.$eq, and the NormalizedFilter AST that validates against it", + "replacement": "the operator the list was standing in for, exactly as in filter-equality-array-comparand-refused. \"One of these values\" is $in: { field: { $in: [\"a\", \"b\"] } } (authoring spelling \"in\"). \"The stored multi-value field holds this value\" is $contains with ONE member: { field: { $contains: \"a\" } } (authoring spelling \"contains\"), and an $or of those for any-of. A filter that meant a single value writes that value: { field: \"a\" }. The list operators ($in / $nin / $between) keep their arrays, empty lists included; every scalar equality comparand, null above all, a Date and a { $field } reference are untouched; and $ne is NOT judged by this entry", + "migrationId": "filter-equality-array-comparand-refused-at-save", + "toMajor": 18, + "rationale": "Ruled on 2026-09-24 (option A), applying the standing refusal of an array in the equality slot to the schema door: FilterConditionSchema (implicit equality) and FieldOperatorsSchema.$eq refuse an array comparand at parse, with the SAME remedy text the shared compile face emits — one constant, two doors; a stored filter carrying the shape is refused loudly on its next save, and never silently dropped, because a dropped filter shows MORE rows than intended. Measured on origin/main a0920b42dc before the change: a dataset whose filter was { stage: [\"won\", \"lost\"] }, and one whose measure filter was { stage: { $eq: [\"won\", \"lost\"] } }, both parsed GREEN, as did FilterConditionSchema and FieldOperatorsSchema on the bare shapes, while the shared comparand-shape face refused both with INVALID_FILTER / 400. So such a document published clean and then failed every query that used it, for a different person, later. The schema door now prints the face's own sentence, from one builder both doors import; the only difference is that the face appends the location (at where.stage) and the schema door does not, because its issue carries the location as its path (filter.stage, measures.0.filter.stage.$eq). The reach is the face's and no wider: the field entries of a condition and of every $and / $or / $not member, but NOT a field spec with no $ key (a nested-relation or deep-equality condition), which the face never descends either. ⚠️ Three positions therefore still refuse only at execution. (1) A list inside a nested-relation condition, { account: { region: [\"a\"] } }: the analytics where door flattens that to the dotted member account.region and refuses it when the filter is charted; a dataset filter and a measure filter refuse it on save as well, which is the sibling entry dataset-filter-nested-relation-equality-array-refused-at-save. (2) The where option of the data-engine calls (find, count, update, delete, aggregate, vector find): its type is a union whose first arm is an open record, so it parses and the face refuses it when the call runs. (3) $ne carrying a list, which no ruling has decided. Two request doors parse these carriers and now answer the shape before the analytics compiler does: the REST dataset selection (its runtimeFilter) and the analytics query body (its where) refuse with VALIDATION_FAILED / 400 and this sentence at the field, one step ahead of the compiler's INVALID_FILTER / 400. Metadata AT REST is not rewritten and this entry adds no D2 conversion, for the reason the runtime entry gives: an array on equality has no single honest value. The read path does not re-validate stored rows, so a stored document keeps loading; what changes is that re-saving it through the metadata protocol (422 INVALID_METADATA), defineStack or os validate is refused at the filter's path. Such a filter has failed every query since the runtime entry, and on the SQL family before it at the top level, so the refusal is a repair and not a loss. ADR-0049 / ADR-0087 / ADR-0112." + }, + { + "surface": "the case-insensitive contains comparand, in BOTH authoring vocabularies — the $ dialect key $icontains inside FilterConditionSchema (query where clauses, read-scope rules, dashboard and analytics filters) and the infix spelling icontains on ViewFilterRuleSchema (view, tab, page and block filters) — where the comparand is the EMPTY STRING or is not a string at all", + "replacement": "a NON-EMPTY STRING, or no condition at all. A comparand that was empty is a predicate that constrains nothing, so the repair is to DROP the condition rather than to write something in it. A comparand that was a number, boolean or null is written as the string it was meant to match: value 42 becomes value \"42\" only if a substring match on the two characters is really what was meant, and if it is not, the operator was the wrong one. On a view rule an OMITTED value is untouched — absence is not a comparand and this rule says nothing about it", + "migrationId": "filter-icontains-comparand-refused-at-parse", + "toMajor": 18, + "rationale": "The protocol half of the maintainer's 2026-09-20 ruling (option C-prime) on the console's filter converter, whose first rule reads, verbatim and untranslated: 「the differences are the protocol's to close」. The platform already DECLARED both refusals, as data, in this package: FILTER_TEXT_CASES carries a REJECTION row for an empty comparand and one for a non-string comparand, each with code INVALID_FILTER and each requiring the refusal to name the operator. All five driver packages run both rows in their own suites, and the drivers re-run for this change (driver-sql on SQLite, driver-memory, driver-mongodb's translateFilter) each refuse both comparands with INVALID_FILTER / 400; the formula matcher does not refuse them, it answers false for every row. Nothing applied them at PARSE on either vocabulary, so the protocol declared the refusal and then admitted the document that would hit it — the declared-not-enforced shape ADR-0049 exists to close. The narrowing is DERIVED from the table, not transcribed beside it: both doors call the published predicate isRefusedTextComparand and the published reason text textComparandRefusalReason, the pair published in this package beside FILTER_TEXT_CASES for exactly this reason, so a row added to the table reaches both doors without an edit at either. $contains, $startsWith, $endsWith, $like and $ilike keep the answer they give today, because widening by analogy is the table's decision and not a door's. The two vocabularies differ on one point and it is a fact about them rather than an extra rule: a view rule's value key is OPTIONAL, so an absent comparand is left unjudged there; the $ dialect has no absent, so an explicit undefined in a comparand slot is the refused non-string shape — the same reading the comparand-type door already takes of that cell. Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion. An empty comparand has no lossless replacement (dropping a condition changes which rows a view returns, which is the author's decision) and a non-string one has no honest coercion (the platform refuses to answer a query nobody wrote). The read path does not re-validate stored rows, so a stored filter keeps loading; what changes is that RE-SAVING it is refused, with the reason text three shipped consumer faces already show at query time. ADR-0049 / ADR-0087 / ADR-0112." + }, + { + "surface": "data.FilterCondition — the lowering of the view operators is_empty, isempty, is_not_empty and isnotempty (on a ViewFilterRule, a sharing rule and any filter array), and a $empty object written as a record field value", + "replacement": "Nothing to rewrite for a rule on a declared field: is_empty now lowers to { field: { $empty: true } } and is_not_empty to { field: { $empty: false } }, answered by the field's declared type — a text-like field is empty when it is null or the empty string, a multi-value field when it is null or the empty list, every other type only when it is null. Where no face holds the column's declared type the rule is refused: on the built-in id, write is_null / is_not_null; on a federated object whose driver does not implement external-object registration (driver-memory, driver-mongodb), bind it on a driver that implements federation (the boot error names the object); on an AnalyticsService built without sourceFieldMeta, pass sourceFieldMeta or write is_null / is_not_null; on a multi-value column over a SQL dialect driver-sql does not model, write is_null / is_not_null. A record write that carries a $empty object as a field value writes the value itself instead; a filter belongs in where", + "migrationId": "filter-is-empty-lowers-to-empty-operator", + "toMajor": 18, + "rationale": "One ruling set what 「is empty」 means once, per field type; a second spelled it as the $empty operator, which each compile face expands from the field's declaration. It was staged out of FILTER_OPERATORS until every face answered it, then added in the same change that flipped the lowering, after measuring that no face drops it. Two consequences reach stored metadata. A stored 「is empty」 on a text or multi-value field finds more rows: the ones holding the empty string or the empty list, which the $null lowering missed. And the rule is refused where the face that answers it holds no declaration for the column — the four compositions the replacement names — where the $null lowering compiled IS NULL. The same change made the write door refuse a $empty object as a field value, because that door refuses every filter operator the protocol enforces as a value: before, a text-like field stored it. Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: the stored spelling is unchanged, and its new meaning is the ruled one. ADR-0087 / ADR-0112." + }, + { + "surface": "data.FilterCondition and the $ne slot of data.FieldOperators — an ARRAY as the comparand of $ne. At the runtime filter doors (the shared comparand-shape face that parseFilterAST and the engine lowering seam both run): { field: { $ne: [...] } }, which the FilterArray sugar [\"field\", \"ne\", [...]] lowers to, and likewise \"!=\", \"<>\", \"neq\", \"not_equals\" and \"notequals\", at any depth under $and / $or / $not, the empty array included. At parse: FieldOperatorsSchema.$ne, its documentation copy EqualityOperatorSchema.$ne, and the NormalizedFilter AST that validates against it", + "replacement": "the declared list-negation operator. \"None of these values\" is $nin: { field: { $nin: [\"a\", \"b\"] } } (authoring spellings \"nin\", \"not_in\", \"notin\"). A filter that meant a single value writes that value: { field: { $ne: \"a\" } }. $ne: null (the has-a-value predicate), every scalar, a Date and a { $field } reference are untouched, and the list operators ($in / $nin / $between) keep their arrays, empty lists included", + "migrationId": "filter-ne-array-comparand-refused", + "toMajor": 18, + "rationale": "Ruled on 2026-09-24 by the director seat, on the standing contract text (option A): the shared comparand-shape face refuses an array under $ne for every driver, and FieldOperatorsSchema.$ne refuses it at parse, with one remedy text naming the declared list-negation operator by its spec spelling — no alias, no window. The governing text is $ne's own published describe: the comparand is a literal, or a { $field } reference to another column of the same table. An array is neither, so the refusal pulls the doors back to what $ne already declared. Measured on the card before any stage landed, on the lowered { tags: { $ne: [\"a\"] } }: driver-sql and driver-memory REFUSED it with 400; driver-mongodb ANSWERED it as MongoDB reads $ne against an array operand, not equal to that array and not holding it as an element, which is every scalar row (mingo, the named proxy; a live mongod was NOT measured); and the formula evaluator matched EVERY row, which on the row-level write check admitted every write a != policy against a list was written to refuse. Those two answering faces were closed first, each at its own face, under rls-predicate-array-comparand-refused and cel-predicate-list-comparand-refused. Measured on origin/main 9e7824a4, after both and before this change: the shared face passed the shape at every depth (so did its FilterArray lowering, and the engine's delegating wrapper), and FieldOperatorsSchema, EqualityOperatorSchema and the NormalizedFilter AST all parsed it GREEN. Now the face refuses it with INVALID_FILTER / 400 before any driver runs, and the operator slot refuses it on parse, with one sentence from one builder: the face names the field and appends the location (at where.tags.$ne); the slot cannot see either, and its issue carries the location as its path. On the SQL family and driver-memory the verdict does not move (400 before, 400 after); the text and the moment move, to the face, before any driver. ⚠️ Not moved by this entry: FilterConditionSchema, the schema every stored filter carrier parses through (dataset, dashboard widget, report, rollup and the rest), does not parse a field's operator map through FieldOperatorsSchema and its own walk does not judge $ne, so such a carrier still SAVES a $ne list and the face refuses it at query time; the ruling names the face and the operator slot, not that walk. Metadata AT REST is not rewritten and this entry adds no D2 conversion: a list under $ne has no single honest value, and whether it meant none of these values or one value is the author's call. ADR-0049 / ADR-0087 / ADR-0112." + }, + { + "surface": "a dashboard date-range preset name (last_7_days / last_30_days / last_90_days, today, yesterday, this_week, last_week, this_month, last_month, this_quarter, last_quarter, this_year, last_year) authored as a bare ORDERING comparand in a filter — a $gt / $gte / $lt / $lte value or a $between endpoint, a greater_than / less_than / before / after / between view filter rule value, or an ordering [field, op, value] filter triple. WHICH DOOR refuses it at publish is decided by the carrier's declared type and by its key. The carriers measured fall in groups, and the groups are a list of what was measured, not a closed partition: the grep in the acceptance criteria is the catch-all. (1) A slot typed FilterConditionSchema — a dashboard widget filter, a dashboard global-filter options-source filter (optionsFrom.filter), a dataset filter, a dataset measure filter, a report runtimeFilter (on the report or on a joined-report block), a rollup summaryOperations.filter and a relatedListFilter — is refused at PARSE, at the comparand's own path, and the @objectstack/lint filter-preset-comparand rule reports it as well. (2) A filter under a key the lint walks, whose declared type carries no preset check, parses GREEN, and the lint rule is the only door that refuses it: a ViewFilterRuleSchema rule array (a view's filter, a page element's dataSource.filter, a page component's filter prop), and a Mongo-shape filter record typed as a loose record rather than FilterConditionSchema (a flow CRUD node's config.filter). The lint is likewise what refuses a preset in an ordering filter triple wherever its walk meets one", + "replacement": "the date-macro window the preset already means — { $gte: \"{30_days_ago}\" } for last_30_days, { $between: [\"{week_start}\", \"{week_end}\"] } for this_week, and so on (the rejection names the exact window per preset; DATE_RANGE_PRESET_MACRO_WINDOWS in @objectstack/spec/data is the table) — or an ISO date such as 2026-01-15. The preset names themselves stay fully legal where a layer resolves them to a window: the dashboard date-filter positions (dateRange.defaultRange, a date global filter defaultValue) and an analytics query's timeDimensions[].dateRange. A filter comparand is not one of those positions", + "migrationId": "filter-preset-ordering-comparand-refused", + "toMajor": 18, + "rationale": "The authoring half (option C) of the maintainer's 2026-08-15 ruling on uninterpretable temporal comparands, ruled alongside the engine door (option B) that refuses them at query time. The preset vocabulary is declared in the dashboard schema and lowered to {date-macro} bounds by the shipped console before any query is sent — so the names were declared in one layer and unrecognised in the next, with no error at the boundary. Authored as a bare comparand (a saved report, an integration, an MCP client, an AI-authored query), the name reached the driver as written and compared false against every row: HTTP 200, count 0, indistinguishable from \"there is no data\" (measured on the defect report: $gte \"last_30_days\" returned 0 of 51 seeded rows where the macro spelling returned the 38 in-window). The engine now refuses the bare name on a declared temporal field at query time (INVALID_FILTER / 400); this entry records the AUTHORING-time half, and that half is two doors with different reach, not one: the FilterConditionSchema parse refuses the shape on the slots typed that way, and the @objectstack/lint filter-preset-comparand rule refuses it on every filter its walk reaches, which makes it the only door for a walked filter whose declared type carries no preset check. Both answer at publish, where the author — an AI author in particular — can still act on the message; the surface's groups say which measured carrier sits under which door. Ordering positions only at the schema door, deliberately: it judges no equality or membership, because a select/picklist column legitimately stores values that collide with preset names and a schema has no field type in hand. The lint rule, which reads the stack's object metadata, additionally refuses a preset in an equality or membership position, in a filter its walk reaches, on a field it can resolve to a declared date or datetime (where the filter binds to no object, or the field resolves to nothing, that arm cannot fire), and on a temporal field the engine door already refuses those with the field type in hand. ⚠️ Metadata AT REST is deliberately not rewritten and there is no D2 conversion: this shape was never written by any first-party producer (every preset in this repo and the example apps sits in a dashboard date-filter position — measured) and never executed usefully (it returned a silent zero before the engine door and a 400 after). Coercing it at load would be the platform guessing which bound the author meant. The read path does not re-validate stored rows, so no stored dashboard becomes unreadable; what changes is that RE-SAVING one is refused with the window named. ADR-0049 / ADR-0078 / ADR-0112." + }, + { + "surface": "data.FilterCondition — every comparand slot the query faces refuse, now refused when the document is PARSED: a $null or $exists flag that is not a boolean (a string such as \"false\", null, a number); a null $gt / $gte / $lt / $lte comparand; an $in or $nin comparand that is not a list, or a list holding null; a $between comparand that is not a two-element list, or whose endpoint is null, blank or a { $field } reference; and an array under $ne. On every schema that carries a FilterCondition: a dataset filter and a dataset measure filter, a dashboard widget filter and an options-source filter, a report and joined-report-block runtimeFilter, a field relatedListFilter and a rollup summaryOperations filter, a solution-blueprint summary filter, an analytics query where, a dataset selection runtimeFilter, a query where and having, the data-engine aggregate call's having, an aggregation filter and a query-filter where; and, on a dataset filter and a dataset measure filter only, the same slots INSIDE a nested-relation condition", + "replacement": "the spelling the refusal prescribes, which is the one the query faces already prescribe. A flag is the boolean itself: $null true is \"has no value\", $null false is \"has a value\", and $exists is the inverse. Absence is the null predicate, never null in an ordering or list position: $eq null is \"has no value\", $ne null is \"has a value\", and \"one of these values OR has no value\" is an $or of an $in and a $null true. A single value for $in is a one-member list, or plain equality. A range is two bounds in a two-element list; a range bounded on one side is a $gte or a $lte; a column-to-column range is a $gte and a $lte whose comparands are { $field } references. \"None of these values\" is $nin, never $ne with a list. The null predicate itself, a { $field } reference as a whole comparand, an empty $in or $nin list and a whitespace endpoint are untouched", + "migrationId": "filter-query-face-comparands-refused-at-save", + "toMajor": 18, + "rationale": "The save door narrows to exactly what the query faces already refuse (the family of comparand shapes the save door accepted and the query faces refused; the $ne member is route A, the same reach and the same one sentence as the equality slot of filter-equality-array-comparand-refused-at-save). The shared comparand-shape face refuses on every query a null ordering comparand (ruled 2026-09-01), a non-list $in / $nin and a malformed $between range, a null list member or endpoint (ruled 2026-08-31), a blank endpoint (ruled 2026-09-20), a { $field } endpoint (ruled 2026-08-11) and an array under $ne (ruled 2026-09-24); every query face refuses a non-boolean $null / $exists flag, because the backends read one in opposite directions. Measured on origin/main af32cf9a before the change: a dataset filter, a dataset measure filter, a dashboard widget filter and a report runtimeFilter each parsed GREEN for one instance of every shape the surface names, while the face refused each one with INVALID_FILTER / 400 and the analytics where door refused every one of them, the flags included. So such a document published clean and then failed every chart built on it. The save door now asks the face itself about each slot, so it refuses exactly what the face refuses and passes what the face passes; the words are the face's, or the sentence the enforced operator slot already prints for the same comparand, never the face's location clause, which the issue's path carries instead. The reach is the face's and no wider: the field entries of a condition and of every $and / $or / $not member, and NOT a field spec with no $ key (a nested-relation condition), which neither the face nor the drivers' flag checks descend. The analytics where door DOES descend one (it flattens the relation to dotted members and judges each), so the two dataset carriers, whose own nested-relation walk already refused an equality list there (dataset-filter-nested-relation-equality-array-refused-at-save), now ask the same judge about every slot inside a relation. ⚠️ So one position still refuses only at execution: a refused shape INSIDE a nested-relation condition on a dashboard widget filter or a report runtimeFilter, which reach the analytics where door too but carry the shared schema's reach only. Metadata AT REST is not rewritten and this entry adds no D2 conversion: none of these shapes has a single honest meaning (that is why each was refused), and a conversion would have to pick one. The read path does not re-validate stored rows, so a stored document keeps loading; re-saving it through the metadata protocol (422 INVALID_METADATA), defineStack or os validate is refused at the filter's path. Such a filter has failed every query since the runtime refusal of its shape, so the refusal is a repair and not a loss. ADR-0049 / ADR-0087 / ADR-0112." + }, + { + "surface": "a STORED filter body the engine executes, where a text operator names a field whose declared type can never store a string. Measured carriers: `sys_saved_report.query_json.filter` (executed verbatim as `engine.find(object, { where: q.filter })`, and reached again by every `sys_report_schedule` row through its `report_id`), `FieldSchema.summaryOperations[].filter` (ANDed with the parent-FK match and handed to `engine.aggregate`), `ListView.filter` and tab filters (`ViewFilterRuleSchema`, whose `contains` / `not_contains` / `icontains` / `starts_with` / `ends_with` spellings lower to the same operators through `AST_OPERATOR_MAP`), and the `FilterConditionSchema` carriers on dashboards (widget `filter`, `GlobalFilter`), datasets and reports (`runtimeFilter`), plus `FieldSchema.relatedListFilter`. NOT this surface: an RLS / sharing / tenant predicate, which the platform composes onto the AST AFTER this door and which the door therefore never judges.", + "replacement": "compare the field with an operator its declared type can answer — `$eq` / `$ne` / `$in`, or a range (`$gte` / `$lt`) for a temporal or numeric field — or aim the text operator at a text-valued field instead. A dotted path into a structured-JSON field (`address.city`) stays legal and is deliberately unjudged. NO rewrite is mechanical: the author's intent is not recoverable from the stored condition — `{ amount: { $contains: '5' } }` may have meant `$eq: 5`, a range, or a filter on a different column altogether — so the loader must not choose one.", + "migrationId": "filter-text-operator-declared-type-refused", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-09-05 (option C-deny: refuse now, over the type sets the contract already declares, minting no new vocabulary), landed at the engine seam. A text operator (`$contains` / `$notContains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike`) over a field whose DECLARED type can never store a string — `NUMERIC_VALUE_TYPES` ∪ `BOOLEAN_VALUE_TYPES` ∪ `CALENDAR_DATE_TYPES` ∪ `INSTANT_TYPES` ∪ `CLOCK_TIME_TYPES` ∪ `STRUCTURED_JSON_TYPES` — is refused at the engine's field-aware door with `INVALID_FILTER` 400 instead of reaching a driver. It is a RUNTIME narrowing over an AUTHORED surface, which is why it is registered here rather than disposed of as needing no prescription: NO schema changed, so a stored filter carrying the refused shape still parses and still loads — `FilterConditionSchema` constrains no field type, and `ViewFilterRuleSchema` takes `field: z.string()` with `contains` in its operator enum — and the first sign of it is a 400 on the read that executes it. Before the door those reads answered `[]` (or every row for `$notContains`, or a SQLite coercion accident) with no diagnostic, which is the silent cell the ruling closed. `objectstack migrate meta` cannot repair the stored bodies for the reason `replacement` records, so this is a structured TODO rather than a graduated conversion." + }, + { + "surface": "an approval flow node whose config the approval node contract (ApprovalNodeConfigSchema) refuses — a key it does not declare (escalation.bogusKey, a top-level key such as steps or onApprove, an alias such as escalation.timeout), a value it refuses (escalation.timeoutHours below 1, an unknown behavior or escalation.action, an empty approvers list, a fallbackApprovers list under any policy but fallback), or a key it requires left out (approvers; escalation.timeoutHours inside an escalation block). Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved from the Studio flow designer, and a flow row already sitting in sys_metadata", + "replacement": "the shape the approval contract declares, written on the node's `config`: `approvers` with at least one approver, and inside an `escalation` block a `timeoutHours` of at least 1 (wall-clock hours; `timeoutHours: 1` is the shortest SLA the contract accepts). An undeclared key is renamed to the key the refusal's did-you-mean names (`timeout` → `timeoutHours`, `mode` → `behavior`, `quorum` → `minApprovals`) or deleted; a process-level key (`steps`, `entryCriteria`, `onApprove`, `onReject`, `rejectionBehavior`) moves onto the flow graph as the refusal's guidance says. To turn an SLA off, delete the whole `escalation` block — an `escalation: { enabled: false }` with no `timeoutHours` is refused like any block missing it", + "migrationId": "flow-approval-node-config-contract-refused", + "toMajor": 18, + "rationale": "An approval node's executor (`plugin-approvals`) parses `node.config` against `ApprovalNodeConfigSchema` before it does anything else and fails the node on ANY issue. Registration already refused an undeclared key, against the descriptor's published `configSchema`, but a refused value (`timeoutHours: 0.5`) registered and then failed every run that reached the node — the config is metadata, and no rerun could succeed. The build doors asked about neither: `FlowSchema.parse` judged only the builtin node types' executor contracts, and only for a key left out, so `objectstack validate` and `objectstack compile` exited 0 on an `escalation.bogusKey` or a `timeoutHours: 0.5` and compile copied it into the artifact. The contract is the spec's own, so the build can judge it with no plugin loaded: the approval node joins a declared contract map beside the builtin executor contracts, read by the one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share (`flowNodeConfigRefusals`), and is judged WHOLE — every issue the contract raises is refused, because the executor refuses on every one. An undeclared key or a refused value is `node-config-refused-by-contract`, anchored at the key, in the contract's own sentence (its did-you-mean included); a key left out keeps `node-config-key-missing` or `node-config-key-required-by-rule`. The builtin arm is unchanged and stays presence-only. A plugin node type whose contract the spec does not declare stays outside the build doors, as before. ⚠️ No D2 conversion: the platform cannot know the approvers, the key or the value the author meant, and no value it could write would keep what the flow did. ⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0019." + }, + { + "surface": "a get_record, create_record, update_record, delete_record, notify, http, screen, map, loop or parallel flow node whose config carries a key its executor contract does not declare — a typo (titl), a key the walk at registration already named (fieldValues on a write node, bulk on update_record, visibleIf on a screen field), a key copied from another node type (outputVariable on an http node, flowName on a loop), or a key nothing reads (bogusKey) — at the config itself, or on a screen field or one of its options, a body-less legacy loop included. Never a key inside a free-form map (a filter, fields, headers, defaults, input, payload or templateData key is author data), never a key on a region object (a loop body, a parallel branch) or on its nodes and edges (the region check at registration owns those), and never a try_catch key, which try-catch-and-retry-policy-undeclared-keys-refused covers once the retry policy closed. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved from the Studio flow designer, and a flow row already sitting in sys_metadata", + "replacement": "the key the contract declares, or no key: rename a typo to the declared key it meant (the refusal carries the contract's did-you-mean for a near miss), follow the contract's own prescription for a known slip (`fieldValues` → `fields`, `bulk` / `all` / `multiple` → `multi: true`, `options: { multi }` → a top-level `multi`, a screen field's `visibleIf` → `visibleWhen`, a loop's `itemVariable` → `iteratorVariable`), and delete a key nothing reads (an `http` node's `outputVariable` among them: the http executor binds no output variable)", + "migrationId": "flow-builtin-node-config-undeclared-keys-refused", + "toMajor": 18, + "rationale": "Each of these executors (`service-automation` `builtin/crud-nodes.ts`, `notify-node.ts`, `http-nodes.ts`, `screen-nodes.ts`, `map-node.ts`, `loop-node.ts`, `parallel-node.ts`) parses the node's `config` against a strict contract before it acts. Until now the build doors' executor-contract arm held key membership back on these types, on the premise that registration judges it: `registerFlow`'s undeclared-key walk (`validateNodeConfigKeys`) refuses such a key against the node type descriptor's `configSchema`. So a `notify` node carrying `bogusKey` passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile` (which copied it into the artifact), and then registration refused the whole flow: at boot it was skipped with a warn, and a flow saved from Studio was stored and then silently not registered. The one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first), `objectstack validate` and the metadata save door share (`flowNodeConfigRefusals`) now refuses such a key on these types as `node-config-refused-by-contract`, anchored at the key, one refusal per key, in the contract's own words and closed with the rename-or-remove remedy, and the descriptor walk stands aside for every type that judge covers (`builtinNodeConfigKeysJudged`), so each type has one judge. Measured before the move: on each of these types the descriptor's declared key sets, at every position the walk descends to, equal the keys the contract accepts there, so registration refuses exactly what it refused before. ⚠️ `try_catch` is the one builtin not moved here: its contract's `retry` was the shared `RetryPolicySchema`, which stripped an unknown key, while its descriptor closes `retry` to five keys. It moves in `try-catch-and-retry-policy-undeclared-keys-refused`, once that schema closed. ⚠️ A body-less legacy `loop` is not parsed at run time, and it is judged here on key membership alone, which is what registration refused there already. ⚠️ A spelling an ADR-0087 D2 conversion still rewrites at load (`object` and `filters` on a CRUD node, `to` / `subject` / `body` / `url` on a `notify`, `flow` on a `map`) is converted before the judge at every door that converts first; met by a direct `FlowSchema.parse` or `defineFlow()` it is refused like any other undeclared key. ⚠️ No D2 conversion: the platform cannot know what an undeclared key was meant to be. ⚠️ Where such a node already sits the whole flow is refused, as registration already refused it: from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load; a save from Studio answers 422 naming the key. ADR-0087, ADR-0031." + }, + { + "surface": "a builtin flow node (get_record, create_record, update_record, delete_record, notify, http, screen, script, subflow, map, loop, parallel, try_catch) whose config carries a value its executor contract refuses — a value of the wrong type (create_record outputVariable 42, a screen field min written as the string 1, get_record limit as a string, update_record multi as a string), a value outside the declared set or range (notify severity loud, screen mode view, loop maxIterations 0, try_catch retry.maxRetries above 10), an empty script function or subflow flowName, or a rule finding on present keys (a notify template beside an inline title). Never a value carrying a token in braces, an undeclared or retired key, a region slot, or an http signingSecret. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved from the Studio flow designer, and a flow row already sitting in sys_metadata", + "replacement": "the value the contract declares, written at the key the refusal names: a string where it wants a string (`outputVariable: 'taskId'`), a number where it wants a number (`min: 1`, `limit: 10`, `maxIterations: 5`, `timeoutMs: 5000`), a boolean where it wants a boolean (`multi: true`, `durable: true`), one of the declared values (`severity: 'warning'`, `mode: 'edit'`), or a value inside the declared range. Outside `http`, a number or boolean slot takes a LITERAL only: those executors parse the config as authored, so a `{token}` template there (`limit: '{page.size}'`, `maxIterations: '{cap}'`) passes the build doors and still fails every run. Only `http` interpolates its config before it parses, so only an `http` slot may also take a sole-token template that resolves to the declared type (`timeoutMs: '{timeout}'`, `durable: '{durable}'`). For a rule finding, follow the rule's own sentence (keep `template` or the inline `title` / `message`, not both)", + "migrationId": "flow-builtin-node-config-values-refused", + "toMajor": 18, + "rationale": "Every builtin executor (`service-automation` `builtin/`) parses its node's `config` against the contract `getBuiltinNodeConfigContracts()` names before it acts, and refuses the node on any finding. The build doors judged only the keys that contract requires, left out, so a present value it refuses — `create_record` `outputVariable: 42`, a screen field `min: '1'` (the shape the Studio designer used to store for a field's Min / Max) — passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile`, registered, and then failed every run that reached the node: the config is metadata, and no rerun could succeed. The one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share (`flowNodeConfigRefusals`) now refuses such a value as `node-config-refused-by-contract`, anchored at the key, in the contract's own words — the code the approval contract already uses. It judges only what the build can know the run will parse, and holds one more class back by ruling: a value carrying a `{token}` is never refused at the build doors for its pre-interpolation type — which is no promise it runs, since every builtin but `http` parses its config as authored and so still refuses a token in a number or boolean slot at its first run; `http` parses after interpolating its whole config, so only token-free values are judged there and never `signingSecret`, which the credential channel may supply; a `loop` with no `body` is not parsed by its executor and is judged for nothing; the region slots of `loop`, `parallel` and `try_catch` are judged as graphs of their own and by `validateControlFlow`. An undeclared or retired key, a `predicate` ledger slot (a screen field `visibleWhen`) and a `value` ledger slot (a CRUD `fields` value) keep the judges they had. ⚠️ No D2 conversion: the platform cannot know the value the author meant. ⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0031." + }, + { + "surface": "a decision node branch — an element of config.conditions[] — written without its expression key, or with expression: null, at any depth including an ADR-0031 region body. That includes a branch whose predicate sits under another key (condition is the edge spelling). Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate, a flow saved from the Studio flow designer with a branch row whose expression cell is empty, and a flow row already sitting in sys_metadata", + "replacement": "the predicate the branch was meant to test, as non-blank bare CEL text under `expression` (`{ label: 'high', expression: 'record.amount > 10000' }`); a predicate written under `condition` moves to `expression`. To keep the branch and its label but never take it, write `expression: 'false'` — that is a CHANGE of behaviour, not a preserved one: a run that reached the branch used to fail there (`condition evaluation error`), and now routes on to the next branch or the declared fallback. ⚠️ Not by dropping a decision's only branch: with no `conditions` the node routes by its out-edges alone, so the out-edge that branch labelled is no longer held back", + "migrationId": "flow-decision-branch-expression-absent-refused", + "toMajor": 18, + "rationale": "`DecisionConditionSchema` declares a branch `{ label, expression }` with `expression` a required `z.string()`, but nothing parses a decision node's open config against it, and the expression-ledger resolver skipped an absent value as \"not authored\" — so a branch with no predicate passed `FlowSchema.parse`, `AutomationEngine.registerFlow` and `objectstack validate`, and the decision executor then handed `evaluateCondition` an envelope with no `source`, which it refuses: the build accepted what the run refused. The ledger now marks the slot `required` (reconciled against that schema's own `required` list), the resolver emits the absent value there, and all three doors refuse it through `predicateSlotRefusal`, leading with `PREDICATE_SLOT_STRING_REFUSAL` — the walk, function and sentence that already refuse the blank string. ⚠️ No D2 conversion: the platform cannot know the rule the author left out, and `'false'` would change what the flow does rather than keep it. ⚠️ Where such a branch already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0032." + }, + { + "surface": "flow.nodes[].config.mode (decision) — an OMITTED mode on a decision that branches on its out-edges and carries two or more conditioned ones", + "replacement": "nothing, where the out-edge conditions partition (exactly one can hold for any record): an omitted `mode` now means exclusive, the first true edge in declaration order wins, and the run is what it always was. `mode: 'inclusive'` where the flow RELIES on more than one branch running for one record — the value the D2 conversion `flow-decision-mode-inclusive-explicit` writes onto every such decision so nothing changes silently. Where the conditions overlap by accident (a `!=` guard beside a later `==` branch), neither: narrow them into a partition, or mark the fallback `isDefault: true`, and delete the written key.", + "migrationId": "flow-decision-edge-branching-first-match", + "toMajor": 18, + "rationale": "A DEFAULT FLIP of a shipped node type, ruled rather than patched: the schema, the docs and the engine's own comment all called an edge-branched decision an exclusive gateway while the traversal took EVERY out-edge whose condition held, one after another, and reported nothing — a CRM application's lead-conversion flow rendered a refusal screen AND ran the conversion in one execution. The traversal now matches the declaration (BPMN exclusive gateway, Salesforce Flow Decision, n8n Switch default), and the every-true-edge behaviour is the BPMN inclusive gateway an author must write down. The KEY converts mechanically and does: `flow-decision-mode-inclusive-explicit` writes `mode: 'inclusive'` wherever two or more conditioned out-edges leave a decision that declares no `conditions` list, so the migrated source runs exactly as before. What does NOT convert is the INTENT: the count cannot tell a partition (where the key is redundant) from a reliance on multi-branch runs (where it is load-bearing) from an accidental overlap (where the old behaviour was the bug), so the mechanical edit list the chain replay prints is where that judgment is made, node by node. And the conversion replays ONLY there: it is a default flip, so the authoring funnel never rewrites a source written against the new contract, and the automation engine's flow rehydration seam and the artifact-ingestion door both refuse it by id (a code-shipped flow, a REST body, a Studio save and a scaffolded artifact all arrive undated). BREAKING for stored rows, by maintainer ruling: the promise that a flow keeps its behaviour is kept by authored sources and built artifacts only. A decision stored in `sys_metadata` with no `conditions` list, no `mode` and two or more conditioned out-edges takes the new meaning on upgrade — it evaluates first-match — and nothing rewrites the row: no stored-row migration, no cutoff, no read-path completion, because nothing about a stored row says it was saved before the flip. The one-line fix, for a stored node that meant every branch, is `mode: 'inclusive'`; `os migrate meta --stored` lists every such node, report only, so an operator can review the candidates before and after the upgrade." + }, + { + "surface": "a structural flow condition, BOTH slots — edges[].condition on FlowEdgeSchema, the branch predicate AutomationEngine.evaluateCondition runs at every traversal, and config.condition on a flow NODE, which is a decision node predicate and on a start node the trigger gate — authored either as an expression envelope carrying only ast ({ dialect: 'cel', ast: … } with no source), or with a source that is blank after trimming, through the envelope key ({ dialect: 'cel', source: ' ' }) or the bare-string shorthand for it (condition: ' '). The node slot joined this entry with the two later changes that rebound AutomationEngine.registerFlow and objectstack validate to the edge door's own rule rather than deriving a second one; it is the same decision reaching the second slot, which is why it is named here instead of in an entry of its own. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, an exported stack passed to objectstack validate, a POST /api/v1/automation body, and a flow row already sitting in sys_metadata", + "replacement": "a non-blank `source` — `{ dialect: 'cel', source: 'record.amount > 10' }`, or the bare string `'record.amount > 10'` — if the edge was meant to branch; or REMOVE the `condition` key entirely if it was meant to be unconditional. ⚠️ Those two are not interchangeable, and the choice is the judgment this entry delegates: a refused condition evaluated to a silent `false`, so the edge NEVER fired, while an absent `condition` is an unconditional edge that ALWAYS fires. Deleting the key to clear the refusal inverts the edge rather than preserving it. An `ast` BESIDE a string `source` is untouched and stays admitted everywhere", + "migrationId": "flow-edge-condition-evaluated-slot-source-required", + "toMajor": 18, + "rationale": "The evaluated-slot rule, carried to the edge condition — the line that first refused an `ast`-only envelope no engine can evaluate, and refused a non-string node predicate at registration instead of letting the evaluator answer it a silent `false`: `FlowEdgeSchema.condition` now composes `EvaluatedExpressionInputSchema` instead of `ExpressionInputSchema`, so an evaluated slot is held to what the engine can actually run. The engine reads `source` alone (`cel-engine.ts` `evaluate`: \"AST-only evaluation not yet supported; persist `source`\"), so both refused spellings landed in its empty-source arm and answered a SILENT `false` on every release that carried them — they parsed, registered, passed `objectstack validate`, and then produced a branch that quietly never fired (measured on 2026-09-05 by driving an `ast`-only envelope through `AutomationEngine.evaluateCondition` directly). The refusal is one rule with one sentence, `EVALUATED_EXPRESSION_SOURCE_REQUIRED`. ⚠️ No D2 conversion is possible, and this is exactly why the change needs a D3 entry rather than none. An `ast`-only envelope carries no `source` to derive one from — lowering an AST to surface syntax is the compiler direction the platform does not run — and dropping a blank `condition` would flip the edge from never-fires to ALWAYS-fires, which is the platform guessing which of two different flows the author meant. ⚠️ And the consequence for a flow ALREADY STORED is wider than the edge, which is the part no author-time prescription reaches. `applyConversionsToStoredItem` is deliberately not applied to `flow` (`spec/src/conversions/stored.ts`, and the same skip in `metadata/src/loaders/database-loader.ts` `rowToData`) because flow-node conversions need the automation engine's live executor registry; flows canonicalize at `registerFlow` instead, which parses through `canonicalizeStoredFlow` → `FlowSchema.parse`. Each of the three boot paths in `service-automation/src/plugin.ts` wraps that call in try/catch, logs one `warn` naming the flow, and CONTINUES — so a stored `sys_metadata` flow with such an edge is no longer registered at all: its trigger is never armed and the WHOLE flow stops running, not just the branch, announced only by that warn line. A repo-wide census at `ae19f5edb` (examples/, packages/, content/, skills/) found zero edge conditions of either spelling against a lit control, so there is nothing in THIS repository to rewrite — a repo reading, which is why the notification is registered here rather than skipped. ADR-0087, ADR-0032." + }, + { + "surface": "a flow edge whose source or target is not the id of a node in the graph that declares it — the flow's own nodes for a top-level edge, the region body's nodes for an edge inside a loop, parallel or try_catch region, so a top-level edge into a region node is one of them — and a later edge of the same graph with the same source, target, type, condition and branch label as an earlier one. Reachable wherever a flow is authored or stored: defineStack flows sources, defineFlow, an exported stack passed to objectstack validate, a flow saved from the Studio flow designer after a node was removed (its edges were left behind, and a node added later under the reused id picked them up), and a flow row already sitting in sys_metadata", + "replacement": "an edge whose `source` and `target` are node ids declared in the same graph as the edge: re-point the endpoint at the node it was meant to reach, or delete the edge. Deleting a dangling edge changes nothing a run did, with one exception: a conditioned edge into a missing node still counted as the branch taken when its condition held, so a default sibling was passed over and, on an exclusive `decision`, the later conditioned siblings were skipped — where a flow relied on that, point the edge at a node that ends the branch. For a repeated edge, delete the later copy: the target then runs once per traversal instead of once per copy — a CHANGE of behaviour wherever the copies ran it more than once, which is the defect being removed. An edge meant to take its own route needs its own `condition` or branch `label`", + "migrationId": "flow-edge-unresolved-or-repeated-refused", + "toMajor": 18, + "rationale": "The engine resolves an edge's endpoints in the graph that declares it — traversal looks the target up there, and a region runs against a view of its own nodes and edges — and runs a target once per out-edge it selects. `FlowSchema` held node ids and edge ids unique and checked neither that an edge names a node of its graph nor that it is not a copy of another, so a draft holding an edge into a node it no longer had, or one edge three times, passed `FlowSchema.parse`, `objectstack validate` and the metadata save door, published with `_diagnostics.valid: true`, and ran: the dangling edge carried the run nowhere, silently, and the repeated edge ran its target once per copy (one record update created three identical records). The parse now refuses both at every depth the region walk reaches: an endpoint at `edges.N.source` / `edges.N.target` (or the region path `nodes.N.config.body.edges.M.target`), naming the missing id and, when it is a node of another graph, that graph; a repeated edge at `edges.N`, naming the earlier copy. Repeated means the key the engine selects on — `source`, `target`, `type`, `condition` (its dialect and source) and branch `label` — so two nodes joined by edges with different conditions, a `fault` edge beside a default one, or `approve` and `reject` branches into one node stay legal. A region edge naming no node of its region was already refused at registration by the region analysis; the top-level half had no refusal anywhere. ⚠️ No D2 conversion: a dangling endpoint carries no intent a rewrite could recover, and dropping a repeated edge changes how many times its target runs. ⚠️ Where such an edge already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack` flows source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0031." + }, + { + "surface": "a flow node whose config leaves out a key its executor contract requires — objectName on get_record / create_record / update_record / delete_record, recipients on notify (and title when there is no template), url on http, function on script, flowName on subflow, collection and flowName on map, collection on a loop that has a body, branches on parallel, try on try_catch, and on screen each field name, each option value and label, and a lookup field reference — and a decision node whose conditions is not an array, holds a branch that is not an object, or holds a branch whose label is absent, null, blank or not a string; at any depth including an ADR-0031 region body. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate, a flow saved from the Studio flow designer (a node added and saved before it is configured; a decision branch row whose label cell is empty; a screen field row whose name cell is empty), and a flow row already sitting in sys_metadata", + "replacement": "the missing key, written on the node's `config` — the value the node was meant to act on (`objectName: 'account'`, `url: 'https://…'`, `collection: '{rows}'`, …). For a decision branch, the label of the out-edge the branch should take (`{ label: 'approved', expression: 'record.amount > 1000' }`, beside an out-edge labelled `approved`), `conditions` written as an array of such objects, and a bare predicate string moved under `expression`. To branch on the out-edges instead, delete `conditions` and put each predicate on its edge's `condition`. A legacy flat-graph `loop` (no `body`) needs no `collection` and is untouched", + "migrationId": "flow-node-config-required-keys-refused", + "toMajor": 18, + "rationale": "A flow node's `config` is an open record, so what its executor requires was checked by no build door: `FlowSchema.parse`, `AutomationEngine.registerFlow` and `objectstack validate` all admitted a node missing a key its executor contract requires, and the executor's own contract parse then refused the node on every run that reached it — the config is metadata, so no rerun could succeed. A decision branch with no label was worse: it never failed, the matched branch reported no label and traversal took EVERY out-edge, so the flow ran green down the wrong paths. All three doors now refuse these shapes through one judge, `flowNodeConfigRefusals`, which parses each builtin node's config against the very contract its executor parses against (`getBuiltinNodeConfigContracts()`, reconciled against the executors' own parse calls) and keeps only the keys left out — a present value of the wrong type and an undeclared key are judged where they were before — plus the decision branch shape its executor reads raw. A key a rule of the contract requires (a notify with no template needs a title; a lookup screen field needs its reference) is refused in the contract's own words. ⚠️ No D2 conversion: the platform cannot know the object, URL, collection, function or out-edge label the author left out, and no value it could write would keep what the flow did. ⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0031." + }, + { + "surface": "the two ledger predicate slots on a flow node — config.conditions[].expression on a decision node (a branch predicate) and config.fields[].visibleWhen on a screen node (a field visibility predicate) — authored as a string that is blank after trimming ('', ' ', a tab or a newline), at any depth including an ADR-0031 region body. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate, and a flow row already sitting in sys_metadata", + "replacement": "the predicate the branch or field was meant to test, as non-blank bare CEL text (`expression: 'record.amount > 10'`, `visibleWhen: 'amount > 0'`); or KEEP what the blank did. On a screen field, drop the `visibleWhen` key: an absent `visibleWhen` shows the field unconditionally, which is what a blank one already did at run time (the resume contract treated it as absent, and the renderer fell back to showing the field). On a decision branch, write `expression: 'false'`: the evaluator answered the blank `false`, so the branch keeps its label and is still never taken. ⚠️ Not by dropping a decision's only branch: with no `conditions` the node routes by its out-edges alone, so the out-edge that branch labelled is no longer held back. On a structural condition removal differs again: dropping a blank `condition` turns a never-firing edge into an always-firing one (`flow-edge-condition-evaluated-slot-source-required`)", + "migrationId": "flow-predicate-slot-blank-string-refused", + "toMajor": 18, + "rationale": "Maintainer ruling A of 2026-09-13: the two sibling predicate slots refuse a blank string at authoring. Both slots are declared bare CEL text (`z.string()`) and both admitted a blank string at every door: the expression ledger resolver skipped it as \"not authored\", and `AutomationEngine.evaluateCondition` answered it `false` — so a decision branch carrying it was never taken, with nothing said at any layer, and a screen field carrying it was shown with its predicate ignored. An earlier fix had pinned that admission as correct because the two sides agreed. The ruling is that self-consistency between parser and evaluator is not a defence when the author's intent is silently dropped — the third instance of one rule, after the structural `config.condition` and a blank evaluated `source`. The blank is now refused at `FlowSchema.parse`, at `AutomationEngine.registerFlow` (which parses first) and at `objectstack validate`, all three through `predicateSlotRefusal`, leading with `PREDICATE_SLOT_STRING_REFUSAL`. ⚠️ No D2 conversion, and the reason is the judgment this entry delegates: the blank is where an author meant to write a rule, and the platform cannot tell a predicate somebody forgot from one they meant to delete. Keeping what ran is mechanical; writing the predicate is what the author intended; only the author knows which. ⚠️ Where such a blank already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0032." + }, + { + "surface": "a script or subflow flow node whose config carries a key its executor contract does not declare — a typo (funtion), a key copied from another node type (a subflow timeoutMs written inside config, an approvers list on a script), or a key nothing reads (bogusKey). script declares function, inputs and outputVariable; subflow declares flowName, input and outputVariable. Never a retired script key (actionType, template, recipients, variables, script), which keeps its own path, and never a key on any other builtin node type, whose undeclared keys registration already judges against the node type descriptor. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved from the Studio flow designer, and a flow row already sitting in sys_metadata", + "replacement": "the key the contract declares, or no key: rename a typo to the declared key it meant (`function`, `inputs`, `outputVariable` on a `script`; `flowName`, `input`, `outputVariable` on a `subflow`), move a value the function or the child flow should receive into `inputs` (script) or `input` (subflow), move a `subflow` timeout to the node itself (`{ id, type: 'subflow', timeoutMs: 30000, config: { … } }`), and delete a key nothing reads. The refusal carries the contract's own sentence, with its did-you-mean for a near miss", + "migrationId": "flow-script-subflow-config-undeclared-keys-refused", + "toMajor": 18, + "rationale": "The `script` and `subflow` executors (`service-automation` `builtin/screen-nodes.ts`, `builtin/subflow-node.ts`) parse the node's `config` against a strict contract (`ScriptConfigSchema`, `SubflowConfigSchema`) before they act, and refuse the node on an undeclared key. No door before the run judged one: `registerFlow`'s undeclared-key check derives the declared set from the node type descriptor's `configSchema`, and these two descriptors publish none (the schemaless class, `SCHEMALESS_NODE_CONFIG_SCHEMAS`), while the build doors' executor-contract arm judged required keys and present values but held key membership back on the premise that registration judges it. So a `script` node carrying `bogusKey` passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile` (which copied the key into the artifact), registered, and then failed every run that reached the node: the config is metadata, and no rerun could succeed. The one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share (`flowNodeConfigRefusals`) now refuses such a key on these two types as `node-config-refused-by-contract`, anchored at the key, one refusal per key, in the contract's own words — the code the value half and the approval contract already use. Every other builtin keeps its undeclared keys where they were judged: at registration, against its descriptor, with that check's own prescriptions. `decision` is schemaless too, but its executor parses no contract, so an undeclared key there fails no run and stays unjudged. A retired `script` key keeps its tombstone path. ⚠️ A spelling the ADR-0087 D2 conversion `flow-node-script-config-aliases` or `flow-node-subflow-flow-alias` still rewrites at load (`functionName`, `input` on a `script`; `flow` on a `subflow`) is converted before the judge at every door that converts first; met by a direct `FlowSchema.parse` or `defineFlow()` it is refused like any other undeclared key, as the missing canonical key already was. ⚠️ No D2 conversion: the platform cannot know what an undeclared key was meant to be. ⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0031." + }, + { + "surface": "flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a single-brace template token", + "replacement": "a double-brace template hole, rendered by the formula template engine over the flow's variables: a variable path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, {{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an assignment node — arithmetic and functions as a CEL value envelope, the date macros and the run-user paths as the value-slot spelling that still reads them — and written as {{ variable }}", + "migrationId": "flow-text-slot-single-brace-refused", + "toMajor": 18, + "rationale": "ADR-0032 Decision 3 fixes one template delimiter, double braces, and deletes the single brace: it collides with CEL map literals, and an author who meets both dialects in one flow mixes them. The 17.x interpolator and the template engine render the same text for a path holding a string, a number, a boolean, null, an absent key or variable, an ISO date string, an object or an array, but not for every value — a Date rendered JSON-quoted under the interpolator and as its ISO text under the engine, and a screen title, screen description or end message that was one token holding an object, an array or a Date rendered String(value) — so no conversion is lossless (ADR-0087 D2) and none is applied. Arithmetic, function calls, the date macros and the run-user paths have no hole spelling: a hole is a path with a formatter, never logic. A flow carrying a single-brace token in a text slot is refused at registration, by objectstack validate and by the node contract; a stored flow carrying one is skipped at boot with a warn naming it." + }, + { + "surface": "the record and previous roots a record-change flow receives — a password or secret field, and an internal field, of the triggering record, on every object", + "replacement": "read a credential through a privileged binder — the flow credential channel for an http node's signing secret, or a privileged server-side read such as the engine's resolveSecretField — never off `record` or `previous`; on those roots a set credential-class field now reads as the mask `SECRET_MASK`, an unset one as null, and an `internal: true` field is absent", + "migrationId": "flow-trigger-record-credential-masked", + "toMajor": 18, + "rationale": "ADR-0100: a credential-class value leaves the engine only through a privileged dereference, and every generic channel serves the mask. The record-change trigger built a flow's record and previous from the engine's own write result, which keeps the stored row whole for privileged in-process callers, so a password field's plaintext, a secret field's stored handle and an internal field's value reached the flow — and from there its variables, a paused run's persisted state and that state's read doors. The trigger now projects both roots through the same helper every external write response uses: a credential-class field (secret, and password outside the exempt managedBy buckets) carries the mask, or null when unset, and an internal field is omitted. Every other field keeps its value, every other variable is untouched, and the engine's own write result, the stored row and the privileged read paths are unchanged." + }, + { + "surface": "flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token", + "replacement": "a CEL value envelope, { dialect: \"cel\", source: \"…\" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, list[0]; a variable whose name starts with $ is read through vars, vars[\"$error\"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", + "migrationId": "flow-value-slot-template-dialect-refused", + "toMajor": 18, + "rationale": "The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. Two spellings are kept with their old meaning, because CEL cannot write them yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has no string form for one) and the run-user paths beginning $User. (the flow CEL scope binds no user). A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it." + }, + { + "surface": "a create_record, update_record or delete_record flow node whose config.objectName is the string sys_metadata or sys_metadata_history, at any depth including an ADR-0031 region body", + "replacement": "Change metadata through the metadata API (`PUT /api/v1/meta/:type/:name`, the metadata protocol), where it is validated and its provenance is recorded. Delete the node, or point its `objectName` at the object the flow really means to write. Elevation (`runAs`, a system context) does not change this.", + "migrationId": "flow-write-node-stored-metadata-target-refused", + "toMajor": 18, + "rationale": "`FlowSchema` accepted a `create_record`, `update_record` or `delete_record` node whose `objectName` names `sys_metadata` or `sys_metadata_history`, the tables that hold stored metadata. The maintainer ruled (2026-10-03) that app-authored work may not write those tables: the metadata protocol is their only writer, where a change is validated and its provenance recorded, and a flow is app-authored automation. The runtime enforces that at the node, refusing the write before it resolves a filter, computes a field or calls the data engine, under every run identity; but every authoring door still accepted such a flow, and the author learned otherwise only at its first run. The parse now refuses it too, through the one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` and `objectstack validate` share (`flowNodeConfigRefusals`), with the runtime's prescription: `objectstack validate`, `defineStack`, compile, an artifact's parse, `registerFlow` and the metadata save door each name the node at `nodes.N.config.objectName`. The refused set is exactly the runtime's: one of those three write nodes, whose `objectName` is a string naming a stored-metadata table by exact name. A `get_record` node is outside it (a read is not a write), and so is a dynamic target, a `{token}` template or an expression envelope: the parse cannot read it as a name, and the run judges the name it hands the data engine. No authored flow writing either table was measured in this repository, its examples, its skills or its docs. There is no mechanical rewrite: retargeting the node or deleting it each changes what the author wrote, and the runtime already never ran it. Where such a node already sits, the whole flow is refused: registered from `sys_metadata` at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register." + }, + { + "surface": "view.form.sections[].fields[].publicPicker — the anonymous public-form record-search picker", + "replacement": "No record search on an anonymous public form. For a choice from a fixed list, a `select` field with static `options`. For a choice of an existing record, the same form behind sign-in, where the lookup field renders with the signed-in user's access.", + "migrationId": "form-field-public-picker-retired", + "toMajor": 18, + "rationale": "The D2 conversion `form-field-public-picker-removed` deletes `publicPicker` from every form field, and the delete is lossless in effect: the block's only reader was the anonymous lookup route, which is gone, and the public-form resolve route now leaves lookup, `master_detail` and `user` fields off the anonymous rendering whatever the row carries. What the strip cannot decide is the visitor's path. A public form that used the picker let an anonymous visitor search and pick a record; after the upgrade that field is simply absent from the form, so a submission arrives without the value. Whether the choice was really from a small fixed set (a `select` with static `options`), or needs a real record and therefore a signed-in user, is a product decision only the author can make." + }, + { + "surface": "view.form.sections[].fields[].options[].default — the per-option pre-selection on a form view's own option list", + "replacement": "The object field's own option list, where `default` is enforced: `default: true` on that field's options entry, or the field-level `defaultValue`.", + "migrationId": "form-view-option-default-retired", + "toMajor": 18, + "rationale": "The D2 conversion `form-view-option-default-removed` deletes `default` from every option of every form-view field it reaches, and the delete is lossless: nothing on the form path ever read it — the insert-path default falls back to the OBJECT definition's options, and no form renderer seeds a value from a form view's. So a form that marked an option as default never pre-selected it, and still does not. The judgment is in the replacement. The form-view key was scoped to ONE form; the object field's `default` applies on EVERY insert path — every form of that object, the API, imports. Moving the marker there makes the form do what its author wanted and also changes what records created elsewhere receive when the value is omitted. Only the author can say whether that wider default is correct, or whether the pre-selection should be dropped." + }, + { + "surface": "view.form.subforms[].columns[] and view.formViews..subforms[].columns[] — the form view's inline grid columns, which used to accept any value", + "replacement": "each entry is the strict, name-keyed inline grid column a relationship field's `inlineColumns` takes — `{ name, label?, type?, … }`, where `{ name }` alone hydrates the rest from the child object's field. Write `name` where a column said `field` (or `fieldName`, `key`); delete `scale` from a column declaring `type: 'currency'`; delete any key the column schema does not declare.", + "migrationId": "form-view-subform-columns-closed", + "toMajor": 18, + "rationale": "Both carriers feed the one console grid, which reads only the keys the column schema declares and keys a column by `name` alone. On the form view the columns were never judged, so a mis-keyed column published clean and drew a blank grid column, and a key the other carrier refuses — `scale` on a currency column, under the maintainer's ruling of 2026-09-23 (option B, `scale` retired from the currency type) and the remedy ruled on 2026-09-24 (option 乙 — a currency's ISO 4217 minor unit decides its display) — published green here. The carrier now references the column schema, so every rule it holds applies here too, with its own prescription. Only the `field` spelling is converted mechanically — by the conversion `form-view-subform-columns-canonicalized`, which rewrites stored rows and assembled artifacts and lists the edit under `os migrate meta`, while an author writing `field` meets the refusal. Which column an unknown key or a mixed `field`/`name` entry meant is the author's call — a conversion that dropped the key would accept on every load what the parse now refuses. Population measured at the change, on origin/main cb4c31dd52: zero authored `subforms` in the repository (the showcase derives its master-detail grids from the data model instead), against one authored `inlineColumns` block as the control. Deployed metadata NOT MEASURED." + }, + { + "surface": "hook.object naming sys_metadata or sys_metadata_history, as the string or as any member of the list, on a hook that carries a body", + "replacement": "Change metadata through the metadata API (`PUT /api/v1/meta/:type/:name`, the metadata protocol), where it is validated and its provenance is recorded. Delete the hook, or point its `object` at the tables the logic really concerns. Elevation (`runAs`, a system context) does not change this.", + "migrationId": "hook-body-stored-metadata-target-refused", + "toMajor": 18, + "rationale": "`HookSchema` accepted a hook whose `body` targets `sys_metadata` or `sys_metadata_history`, the tables that hold stored metadata. The maintainer ruled (2026-10-03) that an app-authored body may not touch those tables: for a body, the metadata protocol is their only writer, where a change is validated and its provenance recorded. The runtime enforces that where a body hook becomes a handler, refusing such a hook at registration so that it never runs, but every authoring door still accepted it: the metadata save door answered 200, and the author learned otherwise only from a server log. The parse now refuses it too, with the runtime's own prescription, so `objectstack validate`, `defineStack`, compile, an artifact's parse and the metadata save door (a 422) each name the target at `object`, or at the list member. The refused set is exactly the runtime's: a hook carrying a `body`, in any form, whose `object` names a stored-metadata table, as the string or as any member of the list, and one such member refuses the whole hook. A hook with no `body` (a code `handler`, which is how the platform writes its own hooks) and the wildcard `'*'` are outside it, as they are at registration: a wildcard names no stored-metadata table, so it binds, and the runtime never runs its body for those tables' events. No authored hook targeting either table was measured in this repository, its examples or hotcrm. There is no mechanical rewrite: retargeting the hook, dropping its body or deleting it each changes what the author wrote, and the runtime already never ran it. A stored hook row of this shape still loads, now with a `[metadata_spec_invalid]` warning and a `_diagnostics` badge, and is still never bound." + }, + { + "surface": "engine.registerHook('beforeFindOne' | 'afterFindOne' | 'beforeCount' | 'afterCount' | 'beforeAggregate' | 'afterAggregate', handler)", + "replacement": "for the findOne pair, register on 'beforeFind' / 'afterFind' — they already fire for `findOne`; for the count and aggregate pairs there is no hook seam at all, so move the logic to `engine.registerMiddleware(fn)` and read `ctx.operation === 'count' | 'aggregate'`, composing the predicate onto `ctx.ast.where`", + "migrationId": "hook-register-undispatched-lifecycle-event-refused", + "toMajor": 18, + "rationale": "`registerHook` took `event: string` and, for a name outside the dispatched set, warned and then REGISTERED the handler anyway. Six of those names are inside the engine's own lifecycle namespace — (`before`|`after`) x `OperationContext['operation']` minus the eight the engine dispatches — so an author writing one of them believes they are subscribing to an engine lifecycle event, and what they get back is an inert declaration: ADR-0078's prohibited fourth state (parsed, unmarked, silently inert) on an authorable seam.\n\nThe measured consequence is a data-visibility one, which is why this is not a cosmetic warning. A downstream consumer registered READ FILTERS on `beforeFindOne` and `beforeCount`, expecting them to scope single-record reads and list totals; they sat inert through every boot behind about forty warning lines. `findOne` was still filtered — `beforeFind` covers it — so the mistake gave no signal there. `count` was not: a `limit`ed list answered a `total` counting rows the caller could not see. `aggregate` was not either: a `groupBy` was not narrowed at all. A filter that was supposed to narrow visibility and silently did not run is a guardrail the author believes they armed.\n\nRefused at REGISTRATION rather than repaired on the dispatch side. Making `count()` and `aggregate()` dispatch hooks would widen what a hook may intercept — a different and much larger decision — and it would also be the wrong seam: read authorization and row filtering are the middleware chain's job, which is what `HookEvent` in `@objectstack/spec` already says and what `count()` already honours (its AST rides the operation context precisely so the security and sharing middlewares can scope it). The refusal names the per-seam repair in its own message, because \"this never fires\" alone cannot tell the two seams apart: one is a rename, the other is a different API.\n\nThe refusal is scoped to those six names, not to everything outside the dispatched set. `triggerHooks` is public, so a plugin dispatching its own event under a name outside the engine's vocabulary (`'myPlugin:flush'`) is a legitimate reading — that is why the change that collapsed the hook taxonomy to the eight dispatched events made this branch a warn — and it still warns and still registers. The population is DERIVED from the operation union rather than typed out, so a new engine verb widens it without an edit; a hand-written list of refused names would be this same defect one layer up.\n\nThis is a RUNTIME registration API, not stored metadata, so — like `hook-register-empty-object-target-refused` at the previous step — there is no `sys_metadata` row for the D2 chain to rewrite, and the ledger entry is the notification channel. The metadata door was never open on this axis: `HookSchema.events` is `z.array(HookEvent)`, and `HookEvent` enumerates exactly the eight dispatched names, so no authored or stored hook could ever carry one of the six. The exposure was entirely on the code door. ADR-0078." + }, + { + "surface": "hook.timeout — the per-invocation time limit of a data hook", + "replacement": "`timeoutMs` — the same limit, in milliseconds, with the unit in the key name.", + "migrationId": "hook-timeout-unit-in-key", + "toMajor": 18, + "rationale": "The D2 conversion `hook-timeout-to-timeout-ms` renames `timeout` to `timeoutMs` in author sources and on stored hook rows, keeping the value, and the rename is lossless: the key always meant milliseconds, and the conversion leaves an already-canonical `timeoutMs` alone and refuses a pair that disagrees. The judgment is the one the rename exists for. The unit used to live only in the key's description, beside body-level keys that spelled theirs, so an author who wrote a seconds value — `timeout: 30` meaning thirty seconds — got a limit of thirty milliseconds and no error, and the rename carries that 30 over unchanged. Only the author knows which unit they meant, so each value needs reading once. A pair left unconverted because the two spellings disagree needs the author to choose, and code that builds or reads a hook definition in TypeScript is outside the chain's reach." + }, + { + "surface": "`HotReloadConfig.stateStrategy` values 'disk' and 'distributed', plus the `HotReloadConfig.distributedConfig` key and the `DistributedStateConfig` def it carried (3 exported names: `DistributedStateConfigSchema` / `DistributedStateConfig` / `DistributedStateConfigParsed`)", + "replacement": "'memory' for in-process state preservation across a reload, or 'none' to disable it — the two values `PluginStateManager` actually implements. There is no in-tree replacement for durable or distributed plugin state: persist it in the host, which owns the process lifetime these strategies pretended to outlive. Real disk or distributed persistence returns only via the ENFORCE route of ADR-0049 — the implementation first, the declaration with it.", + "migrationId": "hot-reload-inert-state-strategies-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove, applied one level INSIDE the library the maintainer's 2026-08-25 ruling on the advanced plugin-lifecycle config kept. That ruling retired the authorable lifecycle-config container and deliberately kept `HotReloadConfigSchema` as a host-driven library parameter type; this card measured the kept vocabulary's own remainder and found the same defect in it. Measured at cdbd9204b6 with a firing positive control (`stateStrategy` resolves to real readers in `core/src/hot-reload.ts`, so the scan sees readers): the 'disk' and 'distributed' arms of `PluginStateManager.saveState` both wrote to the SAME in-memory Map as 'memory' — the in-source comments said 'memory fallback' — and announced the substitution at DEBUG level only, so a host that asked for durable or cluster-replicated state got process-local memory and no error: state that does not survive the restart it was configured to survive. `distributedConfig` had ZERO readers anywhere (every reference inside `packages/spec` itself plus the generated reference page; nothing in objectui), so an author could name a Redis endpoint, a TTL and a replication factor and nothing ever opened a connection — the shape of the plugin sandboxing / integrity / approval config that was never wired to anything (an exported schema no runtime reads is read as a capability), sharpened by cluster-persistence vocabulary an AI author (ADR-0033) reads as proof the capability exists. The key left with the enum value its own doc comment named it \"required\" for, and `DistributedStateConfig` was its orphan value schema. Two routes in one card because the surface has two shapes: an enum-VALUE narrowing is invisible to the four ratchets (the def still emits), so its prescription hangs on the enum's own `error` map dispatched by `issue.input` (the `crypto.hash` / `managedBy: 'system'` precedent); the whole-def removal MUST move them, and that movement is its own evidence. No D2 conversion and no tombstone: `HotReloadConfig` is not an authorable surface — no metadata-type binding, stack collection or manifest embed ever carried it, and nothing in the tree parses `HotReloadConfigSchema` outside its own unit test — so there is no authored document to rewrite and no one who could receive a parse-time prescription. Route 3, the shape of the dynamic plugin-loading family's removal and of that lifecycle-config ruling: this entry IS the declaration." + }, + { + "surface": "`HotReloadConfig.watchPatterns`, and the `HotReloadManager.startWatching` placeholder that read it", + "replacement": "Run your own watcher and call `HotReloadManager.scheduleReload(pluginName, reloadFn)` when a file changes — that is the debounced integration point this class actually implements, and it is unchanged. Declare your globs wherever your watcher reads them; there is no in-tree replacement for the key, because file watching is the HOST's job in this host-driven library. The platform already depends on `chokidar` in `@objectstack/metadata`, `@objectstack/metadata-fs` and `@objectstack/cli` — never in `@objectstack/core` — so a host has a working model to copy.", + "migrationId": "hot-reload-watch-placeholder-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove, applied one symbol over from the inert 'disk' / 'distributed' state strategies retired in the same file, and on the same per-key test. `HotReloadManager.startWatching` contained NO watcher: its whole body was a guard plus `logger.info('File watching started', { patterns })` above an in-source note saying real watching \"would require chokidar or similar / This is a placeholder for the integration point\". `watchHandles` was only ever read, deleted, iterated and cleared and NEVER set, so `stopWatching`'s cleanup branch and the teardown loop over its keys were structurally UNREACHABLE rather than merely untaken (measured with a firing positive control: `reloadTimers.set` resolves a real writer in the same file and the same scan; `watchHandles.set` resolves nothing anywhere). So `watchPatterns` had no reader that ACTED on it — its only two uses were log lines — and an author could declare a glob while no file change could ever trigger a reload. This is the shape of the plugin sandboxing config that was never wired to anything, with the volume turned up: the inert state-strategy fallback at least announced itself at DEBUG, whereas this said \"File watching started\" at INFO — positive confirmation of a capability that did not exist, which an operator, or an AI author (ADR-0033), reads as proof and stops looking. Neither of the other two ADR-0049 states was available: ENFORCE would build for a caller that does not exist (no runtime composes `HotReloadManager` — only its own unit test and `core/examples/phase2-integration.ts` construct it, the same fact that decided the state-strategy retirement's route), and EXPERIMENTAL requires a roadmap, where a scan of every planning doc returned ZERO mentions of hot-reload file watching against 145 control hits in the same files. Route 3 again: `HotReloadConfig` is not an authorable surface — no metadata-type binding, stack collection or manifest embed ever carried it, and nothing in the tree parses `HotReloadConfigSchema` outside its own unit test — so there is no authored document to rewrite and nobody who could receive a parse-time prescription, so there is no D2 conversion either — it would be a transform with no seam that ever runs. The key is TOMBSTONED rather than deleted, and the BUILD is what decided that: the plain deletion was tried first and `gen:schema` gate (a) refused it, because `HotReloadConfigSchema` is not `.strict()` and a bare deletion would be a silent strip (the failure measured when a field key pruned from a non-strict schema still parsed successfully and simply vanished, ADR-0104) — the very defect being retired, one layer down. The state-strategy retirement could take route 3 because what left there was a whole DEF; a key leaving a SURVIVING def has no such exit. This entry IS the declaration." + }, + { + "surface": "identity.apiKey (the whole of `ApiKeySchema` in identity/identity.zod.ts — 1 def, 3 exported names: `ApiKeySchema`, `ApiKey`, `ApiKeyParsed`)", + "replacement": "(removed — there is no replacement schema, because the deleted one never described the real table. The single declaration of `sys_api_key` is the ObjectSchema in `@objectstack/platform-objects` (`identity/sys-api-key.object.ts`): columns `name, prefix, user_id, active_organization_id, scopes, expires_at, last_used_at, revoked, key, id, created_at, updated_at`, snake_case, `revoked` as the kill switch — not `enabled`. Rows are minted by `POST /api/v1/keys` (`runtime/src/domains/keys.ts`) and verified by `core/src/security/api-key.ts`, keyed by the `osk_` prefix. Per-key rate limiting returns only via the ENFORCE route of ADR-0049 through a new ADR — the executor first, the vocabulary second)", + "migrationId": "identity-api-key-schema-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-08-15, disposition B: delete the schema. `ApiKeySchema` documented better-auth's `apiKey` PLUGIN schema — a plugin this platform does not load (`plugin-auth/src/managed-extension-fields.ts` states the table is hand-rolled ObjectStack): `start` and `lastRefetchAt` name columns that do not exist; `enabled` inverts the real `revoked` column's polarity; `rateLimitEnabled` / `rateLimitTimeWindow` / `rateLimitMax` / `remaining` advertise a per-key rate-limit capability nothing implements (the sharpest PD #10 instance — a reader can reasonably conclude API keys support rate limiting); `permissions` and `metadata` have no columns; `organizationId` is camelCase fiction next to the real snake_case `active_organization_id`. Zero consumers measured (08-14, re-verified at the retirement's base commit): only its own unit test, the export snapshots, the generated reference page and a prose mention in `cloud/developer-portal.zod.ts` (corrected in the same PR — the marketplace-key plan it gestured at is ruled NOT live). One table had two declarations and the published one was fiction; the generated reference page rendered it faithfully, which is how the defect surfaced as a docs card. With no carrier key and no authored document there is nothing to tombstone and no seam for a D2 conversion: route 3, the shape of the earlier removals of the dynamic plugin-loading family, the `ui/` interaction configs, the widget / i18n shapes, five declared-but-inert surfaces and two credential-bearing schemas no `sys_metadata` door reached — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration." + }, + { + "surface": "incident-response deadline keys: `IncidentResponsePhase.targetHours`, `IncidentNotificationRule.withinMinutes` / `regulatorDeadlineHours`, `IncidentNotificationMatrix.escalationTimeoutMinutes`, `IncidentResponsePolicy.triageDeadlineHours` / `retentionDays`", + "replacement": "nothing to re-declare — delete the keys. No incident-response engine exists on the platform: nothing tracks a phase against a clock, sends or times an incident notification, notifies a regulator, walks the escalation chain on a timer or sweeps incident records on a schedule, so there is no live mechanism to declare a deadline to. Retention of stored records is the object-level `lifecycle` block (ADR-0057), declared on the object that stores the records and enforced by the LifecycleService — not a number on this policy document", + "migrationId": "incident-response-deadline-keys-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on the unread deadline keys (ruled A: retire per family). Six hour/minute/day-shaped keys sat on the published authorable surface and in the generated reference docs — an author could write `triageDeadlineHours: 4` and reasonably expect the platform to escalate after four hours — and read by NOTHING: the schemas are exported from `@objectstack/spec/system`, mounted by no stack key, registered as no metadata type, absent from the 2026-06 liveness ledgers, and the reader census over every package outside `packages/spec` (tests and changelogs excluded) and over objectui at the pinned sha returned zero hits for every key. Three of the six carried defaults (30 minutes, 1 hour, 2555 days) that were materialized into every parsed document without ever being consulted. A compliance-shaped deadline that fails silently is the worst form of the declared-but-unenforced shape ADR-0049 names; tagging it `[EXPERIMENTAL — not enforced]` was the fallback the ruling did not take. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections; none of these schemas is either, so a conversion would be a transform with no seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent)." + }, + { + "surface": "the incident-response family, retired whole: the eight defs system/Incident, system/IncidentCategory, system/IncidentNotificationMatrix, system/IncidentNotificationRule, system/IncidentResponsePhase, system/IncidentResponsePolicy, system/IncidentSeverity and system/IncidentStatus, and every name system/incident-response.zod.ts exported from @objectstack/spec/system (the eight *Schema consts, their z.input aliases and the three *Parsed aliases)", + "replacement": "nothing to re-declare — no incident-response engine exists on the platform, so there is no working configuration to migrate to. Nothing classified, tracked, escalated or notified an incident and nothing notified a regulator; a compliance record the organisation keeps is ordinary object data, declared as an object with its own fields and enforced by the object engine (validation, permissions, the object-level `lifecycle` block under ADR-0057). If incident response becomes a product capability it re-declares fresh, through the enforce route of ADR-0049 — the engine first, the vocabulary second", + "migrationId": "incident-response-family-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 on the families' remaining keys and defs (ruled A: retire the three compliance-shaped families whole via RETIRED_DEFS_BY_MAJOR, the integration/ErrorMappingConfig precedent; not roadmapped). Eight defs and roughly forty declared keys sat on the exported surface and in the generated reference docs, and were read by NOTHING: the schemas were exported from `@objectstack/spec/system`, mounted by no `stack.zod.ts` key, registered as no metadata type, absent from the 2026-06 liveness ledgers, and the reader census over every package outside `packages/spec` (tests and changelogs excluded), over `examples/**` and `skills/**`, and over objectui at the pinned sha returned zero hits for every exported name, with a lit control. Several keys were boolean capability claims of exactly the shape ADR-0049 names — `IncidentNotificationRule.notifyRegulators`, `IncidentResponsePolicy.requirePostIncidentReview` — so an author (very often an AI, ADR-0033) could write `notifyRegulators: true`, parse clean, and hold a compliance promise the platform never kept, with no error and no feedback. Tagging the family `[EXPERIMENTAL — not enforced]` was the fallback the ruling did not take: it is a human-only signal, and an AI generating from the schema still writes the key and believes it. The deadline-key tombstones of the 2026-09-02 per-family ruling (six sites, `RETIRED_KEYS_BY_MAJOR[18]`, D3 `incident-response-deadline-keys-retired`) leave with their defs' source; their registry entries stay as history. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections; none of these schemas is either, so a conversion would be a transform with no seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent), and with no carrier key there is no shape on which a tombstone could sit." + }, + { + "surface": "object.fields..inlineColumns[].scale on an inline grid column that declares `type: 'currency'` — any declared value, `scale: 0` included, computed or not. `scale` on a `number` column is untouched. A column that declares no `type` is judged as the type it renders as: over a `currency` field of the child object it is entry `inline-grid-column-identity-only-currency-scale-refused`", + "replacement": "no `scale` on a currency inline grid column. DELETE the key — that is the whole migration: a currency amount's decimal places are its currency's, not a column setting. The currency's ISO 4217 minor unit decides how the cell displays the amount and the width a computed amount is rounded to. ⛔ Nothing replaces the key: do not re-declare its value under any other key.", + "migrationId": "inline-grid-column-currency-scale-refused", + "toMajor": 18, + "rationale": "The maintainer's ruling of 2026-09-23 (option B) retired `scale` from the `currency` field type, and the ruling of 2026-09-24 (option 乙 — a currency's ISO 4217 minor unit decides its display) worded the remedy. Neither reached the inline grid column, the strict mirror of the console grid's column, which still offered per-column decimals on a `currency` column; triage read the column as inherited from both rulings, so `InlineGridColumnSchema` now refuses the key on a column declaring `type: 'currency'` at parse, with the field refusal's first sentence and remedy. ⛔ No alias and no grace window, per ruling B. NOT mechanically converted, deliberately, for the reason the field entry `field-currency-scale-refused` gives: a conversion that dropped the key would accept it on every load, which is the grace window the ruling refused; the refusal names the key and its one-line fix instead. The same change rewords the column's `prefix` description: it replaces the resolved currency's symbol and has no default (the grid no longer falls back to a fixed yen sign). Reach: the column schema judges only a DECLARED column `type` — a column that declares none takes its type from the child field when the console hydrates it, which the schema cannot see; `defineStack` judges that column instead (entry `inline-grid-column-identity-only-currency-scale-refused`). Population measured at the change, on origin/main 1c8b320a89: one authored `inlineColumns` block in the tree (the showcase invoice, seven identity-only columns, none declaring `type` or `scale`), no platform object, skill, documentation example or JSON fixture declaring an inline grid column at all, and one test fixture carrying `scale: 2` on a currency column, re-judged in the same change. Deployed metadata NOT MEASURED." + }, + { + "surface": "object.fields..inlineColumns[].scale and view.form.subforms[].columns[].scale (and each formViews entry) on a column that declares NO `type` and whose `name` is a `currency` field of the child object — any value, `scale: 0` included. A column declaring `type: 'number'`, and a column over a field of any other type, keep `scale`", + "replacement": "no `scale` on the column. DELETE the key — that is the whole migration: the column renders as a currency column, and a currency amount's decimal places are its currency's. The currency's ISO 4217 minor unit decides how the cell displays the amount and the width a computed amount is rounded to. ⛔ Nothing replaces the key: do not re-declare its value under any other key, and do not add `type: 'number'` to keep it on a currency amount.", + "migrationId": "inline-grid-column-identity-only-currency-scale-refused", + "toMajor": 18, + "rationale": "The refusal of `scale` on a currency inline grid column (entry `inline-grid-column-currency-scale-refused`, under the maintainer's rulings of 2026-09-23, option B, and 2026-09-24, option 乙) reached only a column that DECLARES `type: 'currency'`, because the column schema cannot see the child field. An identity-only column — the recommended form — over a currency field renders as a currency column all the same, so it published green carrying the refused key, and the console ignored it. `defineStack`'s cross-reference check, which holds the child object's fields, now judges such a column as the type it renders as and refuses it with the column schema's own message. Reach: the child object must be declared in the same stack; a column naming no field of it, or a subform whose child object comes from another package, is not judged there. Population measured at the change, on origin/main cb4c31dd52: one authored `inlineColumns` block (the showcase invoice, seven identity-only columns, none carrying `scale`) and zero authored `subforms`. Deployed metadata NOT MEASURED." + }, + { + "surface": "job.timeout — the per-attempt time limit of a scheduled job", + "replacement": "`timeoutMs` — the same per-attempt limit, in milliseconds, beside the sibling `retryPolicy.backoffMs` that already spelled its unit.", + "migrationId": "job-timeout-unit-in-key", + "toMajor": 18, + "rationale": "The D2 conversion `job-timeout-to-timeout-ms` renames `timeout` to `timeoutMs` in author sources and wherever the chain is replayed, keeping the value, and the rename is lossless: the key always meant milliseconds. The judgment is whether the author knew that. The unit lived only in the description while `retryPolicy.backoffMs` beside it spelled its own, so one job definition carried two conventions; a seconds value copied in — `timeout: 300` for a five-minute job — became a 300-millisecond limit with no error, and the rename carries the 300 over unchanged. A limit that short fails every attempt and burns the retry budget, which is easy to misread as a flaky job. Only the author can say which unit each value was written in, and code that builds job definitions in TypeScript is outside the chain's reach." + }, + { + "surface": "CompatibilityMatrixEntry.estimatedMigrationTime, the migration effort estimate whose unit lived only in a source JSDoc (kernel/plugin-versioning.zod.ts)", + "replacement": "estimatedMigrationTimeHours — rename the key AND state the unit in the describe; the value (hours) is unchanged", + "migrationId": "kernel-compatibility-matrix-estimated-migration-time-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling A of 2026-09-17 on the last two duration keys no closed duration type could express (this one in hours, the other in fractional seconds): rename the key and record an ADR-0087 conversion-layer entry, with no new closed type and no narrowing of anything already stored. The key said \"Estimated migration time in hours\" in a source JSDoc and carried no .describe() at all — the JSDoc-channel shape (a unit stated only in a source comment the reference page never prints), one def over. The JSDoc stops at the source file; .describe() is what content/docs/references/** renders, so the published page printed a bare number directly beside migrationComplexity, whose scale IS named (trivial/simple/moderate/complex/major). A reader comparing \"major\" with \"40\" had no way to know whether 40 was minutes, hours or days. The remedy is BOTH halves, and the second is not optional: renaming alone would leave the two channels that name the unit — the key name and a source comment — agreeing about something the published page does not print, which check:duration-unit-keys refuses as unit-in-jsdoc-not-in-describe (that agreement shape — a unit in the key name and the JSDoc, none in the describe — was ruled an offence on 2026-09-18). So the unit moves INTO the describe and the key name carries it too. HOURS is kept rather than converted to seconds: the value is unchanged, the ruling forbade narrowing, and an effort estimate is authored in hours by the human who writes the plugin manifest. Tombstoned with retiredKey(): CompatibilityMatrixEntrySchema is a plain z.object, not strict, so a bare deletion would strip the old spelling in silence and a manifest would lose its one effort figure with no error anywhere. Why a semantic entry and not a D2 conversion: a compatibility matrix is a plugin-published version manifest — stack.zod.ts declares no collection of them and it is not a registered metadata kind stored as a sys_metadata row — so the chain has no seam that sees one. ADR-0087." + }, + { + "surface": "context.mode — the value 'preview' left the RuntimeMode enum — and context.previewMode, the whole PreviewModeConfig block it keyed (autoLogin / simulatedRole / simulatedUserName / readOnly / expiresInSeconds / bannerMessage, declared on KernelContext and on the TenantRuntimeContext extension). The exported PreviewModeConfigSchema / PreviewModeConfig / PreviewModeConfigParsed names left with the def", + "replacement": "nothing declarative — the capability the block described was never implemented by any layer, so there is no working configuration to migrate to. Preview/demo DEPLOYMENTS belong to the deployment layer, which owns auth per-project (ArtifactKernelFactory in the cloud distribution); the OS_PREVIEW_MODE environment variable stays exactly as it is — deployment ROUTING (widening the trusted-origin list for preview subdomains), unrelated to identity. If a preview experience becomes a product capability it re-declares fresh, with the production-posture hard-refusal as the first-landed half (as the removal ruling recorded)", + "migrationId": "kernel-context-preview-mode-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-08-27 (Option A: remove). The declaration was the sharpest declared-≠-enforced shape on a SECURITY surface: the schema promised \"bypass auth, simulate admin identity\" and named a production guard \"the runtime must enforce\", and NO code path implemented either half. Measured zero consumers in all three repos, each leg with positive controls: objectstack — no runtime branches on the mode; the only non-declaration hits for RuntimeMode or mode === 'preview' are the schema unit test, a type-alias pin and a measurement-test comment (re-verified at dispatch, 2026-08-27, origin/main 15bf9e8). objectui — zero consumers, measured when the removal was ruled. cloud — a census closed 2026-08-26: OS_PREVIEW_MODE there is a routing-only switch (the same switch this repository's serve.ts reads only to add preview-domain wildcards to better-auth's trusted origins); RuntimeMode has zero hits repo-wide; the positive control ArtifactKernelFactory (where serve.ts predicted preview auto-login would live if it existed) has 20+ hits and never touches previewMode. An author — very often an AI (ADR-0033) — could write the six-key block per the reference docs, parse cleanly, and get no behaviour and no diagnostic, while a reader of the docs had no way to tell the block from the keys that work. Bookkeeping: the enum-VALUE half ('preview') puts nothing in RETIRED_KEYS_BY_MAJOR and leaves the four surface ratchets untouched by itself — its prescription hangs on the enum's own error map (the HookBodyCapability precedent); the KEY half is tombstoned with retiredKey() on the non-strict KernelContextSchema (both walked-shape copies registered in RETIRED_KEYS_BY_MAJOR[18]); the DEF half (kernel/PreviewModeConfig, with no carrier left) is registered in RETIRED_DEFS_BY_MAJOR[18]. It is a SEMANTIC entry rather than a D2 conversion because there is no source to rewrite: a kernel context is constructed by host code at boot — not a stack collection member, never stored as a sys_metadata row — so the conversion chain has no seam that would ever see one (the kernel/Manifest:loading disposition). ADR-0049 / ADR-0087." + }, + { + "surface": "the two event-bus retention windows whose name carried no unit: EventPersistence.retention (kernel/events/handlers.zod.ts) and EventSourcingConfig.retention (kernel/events/queue.zod.ts)", + "replacement": "retentionDays on both — rename each key; both values are unchanged, and so is the 365 default on EventSourcingConfig", + "migrationId": "kernel-event-bus-retention-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. What makes these two one entry rather than two is the neighbour they share and the one they do not. Both hang off EventBusConfig, so an author configuring a bus met the same bare word twice and had to learn the unit twice; and on EventSourcingConfig the bare retention sits two keys below snapshotRetention, which is a COUNT of snapshots to keep, not a span of time. `retention: 365` and `snapshotRetention: 10` read as the same kind of number and are not. Suffixing the duration separates the families at the authoring site; snapshotRetention keeps its name, because a count has no unit to carry. Both are retiredKey() tombstones — neither shape is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: an EventBusConfig is the event bus construction argument a host builds in code (stack.zod.ts declares no eventBus key and no metadata kind is bound to one), so it is never a stack collection member and never a stored sys_metadata row, and the conversion chain has no seam that would see one. That is what ruling B prescribes for a key that is not authorable metadata, and the disposition the epoch-instant renames on this same kernel took (epoch-instant-keys-renamed). ADR-0087." + }, + { + "surface": "the three plugin-lifecycle durations whose unit lived in a source JSDoc only: PluginHealthCheck.interval, PluginHealthCheck.timeout and HotReloadConfig.debounceDelay (kernel/plugin-lifecycle-advanced.zod.ts)", + "replacement": "intervalMs, timeoutMs and debounceDelayMs — rename each key; all three values (milliseconds) and their 30000 / 5000 / 1000 defaults are unchanged", + "migrationId": "kernel-health-check-and-hot-reload-durations-unit-in-key", + "toMajor": 18, + "rationale": "Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the maintainer's 「同意」: a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands — the duration-unit rule (the unit lives in the key name or its value type, never in prose alone) executed file by file. Each key named milliseconds in its JSDoc — \"Health check interval in milliseconds\", \"Timeout for health check in milliseconds\", \"Debounce delay before reloading (milliseconds)\" — and the JSDoc above a key is NOT what `content/docs/references/**` renders; `.describe()` is. Measured on this tree by the gate's own census (check-duration-unit-keys --list): all three read [name: -] [prose: -] — no unit in the name and none in the published prose either. `interval` is the sharpest of the three: its describe carried one unit-shaped token, the parenthetical \"(default: 30s)\", which names SECONDS for a value the schema bounds and defaults in MILLISECONDS (min 1000, default 30000). That is the 1000x confusion the rule exists for, published to the one reader who cannot see the source. The suffix is the family's own spelling, counted on this tree: 100 key-position *Ms declarations across packages/spec, timeoutMs 29 of them and intervalMs 3, so both renames land on names the surface already uses. debounceDelay takes the plain suffix rather than a shortened form: it is the only debounce-shaped key spelling in the whole repo (5 key-position occurrences, all of this one key and its fixtures, no debounceMs variant anywhere), while the Delay-plus-Ms pairing is already attested (maxDelayMs, initialDelayMs, retryDelayMs, delayMs) — so unlike the Ttl-versus-TTL question the sibling round had to settle, there is no competing family spelling to choose between. All three old spellings are retiredKey() tombstones: neither PluginHealthCheckSchema nor HotReloadConfigSchema is .strict(), so a bare deletion would be a SILENT STRIP (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — and here the stripped value lands on a setInterval period, a race deadline and a setTimeout delay. Why a semantic entry and not a D2 conversion: the conversion chain walks a normalized STACK, and neither def is an authorable surface — no metadata-type binding, stack collection or manifest embed carries either, and both are library parameters a host passes to PluginHealthMonitor / HotReloadManager in TypeScript (kept twice: as the hot-reload vocabulary that had an implementation when the manifest-side copy was removed, and as a host-driven library when the declarative lifecycle config container was retired) — so a conversion would be a transform with no seam that ever runs. That is the same disposition plugin-auto-restart-never-reinitialised and hot-reload-watch-placeholder-retired recorded for keys on these two defs. The registration-time refusals in PluginHealthMonitor.registerPlugin and HotReloadManager.registerPlugin are the door for the audience that does not parse. Measured on 884e8347d: the only in-repo readers are packages/core/src/health-monitor.ts and packages/core/src/hot-reload.ts, both moved in this same change; and the pinned objectui checkout — the pin this repo builds against, `.objectui-sha` = `f0268ad784854568aa58a2aa791f6a7502259186` — names neither def and neither key: all thirteen exports of plugin-lifecycle-advanced.zod.ts and the string debounceDelay each occur 0 times across its 8234 tracked files (0 across the 7754 at a58626c88, the 7650 at 0abd4f9f8, the 7632 at 9dfaca654, the 7579 at 2e818d0b5, the 10267 at ab1879721, the 10071 at 89cad75d5, the 9912 at 31971ff1e, the 9800 at e420df310, the 9546 at db11afd49, the 9283 at dd3f7e1be, the 8512 at f8a9d0fb0 and the 8303 at 62597c588 too), against lit controls objectstack 12966 and @objectstack/spec 4997 on the same corpus at 87af769e9, which re-count to 13125 and 5043 respectively at 62597c588, to 13347 and 5123 at f8a9d0fb0, to 13745 and 5466 at dd3f7e1be, to 14704 and 5545 at db11afd49, to 15352 and 6024 at e420df310, to 15691 and 6206 at 31971ff1e, to 16044 and 6461 at 89cad75d5, to 16377 and 6665 at ab1879721, to 17227 and 7134 at 2e818d0b5, to 17313 and 7186 at 9dfaca654, to 17390 and 7209 at 0abd4f9f8, to 17468 and 7246 at a58626c88 and to 17956 and 7522 at this pin (git grep -o -F, the method that reproduces every earlier count)." + }, + { + "surface": "the three package and version lifecycle durations whose name carried no unit: UpgradePlan.estimatedDuration (kernel/package-upgrade.zod.ts), PackageDependencyResolutionResult.resolvedIn (kernel/plugin-security.zod.ts) and MultiVersionSupport.rollout.duration (kernel/plugin-versioning.zod.ts)", + "replacement": "estimatedDurationSeconds, resolvedInMs and durationMs — rename each key; every value is unchanged", + "migrationId": "kernel-package-lifecycle-durations-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. These three are one entry because they are one story told to one audience — a package being planned, resolved and rolled out — and because the group is precisely where the unit SPLITS: estimatedDuration is SECONDS while resolvedIn and rollout.duration are MILLISECONDS, three adjacent measurements of the same install, two units, none of them named. A reader who learned the unit from one of these three learned it wrongly for the other two. The rollout case adds a second confusion of its own: duration sat directly beside the unit-less percentage, so one block carried a proportion and a span as indistinguishable bare numbers; percentage keeps its name, because a proportion has no time unit to carry. All three are retiredKey() tombstones; no shape here is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: an UpgradePlan is GENERATED by IPackageService.planUpgrade() before an upgrade runs, a PackageDependencyResolutionResult is emitted by a resolution run, and MultiVersionSupport is a version-routing argument a host constructs — none is a stack collection member or a stored sys_metadata row, so the conversion chain has no seam that would see one. That is what ruling B prescribes for a key that is not authorable metadata. ADR-0087." + }, + { + "surface": "the two plugin health-report metrics whose name carried no unit: PluginHealthReport.metrics.uptime and PluginHealthReport.metrics.responseTime (kernel/plugin-lifecycle-advanced.zod.ts)", + "replacement": "uptimeMs and responseTimeMs — rename each key; both values are unchanged", + "migrationId": "kernel-plugin-health-report-durations-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. uptime is the case this rule was written for, and this repo had already paid for it in documentation: the platform serves a SECONDS-valued uptime on GET /health and stores a MILLISECONDS-valued uptime on this report, so the protocol lifecycle page carried a standing paragraph whose whole job was telling the two apart (\"metrics.uptime is in milliseconds, unlike the seconds-valued uptime of GET /health above\"). A prose warning that has to exist is the symptom; the key name is where the fix belongs. responseTime moves with it because it is a sibling in the same metrics block and because the identical bare name means HOURS on PluginSecurityManifest.vulnerabilityDisclosure.responseTime, renamed by this same card. The other metrics keep their names, deliberately: memoryUsage is bytes, cpuUsage is a percentage, activeConnections is a count and errorRate is a rate — none is a duration, and this rule reaches durations only. Both are retiredKey() tombstones inside the live metrics block, whose siblings must keep parsing. Why a semantic entry and not a D2 conversion: a health report is EMITTED by the monitor each round (packages/core/src/health-monitor.ts) and kept in memory — never authored into a metadata document, never a stored sys_metadata row — so the conversion chain has no seam that would see one, the same disposition HealthStatus.timestamp took (epoch-instant-keys-renamed). ADR-0087." + }, + { + "surface": "the four plugin-security durations whose name carried no unit: SandboxConfig.process.timeout, KernelSecurityPolicy.authentication.tokenExpiration, KernelSecurityPolicy.auditLog.retention and PluginSecurityManifest.vulnerabilityDisclosure.responseTime (kernel/plugin-security-advanced.zod.ts)", + "replacement": "timeoutMs, tokenExpirationSeconds, retentionDays and responseTimeHours — rename each key; every value is unchanged", + "migrationId": "kernel-plugin-security-durations-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. These four are one entry because they are one document — everything here hangs off a PluginSecurityManifest — and because together they are this rule's clearest case in the whole spec: FOUR durations on one manifest carried FOUR DIFFERENT units (milliseconds, seconds, days, hours) and not one of them said so in its name. The sharpest pair is responseTime. On this manifest it means HOURS (how fast a publisher promises to answer a vulnerability report); on PluginHealthReport.metrics, renamed by the same card, the identical bare name meant MILLISECONDS. So `responseTime: 24` was a day on one kernel shape and a fortieth of a second on another, with nothing at the authoring site to tell them apart. The policy was already inconsistent with itself, too: its rate-limit window two blocks above tokenExpiration was ALREADY spelled windowMs, so one security policy carried both conventions. All four are retiredKey() tombstones inside live blocks whose siblings must keep parsing; no shape here is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: a PluginSecurityManifest is a package artifact a publisher ships and a SandboxConfig is the isolation argument a host constructs, so neither is a stack collection member or a stored sys_metadata row and the conversion chain has no seam that would see one. That is what ruling B prescribes for a key that is not authorable metadata. One key deliberately left alone: RuntimeConfig.resourceLimits.timeout on this same file names its unit only in the JSDoc above it (\"Execution timeout in milliseconds\"), a channel the gate does not read: it reads `.describe()` and `.meta({ description })`, and that key's describe (\"Maximum execution time\") names none. So the gate lists it among the duration-shaped keys without judging it — neither an offender nor an exemption — and it is outside this rename; that JSDoc-channel gap was filed as a finding of its own and is closed for this key by kernel-runtime-config-timeout-unit-in-key. ADR-0087." + }, + { + "surface": "RuntimeConfig resourceLimits.timeout (kernel/plugin-security-advanced.zod.ts)", + "replacement": "resourceLimits.timeoutMs — rename the key; the value (milliseconds) is unchanged", + "migrationId": "kernel-runtime-config-timeout-unit-in-key", + "toMajor": 18, + "rationale": "This entry COMPLETES what the kernel-directory duration renames deliberately left alone, and the two are meant to be read as a sequence. That round renamed the four plugin-security durations on this same file (`kernel-plugin-security-durations-unit-in-key`) and recorded, accurately, that one key was out of its scope: RuntimeConfig.resourceLimits.timeout named its unit only in the JSDoc above it (\"Execution timeout in milliseconds\"), a channel check:duration-unit-keys does not read — it reads `.describe()` and `.meta({ description })` — and that key's describe (\"Maximum execution time\") named none, so the gate listed it among the duration-shaped keys without judging it, neither an offender nor an exemption. That JSDoc-channel gap was filed as a finding of its own, and that round's statement about its own scope stays true. The finding is now ruled and this is its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer's 「同意」, which keeps the refusal of a duration key whose JSDoc names a unit its describe does not, remediates the 21-row JSDoc-channel population per file, and lands that widened gate last, into a tree already clean. So the reader who most needs the unit — the reader of the published reference page, who never sees the source JSDoc — got a bare integer on content/docs/references/kernel/plugin-security-advanced.mdx and could not tell 60000 milliseconds from 60000 seconds. The key is renamed and the describe is corrected in the same stroke, because under the duration-unit rule (the unit lives in the key name or a unit-carrying value, never in the describe prose alone) moving the unit into the describe alone is itself a violation (unit in prose, none in the name). Spelled Ms, the same token SandboxConfig.process.timeoutMs on this very file already carries: counted on this tree, the suffixed family spells it that way in every member (29 key-position `timeoutMs` declarations across packages/spec/src/**/*.zod.ts, 40 distinct *Ms keys) and there is no timeoutMillis, timeout_ms or timeoutMS variant anywhere in packages/spec/src. Tombstoned with retiredKey() because the nested resourceLimits object is not strict, so a bare deletion would silently strip the key. Why a semantic entry and not a D2 conversion: a RuntimeConfig is the engine block of the SandboxConfig a host or a plugin security manifest constructs — stack.zod.ts declares no sandbox, security-policy or runtime-config collection and it is not a stored sys_metadata row — so the conversion chain has no seam that runs on it; the same reading the kernel-directory round recorded for the four keys it renamed. Measured on 146c291943: no in-repo runtime reads the key — packages/core/src/security/sandbox-runtime.ts, the one consumer of this shape, reads resourceLimits.maxCpu (3 occurrences of resourceLimits) and spells timeout 0 times; outside the zod file and its test the only live occurrences are the generated rows in content/docs/references/kernel/plugin-security-advanced.mdx, which this rename regenerates. The pinned objectui checkout — this is the pin we build against, `.objectui-sha` = `f0268ad784854568aa58a2aa791f6a7502259186`, re-read from this tree — spells resourceLimits.timeout 0 times across 8234 tracked files, against lit controls timeout 1658, RuntimeConfig 337 and resourceLimits 2 on the same corpus (0 across 7754, and 1431 / 299 / 2, at a58626c88; 0 across 7650, and 1360 / 293 / 2, at 0abd4f9f8; 0 across 7632, and 1360 / 293 / 2, at 9dfaca654; 0 across 7579, and 1351 / 276 / 2, at 2e818d0b5; 0 across 10267, and 1348 / 273 / 2, at ab1879721; 0 across 10071, and 1331 / 273 / 2, at 89cad75d5; 0 across 9912, and 1303 / 273 / 2, at 31971ff1e; 0 across 9800, and 1293 / 273 / 2, at e420df310; 0 across 9546, and 1197 / 273 / 2, at db11afd49; 0 across 9283, and 1172 / 263 / 2, at dd3f7e1be; 0 across 8512, and 1096 / 245 / 2, at f8a9d0fb0; 0 across 8303, and 1086 / 240 / 2, at 62597c588); both resourceLimits hits are prose in packages/app-shell recording that objectui's own AppShellRuntimeConfig shares not one key with the spec's RuntimeConfig, so nothing there authors this key and no pin bump is owed. ADR-0087." + }, + { + "surface": "the three startup-orchestration durations whose name carried no unit: StartupOptions.timeout, PluginStartupResult.duration and StartupOrchestrationResult.totalDuration (kernel/startup-orchestrator.zod.ts)", + "replacement": "timeoutMs, durationMs and totalDurationMs — rename each key; every value is unchanged, and so is the 30000 default on StartupOptions", + "migrationId": "kernel-startup-orchestrator-durations-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. These three are one entry because they are one boundary: a host passes StartupOptions in, and the orchestrator hands PluginStartupResult and StartupOrchestrationResult back from the same call. The file already contained its own counter-example — IStartupOrchestrator.startWithTimeout(plugin, context, timeoutMs) named its parameter timeoutMs while the options object beside it said timeout, so one contract carried both conventions and the suffixed one was already the honest half. totalDuration is the sum of the per-plugin durations, so the two had to move together or the aggregate would have been spelled unlike its parts. All three are retiredKey() tombstones; none of these shapes is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: StartupOptions is a boot-time call argument and the two result shapes are emitted measurements, so none is ever a stack collection member or a stored sys_metadata row and the conversion chain has no seam that would see one — the same disposition HealthStatus.timestamp took on this very file (epoch-instant-keys-renamed), and what ruling B prescribes for a runtime-emitted key. ADR-0087." + }, + { + "surface": "view.list.navigation.view", + "replacement": "page assignment — assign a `record` page to the object and let `isDefault` pick the one that opens. That is the machinery that resolves a detail layout by name; a list view's `navigation` block decides only HOW the detail is surfaced (`mode`, `size`, `preventNavigation`, `openNewTab`, `width`), and every one of those keys is unchanged", + "migrationId": "list-view-navigation-view-retired", + "toMajor": 18, + "rationale": "DECLARED, CONSUMED, AND WRONG — which is why this is a semantic TODO rather than a mechanical strip. The key's describe promised \"the form view to use for details\" and no layer from spec to console ever resolved a view by name. Its only read in the shipped console put the value in the SECOND argument of `onNavigate`, the slot that otherwise carries the navigation-MODE token: an authored `view` did not select a view, it SUBSTITUTED for the mode. At least one consumer in the same bundle reads that argument against a closed two-value vocabulary (`edit` / `view`), so any other authored value matched neither branch — invisible on grids whose handler takes one argument, a dead row click on the ones that do not. The enumeration behind the removal was exhaustive rather than sampled: every `.view` property read in the bundle (three) and every `formViews` read, and NO read anywhere is keyed by an authored view name, so there is no path by which this key or any sibling could have resolved one. ADR-0049 enforce-or-remove; zero authored instances in this repo and the one external author removed its occurrence, so the pull that would justify ENFORCE is zero. A mechanical D2 strip was weighed and declined with the direction: deleting the key silently discards the author's actual intent — \"open the detail in THIS layout\" — and leaves no record of which list view carried it, which is exactly the judgement a semantic TODO exists to hand back. Should \"open the detail in a chosen view\" ever be pulled, it belongs to the page-assignment machinery (`record` pages, `isDefault`), not to a string on a list view." + }, + { + "surface": "view.list / view.listViews.* — the list-view type page and its pageName binding", + "replacement": "Publish the page and give the app a navigation item for it — `{ type: 'page', pageName: '' }` under the app `navigation`, the page mount that has always rendered. Keep the list view only if it should draw rows of its object, as a `grid` or one of its siblings.", + "migrationId": "list-view-page-mount-retired", + "toMajor": 18, + "rationale": "The D2 conversion `view-page-mount-removed` deletes `type: 'page'` (the schema default then parses the view as `grid`) and `pageName` from every view payload in `stack.views[]`, in all three persisted spellings, and the delete is lossless in pixels: no renderer ever routed the page member, so a page view has always drawn an empty grid, and it still does. The author wanted a PAGE in front of users at that place in the app, and the view never showed it. Whether to reach the page through a navigation item, and whether the now-plain grid view should exist at all, are the author's decisions. One boundary is theirs by construction: a page mount declared under `objects[].listViews` is reached by no conversion, so it is refused at its own door until edited by hand." + }, + { + "surface": "view.list.sort / view.listViews.*.sort — the bare string sort clause", + "replacement": "The structured array, `sort: [{ field, order }]`, with `order` written out — a bare field name meant ascending — and one entry per key of a comma-separated clause, in the same order.", + "migrationId": "list-view-sort-string-clause-retired", + "toMajor": 18, + "rationale": "The D2 conversion `list-view-sort-string-clause-to-array` rewrites a string clause in the grammar the wire normalizer splits on — `'created_at desc'`, a bare field name, a comma-separated list — into the array, losslessly, across every view payload in `stack.views[]`. Two cases are deliberately left for the author. A string that does NOT parse as that grammar — above all the leading-minus dialect, `'-created_at'` — is left alone and refused at the door, because guessing a direction would invent an ordering the author never wrote. And a clause under `objects[].listViews` is reached by no conversion, so it is refused at its own door until rewritten by hand. The clause was minted by the schema and refused by the renderer that lowers it into a query, so a view carrying one may already have been failing to load; which order the author meant is theirs to state." + }, + { + "surface": "view.list.tabs / view.listViews.*.tabs — the list view's own tab definitions", + "replacement": "One named list view per tab, under the object's `listViews` — the saved-view switcher above the object's records renders every entry as a tab. The tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry, beside the `columns` the tab should show. A tab whose `view` already named a list view needs nothing more.", + "migrationId": "list-view-tabs-retired", + "toMajor": 18, + "rationale": "The D2 conversion `view-list-tabs-removed` deletes `tabs` from every list payload in `stack.views[]`, in all three persisted spellings, and the delete is lossless in pixels: no renderer ever mounted a tab bar for the key, so a view that declared tabs has always drawn without them, and it still does. The judgment the conversion cannot make is the author's intent: each tab was a named preset the author wanted end users to switch to, and the platform delivers that as a named list view, not as a sub-key of one. Which tabs deserve an entry, what each should filter and show, and whether the switcher already lists an equivalent, are the author's decisions. The tab keys with no list-view counterpart — `icon`, `order`, `pinned`, `isDefault`, `visible` — never had an effect either. One boundary is the author's by construction: tabs declared under `objects[].listViews` are reached by no conversion, so such an object is refused at its own door until edited by hand." + }, + { + "surface": "HttpDestinationConfig `batch.flushInterval` / `retry.initialDelay` / `timeout` and LoggingConfig `buffer.flushInterval` (system/logging.zod.ts)", + "replacement": "`batch.flushIntervalMs` (default 5000) / `retry.initialDelayMs` (default 1000) / `timeoutMs` (default 30000) on HttpDestinationConfig, and `buffer.flushIntervalMs` (default 1000) on LoggingConfig — rename the keys; every value (milliseconds) is unchanged", + "migrationId": "logging-durations-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling A, 2026-09-11: the gate that reads a duration key's JSDoc lands last, after its offenders are fixed file by file — so this entry executes, per file, the rule that a duration number key carries its unit in its name. All four keys named milliseconds in a source JSDoc — \"Flush interval in milliseconds\", \"Initial retry delay in milliseconds\", \"Timeout in milliseconds\" — and the JSDoc above a key is not what `content/docs/references/**` renders; `.describe()` is, and none of the four carried one at all. Measured by the `check:duration-unit-keys` census on this tree before the change, all four read `[name: -] [prose: -]`: no unit in the key and no published prose to supply it, so `content/docs/references/system/logging.mdx` printed a bare 5000 / 1000 / 30000 / 1000 and nothing on the page decided milliseconds from seconds. Under that rule's gate, moving the unit into the describe alone is itself a violation (unit in prose, none in the name), so each key is renamed and given the describe it never had in the same stroke. ⚠️ `flushInterval` was declared TWICE on this file, in two different defs and with two different defaults — 5000 on the HTTP destination's batch and 1000 on the logging buffer — so they are two keys, each with its own tombstone and its own registered row; the prescriptions name their def so a reader who lands on one is not sent to the other. The `Ms` suffix is the family's own spelling, counted in key position on this tree: 272 `*Ms:` declarations in `packages/spec/src` against 75 `*Seconds:`, and the only competing unit spellings are 3 `*MS:` and 9 `*Millis:` — every one of them a name fixed outside this repo (MongoDB's `maxCommitTimeMS` and `connectTimeoutMS`, node-postgres's `idleTimeoutMillis` and `connectionTimeoutMillis` on `PoolConfigSchema`), so unlike the `Ttl`-versus-`TTL` question a sibling round settled there is no in-repo alternative to choose between. All three target spellings were already attested as key-position `*.zod.ts` declarations before this change: `flushIntervalMs` 1 (`kernel/events/integrations.zod.ts`, same 1000 default), `initialDelayMs` 5, `timeoutMs` 30. Tombstoned with `retiredKey()` rather than deleted because none of the four enclosing objects — `HttpDestinationConfig` itself and its nested `batch` and `retry`, and `LoggingConfig`'s nested `buffer` — is `.strict()`, so a bare deletion would have stripped the value in silence. Why a semantic entry and not a D2 conversion: `stack.zod.ts` declares no logging collection and neither `LoggingConfigSchema` nor `HttpDestinationConfigSchema` is referenced anywhere in `packages/spec/src` outside `system/logging.zod.ts`, so the chain has no rehydration seam that runs on an authored logging document — the same reading `tenant-schema-cache-ttl-unit-in-key` recorded for its sibling key. Measured on 4dab2bc5c: no in-repo runtime reads any of the four — outside `packages/spec/src/system/logging.zod.ts` and its test the only occurrences are the generated rows in `content/docs/references/system/logging.mdx`, which this rename regenerates; and the pinned objectui checkout — `.objectui-sha` = `f0268ad784854568aa58a2aa791f6a7502259186` — spells `flushInterval` 0 times, `initialDelay` 0, `HttpDestinationConfig` 0 and `LoggingConfig` 0 across its 8234 tracked files, against lit controls `useState` 2622 and `timeout` 1658 on the same corpus (all four 0 across 7754, against 2491 and 1431, at a58626c88, 0 across 7650, against 2478 and 1360, at 0abd4f9f8, 0 across 7632, against 2477 and 1360, at 9dfaca654, 0 across 7579, against 2477 and 1351, at 2e818d0b5, 0 across 10267, against 2476 and 1348, at ab1879721, 0 across 10071, against 2470 and 1331, at 89cad75d5, 0 across 9912, against 2469 and 1303, at 31971ff1e, 0 across 9800, against 2464 and 1293, at e420df310, 0 across 9546, against 2449 and 1197, at db11afd49, 0 across 9283, against 2435 and 1172, at dd3f7e1be, 0 across 8512, against 2391 and 1096, at f8a9d0fb0, and 0 across 8303, against 2389 and 1086, at 62597c588)." + }, + { + "surface": "the manage_org_presentation platform capability (its PLATFORM_CAPABILITIES entry in @objectstack/spec security, the ORG_PRESENTATION_AUTHORING_CAPABILITY constant exported by @objectstack/metadata-core) and the arm of metaWriteCapabilityVerdict that admitted its holders to org-scoped writes of the five org-overridable types through the /meta item doors", + "replacement": "grant `manage_metadata` to whoever must author views, dashboards, reports, translations or email templates through Studio or `PUT /api/v1/meta//`; such a write now lands environment-wide (`organization_id` NULL) and is served to every organization of the deployment. There is no organization-bounded authoring capability: delete `manage_org_presentation` from every permission set's `systemPermissions`, and delete any import of `ORG_PRESENTATION_AUTHORING_CAPABILITY`. `metaWriteCapabilityVerdict` takes `{ isSystem, systemPermissions, operation }`: drop the `canonicalType` and `activeOrganizationId` members from the call", + "migrationId": "manage-org-presentation-retired", + "toMajor": 18, + "rationale": "ADR-0131 D6 retires the per-organization overlay axis, and the /meta doors stop carrying an organization into a metadata write (the companion entry meta-doors-organization-scope-retired). The capability admitted an organization admin to exactly the writes those doors threaded into the admin's own organization; with no organization threaded, keeping it would have admitted its holders to environment-wide authoring, which is the reach of manage_metadata and a wider one than the capability ever granted. It was granted by no shipped permission set, so a deployment that never granted it by hand observes nothing." + }, + { + "surface": "manifest.id — `ObjectStackManifest.id`, i.e. `defineStack({ manifest: { id } })` and the `id:` key of a package manifest — and its registry face `PackageSchema.manifestId` (`marketplace/package.zod.ts`)", + "replacement": "a reverse-domain identifier matching `MANIFEST_ID_PATTERN` (`kernel/manifest.zod.ts`): two or more lowercase dot-separated segments of letters, digits and inner hyphens, each opening with a letter or a digit, never a hyphen — `com.acme.crm`, `org.apache.superset`. ⛔ Underscores are not admitted, so `manifest.namespace` is never a legal id and never a legal last segment of one: `com.acme.my_app` becomes `com.acme.my-app`. A bare word gains a prefix: `blank` becomes `com.example.blank`. The refusal carries the repaired value it has already checked against the pattern, so the prescription is in the error text, not only here.", + "migrationId": "manifest-id-reverse-domain-required", + "toMajor": 18, + "rationale": "Two declarations named one identity and drifted. `PackageSchema.manifestId` — what the registry stores and addresses a package by — has always carried the reverse-domain regex; `ManifestSchema.id`, the key an author actually writes, was `z.string()` and accepted anything. So a package scaffolded, validated, built and booted with an id the publish path would refuse, and the author met the rule for the first time at the one moment it was most expensive to meet. The two sites now reference ONE exported constant, which is what makes a future divergence a visible edit rather than a silent one. Why the rule holds for a package nobody publishes: the TSDoc's own words are \"unique across the entire ecosystem\" — an id names the artifact for the ecosystem it may one day join, so a private app is named under the same rule as a listed one. Why it is a D3 semantic TODO and not a D2 conversion: the value IS the identity. A mechanical rewrite would re-point every install, dependency declaration and stored `manifest_id` row at a package that, to the registry, is a different one — and the safe choice between \"rename the package\" and \"keep the id and change nothing that depends on it\" is not derivable from the metadata." + }, + { + "surface": "manifest.permissions as a flat list of permission strings (and packages[].manifest.permissions) — the legacy arm of ManifestPermissionsSchema left; the schema is now the structured plugin permission block alone", + "replacement": "the structured block `permissions: { services, hooks, network, fs }` — each a list naming the platform services the plugin resolves, the lifecycle hooks it registers, the network hosts it reaches and the filesystem paths it touches; or no `permissions` key when the plugin needs none", + "migrationId": "manifest-permissions-string-list-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove: the flat list was parsed and never acted on. The loader registers the consented grant set on the environment artifact with the permission enforcer, never the manifest's request, so a list granted, refused and requested nothing at load; the only code that met one was two reports saying it had been skipped. The D2 conversion `manifest-permissions-string-list-removed` deletes the list from existing sources and stored artifacts, losslessly for every load. What it cannot do is translate: a capability string such as `system.user.read` names no service, hook, host or path, so whether the plugin needs a grant at all, and which, is the author's judgement. Authoring now refuses a list at parse with that prescription, and TypeScript rejects it" + }, + { + "surface": "manifest.version — `ObjectStackManifest.version`, i.e. the `version:` key of `defineStack({ manifest })` and of a package manifest — and its three sibling declarations `MetadataPluginManifestSchema.version` (`kernel/metadata-plugin.zod.ts`), `PluginRegistryEntrySchema.version` (`kernel/plugin-registry.zod.ts`) and `PluginMetadataSchema.version` (`kernel/plugin-validator.zod.ts`), plus the `PATCH /api/v1/packages/:id` door in `@objectstack/runtime`", + "replacement": "a SemVer 2.0.0 string matching `SEMVER_2_0_0_VERSION_PATTERN` (`kernel/version-grammar.ts`). ⭐ This is a WIDENING for almost every author: prerelease and build suffixes are accepted for the first time, so `2.0.0-beta.1`, `17.0.0-rc.5`, `1.0.0+20230101` and `1.0.0-rc.1+exp.sha.5114f85` now pass a key that refused all of them, and identifiers may carry either ASCII case. ⛔ The one thing that stops being accepted is a leading zero in the numeric core: `01.1.1` becomes `1.1.1` — or a different version, if the padded form was standing in for one.", + "migrationId": "manifest-version-semver-2-0-0", + "toMajor": 18, + "rationale": "One concept — \"the version of a package or plugin\" — was judged by four different grammars across ten carriers in two repositories, and the strictest of them, this one, refused `2.0.0-beta.1`: the exact string a sibling declaration documented as an example of itself. The contradiction was observable between doors on the same resource, not merely between schema files — the build step refused a prerelease the publish door accepted, while the install door parsed nothing at all. The maintainer ruled one canon, and named it after the standard the repository already claimed in this key's own `.describe()`, in the generated reference docs, in the Studio help text and in two ADRs: SemVer 2.0.0. Why the narrowing is not losslessly convertible: a version is an identity. `01.1.1` and `1.1.1` are the same release to a reader and different strings to every registry row, dependency declaration and installed artifact that stored one of them, and which of the two an author meant is not derivable from the metadata." + }, + { + "surface": "mapping.fieldMapping[].params.object / .fromField / .toField / .autoCreate — the per-entry reference-resolution keys of a lookup mapping", + "replacement": "Nothing on the mapping. A `lookup` entry copies the cell through, and reference resolution runs afterwards off the TARGET field's own metadata: its `reference` names the object searched, and the cell is matched as a display value (a name, an email or a record id). Records a row points at must exist before the import runs.", + "migrationId": "mapping-lookup-params-retired", + "toMajor": 18, + "rationale": "The D2 conversion `mapping-lookup-params-removed` deletes the four keys from every mapping entry's params, and the delete is lossless: the import path never read them, so stripping them changes no imported row. The judgment is about what the author believed. `autoCreate` read as \"create the referenced record when nothing matches\", and nothing was ever created — an unresolved cell fails its row with `import_reference_not_found`, with or without the key. An import pipeline built on that belief has been losing those rows, and now needs the referenced records seeded first. `object`, `fromField` and `toField` read as the target and the matching columns, and were never consulted: where they named something OTHER than the target field's own `reference` or a column the resolver matches on, the rows were linked by the field's metadata, not by the mapping — and only the author knows which one they meant." + }, + { + "surface": "datasource.config.persistence.autoSaveInterval on the memory driver — the file and auto persistence arms", + "replacement": "`autoSaveIntervalMs` — the same interval, in milliseconds, on both arms; the minimum of 100 and the file arm's 2000 default are unchanged.", + "migrationId": "memory-persistence-auto-save-interval-unit-in-key", + "toMajor": 18, + "rationale": "The D2 conversion `memory-persistence-auto-save-interval-to-ms` renames the key on both persistence arms of every memory-driver datasource and on stored datasource rows, keeping the value, and leaves a string persistence mode, a custom adapter and every other driver's config alone; the rename is lossless because the key always meant milliseconds. The judgment is whether each value was written in that unit. Nothing in the old name said so, and the auto arm's description named no unit at all. A seconds value below 100 was already refused by the bound, but one above it was not — `autoSaveInterval: 300` meant as five minutes saved every 300 milliseconds, and the rename keeps 300. The interval also bounds how much in-memory data a crash can lose, so the author is choosing a durability trade-off, not only a number." + }, + { + "surface": "memory driver config `persistence.path` (file persistence and the `auto` override) and `persistence.key` (localStorage and the `auto` override) — values containing `${…}` placeholder syntax", + "replacement": "the literal path or key. For environment-specific destinations, leave the key unset and let the shared datasource factory scope the default per datasource, or compute the config value in code before it enters `defineStack`", + "migrationId": "memory-persistence-placeholder-refused", + "toMajor": 18, + "rationale": "The unresolved-placeholder defect one surface over from the datasource connection keys, where it is already refused: a `${…}` placeholder in memory persistence config is resolved by NOTHING — the driver would create and write a literal `./${DATA_DIR}/…` path, or write under the literal placeholder-bearing localStorage key, so the dump lands in a wrongly-named location with no error naming the unresolved placeholder (authored under the same false belief the 2026-08-13 ruling closes: placeholder syntax in connection-material keys is refused at publish, because nothing resolves it). These two keys are config-material like the connection keys, so the parent adjudication applies with its reason intact; the memory driver's `initialData` stays deliberately UNJUDGED — it carries arbitrary record values, where a literal `${…}` may be legitimate data. There is no mechanical rewrite: the placeholder names a value that exists only in the author's intended deployment environment, which a source-file transform cannot know." + }, + { + "surface": "the organization the /meta doors of @objectstack/rest and of the runtime dispatcher thread into a metadata write (PUT, DELETE, publish, rollback) or read (item, list, layered, published, drafts, history, audit, diff, diagnostics, references) of the five org-overridable types; the organizationIdForMetaWrite export of @objectstack/metadata-core; the metaReadOrganizationId export of @objectstack/rest; and the Default Organization read of the email-template boot sweep in @objectstack/plugin-email", + "replacement": "nothing to write: every `/meta` write now lands environment-wide (`organization_id` NULL) and every `/meta` read resolves environment → code, for every caller and every tenancy posture. Delete any import of `organizationIdForMetaWrite` (a write carries no organization) or `metaReadOrganizationId` (a read carries none either). A caller that needs the vetted organization of a request for another purpose still reads `metaCallerOrganizationId`", + "migrationId": "meta-doors-organization-scope-retired", + "toMajor": 18, + "rationale": "ADR-0131 D6 retires the per-organization overlay axis: environment metadata written by Studio, by the cloud build agent or by a template install belongs to the whole deployment. The doors threaded the active organization for view, dashboard, report, translation and email_template, so under the single posture, where the Default Organization is active, every Studio save of those types was stored under that organization. The read and the write flip together: reads-first would hide the organization rows the doors still wrote, writes-first would let those rows shadow new environment saves." + }, + { + "surface": "kernel.cluster metadata change event payload (`MetadataChangedEventPayloadSchema` in kernel/cluster.zod.ts — 2 defs, 4 exported names: `MetadataChangedEventPayloadSchema`, `MetadataChangedEventPayload`, `MetadataChangeOperationSchema`, `MetadataChangeOperation`)", + "replacement": "Nothing to migrate to, because nothing ever emitted or consumed it. The cluster invalidation channels that actually run are the three lanes documented in content/docs/kernel/cluster.mdx §6.2: `metadata.changed` (`ClusterMetadataChangedPayload` in `@objectstack/metadata` — the origin node, the metadata type and the replayed watch event), `metadata.mutated` (`ClusterMetadataMutationPayload` in `@objectstack/metadata-protocol`) and `datasource.mutated` (`ClusterDatasourceMutationPayload` in `@objectstack/service-datasource`). A host that needs cross-node cache invalidation subscribes to one of those; a host that held the retired type for a transport of its own keeps a local type — the spec no longer declares one.", + "migrationId": "metadata-changed-event-payload-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove (triage ruling 2026-09-02 on the spec seat: remove via the ADR-0087 route, not \"make a consumer\" — that is contract growth with no pull). The docblock declared that all metadata persistence layers MUST emit a `metadata:changed` event with this payload and that every reader MUST subscribe and compare `version` before invalidating. Measured at the retirement's base commit with positive controls: zero runtime producers, zero subscribers, zero imports outside packages/spec (its own unit test, the isomorphic alias pin and the generated artifacts) in objectstack, and nothing in objectui at the pinned sha. It was unenforceable by construction — the `version` field is `z.bigint()`, which the standard JSON serializer refuses, so the payload as declared could not cross any pubsub transport without a codec no driver ships: a MUST-emit contract no conforming emitter could satisfy. The shipped channels all carry an address-only signal whose receiver re-reads its own store (the 2026-09-01 ruling for the registry lane), the opposite of the declared version-compare receipt, so the one plausible future consumer was decided against; the 2026-08-27 ruling on transitions removes a staged window. `MetadataChangeOperationSchema` existed only to type the payload's `operation` field and leaves with it as its orphan value schema (the `DistributedStateConfig` precedent). Route 3: not an authorable surface — no metadata-type binding, stack collection or manifest embed ever carried it, and nothing parsed it outside its own unit test — so no tombstone and no D2 conversion; `RETIRED_DEFS_BY_MAJOR[18]` (`kernel/MetadataChangedEventPayload`, `kernel/MetadataChangeOperation`) plus this entry ARE the declaration." + }, + { + "surface": "the paper metadata-customization protocol: `kernel/metadata-customization.zod.ts` whole (`MetadataOverlay`, `FieldChange`, `CustomizationOrigin`, `MergeConflict`, `MergeStrategyConfig`, `MergeResult`, `CustomizationPolicy`) / the section-5 Overlay/Customization API contracts (`api/MetadataOverlayResponse`, `api/MetadataOverlaySaveRequest`, `api/MetadataEffectiveResponse`) / the optional `getOverlay`/`saveOverlay`/`removeOverlay`/`getEffective` members of `contracts/metadata-service.ts` / the authorable keys `MetadataPluginConfig.customizationPolicies`, `MetadataPluginConfig.mergeStrategy` and `MetadataManagerConfig.persistence.overlayWritable` (tombstoned; see `RETIRED_KEYS_BY_MAJOR[18]`)", + "replacement": "nothing to re-declare — delete any authored keys. The customization mechanisms that actually ship: ADR-0005's org-scoped overlay (opt-in via `allowOrgOverride` on `DEFAULT_METADATA_TYPE_REGISTRY`, stored as `sys_metadata` org rows, written through the REST meta write doors and read back through `getMetaItemLayered`'s `code`/`overlay`/`effective` layers), and ADR-0126's packaged-metadata customization model (clone with a new machine name + ledger disable — never a field-level patch overlay)", + "migrationId": "metadata-customization-protocol-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; the maintainer's ruling of 2026-08-29 adopted retirement and rejected a re-scope, and it was executed widened to the full coupling set the fork report on that ruling measured: the module declared a three-layer platform/user patch-overlay protocol with field-level change tracking and a 3-way-merge story, published reference docs described it as the customization architecture — and nothing reachable implemented it. The one implementation (`packages/metadata`'s manager limb) was served by no route and called only by its own unit tests; no merge engine ever existed; no code read a `CustomizationPolicy`. ADR-0126 §6 wall 4 supersedes the protocol as a matter of record (\"nothing may build against it\") — the per-field overlay layer it described is precisely what the 2026-08-24 lock-and-clone ruling (lock the packaged base, customize a clone) left deliberately unchartered. Why D3 semantic and not a D2 conversion: the defs leave with no carrier key in any stack collection, and the three tombstoned keys live on plugin/manager configs, which are not stack collection members (`PLURAL_TO_SINGULAR` has no `plugins` entry) — a MetadataConversion would be a transform with no seam that ever runs (the `kernel/Manifest:loading` precedent)." + }, + { + "surface": "restServer.metadata.endpoints.items / restServer.metadata.endpoints.item", + "replacement": "An `endpoints.*` switch now gates exactly the face its name states, reads and writes alike. `items` gates `GET {prefix}/:type` and nothing else; the whole-store operations it used to take with it — `GET {prefix}/diagnostics`, `GET {prefix}/_drafts` and the `POST {prefix}/_migrate-stored` write door — answer to the new key `endpoints.maintenance` (default `true`). `item` now gates the WHOLE per-item face: `GET` / `PUT` / `DELETE {prefix}/:type/:name`, `/references`, `/layers`, the history family (`/history`, `/audit`, `/diff`, `/published`, `/publish`, `/rollback`) and `GET {prefix}/book/:name/tree`. ⇒ An embedder that authored `endpoints: { items: false }` to close the whole-store family writes `endpoints: { items: false, maintenance: false }`. An embedder that authored `endpoints: { item: false }` to close only the per-item READS has no key that keeps the writes: the per-item face is one face, so leave `item` on and close the surface at `api.enableMetadata`, or per object at `enable.apiEnabled` / `enable.apiMethods`. `types` is unchanged and `api.enableMetadata` remains the master switch above all four.", + "migrationId": "metadata-endpoints-switch-radius-repartitioned", + "toMajor": 18, + "rationale": "Not losslessly convertible, and not compiler-carried either — the two channels that would otherwise reach a consumer are both blind here. No key is renamed, removed or retyped: every one is an optional boolean, so `{ items: false }` compiles and parses exactly as before and simply mounts a different route table. A D2 conversion would have to GUESS which of the four routes the author meant to close, and the two readings differ by a write door — rewriting `{ items: false }` to `{ items: false, maintenance: false }` preserves the old mounts but presumes an intent the author never expressed, while leaving it alone re-mounts `POST {prefix}/_migrate-stored`. That is a judgment, so it is delegated rather than automated. The change itself is the ADR-0049 declared-vs-enforced defect in the direction the liveness ledger structurally cannot look: all three keys were genuinely live, and what had drifted was each one's RADIUS against its own `describe()` — `items` gated a migration write door while naming a listing read, and `item` gated four reads while its own `PUT` / `DELETE` and the history family answered to `api.enableMetadata` alone. The maintainer ruled the two together on 2026-09-06 as one principle: every `endpoints.*` switch gates exactly the face its name states, and the whole-store family gets a key of its own. Measured population at the time of the move: ZERO — no shipped boot path constructs a `RestServerConfig`, so only programmatic embedders can have authored these keys at all." + }, + { + "surface": "metadata item names (the `name` half of the `type`/`name` addressing pair — `saveMetaItem` / `publishMetaItem`, `PUT /api/v1/meta/:type/:name` and the compound `:type/:section/:name` fold)", + "replacement": "lowercase snake_case segments, optionally dot-qualified — the pattern family of METADATA_ITEM_NAME_PATTERN, i.e. one or more [a-z][a-z0-9_]* segments joined by single dots (`crm_lead`, `crm_lead.pipeline`). A name that spelled a sub-resource with a slash (`views/all_leads`) is re-authored with a dot qualifier (`crm_lead.pipeline` — the `ViewItemNameSchema` convention, now enforced with the qualifier optional) or flattened with an underscore (`views_all_leads`); containment is expressed by structure, never by a separator inside the identity string.", + "migrationId": "metadata-item-name-grammar-enforced", + "toMajor": 18, + "rationale": "Maintainer ruling (2026-08-25): metadata item names must not contain `/` — identity-with-separator is the measured root cause of a defect family (URL arity mismatches, dual-arity route-mount obligations, route shadowing, a two-rule URL spelling split in one SDK file). The grammar was entirely unconstrained at the door: the empty string, `//` and `Views/All Leads` were all accepted and stored as item names, and a slash in the name bypassed the unrecognised-metadata-type refusal (`type=fieldz name=a/b` was accepted while `type=fieldz name=a` was 400). Whether a stored slash-name (out-of-repo deployments only — the in-repo census measured zero) should be renamed, and to what, is a judgment the chain cannot make, so no mechanical conversion ships with the narrowing." + }, + { + "surface": "MetadataManagerConfig `cache.ttl` / `cache.databaseLoader.ttl` (kernel/metadata-loader.zod.ts)", + "replacement": "`cache.databaseLoader.ttlMs` (milliseconds, default 60000) — rename the nested key; the value is unchanged. The outer `cache.ttl` has NO replacement: its respelling `ttlSeconds` was retired before it shipped (see `metadata-manager-config-inert-cache-keys-retired`) — delete the key; nothing ever read it", + "migrationId": "metadata-manager-config-cache-ttl-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B of 2026-09-02 on duration-shaped keys (no grandfathered baseline): the unit of a duration-shaped `z.number()` key lives in the key NAME or in a unit-carrying value, never only in the description. This block was the founding specimen: two keys spelled `ttl` fourteen lines apart, the outer one in SECONDS (3600) and the nested DatabaseLoader one in MILLISECONDS (60000), each unit named only in `.describe()`. An author who copied the outer number into the inner block got a 3.6-second cache with no error anywhere — the number was valid, the type was right, the cache was simply cold. Both keys are retiredKey tombstones (the nested objects are not strict; a bare deletion would strip the old key in silence). Why a semantic entry and not a D2 conversion: `MetadataManagerConfig` is the runtime MetadataManager's constructor config, not a stack collection member and never a stored row, so the chain has no seam that ever runs on it (the `kernel/Manifest:loading` and `metadata-plugin-additional-types-retired` precedent). The one in-repo reader, `DatabaseLoader` (`packages/metadata`), reads `cache.databaseLoader.ttlMs` at the same magnitude it read `ttl`; the outer `cache.ttl` had no runtime reader (measured on ca46f8f12, and retired on its own under ADR-0049 before this rename shipped — so this entry's outer half is a deletion, not a rename, and the `ttlSeconds` spelling never reached a published release)." + }, + { + "surface": "MetadataManagerConfig `cache.enabled` / `cache.ttlSeconds` (formerly `cache.ttl`) / `cache.maxSize` (kernel/metadata-loader.zod.ts; tombstoned, see `RETIRED_KEYS_BY_MAJOR[18]`)", + "replacement": "nothing to re-declare — delete the three outer keys. The cache that actually runs is the DatabaseLoader read-through LRU under `cache.databaseLoader`: its `enabled` (default true) is the switch, `ttlMs` (milliseconds, default 60000) the TTL and `maxSize` (an entry count, default 500) the cap", + "migrationId": "metadata-manager-config-inert-cache-keys-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove (the owning seat's ruling, conditioned on the measurement below and re-taken on the merged ref): the outer `cache` block of `MetadataManagerConfig` advertised three knobs — `enabled` (default true), `ttlSeconds` (default 3600; `ttl` until the duration-unit rename) and `maxSize` (\"bytes\") — that no runtime read. The only consumer of the block is `MetadataManager` (`packages/metadata`), which hands `cache.databaseLoader` and nothing else to `new DatabaseLoader({ cache })`; a repo-wide reader census over `packages/**` (tests and changelogs excluded) found no runtime reader of any outer key, while the same grep shape found the nested `cache?.databaseLoader` read twice (the positive control). An author writing `cache: { enabled: false }` or `cache: { ttlSeconds: 60 }` got a clean parse and a cache that behaved exactly as before, and the published reference page documented all three as if they configured something. All three are retiredKey tombstones (the nested object is not strict; a bare deletion would strip them in silence — the same no-op one layer down). The duration-unit ruling's `ttl` → `ttlSeconds` rename, registered under this same major and never shipped, is folded into the removal: `cache.ttl`'s tombstone now prescribes deletion rather than a rename to a key that is itself retired, so a 17.x author sees one hop. Why a semantic entry and not a D2 conversion: `MetadataManagerConfig` is the runtime MetadataManager's constructor config, not a stack collection member and never a stored row, so the chain has no seam that ever runs on it (the `kernel/MetadataManagerConfig:persistence.overlayWritable` precedent). The other candidate — wiring readers for a second cache layer — was not taken: no consumer for one exists, and an implementation for an unmeasured need is the shape ADR-0049 refuses." + }, + { + "surface": "metadata plugin `config.additionalTypes` (on `MetadataPluginConfig`)", + "replacement": "nothing to re-declare — delete the key. There is no declared-kind channel: a kind enters the live metadata-type set as a side effect of registering an ITEM of that kind (`SchemaRegistry.registerItem` during app/manifest registration, or `MetadataManager.register` at runtime). Bind the kind's schema with `registerMetadataTypeSchema(type, schema)` from the plugin's `init(ctx)` so `GET /api/v1/meta` serves a real JSON Schema for it", + "migrationId": "metadata-plugin-additional-types-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling of 2026-08-14: remove the key, jointly with refusing unknown types at the `/meta` boundary by the static registry. The key was declared, authorable, on the published authorable surface, and documented on four docs pages as THE way a plugin registers a custom metadata type — and read by NOTHING. The only production writer of the manager's type registry is `setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY)` (`packages/metadata/src/plugin.ts`), called exactly once outside tests, and it REPLACES the array outright; nothing ever merged `additionalTypes` into it. Measured against the real `MetadataManager`: declared count == live count (27 == 27), `getRegisteredTypes()` sorted equals the built-in registry sorted. So an author who followed the published instructions wrote the key, got no error, and nothing happened — the same silence trap as the plugin lifecycle's `onInstall` (a documented hook with no invocation site), one level down, in exactly the AI-authoring path (ADR-0033). The joint consequence: with this plugin-declared channel removed, the static registry is the total universe of legal metadata kinds, which makes refuse-by-static-registry at the /meta boundary safe by construction. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections. A metadata-plugin config is neither — `PLURAL_TO_SINGULAR` has no `plugins` entry, so it is not a stack collection member and a conversion would be a transform with no seam that ever runs (the `kernel/Manifest:loading` precedent)." + }, + { + "surface": "The ADR-0087 migration chain and change manifest, imported from the package root @objectstack/spec: MIGRATIONS_BY_MAJOR, MIGRATION_MAJORS, MIGRATION_SUPPORT_FLOOR, RETIRED_KEYS_BY_MAJOR, RETIRED_DEFS_BY_MAJOR, applyMetaMigrations, composeMigrationChain, MigrationFloorError, composeSpecChanges, composeReleaseChanges, the seven change-manifest schemas (SpecChangesSchema, SpecConvertedSchema, SpecMigratedSchema, SpecSurfaceAddSchema, SpecSurfaceRemoveSchema, SpecReleaseChangesSchema, SpecReleaseSurfaceSchema), and the types MigrationStep, MigrationApplication, MigrationChainResult, MigrationHopResult, MigrationTodo, SemanticMigration, SpecChanges, SpecConverted, SpecMigrated, SpecSurfaceAdd, SpecSurfaceRemove, SpecReleaseChanges, SpecReleaseSurface, SurfaceDiff, ReleaseSurfaceDiff and PreviousReleaseRegistries", + "replacement": "the same names, unchanged, imported from `@objectstack/spec/migrations` — change the import path and nothing else. The chain, its steps and semantic entries, the retired-key and retired-def tables and the change-manifest schemas are the same objects, and `objectstack migrate meta` replays the same chain. The ADR-0087 conversion layer stays on the package root: `ALL_CONVERSIONS`, `CONVERSIONS_BY_MAJOR`, `applyConversions`, `applyConversionsToFlow`, `applyConversionsToStoredItem`, `collectConversionNotices`, the three `CONVERSION_*_CODE` constants and their types still import from `@objectstack/spec`.", + "migrationId": "migrations-entry-split", + "toMajor": 18, + "rationale": "The maintainer ruled that the console first-screen size ceiling is raised now and paid back at the source; this split is that payback. The migration registry is mostly the guidance text `objectstack migrate meta` prints, and the package root re-exported it. The registry does work when its module loads (the list of majors and each step's rationale are computed then), so no bundler could prove it unused, and all of that text rode in every bundle of the root, whatever the consumer imported: 1,761,987 of the root ESM bundle's 3,766,221 bytes. With the chain on its own subpath the CommonJS root is 2,009,810 bytes instead of 3,780,033, and a browser bundle of the ten names the Studio console imports from the root drops from 700,884 to 301,287 bytes gzipped. The conversion layer does not move: `defineStack` and `normalizeStackInput` read it at run time, so moving its names would narrow the root and shrink it by under two kilobytes. The split moves an import path, which is TypeScript source rather than metadata — nothing authors, stores or parses it — so there is no source a D2 conversion could rewrite, and the move is recorded here." + }, + { + "surface": "The `sort` prop of `object-grid` and `object-calendar` in `ComponentPropsMap` (the FORM: the accept-anything `z.unknown()` at both block doors, vs the `SortItem` array `[{ field, order }, ...]`)", + "replacement": "`z.array(SortItemSchema)` at both doors — the array `ElementDataSourceSchema.sort`, `ListPageSchema.sort` and `element:record_picker`'s flat `sort` shorthand already carry. The legacy OData-ish clause `sort: 'created_at desc'` becomes `sort: [{ field: 'created_at', order: 'desc' }]`; a bare field name `sort: 'created_at'` meant ascending and becomes `sort: [{ field: 'created_at', order: 'asc' }]` — `order` is required in `SortItemSchema`, so it is written out rather than omitted. A comma-separated clause becomes one array entry per key, in the same order. `record:related_list` is NOT moved by this entry: its string is the `'field'` / `'-field'` dialect read by `RelatedList.normalizeSortSpec`, which never reaches `convertSortToQueryParams`, and retiring it was not ruled. `object-grid.defaultSort` is a different key, retired separately by the `ui__ObjectGridProps__defaultSort` entry.", + "migrationId": "object-block-sort-item-array", + "toMajor": 18, + "rationale": "One `sort` spelling platform-wide, the array: the maintainer's ruling of 2026-09-07 (option B) retired the legacy string `sort` clause, and its consumer half is the objectui change that drops the string arm from `convertSortToQueryParams`. One item of that ruling is this entry's subject: 「`ComponentPropsMap` for `object-calendar` and `object-grid` constrains the `sort` value to the array shape (today it accepts anything), so the spec, the registrations and the helper agree; that is a pull-back to the declared contract, ordinary tier」. The `z.unknown()` at both doors was a read-point record from the change that brought the `object-*` blocks into `ComponentPropsMap` (the maintainer's ruling of 2026-08-12), the same vintage as the `filter` doors the `element-data-source-and-object-block-filter-rule-array` entry moved, and not an exception to the ruling: measured on `@objectstack/spec` 17.2.0 an array, a string and a bare NUMBER all returned `success: true` while `bogusProp` was refused by name on the same call, so key checking was live and only the VALUE was unheld. Meanwhile objectui's own html tier has published `type: 'array'` for the grid all along (`plugin-grid/src/index.tsx:222`) and answered `type-mismatch` on the string — a spelling `@object-ui/core` implemented, the docs taught and the validator refused, which is what made this a ruling rather than a mechanical widening. Sequenced measurement-first: at the objectui pin this repo builds against (`53ded82b`) the string is still lowered — `ObjectGrid.tsx:1844-1851` carries an explicit `typeof === 'string'` arm onto `$orderby`, and `ObjectCalendar.tsx:431` hands `schema.sort` to `convertSortToQueryParams`, whose string arm is still present at `sort-query.ts:66-70`. So this declaration lands AHEAD of the pinned consumer, which the ruling permits explicitly (either order; the registrations already declare the array). The in-repo sweep found ZERO authored `sort` on either block — the two showcase pages that author `object-grid` (`command-center.page.ts`, `my-work.page.ts`) declare none — with the same grep shape finding 40+ string `sort` values at OTHER doors (view definitions, ObjectQL `query.sort`) as the control that the sweep fires; so this entry carries the prescription for authors outside the repo. ⚠️ Metadata AT REST is deliberately NOT rewritten and this disposition adds no D2 conversion: `os migrate meta --stored` replays D2 conversions only, and the read path does not re-validate stored rows (`applyConversionsToStoredItem` replays the chain without validating, by its own contract), so a stored page carrying a string `sort` keeps loading and is still rendered by objectui at the pinned `.objectui-sha`. What changes is that RE-SAVING it is refused at the `sort` door, on its next save and not before. ADR-0049, ADR-0087." + }, + { + "surface": "`object-grid` component props — `data` (the KIND: bare array `z.array(z.unknown())` vs the `ViewDataSchema` provider object)", + "replacement": "`ViewDataSchema` — the provider-discriminated object (`provider: 'object' | 'api' | 'value' | 'schema'`). Static inline rows move from `data: [...]` to `data: { provider: 'value', items: [...] }` — the same rows, wrapped in the one arm that means \"hardcoded data array\". The other three arms are unchanged `ViewDataSchema` semantics; `staticData` (the deprecated bare-array shortcut the renderer still reads) keeps its shape but is not the prescription", + "migrationId": "object-grid-data-view-data-converged", + "toMajor": 18, + "rationale": "Two entries of one contract disagreed on the KIND (contract-vs-contract, found by objectui's declared-arm parity gate): `ComponentPropsMap['object-grid'].data` said bare array ('Static inline rows — bypasses the object query') while `ViewDataSchema` — the authority objectui aligned the grid's registry declaration to, pinned by `gridDataInputContract.test.ts`, and what `ObjectGridSchema.data` resolves to — is an object discriminated on `provider`. Measured on @objectstack/spec@17.2.0: `{ provider: 'value', items: [] }` — the pinned-legal form — was REFUSED by the props-map entry (`expected array, received object`) while the bare array parsed. Whichever authority a value satisfied, the other refused it, and the objectui parity gate had to carry the reasoned exemption `object-grid.data:object` to look away. The maintainer's ruling of 2026-08-25 (option A) converged the props-map entry onto `ViewDataSchema`; the bare-array form is the deprecated `staticData` shortcut that objectui's deprecated-alias carve-out already refuses to publish as authoring surface. The ruled migration check ran with the change: the sweep of generated artifacts, templates and first-party corpora (examples/, skills/, create-objectstack, spec fixtures) found ZERO bare-array `data` authors, so no rewrite ships — this entry carries the prescription for authors outside the repo." + }, + { + "surface": "the object-grid page block's defaultFilters property — the legacy base-filter fallback in ComponentPropsMap, which was z.unknown and therefore accepted a bare string, a number, a MongoDB-style record, an ObjectQL AST tuple array and a list of malformed rules alike", + "replacement": "the same ViewFilterRule array form its sibling filter takes — [{ field, operator, value }, ...]. A record-form fallback { status: \"active\" } becomes [{ field: \"status\", operator: \"equals\", value: \"active\" }] and several record keys become several rules, which AND; an operator object { amount: { $gt: 100 } } lifts the operator into the rule, becoming [{ field: \"amount\", operator: \"greater_than\", value: 100 }]; an AST tuple array [[\"owner_id\", \"=\", \"{current_user_id}\"]] becomes [{ field: \"owner_id\", operator: \"equals\", value: \"{current_user_id}\" }], value placeholders and date macros unchanged. Legacy operator shorthands are accepted and normalized on parse. Better still, write the rules on filter and delete this key: it is read only when filter is absent, and its own description has prescribed filter all along", + "migrationId": "object-grid-default-filters-rule-array", + "toMajor": 18, + "rationale": "The protocol half of the maintainer's ruling C-prime of 2026-09-20 on objectui's render-time filter converter — the protocol is the only refusal set, so a document it accepts never throws at render time — verbatim, untranslated: 「the differences are the protocol's to close」. This is the SAME value in the SAME role as filter — the key's own description says it is read only when filter is absent — and the consumer reads it through the SAME lowering sink, so every refusal that sink can give was reachable from a document the protocol had just accepted. filter converged on the rule array with the rest of its family; this key was not named by that ruling and kept the pre-convergence read-point shape, which left the block with one declared door and one undeclared door onto one seam. The parse receipt said nothing about what the grid would then do with the value, and in the objectui version this release pins that depended on the shape: ObjectGrid lowers defaultFilters through toFilterNode whenever filter lowers to nothing, so a record form and an AST tuple array were lowered and applied as declared; a bare string or a number was dropped without a word, so the grid sent no filter and listed its rows unfiltered; and a list of malformed rules was refused — on the wire with 400 INVALID_FILTER, or by the client before any request for the value shapes it judges itself. ⛔ This entry is a NARROWING and deliberately not a retirement. Refusing the key outright — the other arm the finding offered — removes an accepted shape and needs its own ruling; the deprecation already stated in the description is unchanged and still says to prefer filter. Metadata AT REST: the record form and the AST tuple array at this key are rewritten to the rule array by the same D2 conversion as its sibling filter, page-component-filter-record-to-rule-array, wherever the mapping is lossless — by os migrate meta --stored, and on every stored-row read until it runs. What it cannot map losslessly is left exactly as stored and keeps rendering as it does today — a combinator, a null value, an operator the rule vocabulary does not spell, or the bare string or number this key also took — and its door refuses such a value only as the component-props gate's advisory finding (os validate, os build, os lint), since a re-save through the metadata API is not refused there: a record form with the message the filter door gives, a worked rewrite computed from the author's own keys and a pointer to this entry's conversion table, and a bare string or number or an AST tuple array with the schema's plain type refusal. ADR-0049 / ADR-0087." + }, + { + "surface": "page.component.object-grid.defaultSort — the legacy single-pair second spelling of the grid sort", + "replacement": "`sort: [{ field, order }]` — the array every read path honours; a single pair is a one-entry array.", + "migrationId": "object-grid-default-sort-retired", + "toMajor": 18, + "rationale": "The D2 conversion `object-grid-default-sort-removed` follows the renderer's own precedence: where `sort` was absent the `defaultSort` pair WAS the grid's sort, so it moves to `sort` as a one-entry array; where `sort` was present the pair was never read, so it is deleted. Both are behaviour-preserving, and the second is where the judgment sits. A grid that authored both keys with DIFFERENT orders has always loaded in the `sort` order while its author may believe `defaultSort` governed the initial load — the key's name says it should have. The conversion keeps the order users have been seeing and discards the one the author wrote; only the author can say which one they meant. Code that builds object-grid props (a host, a generator) must also stop emitting the key, which no conversion reaches." + }, + { + "surface": "page.component.object-grid.resizableColumns — the legacy second spelling of the grid column-resize switch", + "replacement": "`resizable: true | false` — the one spelling the grid reads; the value is the same boolean.", + "migrationId": "object-grid-resizable-columns-retired", + "toMajor": 18, + "rationale": "The D2 conversion `object-grid-resizable-columns-removed` follows the renderer's own precedence, `resizable ?? resizableColumns`: where `resizable` was absent the legacy value WAS the grid's setting, so it moves to `resizable` unchanged; where `resizable` held a value the legacy key was never read, so it is deleted. Both are behaviour-preserving, and the second is where the judgment sits. A grid that authored both keys with DIFFERENT values has always behaved as `resizable` said, while its author may believe the other key governed it. The conversion keeps what users have been seeing and discards the value the author also wrote; only the author can say which one they meant. Code that builds object-grid props (a host, a generator) must also stop emitting the key, which no conversion reaches." + }, + { + "surface": "object `indexes[]` entries (`IndexSchema`) — undeclared keys", + "replacement": "the declared surface: `name` / `fields` / `unique` (ADR-0120 scope). A key that names no declared capability is simply removed. `where` — the console fallback editor's drifted spelling for a partial-index predicate, removed when objectui converged that editor onto `IndexSchema` — gets a curated prescription: partial indexes are built at the database layer (`CREATE [UNIQUE] INDEX … WHERE` from a runtime migration), never declared here", + "migrationId": "object-index-unknown-keys-refused", + "toMajor": 18, + "rationale": "The unknown-key strictness campaign held this site open on a measured risk, the kind that had already made a console save answer 422 (a strict schema refusing a key the console itself writes): objectui's embedded index editor shipped a drifted hand-copied schema offering `where` and `brin`, spliced its output into `object.indexes[]` and PUT the whole object, so closing the shape would have 422'd a control the console itself rendered. objectui then converged that editor to the declared surface, spending the hold's evidence. Before this close an undeclared key on an index parsed clean and was silently dropped — an admin filling the old \"Partial-index predicate\" control got a green save while no driver ever read the predicate (`syncDeclaredIndexes` consumes `name`/`fields`/`unique` only). Undeclared keys are now refused at parse time with a prescriptive message; the protocol-17 `type`/`partial` tombstones keep answering their own migration text." + }, + { + "surface": "page.component.object-kanban.quickAdd — the per-column quick-add switch on the metadata-driven board", + "replacement": "(removed from the metadata board.) Delete the key; `object-kanban` offers no quick-add control. On a metadata board, records are created through the object's ordinary create action.", + "migrationId": "object-kanban-quick-add-retired", + "toMajor": 18, + "rationale": "The D2 conversion `object-kanban-quick-add-removed` deletes `quickAdd` from every `object-kanban` component, and the delete is lossless: the board forwarded the flag, but the control also needs a host-supplied `onQuickAdd` function that JSON cannot carry and no producer ever put on an object-kanban node, so the gate was permanently false and no board ever showed the control. The residue is the requirement behind the flag. An author who set `quickAdd: true` wanted users to add a card inside a column; that never happened and still does not. Whether the board can live without it is a product decision about that board — not something a key delete can make." + }, + { + "surface": "page.component.object-master-detail-form.details[].sortField — a detail entry's authored line-position field", + "replacement": "Nothing on the entry: delete the key. The line grid stamps each line's position into the child object's own field, derived from the child object: its first field named `position`, `sort_order`, `sequence`, `line_no`, `line_number` or `sort`. To keep the line order a drag-reorder sets, give the child object one of those fields (under the name the deleted key named, when it is one of them).", + "migrationId": "object-master-detail-form-detail-sort-field-retired", + "toMajor": 18, + "rationale": "The D2 conversion `object-master-detail-form-detail-sort-field-removed` deletes `sortField` from every `object-master-detail-form` detail entry, and the delete is lossless: the console stopped reading the authored override, and the line grid stamps the field it derives from the child object whatever the entry says. What the conversion cannot decide is where the line order lives. An entry whose key named a field the derivation does not pick — a name outside that list, or a second sort-named field after the first — saves its line order into the derived field instead, or nowhere when the child object has none. An entry that names `relationshipField` and at least one column and gives every column a `type` is kept exactly as authored: no child schema is loaded for it, so no line position is stamped and a drag-reorder is not saved, before and after the upgrade alike." + }, + { + "surface": "object.tenancy.organizationField — the column a platform row is stamped from, as distinct from the column the object is walled by", + "replacement": "`tenancy.tenantField` — one column that both walls the object and stamps its platform rows. The stamp-only divergence is a platform-internal fact now, kept for the platform's own credential table.", + "migrationId": "object-tenancy-organization-field-retired", + "toMajor": 18, + "rationale": "The D2 conversion `object-tenancy-organization-field-removed` deletes the key from every object's `tenancy` block in author sources and on stored object rows, and the delete is lossless: the key's only readers were three platform-row writers, pinned by name to platform tables, so an application that declared it was never read. The judgment is what the declaration was for. An author who set `organizationField` to a column other than `tenantField` asked for platform rows (audit stamps, approval rows, automation-run records) to carry a different organization column than the one walling the data — and never got it. If the object's real tenant column is not `organization_id`, the fix is `tenancy.tenantField`, which moves the wall as well as the stamp; whether moving the wall is correct for that object is a data-isolation decision only its author can make." + }, + { + "surface": "metrics.slis[].successCriteria, the CEL predicate arm of the union (the structured { threshold, operator, percentile? } arm is untouched) / tracing.sampling.composite[].condition, the CEL predicate arm of the union (the structured filter arm is untouched). Both arms were reachable in two spellings: the bare-string shorthand and the { dialect: 'cel', source } envelope. Reachable wherever metadata is authored or stored: defineStack sources, an exported stack passed to objectstack validate, a POST body on a metrics or tracing config, and a row already sitting in sys_metadata", + "replacement": "the structured arm each slot already carried, or your observability infrastructure. On `successCriteria` write the threshold rule — `{ threshold: 300, operator: 'lt', percentile: 0.95 }` — which is the shape an SLO product consumes. On a composite sampling `condition` write a structured filter: a plain object of match criteria carrying no `dialect` key, e.g. `{ service: 'api', attributes: { 'http.route': '/v1/orders' } }`. ⚠️ Neither replacement is mechanical, and neither is a like-for-like: a criterion or a sampling rule the structured shape cannot express has no home in application metadata at all and belongs in the SLO product or the OpenTelemetry sampler configuration that actually evaluates it", + "migrationId": "observability-cel-predicates-retired", + "toMajor": 18, + "rationale": "DECLARED, DOCUMENTED, AND EVALUATED BY NOTHING — which is why this is a semantic TODO rather than a mechanical strip. Both arms parsed, normalized a bare string to `{ dialect: 'cel', source }`, registered and were served back, and no service, plugin, runtime or CLI path ever read either key: an identity scan over the whole tree finds every hit for `successCriteria`, `ServiceLevelIndicatorSchema` and `TraceSamplingConfigSchema` outside `packages/spec/src` to be a generated artefact or prose, and inside it the only readers are the schemas' own unit tests plus the two census tests that enumerate expression slots. So an author — very often an AI reading the generated reference page, ADR-0033 — who wrote `successCriteria: 'p95 < 300ms'` got a green parse and no signal, indistinguishable from a predicate that ran and answered. ADR-0049 enforce-or-remove, ruled A by the maintainer on 2026-09-18: by the standing criterion that a declared-but-unread capability is kept only when mainstream platforms in the domain have it, application platforms do not carry SLI success criteria or trace-sampling conditions as authorable application metadata — that lives in observability infrastructure (SLO products, OTel sampling policy) and is structured there, not a free expression. The `cron-declared-unwired` family was retired outright under the same ADR after the same measurement. A mechanical D2 strip was weighed and declined: a predicate is an intent no threshold/operator pair or attribute filter records, so stripping the key would delete what the author meant and leave no trace of which SLI or which sampling branch lost it — exactly the judgment a semantic TODO exists to hand back. ⚠️ And a strip here is not merely lossy, it is INVALID: `successCriteria` is a REQUIRED key, so removing it leaves an SLI that no longer parses, and a composite sampling branch that loses its `condition` declares no condition at all — inert today, and the moment a sampler is wired it reads as UNCONDITIONAL. That is the difference from the two error-map precedents this retirement copies its MECHANISM from — `crypto.hash` on HookBodyCapability and `managedBy: 'system'` — both of which also registered a D2 conversion, because for each of them a mechanical rewrite existed. Here none does, which is what makes D3 the right disposition rather than merely an available one. ⚠️ The structured arm of each union is NOT decided here: it is equally unread today, and it is measured on its own card. ADR-0087, ADR-0058 D7, ADR-0049." + }, + { + "surface": "api.PackageApiContracts.upgradePackage / api.PackageApiContracts.resolveDependencies / api.PackageApiContracts.uploadArtifact — the three contract-map entries that bound POST /api/v1/packages/upgrade, POST /api/v1/packages/resolve-dependencies and POST /api/v1/packages/upload", + "replacement": "nothing — no route serves any of the three paths, so there is no entry to read instead. Delete every read of `PackageApiContracts.upgradePackage`, `PackageApiContracts.resolveDependencies` and `PackageApiContracts.uploadArtifact`, and every URL built from them or from the three hard-coded paths: a request to any of them was never answered. The per-route request/response schemas (`PackageUpgradeRequestSchema`, `PackageUpgradeResponseSchema`, `ResolveDependenciesRequestSchema`, `ResolveDependenciesResponseSchema`, `UploadArtifactRequestSchema`, `UploadArtifactResponseSchema`) stay published, bound to no route. The four surviving entries (`listPackages`, `getPackage`, `installPackage`, `uninstallPackage`) are unchanged. If the platform later serves a package upgrade, dependency-resolution or upload route, its entry arrives in the same change that mounts it.", + "migrationId": "package-api-contracts-unmounted-entries-retired", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-09-23 (option A: retire the three contract-map entries that name paths nothing mounts). The contract map is the declaration SDKs, codegen and AI clients are entitled to trust, and three of its seven entries named paths the composed runtime mounts nowhere: the package dispatcher has no branch for a single-segment POST under /packages and `@objectstack/rest` mounts only /packages/publish there, so all three answered handled=false while the four surviving entries answer 200/201 (measured on one HttpDispatcher over a real SchemaRegistry) — and the generated reference page printed all three as live endpoints. Unlike `installPackage` (rebound by an earlier fix onto the serving POST /api/v1/packages), no serving door existed to rebind them onto, and mounting three capabilities with zero measured pull was ruled out (ADR-0049 enforce-or-remove). Zero consumers measured at the retiring PR's base: across this repository the three paths occur only in the declaring file, its unit test and the generated page, and the pinned objectui checkout names none of the three keys, none of the paths and not `PackageApiContracts` itself. A contract-map entry is not metadata — nothing authors, stores or parses it — so there is no source a D2 conversion could rewrite, and the removal is recorded here." + }, + { + "surface": "api.installPackage request body, WRAPPED form — an undeclared TOP-LEVEL key beside manifest on POST /api/v1/packages (PackageInstallRequestSchema, the wrapped branch of PackageInstallBodySchema)", + "replacement": "the declared wrapped body: `manifest`, plus any of the declared install options (`settings`, `enableOnInstall`, `overwrite`, `platformVersion`, `artifactRef`). A misspelled option is respelled as the option it meant — `enabledOnInstall` → `enableOnInstall`, which the refusal itself offers — and any other undeclared key is removed. The bare form (a manifest as the whole body) is unchanged: it was already closed, and it still carries no install options.", + "migrationId": "package-install-request-unknown-keys-refused", + "toMajor": 18, + "rationale": "One rule for the whole install contract (the maintainer's ruling of 2026-09-27, option A: the wrapped form refuses an unknown top-level key by name). The manifest and the bare form already refused an unknown key by name; the wrapped top level was the one position still declared strip mode, so `{ manifest, enabledOnInstall: false }` — a misspelled `enableOnInstall` — parsed green with the key DROPPED, and the install door, which answers exactly what this declaration says since it parses the whole body (c02fa1276), installed the package ENABLED: the caller's explicit `false` inverted, with no word said. The sentence that had forbidden this close rested on «the declaration must not refuse a body the door answers 201 to», which held only while the door did not parse its body; with the door answering per declaration the premise became circular and constrains nothing. No alias and no grace window. Not losslessly convertible: an unknown key has no mapping target, and auto-deleting it would repeat the silent drop this closes, so each occurrence needs the caller's decision — respell or remove. First-party reach measured before the close: the SDK install call sends only `manifest`, `settings`, `enableOnInstall` and `overwrite`, and the objectui package dialog sends only `{ manifest }`, so no in-repo caller breaks. Out-of-repo callers are NOT MEASURED — a caller that sends a private top-level key now gets a 400 naming it." + }, + { + "surface": "PackageManifestSchema.version (`marketplace/package-version.zod.ts`) — the `version` key inside the manifest snapshot frozen into `sys_package_version.manifest_json` at publish time", + "replacement": "a SemVer 2.0.0 string matching `SEMVER_2_0_0_VERSION_PATTERN` (`kernel/version-grammar.ts`). This key was a bare `z.string()`, so it is the one carrier where the grammar is entirely new: `latest`, `v1.0.0`, `1.0`, the empty string, a trailing space and `2.0.0-beta.1extra!` were all accepted and sealed into a published snapshot, and each is refused now. A dist-tag becomes the version it pointed at (`latest` → `1.4.2`); a `v`-prefixed string drops the prefix (`v1.0.0` → `1.0.0`); a two-segment string gains its patch (`1.0` → `1.0.0`).", + "migrationId": "package-manifest-version-grammar-enforced", + "toMajor": 18, + "rationale": "A downstream told \"the spec validated it\" got no validation at all from this carrier. The sibling key it belongs to — `PackageVersionSchema.version`, the row this manifest hangs off — enforced a grammar the whole time, so the SAME release was judged by a rule in one field and by nothing in the adjacent one, and the unjudged value is the one that got frozen and shipped. That is the shape Prime Directive #10 refuses: a declaration advertising a constraint the runtime never applies. The canon ruling gave every carrier of this concept one grammar, and a carrier with no grammar could not be left out of it without keeping the hole open under a new name. Why a D3 semantic TODO rather than a D2 conversion: the repairs above are one-directional guesses. `latest` names whichever release was current when the snapshot was sealed, which is not recoverable from the snapshot, and `1.0` may mean `1.0.0` or the newest `1.0.x` — a transform that picked either would seal a different release under the same checksum." + }, + { + "surface": "api.packageRollbackResponse (`PackageRollbackResponseSchema` in api/package-api.zod.ts — 1 def, 3 exported names: `PackageRollbackResponseSchema`, `PackageRollbackResponse`, `PackageRollbackResponseParsed` — plus the `PackageApiContracts.rollbackPackage` contract-map entry that bound it to `POST /api/v1/packages/:packageId/rollback`)", + "replacement": "`RollbackToPackageCommitResponseSchema` (api/package-lifecycle.zod.ts) — the transcription of what the live route actually answers: the dispatcher routes `POST /packages/:id/rollback` (body `{ commitId }`) to `rollbackToPackageCommit`, the ADR-0067 COMMIT rollback, whose declared return is `{ success, revertedCommits: string[], failed: Array<{ commitId, error }> }`. Consumers of the retired type were reading a VERSION-rollback shape (`restoredVersion`) the route has never answered; read `revertedCommits`/`failed` instead. `PackageRollbackRequestSchema` stays published (ruled out of the retirement), bound to no route.", + "migrationId": "package-rollback-response-retired", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-08-27 on the client SDK's unbound response contracts, sub-question 3A: retire this false declaration first, then author the true one. The schema declared a version rollback — `{ success, restoredVersion?, message? }`, matching its file header \"Rollback a package\" — while the live path it was contract-bound to serves the ADR-0067 commit rollback: a different operation with a different result. Binding it in the SDK would compile and be false (the change that typed the SDK's un-annotated return values left a compile-time guard against exactly that substitution). Zero consumers measured across objectstack, objectui and cloud (the ruling's own survey, re-verified at the retiring PR's base): only its own unit test and that negative guard. A published declaration that outran the implementation is the hazard of response bodies never checked against the schemas that declare them, realised in the opposite direction — not \"no declaration\" but a WRONG one — and it is retired BEFORE the true schema is authored so no window exists in which both claims are published." + }, + { + "surface": "PackageVersionSchema.version (`marketplace/package-version.zod.ts`) — the `version` column of a `sys_package_version` row, and through `CreatePackageVersionRequestSchema.version`, which references it, the version a draft release is created with", + "replacement": "a SemVer 2.0.0 string matching `SEMVER_2_0_0_VERSION_PATTERN` (`kernel/version-grammar.ts`). Two changes, opposite in direction. ⭐ WIDER: suffix identifiers may now carry either ASCII case, because SemVer 2.0.0 is case-preserving — `1.0.0-Beta.1` and `1.0.0+Build.5` are accepted where this key used to demand lowercase, and the plugin boot path has always accepted them. ⛔ NARROWER: the forms the standard forbids are refused — `01.1.1` (§2), `1.0.0-0123` and `1.0.0-alpha..1` (§9), `1.0.0+.` (§10).", + "migrationId": "package-version-row-semver-2-0-0", + "toMajor": 18, + "rationale": "This key's own docstring advertised `2.0.0-beta.1` as an example of itself while a sibling carrier of the same concept refused that exact string — the contradiction the canon card was filed over. The lowercase restriction was the narrowest published accept set of the four and had no standard behind it: it made a release row refuse a version the runtime that loads the release accepts, so a publisher could be turned away for a capitalisation the loader would never have noticed. Why the narrowing is a D3 semantic TODO rather than a mechanical rewrite: a published version row is immutable by contract — `manifestJson` and `checksum` freeze on transition to `published` — so a stored degenerate version is not edited in place at all. It is republished under a version that sorts, and whether the old row should be deprecated or left standing is a release decision the chain cannot make." + }, + { + "surface": "api.listPackages limit and cursor — the two query parameters of GET /api/v1/packages declared by ListInstalledPackagesRequestSchema. The same entry covers the limit default: the request schema no longer declares default(50)", + "replacement": "the `status`, `type` and `enabled` filters — this route answers the whole installed set and has no page 2. There is no replacement for `cursor`, deliberately: nothing ever minted one, so no caller holds a value to carry over, and the response `nextCursor` it would have paired with was never emitted. Callers that looped on it were re-reading the first and only page. For the removed `limit` default, there is nothing to send instead and nothing to restore: the server has never capped this list, so a caller that omitted the key received every installed row before this change and receives every installed row after it. A client that sized a buffer to the declared 50 should size it to the installed set instead", + "migrationId": "packages-list-pagination-retired", + "toMajor": 18, + "rationale": "One capability, both halves, never half-deleted (maintainer ruling 2026-09-13, route 2 of three; routes 1 — build paging — and 3 — refuse unknown names — were considered and refused). `limit` and `cursor` were declared on the request and honoured on neither: the serving door filters on `status`, `type` and `enabled` and then returns every remaining row, and no emit site has ever written the response half `nextCursor`. `limit` is the sharper of the two because the repo's own ingress rule names it as the parameter whose silent drop is worst, and it is the silent-WIDENING half that was live: a caller asking for one row was handed the whole table alongside a `hasMore: false` that agreed with it. The `.default(50)` goes with the key because the FICTION WAS THE MECHANISM, not the number: nothing parses a query string through this schema, so the default has never stamped anything onto anything, while a reader of the published contract was entitled to believe an unparameterised list is capped. Re-spelling it as the real cap was not available — there is no cap. Pagination was removed rather than implemented because the installed-packages list is a small bounded collection and paging is not part of its meaning: route 1 would have grown a cursor protocol for a table of tens of rows, and the dispatch checked first whether a platform-wide cursor convention already existed that this door could have joined by reuse. It does not — no REST list door in the tree paginates, the one encode/decode cursor pair in the repo belongs to the storage-adapter list contract and is imported by no door, and the travel of this platform is the other way: `data.query.cursor` and `api/ListNotificationsRequest:cursor` were both retired before this one, for the same reason. Route 2, and the bookkeeping splits exactly as the notifications `cursor` retirement did. There IS a tombstone: the schema is non-strict, so a bare deletion would have made Zod SILENTLY STRIP whatever a generated client kept sending — a clean parse and a parameter that never takes effect, which is this issue's own defect re-created one layer down (ADR-0104). So both keys are `retiredKey()`, typed `never` for tsc and raising the prescription at any parse, and both are registered in RETIRED_KEYS_BY_MAJOR[18]. There is NO D2 conversion: a conversion rewrites an authored source or a stored `sys_metadata` row, and this shape is HTTP-only — nobody authors a `ListInstalledPackagesRequest` and nothing persists one. There is no `acceptRetiredDefaultResidue` stage either, for the same reason one layer along: nothing ever parsed this schema, so the retired default materialized into no artifact and there is no residue to accept. The same card closes the divergence in the OTHER direction, which is not a migration for anyone and is recorded here only so the two are not read apart: `type` (list), `version` (by-id) and `keepData` (uninstall) are query parameters the doors already executed and no request schema declared, and they are now declared where they are executed. No accept set moves — the doors served them before and serve them identically now. ADR-0049 / ADR-0087." + }, + { + "surface": "`page.assignedProfiles` — the per-page audience list (REMOVED)", + "replacement": "the object's permission sets, bound to people through positions. The page shows DATA; gate that data with the permission sets on the objects it reads (`objects..allowRead` and the field-level bits), and bind each set to the people who should hold it through a position (`sys_position_permission_set`). There is no per-page audience key to move the list into, and ADR-0090 D2 deleted the Profile concept the old list was written in, so each name in a retired `assignedProfiles` list has to be re-expressed as a permission set + position pair.", + "migrationId": "page-assigned-profiles-audience-to-permission-set", + "toMajor": 18, + "rationale": "The D2 conversion `page-assigned-profiles-removed` STRIPS the key mechanically, but the strip is not the whole migration and must not read as one: the author who wrote the list was declaring an intent (\"only these people see this page\") that the platform never honoured. Measured at the ruling: zero readers in this repository and zero in objectui — no renderer, route or metadata read door consulted the key — so the page has been open to every caller who could reach it for as long as the key existed. Deleting it therefore changes no behaviour and closes no hole; it makes an unkept promise stop being made. Which permission set corresponds to a given profile name is a judgement no walker can derive, which is why this is a TODO rather than a rewrite." + }, + { + "surface": "page.components[].responsive — the per-breakpoint columns / order / hiddenOn block, and the exported ResponsiveConfig shape with its breakpoint maps", + "replacement": "The sibling `responsiveStyles` (ADR-0065): per-breakpoint CSS maps compiled to id-scoped CSS at render — for example `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens.", + "migrationId": "page-component-responsive-retired", + "toMajor": 18, + "rationale": "The D2 conversion `page-component-responsive-removed` deletes `responsive` from every page component wherever one can be authored, and the delete is lossless: no renderer ever read the block, so the per-breakpoint columns, order and visibility it declared parsed, validated and did nothing. This was also the block an earlier tombstone prescribed as the live alternative for dashboard widgets, so an author who followed that advice moved an inert key to an inert key and may still believe their page adapts to small screens. What remains is theirs to decide: whether the layout they declared is one they still want, and if so how to say it in CSS that is applied — `hiddenOn` maps to a `display` rule per breakpoint, while column spans and order are layout choices with no one-to-one CSS rewrite. Code that imported the retired shape (ResponsiveConfigSchema, BreakpointName, the breakpoint maps) must drop the import; nothing replaces it." + }, + { + "surface": "page.component.page:header.breadcrumb — the page header's \"Show breadcrumb\" switch", + "replacement": "Nothing: delete the key, whether it was `true` or `false`. The navigation trail is drawn once, by the app shell's header, and is unchanged.", + "migrationId": "page-header-breadcrumb-retired", + "toMajor": 18, + "rationale": "The D2 conversion `page-header-breadcrumb-removed` deletes `breadcrumb` from every page header, and no trail is lost: the renderer drew an empty slot for it and nothing ever filled that slot. The slot was the only thing either value changed — present for `true` and for an absent key, gone for `false` — so a header that said `false` reads as absent after the strip and shows the empty slot's spacing again until the renderer stops drawing it. What the conversion cannot decide is whether a page needs a trail of its own: inside an app the shell already draws one, and a page outside the shell that needs one is a feature to ask for, not a key to keep." + }, + { + "surface": "page.requires on a page whose kind is react, full or slotted — a page that omits kind included, since its kind is full", + "replacement": "Nothing: delete the key. On an html page (and its deprecated jsx alias) the platform derives `requires` from the source at save and stores it, so it is omitted there too; on a react, full or slotted page nothing ever derived or enforced it, and nothing takes its place.", + "migrationId": "page-requires-non-compiled-kind-refused", + "toMajor": 18, + "rationale": "`PageSchema` admitted `requires` on every page kind, but the platform derives it only on the kinds whose source the metadata save door compiles: saving an html page (alias jsx) on a server that has the deployment's SDUI component manifest compiles the source, stores the plugin namespaces it uses as `requires`, and refuses a written list that disagrees. A react source is executed at render and never compiled at save, and full and slotted pages have no source, so on those kinds nothing derived the key, the Studio page editor dropped it on every save, and its one reader was a load-time warning. The maintainer ruled (2026-10-03) that the key is accepted only on html and jsx pages. The parse now refuses it on react, full and slotted pages, and a page that omits kind is a full page: `objectstack validate`, the metadata save door (a 422) and every other door that parses a page name the key, the page's kind and the compiled kinds. An empty list is refused like a full one, because the key is what is refused. No page body authoring the key on those kinds was measured in this repository, cloud, hotcrm or objectui. The D2 conversion `page-requires-non-compiled-kind-removed` deletes it from such pages: stored rows and built artifacts replay it at load, with a notice, and `objectstack migrate meta --from 17` lists the edit for authored sources, which the parse refuses until it is made. The delete loses nothing a page did. What it cannot decide is whether the page should have been an html page: an author who wrote the list to have plugin presence checked gets that check only on an html page, where the platform derives the list from the source and judges it at save and load." + }, + { + "surface": "permission.objects..allowRestore / permission.objects..allowPurge — the object-permission bits for undelete and hard delete", + "replacement": "(removed — the `restore` and `purge` operations they claimed to gate do not exist.) A dispatched `restore` or `purge` is denied fail-closed by the permission evaluator's destructive-operation backstop for every principal. The bits return together with the operations they gate. `allowTransfer`, the third lifecycle bit, is enforced and stays.", + "migrationId": "permission-restore-purge-bits-retired", + "toMajor": 18, + "rationale": "The D2 conversion `permission-allow-restore-purge-removed` deletes both keys from every object permission in author sources (both values, `true` included), and the delete is lossless: no destructive lifecycle verb is in the engine's dispatch vocabulary, so a grant delivered nothing and a denial locked nothing — every such request was, and stays, denied. The judgment is about what people believed. An admin who wrote `allowPurge: false` believed a lock existed; an admin who wrote `allowPurge: true` for a compliance role believed that role could hard-delete a record on request — a GDPR erasure, for instance. Neither was ever true. Any process, runbook or audit statement that relies on either belief needs another path, and deciding that path is a governance decision no conversion can make. Separately, an artifact built by the 17.x toolchain carries both keys materialized as the literal `false`; that one value is tolerated at load as inert residue and stripped, and every other value — `true`, or a string or number spelling — is refused with the prescription." + }, + { + "surface": "permission.rowLevelSecurity[].tags — the free-form categorization tags on a row-level security policy", + "replacement": "(removed — no mainstream platform tags a row-level policy, and nothing here ever read one.) A policy is identified by its `name` and its `object`, and reported by those and its predicate; its purpose belongs in `description`. Whom a policy applies to is decided by `positions`, never by a tag.", + "migrationId": "permission-rls-tags-retired", + "toMajor": 18, + "rationale": "The D2 conversion `permission-rls-tags-removed` deletes `tags` from every row-level security policy in author sources and in stored permission rows, and the delete is lossless: the RLS compiler never consulted the key and nothing else acted on it — no report, audit filter or review queue selected on it — so no access decision changes. The judgment is about what people believed. An admin who tagged a policy `gdpr` or `pci` may have expected a compliance report, an audit filter or a review queue to pick it up; none ever did. An author who wrote a tag such as `managers_only` may have believed it scoped the policy; it never did — only `positions` narrows whom a policy applies to. Any report, runbook or control that relies on either belief needs another path, and choosing that path is a governance decision no conversion can make." + }, + { + "surface": "OrgScopingEntitlement.platformGlobalObjects — an object a deployment declares platform-global no longer keeps its injected organization_id column with the organization wall stood down over it; on that deployment the injected-columns plan withholds the column, and the engine registers the object with no organization_id and declaring systemFields.tenant false", + "replacement": "Nothing to rewrite where no deployment declares the object. On the declaring deployment, the declared object has no `organization_id`: rewrite any authored filter, list-view column, report grouping, formula or seed key that names `organization_id` on it, or drop it; a write naming it is refused `INVALID_FIELD` and a filter `INVALID_FILTER`. The object is governed by object permission, not by the organization wall", + "migrationId": "platform-global-object-organization-column-retired", + "toMajor": 18, + "rationale": "ADR-0131 D7: \"an object a deployment declares platform-global gets no organization column on that deployment (the injected-columns plan reads the declaration), so Layer 0 and the driver agree by having nothing to scope\". Before this, the declaration stood the security layer's organization wall down for the object while the column stayed, so the SQL driver went on scoping a read by the caller organization that the wall had stopped scoping — measured on a booted kernel with a fixture provider, before the change. ADR-0131 retires that stand-down (\"replaced by D7's no-column\"). The engine reads the declaration at its plugin start(), before the first schema sync: every plugin init() has completed by then (ADR-0116, the Phase 1/2 split) and the org-scoping provider registers the service in its init(), declared in providesServices, so an object registered earlier is re-planned before its table is created. An absent declaration leaves every object's plan byte-identical; a malformed one is refused loudly and declares nothing. An object that declares its own organization_id keeps it and stays walled on it. Existing databases: schema sync is additive, so the physical column stays on a declaring deployment and the boot drift report names it orphaned; the operator removes it, and nothing moves at boot." + }, + { + "surface": "The two platform audit time-zone columns — `sys_job.timezone` and `sys_report_schedule.timezone` — carrying a string that is not a member of the IANA time-zone database (`Asia/Shangai`, `Europe/Munich`, `UTC+8`, `PST`).", + "replacement": "The canonical IANA zone id the deployment meant, written in the spelling the tzdb uses: `Asia/Shanghai`, `Europe/Berlin`, `America/Los_Angeles`. `UTC` is a member and is admitted — membership is the shared `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf('timeZone')` enumeration, which omits `UTC` and would refuse the one fallback this contract names. ⚠️ A non-member is RE-AUTHORED, never repaired on the deployment's behalf: the correct zone behind a typo is a fact only the deployment holds, which is what makes this entry semantic rather than a D2 conversion.", + "migrationId": "platform-timezone-columns-iana-domain-refused", + "toMajor": 18, + "rationale": "The change validating `sys_job.timezone` and `sys_report_schedule.timezone` against the IANA domain gave both columns `valueDomain: 'iana_time_zone'`, which had been declared on `sys_business_unit.timezone` / `sys_organization.timezone` since those two objects first gained a timezone column. It is a WRITE-TIME narrowing of the `min`/`max`/`maxLength` transition-gate class: a value already stored outside the domain is never re-read against it, no DDL is planned, and `objectstack migrate meta` has nothing to rewrite — the changeset that shipped it says so in those words, and this entry does not contradict it. What the changeset had no way to carry is that a deployment holding such a value now has WORK TO DO: the next write of that row is refused with the ADR-0114 field code `value_domain`, and until then `sys_report_schedule.timezone` keeps doing the thing the narrowing exists to stop — `ReportService.nextRunAt` hands a non-member zone to croner, whose throw was caught and turned into a silent fall back to `interval_minutes`, so \"every weekday 09:00 Asia/Shanghai\" became \"every 1440 minutes, forever\". Not a throw and not a fall back to UTC: the wrong instant, permanently. ⛔ It went out with NO `**BREAKING**` marker, so the repo's own breaking-change detector classified it non-breaking and asked for no ADR-0087 disposition at all — measured on the shipped changeset. A ruling closed that hole (the declaration now carries a `(narrowing)` arm the gate reads instead of a prose banner) and this row is the other half of the same ruling: the narrowing that already shipped is RECORDED, ⛔ not re-released and ⛔ not ratified in silence. Maintainer ruling 2026-09-07, option B: the clause-② declaration gains a widen / narrow arm that the ADR-0087 classifier reads, and each narrowing that already shipped without a banner is recorded as one ledger row. The direct precedents for registering a change no transform can apply are `schedule-flow-acting-organization-required` (protocol 18) and `rest-requireauth-default-flip` (protocol 12) — behaviour-only, a deployment judgement, registered anyway because the prescription is real." + }, + { + "surface": "`PluginHealthCheck.autoRestart`, `PluginHealthCheck.maxRestartAttempts` and `PluginHealthCheck.restartBackoff`, and the `PluginHealthMonitor.attemptRestart` path that read them", + "replacement": "Poll `PluginHealthMonitor.getHealthStatus(pluginName)` / `getHealthReport(pluginName)` and act on `unhealthy` / `failed` in the HOST. There is no in-tree replacement for the keys, because restarting a plugin is the host's job in this host-driven library and the monitor could not do it even in principle: `Plugin.init(ctx)` needs a `PluginContext`, which only the kernel constructs and which it exposes to nobody (`ObjectKernel.context` is private, `KernelBase.createContext` is protected). Recreate the kernel, or let your supervisor restart the process — whichever level actually owns the plugin's lifetime. The monitor reports; it does not act.", + "migrationId": "plugin-auto-restart-never-reinitialised", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove, applied one class over from the two hot-reload retirements in the same host-driven lifecycle library — the file-watching placeholder whose `startWatching` logged success while watching nothing, and the `'disk'` / `'distributed'` state strategies that fell back to memory in silence — and for a sharper reason than either: this key HAD a reader that acted, and what it did was not what the key declared. `attemptRestart` called `plugin.destroy()` and stopped there. The comment above the call read \"Call destroy and init to restart\", and `init` appeared in `health-monitor.ts` ONLY inside that comment. So what a plugin actually got was: destroy, a log line reading 'Plugin restarted', status `recovering`, and periodic health checks continuing to run against the destroyed instance — which the default check when no `checkMethod` resolves (`{ name: 'plugin-loaded', status: 'passed' }`) passes indefinitely. The TERMINAL report on a destroyed, never-re-initialised plugin was therefore `healthy`, reproduced at ee3595cefd with `successThreshold: 3` as failed -> recovering (destroyed=1, alive=false) -> recovering -> recovering -> healthy (destroyed=1, alive=false). The fix that made `successThreshold` bind from every status that records a failure made that MORE convincing rather than less, because reaching `healthy` now costs `successThreshold` CONSECUTIVE passing rounds, so the plugin has to earn a declared number of passes to be misreported. Meanwhile `restartAttempts` was incremented as though a restart had occurred, and `maxRestartAttempts` / `restartBackoff` scheduled further \"restarts\" of a plugin that was never brought back up. Neither of the other two ADR-0049 states was available: ENFORCE would have to BUILD the restart, and the class cannot host one — `Plugin.init(ctx)` needs a `PluginContext`, and the only two `plugin.init(...)` call sites in the tree are the kernel's own boot loops over the full plugin list, with a context that is private on `ObjectKernel` and protected on `KernelBase`, so a host-provided re-init hook would have had nothing to call (positive control: the same scan resolves five real non-test `plugin.destroy()` call sites, so it sees lifecycle drivers). Building that API for a caller that does not exist — no runtime constructs `PluginHealthMonitor`, which is why the maintainer retired its declarative config container on 2026-08-25 and kept the classes as a host-driven library — is exactly the speculation ADR-0049's staged decision names as the wrong default at this milestone, where the shippable liability is the false promise and not the missing feature. EXPERIMENTAL requires a roadmap, and a scan of the whole `docs/` planning + ADR corpus returned ZERO mentions of plugin auto-restart against 118 control hits for \"health\" and 13 for \"hot reload\" in the same corpus. The other two keys leave with the first rather than as a tidy-up: with no restart, \"Maximum restart attempts before giving up\" and \"Backoff strategy for restart delays\" have nothing left to be the vocabulary OF — the same test that took `distributedConfig` out with the `stateStrategy` value it was documented as being required for (ruled 2026-08-26: a vocabulary of nothing is not a vocabulary). All three are TOMBSTONED rather than deleted, for the reason the file-watching retirement recorded: a key leaving a SURVIVING def has no route-3 exit, and `PluginHealthCheckSchema` is not `.strict()`, so a bare deletion would be a silent strip (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — a milder form of the very defect being retired. There is no D2 conversion, because `PluginHealthCheck` is not an authorable surface: no metadata-type binding, stack collection or manifest embed ever carried it, so there is no authored document to rewrite. This entry IS the declaration." + }, + { + "surface": "manifest.contributes.events / manifest.contributes.menus / manifest.contributes.themes / manifest.contributes.translations / manifest.contributes.actions / manifest.contributes.drivers / manifest.contributes.fieldTypes / manifest.contributes.functions / manifest.contributes.commands (nine of the block's eleven members; `kinds` and `routes` are NOT part of this retirement)", + "replacement": "delete the keys — each capability already has its one enforced channel: `events` → subscribe imperatively in plugin code (`ctx.hook('kernel:ready', …)` from `init`/`start`); `menus` → the app `navigation` tree or `manifest.navigationContributions` (ADR-0029 D7); `themes` → the stack-level `themes` metadata collection (an unrelated `ThemeSchema` surface); `translations` → the `translation` metadata type, authored with `defineTranslationBundle` in `defineStack({ translations })`; `actions` → the stack `actions` collection or `engine.registerAction`; `drivers` → register a kernel service named `driver.*` (objectql calls `registerDriver` on it); `fieldTypes` → nothing (no registration seam exists; the vocabulary is the spec `FieldType` enum); `functions` → `defineStack({ functions })` → `engine.registerFunction`; `commands` → oclif native plugin auto-discovery (an `oclif` section in the plugin's own `package.json`; see `cli-extension.zod.ts`)", + "migrationId": "plugin-manifest-contributes-dead-members-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove: the nine members retire together, once the cloud half of the census below had come back clean. A census, monorepo-wide and non-test with control probes, measured that the ENTIRE monorepo contains exactly one read of `manifest.contributes` — `packages/objectql/src/engine.ts`, member `kinds` — so all nine members above parsed, entered the manifest, and changed nothing. The census stands on three repos: objectstack (re-verified on current main at claim time), objectui (0 property reads; control: 63 files carry the bare word), and cloud (measured clean 2026-08-24 at `5b5925a`: zero `manifest.contributes` reads, controls held). Several members were actively misleading: `events` was authored in-repo by a plugin that already subscribes imperatively; `commands` documented Commander.js resolution the CLI dropped for oclif auto-discovery; `fieldTypes` advertised a registration seam that has never existed. Why D3 semantic and not a D2 conversion: the conversion chain walks a normalized STACK and `PLURAL_TO_SINGULAR` has no `packages` / `plugins` entry, so a manifest is not a stack collection member and a conversion would be a transform with no seam that ever runs (the `kernel/Manifest:loading` precedent, recorded verbatim in its retired-key entry)." + }, + { + "surface": "manifest.contributes.routes (the one member the nine-member retirement deliberately left to its own fork; `kinds` is now the block's sole surviving live member)", + "replacement": "delete the key. A route that needs real handler CODE is mounted imperatively: resolve the `http.server` service from the plugin context and register the handler on `kernel:ready` (plugin-hono-server registers the service; `examples/app-showcase` mounts POST /api/v1/showcase/recalc that way). A declarative endpoint over a pipeline the platform already runs — query/return records, trigger a flow — is `defineStack({ apis })` (live since protocol 17, once the declarative endpoint executor was built and the loud refusal of a non-empty `apis:` became execution)", + "migrationId": "plugin-manifest-contributes-routes-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-08-22 (Option B of the enforce/remove/enforce-later fork, accepted verbatim 「接受所有」 on the decision batch carrying the four-axis analysis): remove the key, and redirect every author-facing recommendation of it to the imperative `http.server` mount. A monorepo-wide census with control probes measured zero readers of the key: the HttpDispatcher never registered a prefix from the declaration, so an entry parsed cleanly and served nothing — while FOUR published surfaces presented it as working machinery, one of them a customer-published skill (`skills/objectstack-api` told authors to choose it when \"the endpoint needs real handler CODE\"). That is ADR-0049's silent no-op with a published recommendation attached. Per the ruling's own sequencing the author-facing corrections landed FIRST (the skill's decision table, the dispatcher protocol doc, ADR-0088:40 and app.mdx, each redirected to the imperative mount), and the two remaining teaching sites (the plugin-rest-api.zod.ts worked manifest example, the metadata-plugin.zod.ts `router` delivered-form comments) are redirected in the removal PR itself. The cloud precondition was discharged first: a census of the cloud repository at 5b5925a found zero `manifest.contributes` reads, controls green. Enforce (fork A) was weighed and rejected on all four facets: net-new execution surface plus a prefix-claim authority question (who may claim `/api/v1/…`) for a declarative spelling with zero measured authors, while the capability is already reachable imperatively. Why D3 semantic and not a D2 conversion: a manifest is not a stack collection member (`PLURAL_TO_SINGULAR` has no `packages`/`plugins` entry), so a conversion would be a transform with no seam that ever runs." + }, + { + "surface": "manifest.capabilities / manifest.configuration / manifest.extensions (three top-level containers; retiring the container settles every key beneath it — `capabilities.{implements,provides,requires,extensionPoints,extensions}` and `configuration.{title,properties}` — at once)", + "replacement": "delete the keys — each declared purpose either has its one enforced channel or never existed: `configuration` (a `{ title, properties }` settings surface no UI rendered and no loader resolved) → pass options to the plugin's constructor in `defineStack({ plugins: [new MyPlugin({ … })] })`, the channel hosts already use; `capabilities` (protocol/interface declarations sold as \"interoperability and automatic discovery\") → nothing — no discovery path ever existed; real dependency resolution runs off top-level `manifest.dependencies`, which stays; `extensions` (an untyped `z.record(z.string(), z.unknown())` catch-all) → the enforced extension channels: `contributes.kinds` registers metadata kinds, `navigationContributions` (ADR-0029 D7) injects navigation, and code-level extension lives in the plugin itself (`init`/`start`)", + "migrationId": "plugin-manifest-dead-containers-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove, dispatched once the cloud half of the census below came back clean. A census, monorepo-wide and non-test with control probes, measured ZERO reads of each container itself, which settles all eight keys beneath them — a key cannot be read if the object holding it never is. The census stands on three repos: objectstack (re-verified on current main at claim time; every bare `.capabilities` hit classifies to a different surface — driver loader contracts, the QuickJS sandbox argument set, REST discovery, the ADR-0066 stack-level `capabilities` collection), objectui (0 container reads; control: `manifest.(id|name|namespace|version)` reads findable), and cloud (measured clean 2026-08-29 at `15f55df`: zero reads of all three, controls positive). `configuration.properties.secret` made this false compliance rather than tidying: its describe() promised \"value is encrypted/masked (e.g. API Keys)\" and nothing ever encrypted, masked or parsed it, so the key's own text was an unkept assurance about credential handling. Why D3 semantic and not a D2 conversion: the conversion chain walks a normalized STACK and `PLURAL_TO_SINGULAR` has no `packages` / `plugins` entry (re-verified — its `capabilities` entry is the unrelated ADR-0066 stack collection), so a manifest is not a stack collection member and a conversion would be a transform with no seam that ever runs (the `kernel/Manifest:loading` precedent, recorded verbatim in its retired-key entry). `PluginCapabilityManifestSchema` stays published: the plugin-registry surface (`plugin-registry.zod.ts`) still declares it, so this is a carrier-key tombstone with no def removal." + }, + { + "surface": "manifest.contributes.kinds[].globs (the `kind` bucket itself and its `id` are untouched)", + "replacement": "delete the key — a kind entry is `{ id, description? }`. File-type discovery is single-channel on the metadata type registry's `filePatterns` (`MetadataTypeSchema`, registered via `registerMetadataTypeSchema` / the default registry), which `contributes.kinds` never extended; if plugin-extensible discovery is ever wanted, it gets designed against that registry, not revived here", + "migrationId": "plugin-manifest-kind-globs-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-08-24 (「接受你的建议。」) on the aligned four-facet analysis: remove, through the full ADR-0049 ceremony. The sub-field was declared-but-unenforced on an authorable published surface: the schema promised that declaring `globs` \"enables the system to parse and validate new file types\" (its own example: a BI plugin handling `*.report.ts`), and the platform accepted it, stored it, and served it back through `GET /metadata/kind` — while the discovery the description promised never ran, because real glob-driven artifact discovery reads `filePatterns` off the metadata type registry and `metadata-plugin.zod.ts` records outright that `contributes.kinds` does not extend it. Measured by the engine-lane fix that made kind registration log its declared `id` (which also found the `kind` bucket itself reachable through `GET /metadata/:type`), and re-verified at claim time with a positive control: zero value reads anywhere (the only non-test occurrences of the path are the schema declaration and two type positions), and no in-repo manifest authors the key outside test fixtures. Enforce was weighed and rejected on all four facets: it would build a SECOND discovery channel parallel to `filePatterns` for a spelling with zero pull. Why D3 semantic and not a D2 conversion: a manifest is not a stack collection member (`PLURAL_TO_SINGULAR` has no `packages`/`plugins` entry), so a conversion would be a transform with no seam that ever runs." + }, + { + "surface": "the plugin-security scan-result family: the defs KernelSecurityScanResult and KernelSecurityVulnerability (kernel/plugin-security-advanced.zod.ts), their two authorable carriers on PluginSecurityManifest — scanResults and vulnerabilities — and the sibling verdict block PluginQualityMetrics.securityScan (kernel/plugin-registry.zod.ts)", + "replacement": "nothing to re-declare — delete the keys and every import of the two types. Plugin security scanning is not a platform capability and there is no replacement schema. What the platform does still enforce, and what to reach for instead: `permissions` and `sandbox` on the same PluginSecurityManifest are unchanged, and artifact provenance is answered by `verifyPluginArtifactIntegrity` and the plugin signature verifier — which tell you an artifact is the one its publisher signed, and never that it is safe. For dependency vulnerabilities use the tools built for it against your own project (npm audit / pnpm audit, Dependabot, the GitHub Advisory Database, OSV) and treat an unaudited third-party plugin as untrusted code. A publisher who used scanResults to advertise diligence keeps the surviving securityContact and vulnerabilityDisclosure blocks, which are contact terms rather than a verdict.", + "migrationId": "plugin-security-scan-result-surface-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-09-07 (adopted verbatim 「同意」): retire the scan-result family and its securityScan sibling, because once the scanner was gone nothing so much as imported their types. This is the second half of the scanner retirement recorded as plugin-security-scanner-retired. That retirement removed PluginSecurityScanner — a @objectstack/core class that shipped as a SECURITY control and could not fail, whose verdict was status \"passed\" for every plugin it was ever handed. The SCHEMAS the scanner fed survived it, and the scanner had been their only importer of any kind (a type-only import in packages/core/src/security/security-scanner.ts), so the family went from one type-only importer to zero consumers while staying fully published: 27 authorable rows across kernel.json, six api-surface exports, two authorable defaults and two json-schema manifest keys. An author could write any of it, be accepted, and get nothing — declared-not-enforced, Prime Directive #10, one layer out from the class removed for the same reason. The census was taken on origin/main after that removal landed, with a lit control (five hits for PluginSecurityManifest inside the declaring module) proving the file greppable, and found no .parse or .safeParse site against either schema anywhere in packages/**. securityScan is the sharpest member: scanResults published a report, but securityScan.passed published a VERDICT, so a plugin could declare itself clean with nothing behind it. Route: the two defs leave the build whole (RETIRED_DEFS_BY_MAJOR[18]) because nothing parses them and a prescription nobody can receive is not worth its cost; the three authorable keys are retiredKey() tombstones (RETIRED_KEYS_BY_MAJOR[18]) because both carrying shapes are non-strict, where a bare deletion is a silent strip (ADR-0104). Why this entry and not a D2 conversion: a plugin security manifest and a plugin registry entry are package artifacts a publisher ships, never stack collection members and never stored sys_metadata rows, so the conversion chain has no seam that would see one — the disposition the sibling kernel-plugin-security-durations-unit-in-key entry already records for this same manifest. No deprecation window (maintainer 2026-08-27: 「项目在创业阶段,用户也很少,短期不考虑渐进」). Scope note, recorded rather than acted on: PluginSecurityManifest.vulnerabilities is a forced consequence rather than a name the ruling listed — it was the last authorable referent of KernelSecurityVulnerability and could not outlive the def. Two neighbours the ruling made CONDITIONAL are deliberately untouched here because the repository the condition names, objectstack-ai/cloud, is not reachable from the session that executed this: the marketplace \"scanning\" status stays exactly as it is — unremoved, and NOT recorded as checked. Its two siblings were MEASURED rather than assumed, and the record is corrected here: both were ALREADY GONE when that ruling was written. The incident \"malware\" type was a member of system/IncidentCategory, and the whole incident-response family was retired whole, with the training and change-management families (maintainer ruling 2026-09-05: not roadmapped, so retired rather than marked experimental — two days BEFORE the 2026-09-07 ruling that made it conditional); see incident-response-family-retired. And marketplace-admin.zod.ts was deleted outright with the cloud subpath (ruled 2026-09-07: cloud does not re-host the control-plane files it never consumed); see cloud-subpath-retired. Verified on this tree by shape: both files return zero tree entries and no *.zod.ts names malware at all, against a lit control where \"scanning\" still returns a live declaration in marketplace.zod.ts. So the conditional question is ONE enum member wide, not three, and the objectstack-ai/cloud producer grep it still owes is that much smaller. ⚠️ The out-of-repo consumer population is NOT MEASURED. @objectstack/spec is published, so this removal is breaking for consumers no download, dependent or source telemetry was consulted for — accepted as an input to the ruling, exactly as that retirement states of its own three exports, and not a reason to soften the removal. ADR-0049, ADR-0087." + }, + { + "surface": "`@objectstack/core` runtime exports: `PluginSecurityScanner`, and the two types declared only to feed it, `ScanTarget` and `SecurityIssue`", + "replacement": "nothing to re-declare — delete the import and every call. Plugin security scanning is not a platform capability and there is no replacement export. A caller that branched on `result.status === \"passed\"` takes that branch unconditionally, because it is the only branch the scanner ever produced. What the platform does still enforce, and what to reach for instead: artifact integrity and signatures (`verifyPluginArtifactIntegrity`, the plugin signature verifier) answer \"is this the artifact the publisher signed?\" and never \"is this artifact safe?\"; plugin permissions and the sandbox resource limits are unchanged. For dependency vulnerabilities use the tools built for it against your own project — `npm audit` / `pnpm audit`, Dependabot, the GitHub Advisory Database, OSV — and treat an unaudited third-party plugin as untrusted code.", + "migrationId": "plugin-security-scanner-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05: retire the class and its two companion types with no replacement export, rather than repair it. The class shipped on `@objectstack/core`'s public barrel as a SECURITY control and could not fail. `scan()` composed five private scanners: four of them (`scanCode`, `scanMalware`, `scanLicenses`, `scanConfiguration`) allocated an empty issue array, logged and returned it with no code in between, so none could report a finding for any input; the fifth, `scanDependencies`, ran a real loop but matched only against an in-memory vulnerability database whose sole writer, the public `addVulnerability`, had zero callers in objectstack, in objectui at the pinned sha, or in the one demonstration that constructed the scanner, and `updateVulnerabilityDatabase()` logged twice and fetched nothing. The database was therefore empty on every code path that has ever executed: no issue was ever produced, the score stayed 100, and the verdict was `status: \"passed\"` for every plugin the scanner was ever handed — a malicious one as readily as a benign one. Repair was refused by name: a real vulnerability scanner is a feature with a design surface, not a defect fix. Why this entry exists at all, and why D3 semantic rather than a D2 conversion: `PluginSecurityScanner` has no spec schema and never had one — it is a runtime TS class, so there is no authorable key to tombstone with `retiredKey()`, no stored `sys_metadata` row that could carry it (a scanner was constructed per call and every result lived in a per-instance Map discarded with the object), and hence no seam `applyConversionsToStoredItem` would ever reach. The enforced channel is tsc, at the consumer's own import site; for anyone it does not reach, this ledger entry and the generated upgrade guide are the only channel there is. That is the disposition of `contracts.IDataDriver.findStream` (removed with no tombstone, because nothing parses a driver object) and of `actor-user-roles-to-positions` (the `ctx.user` `roles` alias, closed at once on the maintainer's word rather than given a window) — a TS/API contract, no stored source, no tombstone, tsc at the call site — applied to a surface one layer further out than either: those are declared in `packages/spec`, this one only in `packages/core`. ⚠️ The out-of-repo consumer population is NOT MEASURED. Zero constructors were found in objectstack, in objectui at the pinned sha, and in the deleted example, but no download, dependent or source telemetry was consulted for consumers of the published package, so this is breaking for an unmeasured population rather than a removal proven to break nobody." + }, + { + "surface": "plugin.version — `PluginSchema.version` (`kernel/plugin.zod.ts`), the key a plugin object carries into `kernel.use()`, and the boot-path predicate that judges the same string in `@objectstack/core` (`plugin-loader.ts`)", + "replacement": "a SemVer 2.0.0 string matching `SEMVER_2_0_0_VERSION_PATTERN` (`kernel/version-grammar.ts`). ⭐ Only EIGHT strings stop loading, all of them forms the standard forbids: `01.1.1`, `1.01.1`, `1.1.01` (§2, a leading zero in a numeric identifier — drop it); `1.0.0-0123`, `1.0.0-alpha..1`, `1.0.0-alpha..`, `1.0.0-.` (§9, a prerelease identifier that is empty or carries a leading zero — name it, or remove the empty segment); `1.0.0+.` (§10, an empty build identifier — name it or drop the `+` suffix). ⛔ Nothing else moves: every valid prerelease and build form this key accepts today it still accepts, `1.0.0-alpha.1` and `1.0.0-rc.1+exp.sha.5114f85` included.", + "migrationId": "plugin-version-semver-2-0-0", + "toMajor": 18, + "rationale": "The canon ruling made one grammar serve every carrier of \"the version of a package or plugin\", and named it after the standard: SemVer 2.0.0. This key had the widest of the four accept sets, which is why it is the only one that narrows without also widening. The narrowing is bounded deliberately, and the bound is what keeps the earlier widen-never-narrow ruling on this path honoured rather than reversed: that ruling's subject is what LOADS, and none of the eight is a valid prerelease. What they have in common is that no precedence order exists for any of them — `dependency-resolver.ts` in `@objectstack/core` can place none of them in an order — so a plugin versioned this way could be published and never compared against its own successor, which is a worse outcome than the refusal. Why it is a D3 semantic TODO and not a D2 conversion: each of the eight has several defensible repairs and the metadata does not say which was meant, and a version is how a release is addressed — rewriting one silently re-points whatever already resolved the old string." + }, + { + "surface": "the data write doors — a predicate-scoped (multi) update or delete, on every object and for every principal", + "replacement": "read a predicate update or delete as reaching only the rows the caller can read: the result counts those rows alone, a predicate that reaches only hidden rows succeeds with zero rows, and a predicate whose readable match exceeds one write's row ceiling is refused with 400 `INVALID_FILTER` — narrow it and write in batches", + "migrationId": "predicate-write-unreadable-row-not-matched", + "toMajor": 18, + "rationale": "A WRITE-DOOR ANSWER, made one with the read door's, on the predicate door as on the by-id door. The rows a predicate update or delete matched came from its write scope alone, so a row the caller cannot read was matched whenever that scope reached it: a per-row gate then refused the write with a 403, or the row was written and counted. Either answer told a hidden row apart from no row. The write middleware now asks the read door which rows the caller's own predicate returns — a read in the caller's context that every data middleware's visibility applies to — and narrows the matched set to them, so a row the caller cannot read is not written, not counted and not refused. A read the read door refuses keeps the write's previous answer, and a readable match larger than one predicate write's row ceiling is refused rather than cut off. A caller who can read a matched row but may not write it keeps its answer. Writes the platform issues under the caller's context — a cascade, a hook's own write, the referential clear of a lookup — keep their previous answer, and by-id writes are unchanged." + }, + { + "surface": "qa.scenarios[].requires.plugins", + "replacement": "`requires.services` — the discovery service keys the scenario needs (for example `auth`, `analytics`, `automation`, `ai`), each judged against the target's discovery document: met only when the target declares the service `enabled` with status `available`. The plugin → service mapping follows the provider table discovery itself reports (`CORE_SERVICE_PROVIDER`): `@objectstack/plugin-auth` fills `auth`, `@objectstack/service-analytics` fills `analytics`, `@objectstack/service-automation` fills `automation`, and so on. A plugin that fills no discovery service slot has no service to require.", + "migrationId": "qa-scenario-requires-plugins-retired", + "toMajor": 18, + "rationale": "`requires.plugins` was declared as a precondition and checked by nothing: `os test` reaches its target over HTTP, no served surface lists the loaded plugins, and the plugin spelling (package name or `plugin.name`) was never defined — so a scenario naming a missing plugin ran anyway and failed, or passed, on whatever the missing plugin caused. The block is now enforced (ADR-0049): core's TestRunner judges `requires` before the first step, and an unmet entry makes the scenario SKIPPED with a reason, counted separately and never as passed. `plugins` could not join that judgement honestly, so it retires into `services`, which the target's discovery document already answers (ADR-0076 D12: advertise only what is mounted). The consumer still owes the judgement because a plugin name does not always map to one service — a plugin that fills no discovery slot was never a checkable precondition, and only the suite's author knows what the scenario needed it for." + }, + { + "surface": "api.RealtimeEventType — the values 'record.created', 'record.updated', 'record.deleted' and 'field.changed' left the enum. It types SubscriptionEvent.type, so it reaches Subscription.events[].type and RealtimeConfig.subscriptions[].events[].type", + "replacement": "the names the runtime emits, which are now the whole enum: 'data.record.created' / 'data.record.updated' / 'data.record.deleted' for a single-record write, and 'data.records.updated' / 'data.records.deleted' for a predicate write (multi: true), which carries a count and no record. 'record.created' becomes 'data.record.created'; 'record.updated' and 'record.deleted' become their data.record twins, plus the data.records twin where a predicate write must be heard too; 'field.changed' becomes 'data.record.updated', whose DataEvent payload lists the changed fields in changes", + "migrationId": "realtime-event-type-unemitted-values-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove. RealtimeEventType was published in the generated API reference as the vocabulary of a realtime subscription, and no producer anywhere emitted any of its four values. What the runtime publishes is the DataEventType / BulkDataEventType vocabulary: the ObjectQL engine sends data.record.created, data.record.updated and data.record.deleted for each written record and data.records.updated / data.records.deleted for a predicate write, and it parses every event through DataEventSchema / BulkDataEventSchema before publishing. A subscription written with the only names the reference showed could therefore never fire, and nothing said so. The direction was settled before this change: the enum moves to the emitted names, and the runtime keeps publishing exactly what it published — changing the runtime's live event names to match an enum nothing had ever used would break every real subscriber. field.changed is the same dead spelling that DataEventType already dropped in protocol 17 (the entry data-field-changed-event-retired): no per-field event exists, because an update's per-field detail rides on data.record.updated as changes. Metadata change events (metadata.{type}.{action}) were not added: a subscription event is record-shaped (object names a data object, filters narrows records), and metadata events have their own MetadataEventType contract and client primitive. Bookkeeping: an enum VALUE puts nothing in RETIRED_KEYS_BY_MAJOR and leaves the four surface ratchets untouched; its prescription hangs on the enum's own error map (the HookBodyCapability precedent). It is a SEMANTIC entry rather than a D2 conversion because there is no source to rewrite: stack.zod.ts has no realtime key, no metadata type holds a subscription, and the open framework mounts no realtime transport that would parse one (maintainer ruling of 2026-09-04: realtime stays out of open core) — so the conversion chain has no seam that would ever see a subscription. ADR-0049 / ADR-0087." + }, + { + "surface": "`record:chatter` / `record:discussion` component props (one shared schema object): `position` vocabulary, and the schema defaults on `position` / `collapsible` / `defaultCollapsed` (DROPPED)", + "replacement": "`position: 'bottom' | 'right' | 'left'` — the renderer's own vocabulary (`right`/`left` dock a side panel, `bottom` renders in flow). 'sidebar' → 'right', 'inline' → 'bottom', 'drawer' → 'right' (no overlay drawer ever existed). No key replaces the dropped schema defaults: an unset key now stays unset and the renderer's own fallbacks apply (position 'bottom', collapsible off, defaultCollapsed off)", + "migrationId": "record-chatter-position-vocabulary-converged", + "toMajor": 18, + "rationale": "The schema declared a `position` vocabulary no read point ever compared (`sidebar`/`inline`/`drawer`), while the renderer chain — panel branches, designer registration, merge fallback, three sites in agreement, measured at objectui pin `665661ab0932` — speaks exactly `bottom`/`right`/`left`. So the spec-valid `sidebar` (the schema's own DEFAULT, materialized onto every parsed node that said nothing) silently fell through to the in-flow render, and the value that actually docks the panel (`right`) was refused at publish — declared ≠ enforced in both directions on the same key. The maintainer ruling of 2026-08-15 on this row converged it on the renderer's vocabulary with no mapping layer, and dropped all three schema defaults per the `maxVisible` principle (renderer fallbacks stay the renderer's facts): the old `collapsible` default (`true`) additionally INVERTED the renderer merge's own fallback (`false`), so \"the author said nothing\" parsed into \"the author asked for collapsible\". The mechanical rewrite is the ADR-0087 D2 conversion `record-chatter-position-vocabulary` (retired from the load path — the enum refuses the old spellings at parse with a per-value prescription; stored rows replay clean via the rehydration seam). This semantic entry exists for the two judgements the chain cannot make: whether `drawer` → `right` (a docked panel standing in for a never-implemented overlay) is the presentation the author wants, and whether a page that relied on the old materialized `collapsible: true` default should now author it explicitly. ADR-0087, maintainer ruling 2026-08-15." + }, + { + "surface": "page.component.record:highlights.fields[].icon — the per-chip icon on an object entry of the highlights field list", + "replacement": "(removed — the highlight chip has no icon slot.) Carry whatever the icon was meant to signal in what the chip does render: its label, or the field's own value.", + "migrationId": "record-highlights-field-icon-retired", + "toMajor": 18, + "rationale": "The D2 conversion `record-highlights-field-icon-removed` deletes `icon` from the object entries of every `record:highlights` field list, and the delete is lossless: the chip renders a label and a value and nothing else, the registration path carries field names only, and the Studio designer publishes the list as plain strings, so an authored icon was accepted and drawn by nothing. The residue is the author's intent. Six author-facing surfaces advertised the key, so an author may have chosen an icon to carry meaning — a warning glyph beside a risk score, a flag beside a region — and designed the page assuming a reader would see it. That meaning was never shown and is not shown now; only the author can say whether it matters and where it should live instead." + }, + { + "surface": "restServer.api.responseFormat / restServer.api.documentation.enabled", + "replacement": "(removed — delete each key; neither had an effect to preserve. Whether the server publishes its OpenAPI document and the docs viewer is `api.enableOpenApi`, the switch the mount already reads. Response shapes are fixed — each route answers in the response schema `@objectstack/spec/api` declares for it — and are not a server-wide option, so there is no replacement for `responseFormat`.)", + "migrationId": "rest-api-config-dead-keys-retired", + "toMajor": 18, + "rationale": "The `rest_api` liveness census found every member of these two keys `dead`: `normalizeConfig` parsed them, applied their defaults and copied them into the REST server's config, and no site ever read them back. So `responseFormat.envelope: false` unwrapped no response, `includeMetadata` and `includePagination` gated nothing, and `documentation.enabled: false` turned no document off — the document's existence was, and is, decided by `api.enableOpenApi` at the mount. Enforce-or-remove (ADR-0049) resolved both to REMOVE: mainstream data APIs keep a fixed response envelope that no administrator toggles server-wide, a configurable envelope would fork the declared response shapes the client SDK parses and the served /openapi.json describes, and `documentation.enabled` duplicates a switch that is already enforced. `RestApiConfigSchema` and its inline `documentation` block are non-strict `z.object()`s, so each key is a `retiredKey()` tombstone and its ledger row stays `dead` with a REMOVED note. No stored or built artifact carries either key, so no emitted default needs to be tolerated as residue: the config is a construction argument that is parsed and consumed in the same process. The consumer still owes the judgment because a host that WROTE `envelope: false` or `documentation.enabled: false` believed its clients saw a different shape or no document, and only that host knows which clients were built on the belief." + }, + { + "surface": "restServer.api.documentation.version", + "replacement": "(removed — delete the key. The served OpenAPI document's `info.version` is the protocol version, i.e. the version of the `@objectstack/spec` package that generated the document, with no configured override. An app that wants to publish its own release number writes it into `api.documentation.description`, which the served `info.description` now carries.)", + "migrationId": "rest-api-documentation-version-retired", + "toMajor": 18, + "rationale": "The `rest_api` liveness census found `documentation.version` `dead`: `normalizeConfig` parsed it and copied it into the REST server's config, and no site read it back, so `version: '2.3.0'` never reached the served document. Enforce-or-remove (ADR-0049) split the `documentation` block by who owns each field. The title, description, terms of service, contact and license are the publisher's identity and are now enforced. `info.version` is a fact of the protocol: an earlier ruling made the served `info.version` equal the published artifact's, so an integrator can read which protocol version they are talking to, and it removed the serve-time override that had made the field mean the route identifier. A publisher-set version would give the field a third meaning, so the key is retired instead of enforced. `RestApiConfigSchema`'s inline `documentation` block is a non-strict `z.object()`, so the key is a `retiredKey()` tombstone and its ledger row stays `dead` with a REMOVED note. The consumer still owes the judgment because a host that WROTE `documentation.version` believed its integrators read that number from the document, and only that host knows whether any client was built on the belief and where the number should be published instead." + }, + { + "surface": "RestApiEndpoint.handlerStatus (the implemented / stub / planned marker an endpoint in a REST API plugin route registration could carry), the HandlerStatusSchema / HandlerStatus value def it was typed with, and the RouteCoverageEntrySchema / RouteCoverageReportSchema report shapes (with their RouteCoverageEntry / RouteCoverageReport types) that re-declared it", + "replacement": "nothing declarative — the key never changed what the platform served, so there is no working configuration to migrate to. Delete the key; an endpoint that has no handler yet is simply not registered. Route readiness that IS measured is unchanged and lives elsewhere: the discovery payload reports each service's status and handlerReady (api/discovery.zod.ts), and packages/runtime/src/route-ledger.ts asserts per-route coverage in CI. A declared-but-unbuilt route answering 501 instead of 404 is a new capability the ruling explicitly excluded (zero pull); if it is ever wanted it re-declares fresh under its own ruling, executor first", + "migrationId": "rest-api-endpoint-handler-status-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling of 2026-09-01 on this key: remove it with a tombstone; enforce excluded. The key was DOCUMENTED to cause a specific runtime behaviour — its docstring said a stub handler \"returns 501 Not Implemented\" — and that behaviour has a different cause: every DispatcherErrorCode.enum.NOT_IMPLEMENTED site (runtime/src/endpoint-executor.ts ×3, runtime/src/api-mapping.ts, runtime/src/api-endpoint-step.ts) is the declarative-endpoint executor refusing a target or mapping it cannot serve, and none of them consults handlerStatus. Measured at the retirement base (origin/main a9b2be0b0, 2026-09-02, skills/** and tests excluded): the only identifier hits were the declaration on RestApiEndpointSchema, the re-declaration on RouteCoverageEntrySchema and a docblock saying adapters SHOULD warn on it; RouteCoverageReportSchema — the one shape that would have carried the status outward — had zero constructors in objectstack, objectui (pinned sha) and cloud. So an author who wrote handlerStatus: 'stub' expecting the dispatcher to answer 501 got an ordinarily served route, and the declaration reported progress to nobody — a declared ≠ enforced gap on the same endpoint vocabulary whose ApiEndpointSchema had already been closed strictly once `api` became a registered metadata type, and the surface a published skill had been teaching as working machinery (this finding came out of correcting that skill sentence, in a factual sweep of the API skill). Bookkeeping: the KEY is tombstoned with retiredKey() on the non-strict RestApiEndpointSchema (api/RestApiEndpoint:handlerStatus in RETIRED_KEYS_BY_MAJOR[18]); the DEFS leave whole — api/HandlerStatus (orphan value enum once both carriers are gone — an exported value schema with no consumer reads as a capability, so it leaves with its key), api/RouteCoverageEntry and api/RouteCoverageReport (route 3: nobody ever parsed or constructed one) — all three in RETIRED_DEFS_BY_MAJOR[18]. It is a SEMANTIC entry rather than a D2 conversion because there is no source to rewrite: nothing in the tree parses RestApiEndpointSchema outside its own unit tests — a REST API plugin route registration is not a stack collection member and never a sys_metadata row — so the conversion chain has no seam that would ever see one (the kernel/Manifest:loading disposition). ENFORCE was excluded by the ruling: mounting a 501 stub for stub / planned endpoints is a zero-pull new capability, not a repair. The same ruling records the class direction for two sibling ADR-0049 findings (the unbound branded identifier schemas and the event-name schema no runtime reads; not ruled by it): a declared-but-unenforced key with no pull retires; enforce/bind only on a named consumer or measured pull. ADR-0049 / ADR-0087." + }, + { + "surface": "the three REST-plugin durations whose name carried no unit: RestApiEndpoint.timeout, RestApiEndpoint.cacheTtl and RestApiPluginConfig.performance.defaultCacheTtl (api/plugin-rest-api.zod.ts)", + "replacement": "timeoutMs (milliseconds), cacheTtlSeconds (seconds) and defaultCacheTtlSeconds (seconds, default 300) — rename each key; every value is unchanged", + "migrationId": "rest-api-plugin-durations-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. RestApiEndpoint is this rule's clearest specimen after the founding one: `timeout` in MILLISECONDS and `cacheTtl` in SECONDS sat three lines apart on one shape, each unit named only in its describe, so the two numbers were indistinguishable at the authoring site and a value copied from one to the other was off by 1000x with no error anywhere. performance.defaultCacheTtl travels with them rather than in its own entry because it is the plugin-wide DEFAULT behind the per-endpoint override: renaming the override and leaving the default bare would have spelled one value two ways across one config. All three are retiredKey() tombstones — these shapes are not strict, so a bare deletion would strip the old key in silence, and defaultCacheTtl is a tombstone INSIDE the live `performance` block, whose siblings must keep parsing. Why a semantic entry and not a D2 conversion: a RestApiPluginConfig is the REST plugin's construction argument and a RestApiEndpoint is a route registration inside it — neither is a stack collection member or a stored row, so the chain has no seam that ever runs on them. That is the disposition api/RestApiEndpoint:handlerStatus already carries on this very shape (rest-api-endpoint-handler-status-retired), and what ruling B prescribes for a key that is not authorable metadata. ADR-0087." + }, + { + "surface": "restServer.crud.patterns / restServer.crud.objectParamStyle / restServer.metadata.cacheTtl / restServer.metadata.endpoints.schema / restServer.batch.operations.upsertMany / restServer.batch.defaultAtomic / restServer.routes.includeObjects / restServer.routes.excludeObjects / restServer.routes.nameTransform / restServer.routes.overrides", + "replacement": "(removed — delete each key; none had an effect to preserve. Per-object API exposure is declared on the object: `enable.apiEnabled: false` hides it from the REST data surface (404) and `enable.apiMethods` whitelists its operations (405). The data base path is `crud.dataPrefix`, deployment-wide. An endpoint on a custom path or method — or one that needs its own `summary` / `description` / `cacheTtl` — is a declarative `api` endpoint (`type: 'object_operation'`). Batch atomicity is the per-request `options.atomic` (ADR-0119 D4); upsert is an operation type of the generic `POST /data/:object/batch` endpoint, gated by `batch.enableBatchEndpoint`.)", + "migrationId": "rest-server-config-dead-keys-retired", + "toMajor": 18, + "rationale": "The liveness census that enrolled the four `RestServerConfig` sub-objects found 15 of their 32 rows `dead`: parsed, defaulted and normalized into the REST server's config by `normalizeConfig` (which parses them, rather than casting them, since an earlier fix) and never read back. `crud.patterns` and `routes.overrides` described route customization the server mounts from fixed pairs; `routes.includeObjects` / `excludeObjects` and `overrides.enabled` / `operations` duplicated the object's own enforced exposure keys; `nameTransform` and `objectParamStyle` were enums validated and then ignored; `metadata.endpoints.schema` and `batch.operations.upsertMany` gated routes that were never built; `metadata.cacheTtl` fed no cache and no header; `batch.defaultAtomic` would have silently overridden a per-request contract ADR-0119 D4 had deliberately set. Enforce-or-remove (ADR-0049) resolved every family to REMOVE because each promised capability either already exists at its proper seat (the object, the declarative endpoint, the batch request) or would contradict a fixed contract (the client SDK, the discovery document and the served /openapi.json all describe the mounted CRUD paths; the object `name` is the REST path segment). All four schemas are non-strict `z.object()`s, so each key is a `retiredKey()` tombstone and its ledger row stays `dead` with a REMOVED note; `api/CrudEndpointPattern`, the value def of `crud.patterns`, leaves with it. No D2 conversion: a `RestServerConfig` is plugin TS configuration, never a stack collection member or a `sys_metadata` row (the `openApi31` precedent). A closed-set sweep of the cloud repository at 9b6abe0f2fd5: zero hits, structural — cloud never authors a `RestServerConfig`." + }, + { + "surface": "security.PermissionSet rowLevelSecurity[].check (RowLevelSecurityPolicySchema) on a policy whose operation is select or delete. A blank check (empty or whitespace only) declares nothing and is not refused", + "replacement": "what the predicate was meant to guard, written where it runs. To limit which rows a select policy lets a caller read, or which rows a delete policy lets a caller delete, write the predicate as `using` on that policy (remove `check`; if the policy already has a `using`, AND the two with &&). To validate rows as they are written, declare the `check` on a policy whose `operation` is `insert`, `update` or `all` instead. The refusal lands at rowLevelSecurity[N].check, names the operation, and states both rewrites", + "migrationId": "rls-check-on-select-or-delete-policy-refused", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove and ADR-0058 D4. A `check` judges the post-image of a write: the new row of an insert, the changed row of an update. A select or delete writes no row, and the plugin-security write gate collects only the policies whose operation is the write's own or `all`, so a `check` on a select or delete policy was accepted, stored and never evaluated. Measured on main before this change: a policy carrying only check record.status != 'archived' on select or delete admitted every insert and update of an archived row, and beside a USING-only `all` sibling it did not replace that sibling's `using` default the way a `check` on an insert, update or all policy does. An author (an AI author above all) who wrote a check on a delete policy believed deletes were guarded by it. The refusal is a non-transforming refinement on the policy schema, so it reaches every door that parses a permission set: defineStack, os validate, and the metadata save path, whose permission type validates against PermissionSetSchema. Metadata AT REST is not rewritten and this entry adds no D2 conversion: dropping the key would silently discard the predicate the author wrote, and moving it to `using` would start filtering reads or deletes the policy never filtered before — both change which rows the policy admits, which is the policy author's decision. Ships at once, no transition window and no advisory lint phase." + }, + { + "surface": "security.PermissionSet rowLevelSecurity[].check — a CEL predicate comparing a field with != or == against a list, a list literal or a current_user membership array, and the negation of such an ==. They lowered to { field: { $ne: [...] } }, { field: [...] } and { $not: { field: [...] } }, which the @objectstack/formula evaluator matchesFilterCondition now refuses, together with { field: { $eq: [...] } }, at any depth under $and / $or / $not, the empty array included", + "replacement": "the list operator the comparison was standing in for. \"One of these values\" is in: record.status in [\"open\", \"pending\"]. \"None of these values\" is the negated in: !(record.status in [\"closed\", \"archived\"]). Scalar != and ==, null, Date comparands, and { $field } references between single-valued columns evaluate exactly as before", + "migrationId": "rls-predicate-array-comparand-refused", + "toMajor": 18, + "rationale": "Ruling A of 2026-09-24 refuses an array comparand under $ne, and ruling 乙 of 2026-09-23 refuses one in the implicit-equality slot, each for every driver at once; this change lands both on the formula face, the evaluator plugin-security runs against the post-image of an insert or update to enforce a row-level check. It compared strictly, and no stored value ever equals an array, so a check written record.status != [\"closed\", \"archived\"], or != against a current_user membership array, matched EVERY post-image, and a check written !(record.status == [\"closed\", \"archived\"]) did the same: every write such a policy was written to refuse was admitted and stored. The positive record.status == [\"open\", \"pending\"] refused every write (403). The evaluator now refuses all of these shapes before any record is judged. The message withholds the field, the operator and the value. Metadata AT REST is not rewritten and this entry adds no D2 conversion: the platform cannot tell which list operator a list comparison was standing in for, and a policy rewritten on the author's behalf would change which writes it admits (the negated forms would start refusing writes they admitted, the positive form would start admitting writes it refused), which is the policy author's decision. ADR-0058 D4 / ADR-0087 / ADR-0112." + }, + { + "surface": "security.PermissionSet rowLevelSecurity[].using and .check, and sharingRules[].condition — a CEL predicate comparing a field with another field (==, !=, >, >=, <, <=) where the two declared columns share no comparison class: text against a number, a date against a datetime, a boolean against text, and any column against a file field (file, image, avatar, video, audio) or a formula field. In a filter passed to matchesFilterCondition together with the object's declared columns (options.fields), a { $field } comparison under $eq, $ne, $gt, $gte, $lt or $lte between two such columns, and between a column and a json or multiple field", + "replacement": "a comparison between two columns of one comparison class: a number with a number (number, currency, percent, rating, slider, progress, summary), text with text (the string types, autonumber, a single select or radio, a single lookup or user, a master_detail or a tree), a boolean with a boolean, a date with a date, a datetime with a datetime, a time of day with a time of day. A file field and a formula field cannot be compared with another column at all: compare the field with a literal or test it for null. If the two columns do hold comparable values, one of them is declared with the wrong type, so correct that declaration rather than the predicate. Comparisons between two columns of one class lower and evaluate exactly as before", + "migrationId": "rls-predicate-cross-class-field-comparison-refused", + "toMajor": 18, + "rationale": "A column-to-column comparison has one meaning only within one comparison class: across classes SQLite orders every TEXT above every INTEGER while the in-process evaluator coerces (\"open\" > 5 is false). A formula field is virtual, with no stored column to reference. The file family is refused by name, whatever the deployment stores: during the ADR-0104 dual-encoding window one media column can hold a bare id and another the JSON-quoted form of the same id, so no comparison against the family is provably one answer on every path. driver-sql has refused such a comparison on the read since it first compiled a { $field } reference to a column-to-column comparison (a text column ordered against a number answered differently on SQLite than in memory, so the pushdown refused it), so a policy written record.status != record.amount (text and a number), record.status != record.photo (text and an image) or record.status != record.is_open (text and a formula field) got three answers, measured through the real plugin-security on driver-sql, on SQLite and PostgreSQL: os validate called it valid, every read it scoped answered INVALID_FILTER / 400 and every by-id update or delete it scoped 403, and an insert or update its check judged, or its using standing in as the check, was admitted and stored, because the write check compared the two raw values. The classification is now exported once from @objectstack/spec/data (crossFieldComparisonVerdict) and read by every judge. The authoring arm: the rls-predicate-unenforceable rule refuses the comparison in using and check, on every operation, at os validate, build and lint and at the metadata save door for a permission set, and the sharing-rule-unlowerable-condition rule refuses it in a sharing-rule condition at os validate, build and lint. The write-check arm: the row-level write gate hands matchesFilterCondition the object's declared columns, and a comparison the classification does not define is refused INVALID_FILTER / 400 for every insert and update the check judges, before any record is read, with nothing stored; the message withholds the columns and the server log names the policy and both. A comparison against a json or multiple field is now refused by its declared type on the write too, where the earlier refusal of an array comparand under $ne judged it by the value each record held. driver-memory, a test driver with no field-reference arm, still reads such a comparison as a literal. Shipped producers were counted before the change: no shipped row-level policy or sharing-rule condition compares two fields of different classes. Metadata AT REST is not rewritten and this entry adds no D2 conversion: the platform cannot tell which comparison the author meant, and rewriting it on the author's behalf would change which rows and writes the policy admits, which is the policy author's decision. ADR-0058 D4 / ADR-0087 / ADR-0112." + }, + { + "surface": "security.PermissionSet rowLevelSecurity[].check, and .using where it stands in as the check — a CEL predicate ordering a field against a bound (>, >=, <, <=) where the field holds a list or an object on the record being written, as a json column or a multiple lookup does, or as a list written into a text or number field does. In a filter passed to matchesFilterCondition, $gt / $gte / $lt / $lte and $between on a field whose value on the record is a list or a plain object, whatever the comparand", + "replacement": "a comparison that names one value. Order a single-valued column (record.priority > 2), or test membership in the list with in (record.status in [\"open\", \"pending\"]); a json or multiple field has no ordering. A record whose json column holds one scalar is compared exactly as before, and so are null and Date values, and every equality (==, !=, in) against a stored list", + "migrationId": "rls-predicate-stored-list-ordering-refused", + "toMajor": 18, + "rationale": "The mirror, with the list on the record's side, of the earlier refusal of an ordering operator against an array comparand (one of the same-class leaks that followed the 2026-09-24 ruling refusing an array under $ne), measured through the real plugin-security on driver-sql and driver-memory. record.tags > \"a\", with tags a json column holding [\"m\"], lowered to { tags: { $gt: \"a\" } }, and the write-check evaluator compared the list's JavaScript string form (\"m\" > \"a\"), so the check admitted and stored the write; record.meta < \"a\" with meta holding { a: 1 } compared \"[object Object]\" and did the same, and so did a multiple lookup. driver-sql's read refuses every ordering comparison, and $between, on a column it stores as JSON text, by declared type (400), because such a comparison can never mean what the caller wrote; the in-process write check now follows it, per record: INVALID_FILTER / 400 and nothing stored, on an insert and on a by-id update, including one that edits another field of a row whose stored column holds a list. A list written into a text or number field under an ordering check, admitted before and stored as the text \"[500]\" by driver-sql, is refused the same way. driver-memory, a test driver, still compares a stored list element by element on a read, so there the write and the read part. Shipped producers were counted before the change: no shipped row-level or sharing-rule predicate orders a field at all. Metadata AT REST is not rewritten and this entry adds no D2 conversion: the platform cannot tell which comparison an ordering over a list was standing in for, and rewriting it on the author's behalf would change which writes it admits, which is the policy author's decision. ADR-0058 D4 / ADR-0087 / ADR-0112." + }, + { + "surface": "the saved-report stack, whole: the `reports` platform capability token (`requires: ['reports']`, its `PLATFORM_CAPABILITY_TOKENS` member and its `PLATFORM_CAPABILITY_PROVIDERS` row); the saved-report service contract in `@objectstack/spec/contracts` (`IReportService`, `SavedReport`, `ReportSchedule`, `ReportQuery`, `ReportFormat`, `ReportRunResult`, `SaveReportInput`, `ScheduleReportInput`); the `sys_saved_report` and `sys_report_schedule` platform objects (`SysSavedReport` / `SysReportSchedule` in `@objectstack/platform-objects/audit`) and their names in `PLATFORM_PROVIDED_OBJECT_NAMES`; the eight `/api/v1/reports` routes (list, save, get, delete, run, schedule, list schedules, unschedule); the `reports` namespace of `@objectstack/client`; and the `@objectstack/plugin-reports` package that served them. NOT the `report` metadata kind (`ReportSchema`, `/meta/report`, datasets, analytics), which is unchanged.", + "replacement": "Delete `'reports'` from `requires` — `defineStack` now refuses it with this prescription. A report is `report` metadata: `ReportSchema` over a dataset (ADR-0021), served by the analytics service every server mounts. A saved ad-hoc object query — what a `sys_saved_report` row held — is a ListView on that object. Code that imported the contract types or called the client namespace deletes those lines; there is no successor API and no scheduled-delivery replacement.", + "migrationId": "saved-report-stack-retired", + "toMajor": 18, + "rationale": "Maintainer ruling 2026-09-25 (verbatim: 「A. 退役」, then 「你直接派发处理这个退役任务。」). The stack persisted a raw object query (`object_name` plus `{filter, fields, orderBy, limit, groupBy}`) with a render format and an owner — the same object-plus-raw-query shape ADR-0021 removed from the `report` kind as its legacy inline query form, alive in a parallel table under the same word. Measured on the main branch of all three repos before removal: zero callers of the routes, the client namespace or the service contract outside their own tests, and no app declaring the capability. A declared capability with no consumer is a surface an author (most often a model) reaches for and confuses with the real report kind, so it is retired at once, with no deprecation window." + }, + { + "surface": "The START NODE `config.organization` key of every time-triggered flow — a `type: 'schedule'` flow carrying a `config.schedule` cadence, and the `timeRelative` sweep that carries its cadence in the same slot (`FlowTriggerKind` `schedule` / `time_relative`) — TOGETHER WITH the deployment variable that decides whether such a flow arms at all, `OS_AUTOMATION_SCHEDULED_WORK_ENABLED`. Nothing is renamed, retired or re-typed: the start node's `config` is an OPEN record (ADR-0018), so the key is an ADDITION to a slot that already accepted it, and every flow that parses today parses byte-identically after the change. What narrows is the BIND-time accept set and the RUN-time data plane — and what the 2026-09-12 and 2026-09-16 amendments narrow further is WHERE that narrowing applies: the declaration is required under tenancy posture `isolated` only, is OPTIONAL under `group` (where an undeclared run acts as the swept record's own organization), is not read under `single`, and no time-triggered flow arms anywhere until the deployment switches package-authored scheduled work on.", + "replacement": "Two deployment decisions, in this order. (1) DECIDE WHETHER THIS DEPLOYMENT RUNS PACKAGE-AUTHORED SCHEDULED WORK AT ALL: `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true` arms time-triggered flows and packaged `defineJob` cron jobs; unset — the global default, in every posture and every kernel — arms neither, and every such flow is listed by `getTriggerBindingAudit()` and the CLI startup summary as DISABLED BY DEPLOYMENT POLICY rather than as a binding failure. Platform-internal jobs (approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership backfill) are NOT gated by it: the boundary is \"authored by a package\", not \"runs on the job service\". (2) ONLY IF THE SWITCH IS ON AND THE POSTURE IS `isolated`, declare the organization each flow runs as, on the start node beside the cadence: `config: { schedule: { … }, organization: '' }`. There is deliberately NO fan-out — a sweep wanted in N organizations is N flows, one per organization — and deliberately no fallback: nothing on this path ever chooses an organization, because a wrong `organization_id` is silently authoritative to every report, export and cleanup that filters by organization, while a refusal is visible at boot and names its flow. Under the `single` posture with the switch on, declare NOTHING: the run carries no organization and every tenant-scoped insert beneath it resolves the deployment's one organization through the guard that makes a system-context write resolve the install's organization. Under the `group` posture with the switch on, declaring is OPTIONAL and both shapes are supported: a declared flow behaves exactly as under `isolated` (the declaration bounds SELECTION and identity alike), while an UNDECLARED flow arms, reads group-wide — which ADR-0105 D1 makes inherent to the posture — and stamps each run it launches with the SWEPT RECORD's own organization, the same subject-first order `sys_automation_run` already uses. ⚠️ An undeclared `group` flow that reaches a tenant-scoped write with NO record to derive from — a record-less cron emitting a notification — is REFUSED at that write (`walled-posture`, ADR-0112), loudly and by name; declare `config.organization` on that flow, which is the remedy the refusal itself prints. ⚠️ Three consequences apply to an `isolated` deployment that splits one flow into N, and each is deployment work: (1) rows whose tenant column is NULL stay visible to a scoped read (`org = :tenant OR org IS NULL`), so after the split each such row is matched ONCE PER FLOW — N runs and N notifications for one row, each acting as a different organization; (2) the dispatch-claim key embeds the flow name (`schedule::`, `time-relative:::`), so renaming one flow into N abandons the current window's claims and a window already delivered under the old name can deliver once more under the new ones; (3) a run SUSPENDED before the upgrade rehydrates its context from `context_json`, which carries no `tenantId`, so it resumes org-less — drain or accept in-flight suspended runs rather than assuming the upgrade confines them retroactively.", + "migrationId": "schedule-flow-acting-organization-required", + "toMajor": 18, + "rationale": "Three maintainer rulings, all verbatim and untranslated, in the order they were given. 2026-09-08: 「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。」 A time-triggered run is launched from a job tick and a job tick carries no identity, so the run reached the tenancy guard with nothing to offer it: the notification wrote `organization_id = NULL`, every tenant-scoped row beneath it was refused, and the tick still summarised itself as healthy. 2026-09-12, on the same surface: 「schedule 是风险很大的模型,尤其在云端,无算是单独多租户还是每库一租户,可能造成极大的资源浪费。对于单租户或着集团版私有部署,我觉得不需要做限制。定时任务 如果不好处理,现在也没想清楚,有没有可能定义为一个环境变量,根据环境变量控制?」 and 「group 默认也关,云端每库一租户全局默认关」. Whether clock-driven work is affordable is a fact about the DEPLOYMENT — its database, its tenants, its budget — that no author can know and no metadata key should ask them for, so the gate is a deployment variable read at boot and the global default is OFF. 2026-09-16, reopening the `group` half of that amendment and nothing else: 「group 模式是本地部署的,运行 schedule 应该是可以的,但是你没有权限,可以单独开一个决策卡」 — ruled A′ the same day: under `group` with the switch on, a flow binds without a declaration. The 2026-09-08 ruling was made for the MULTI-TENANT shape, and `group` is not one: ADR-0105 D1 defines it as one legal group over one database with group-wide visibility and cross-org workflow INHERENT to the shape, so a group-level batch job is a capability of the posture rather than the cross-organization task the ruling forbids. What was genuinely unanswered — recorded as unanswered by ruling G item 3 — was which organization such a run's inserts belong to, and the answer is the one `sys_automation_run` was already ruled to use: the SUBJECT RECORD's organization, with the acting context as the fallback and never the primary. Filling the acting context the same way makes the inbox, delivery and history rows of one run agree about its owner; leaving them to disagree was the defect, not the fix. ⛔ The rejected arm is recorded too, because it is the one a later reader will re-propose: falling back to the bootstrap organization (`slug='default'`) for a record-less run. Under a wall that organization is minted ADMIN-KEYED by the enterprise organizations runtime and may not exist at all, and where it does it is whichever organization the platform owner registered under — plausibly one plant of many. That is the silently-authoritative wrong owner this entry already forbids, so a record-less undeclared run is refused instead. Where the switch is on, the 2026-09-08 ruling therefore stands unchanged under `isolated`, is satisfied per-record under `group`, and is moot under `single`, which holds exactly one organization and therefore has no cross-organization task to forbid. ⛔ NOT losslessly convertible, and the reason is that both remedies are values only the deployment holds: an organization id is minted per install at runtime and the switch is an operator decision about cost, so there is no authored artifact and no stored representation a transform could rewrite — `objectstack migrate meta` cannot know which organization a given sweep belongs to, nor whether this deployment wants scheduled work at all, and inventing either is precisely what the rulings forbid. Registered under ADR-0087 D3 rather than left silent because the change DOES carry a prescription — \"decide the switch, then declare one flow per organization under a wall\" is deployment work a human must do, which is what D3 says a structured TODO is for. The direct precedent is `rest-requireauth-default-flip` (protocol 12): behaviour-only, no shape moved, a deployment judgement no transform can make, registered anyway." + }, + { + "surface": "the `sys_scim_provider` platform object (`SysScimProvider` in `@objectstack/platform-objects/identity`, re-exported from the package root) and its name in `PLATFORM_PROVIDED_OBJECT_NAMES` (`@objectstack/spec/system` constants). The rc.1-era `@better-auth/scim` connection row: one row per SCIM bearer connection, written only by the retired `/scim/generate-token` endpoint.", + "replacement": "(removed — no direct replacement row. The stable `@better-auth/scim` 1.7.x line, which the platform adopted as one whole-model migration, derives no `scimProvider` model: SCIM state lives in the seven stable platform objects (`sys_scim_connection_binding`, `sys_scim_group`, `sys_scim_group_member`, `sys_scim_identity_tombstone`, `sys_scim_projection_grant`, `sys_scim_subject`, `sys_scim_user`) and connection credentials in the ObjectStack-owned `sys_scim_connection_credential`, minted/verified by `scim-connection-service.ts` behind the application-owned `verifyBearerToken`. A SCIM-enabled deployment re-registers its connections on the stable surface; rc.1 token digests are not portable on any path, so the IdP reissues its token — a migration-day operator action, not a code rewrite.)", + "migrationId": "scim-provider-object-retired", + "toMajor": 18, + "rationale": "Maintainer ruling 2026-08-24 on the disposition of `sys_scim_provider` (verbatim, in part: 「不需要考虑历史数据」) — disposition A: retire, with no data-migration path owed for existing rows (reaffirmed 2026-08-25: SCIM has no real customers; the binding constraint is a smooth upgrade). Executed as a retirement of its own after the stable-1.7.1 migration landed: the installed library derives no `scimProvider` model, so the object backed nothing — nothing could write a row to it any more. Retiring it also removes its `provider_id` unique index, whose stricter-than-upstream uniqueness (one `provider_id` across every organization, where upstream scopes it per organization) was flagged while the SCIM upgrade was parked, and left pending exactly this retirement." + }, + { + "surface": "The `reference` key of a `type: 'lookup'` field on a `screen` node — `flows[].nodes[].config.fields[]` where the node `type` is `screen` and the field `type` is `lookup` (`ScreenFieldConfigSchema`). Nothing is renamed, retired or re-typed and the key set does not move: `reference` was already declared and already optional in the shape. What narrows is the ACCEPT SET for one value of the sibling `type` — a `lookup` field with no `reference`, or with a blank one, parsed before this major and is refused now. Every other widget hint is untouched, and a `lookup` field that already names its target parses byte-identically.", + "replacement": "Name the object whose records the picker offers, beside the type: `{ name: 'resolved_by_article', type: 'lookup', reference: 'crm_knowledge_article' }`. The value is an object NAME (the canonical id — same string `FieldSchema.reference` carries), not a label and not a record id. ⚠️ There is deliberately no default and no inference: a picker pointed at the wrong object is worse than one that refuses to load, because it offers a human a plausible list of the wrong records and the flow stores the id it is given. Where the field genuinely has no target object — the author was using `lookup` to mean \"type an id here\" — the fix is the other direction: change `type` to `'text'`, which is what that field actually was, and keep the prose that asked for an id in `inlineHelpText`.", + "migrationId": "screen-field-lookup-reference-required", + "toMajor": 18, + "rationale": "Maintainer ruling A′, 2026-09-13, verbatim, untranslated: 「同意」. ADR-0078 forbids metadata that parses, carries no marking and does nothing — and its own worked example of that state is a `lookup` with no `reference`: the field renders a picker, the picker has no object to query, and nothing anywhere says so. The key shipped OPTIONAL on this surface one release earlier, on the argument that flows declaring a bare `lookup` already exist; the ruling reversed that, holding that a degraded shape which ships is not a reason to bend the contract to it. ⛔ NOT losslessly convertible, and the reason is the same one `schedule-flow-acting-organization-required` gives: the remedy is a value the artifact does not contain. A bare lookup records the field name and nothing about its intended object, so `objectstack migrate meta` can identify every site but can answer none of them — and a conversion that guessed (the first object with a matching-looking name, the flow's trigger object) would write an authoritative wrong answer into metadata a human then trusts. Registered under ADR-0087 D3 rather than left silent because the change DOES carry a prescription a human can execute, which is what D3 says a structured TODO is for." + }, + { + "surface": "contracts.emailService.sendTemplate input.org", + "replacement": "(removed — never implemented; delete the key from the call. It is NOT replaced by `organizationId`: that member is the delivery row's tenant stamp (`sys_email.organization_id` pass-through, added so the email writer stamps a delivery row's organization at the source) and opts into no template overlay resolution)", + "migrationId": "send-template-input-org-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove. `SendTemplateInput.org` was declared as \"Tenant id for org-overlay resolution (when supported)\" and no implementation ever read it: `@objectstack/plugin-email` — the only IEmailService implementation — resolves templates on `(name, locale)` only, so a caller passing `org` got no org-overlay resolution and no error; the \"(when supported)\" hedge was the declaration admitting the gap. After the delivery-row stamp landed `organizationId` beside it, the input carried two org-shaped keys of which one did nothing — exactly the shape that invites an AI author to pick the wrong one. There is no behaviour to preserve and nothing stored to rewrite: the key only ever appeared in a call-time input bag (the `data.engine.update options.upsert` precedent), which is why this is a D3 semantic entry with no D2 conversion — no metadata seam ever runs on it. Org-overlay template resolution, if it ever earns a measured business pull, is a new capability with its own ruling — not this key revived." + }, + { + "surface": "GET /api/v1/auth/get-session -> user.positions[] (and the client CEL root `current_user.positions` bound from it)", + "replacement": "the SAME key, carrying the SECURITY positions — the set `/auth/me/permissions` reports and `resolveUserAuthzGrants` resolves. A reader that wanted the better-auth role scalar reads `user.role`, which is unchanged and still published", + "migrationId": "session-payload-positions-security-axis", + "toMajor": 18, + "rationale": "A MEANING change, not a rename: no key moved, so nothing in this entry can be found by grepping for a removed spelling — which is exactly why it needs a ledger row. `customSession` built `user.positions` from the better-auth `sys_user.role` scalar split on commas, PLUS the active membership mapped to `org_*`, PLUS `platform_admin`, and read NOTHING from `sys_user_position` (ADR-0057 D4), the source of truth for custom positions. The Console binds that array straight through as the CEL root `current_user` (objectui `expressionUser.ts`: `positions: user.positions ?? []`), so an `action.visible` / `visibleWhen` / nav `visible` narrowed by a business position answered FALSE for EVERYONE, including the user who genuinely held it. ⭐ The failure was silent and in the invisible direction: the root was bound and the key was present, so `has(current_user.positions)` was true and CEL raised nothing — the predicate simply returned FALSE. A predicate that FAULTS fails OPEN in the shell and would have shown the button; a successful FALSE shows nothing and reports nothing. The documented example `'org_admin' in current_user.positions` kept working throughout, because `org_admin` is the one name that sits on BOTH axes — which is why no example, test or doc could reveal the split. This was a DECLARED contract being violated rather than an ambiguous name: `EvalUserSchema` already specified `positions` as \"built-in identity names + position names\", exposed to \"every predicate surface (server formula, server RLS, client UI gates) ... with an identical shape\" so a predicate \"evaluates identically wherever it is written\". `/auth/me/permissions` and every server-side evaluator (`ExecutionContext.positions`) already resolved the security axis; the session payload was the one producer that did not, because it derived the value itself instead of asking the authority `resolve-authz-context.ts` reserves that job for. ⚠️ NO renamed auth-role array accompanies this, and that is a measured disposition rather than an omission: everything the old union contributed beyond the security axis was the `sys_user.role` scalar's own tokens, and that scalar is ALREADY published unchanged as `user.role` — the single exception ADR-0090 D3's \"role\" word ban carves out for third-party schema. Minting a `roles` array would revive the exact banned identifier `check:role-word` ratchets against, to publish information the payload already carries. Maintainer ruling 2026-09-05: option A, one name, one meaning — `current_user.positions` means the security positions everywhere. ADR-0068 D1/D2, ADR-0090 D3/D5, ADR-0057 D4." + }, + { + "surface": "api.session.user.language", + "replacement": "`GET /auth/me/localization` → `locale` (the user's own `sys_user.locale` when set → the request's `Accept-Language` → the deployment default)", + "migrationId": "session-user-language-retired", + "toMajor": 18, + "rationale": "`SessionUserSchema.language` was declared with a permanent default of `'en'` and described as \"Preferred language\", and had no producer and no consumer anywhere: no session endpoint wrote it, no client read it (objectui measured zero readers at its pinned sha), so a reader trusting the published contract received a constant that was not the user's language. Meanwhile the user's real preference landed as the first-class column `sys_user.locale` (ruled 2026-09-01 once measured demand for a per-user notification locale arrived), which the session type could not see — three spellings of one concept on the published surface, none of them right. The maintainer ruled option D (2026-09-03): retire the dead key under ADR-0049 enforce-or-remove and make `GET /auth/me/localization` the ONE read face, with its `locale` projecting the user column first. This is a RESPONSE surface — the server mints a `SessionUser` and nobody authors or persists one — so there is no source for the chain to rewrite; the schema tombstones the key via retiredKey() and consumers move their read to the endpoint. No replacement field joins the session contract until a session endpoint really produces one (no dual-spelling window, 不渐进). ADR-0049, ADR-0087." + }, + { + "surface": "the default export of objectstack.config.ts when it is not the value defineStack or composeStacks returned: a plain object literal, a spread or Object.assign copy of a built stack, a JSON copy of one, a module with no default export — and each input handed to composeStacks", + "replacement": "export what the producer returned: `import { defineStack } from '@objectstack/spec'; export default defineStack({ … });` with every stack key inside the call (`api`, `plugins`, `requires`, …), or `export default composeStacks([defineStack({ … }), …])`. Named exports beside it (`onEnable`, `functions`) are unaffected. `defineStack(config, { strict: false })` also satisfies the doors — it is still the producer — but skips its judgement, so reserve it for sources a strict parse cannot yet read", + "migrationId": "stack-config-default-export-unbuilt-refused", + "toMajor": 18, + "rationale": "The stack family's cross-field refusals — unknown `requires` capability, cross-references to objects the stack does not define, the namespace prefix, one app per app package, the hierarchy-scope and trigger capability requirements — run inside `defineStack` and nowhere else. A config exporting a plain object skipped all of them: `objectstack validate` and `objectstack build` ran only the schema parse, answered success, and the build shipped the artifact, so the defect surfaced at deploy or never (a trigger flow that silently never fires). Judging the export at the door instead is not possible: a built stack carries each bound standalone action twice (top level and merged into its object), so re-running the family on `defineStack` output refuses every correct project with a bound action. So both producers stamp a non-enumerable provenance mark on what they return (`hasStackProvenance`), and `objectstack validate` / `objectstack build` refuse an unmarked default export right after load with `STACK_PROVENANCE_MISSING` (exit 1), before any other judgement; `composeStacks` refuses an unmarked input with the same code. A copy of a built stack is refused too, because the mark does not survive a spread or JSON round-trip — by design, since the copy is not what the producer judged. ⚠️ No D2 conversion: the module shape is source code, not metadata. `objectstack serve`, `objectstack migrate` and `objectstack lint` load the config as before. ADR-0087." + }, + { + "surface": "stack `themes` (the carrier collection, and `ThemeSchema` with its sub-blocks)", + "replacement": "delete the `themes:` key (and any `defineTheme` calls). To colour the shipped console, set `app.branding.primaryColor` / `accentColor` — the one live colour surface (read by objectui, driving `--primary`, `--accent` and their derived CSS variables). A palette value your own stylesheet consumed has no spec slot any more: move it into your own CSS.", + "migrationId": "stack-themes-carrier-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21 (disposition B: 退役授权面 — objectui engine code and its unit tests are retained). The pipeline was live from the authoring gate (`ObjectStackDefinitionSchema.themes`, `defineTheme`) through artifact ingest (`ARTIFACT_FIELD_TO_TYPE.themes`) and stopped there, measured: zero non-test readers of `.themes` or stored `theme` items across core/runtime/rest/services/plugins; `theme` never in `MetadataTypeSchema`, `DEFAULT_METADATA_TYPE_REGISTRY` or `BUILTIN_METADATA_TYPE_SCHEMAS`; the only mounted ThemeProvider is the app-shell chrome light/dark toggle, unrelated to `ThemeSchema`; and no key anywhere selected an active theme. So an author (human or AI) who wrote a theme shipped it through every green gate and saw nothing change — the declared-but-unenforced shape ADR-0049 exists to delete. What colours a console today is `app.branding`, and that path is live and untouched." + }, + { + "surface": "top-level stack definition keys (`ObjectStackDefinitionSchema`) — undeclared keys", + "replacement": "the declared top-level surface. A key the schema does not declare is refused at parse with a prescriptive message naming the key, suggesting the closest declared key on a near miss (`objectz` → `objects`, `flow` → `flows`), and carrying a curated prescription for the known retirements (`approvals`/`approvalProcesses` → Approval-node flows per ADR-0019; `workflows` → `state_machine` validation rules per ADR-0020; `portals` removed with the dead `PortalSchema`; `storage` is deployment config, OS_STORAGE_*; `onDisable` was never invoked, and left with the lifecycle-hook family the kernel never implemented). `onEnable` is now DECLARED rather than silently stripped — the runtime has always executed it off the authored bundle (a config-booted app keeps it too, since the fix that stopped the loader dropping it and every script action handler it registered)", + "migrationId": "stack-top-level-unknown-keys-refused", + "toMajor": 18, + "rationale": "The outermost authoring door was the last strip-mode surface of the unknown-key strictness campaign: an unknown top-level stack key parsed green and its value was silently dropped. Measured on 17.0.0 GA: three injected bogus top-level keys added ZERO warnings to `os validate` and exited 0 — even `--strict` could not catch them, because the `defineStack:` naming diagnostic printed at load, outside the warning tally. The failure population is a typo or stale key (`flow` for `flows`, `approvalProcesses` after the 7.4 removal) shipping an artifact with a whole metadata family absent at runtime, debugged from the far end — the root of a downstream application's report of a top-level typo that shipped an artifact minus a whole family with `validate` and `build` both green. Unknown top-level keys are now refused at parse time, which fails `validate` (and every other path through this one parse) outright; the near-miss guidance that used to arrive as a load-time warning now rides the refusal itself." + }, + { + "surface": "`error.code` values `BATCH_PARTIAL_FAILURE`, `BATCH_COMPLETE_FAILURE` and `TRANSACTION_FAILED` — three `StandardErrorCode` members retired from the closed catalog (ADR-0112 amendment 2026-08-18), so constructing or parsing an ApiError with any of them now refuses at the vocabulary boundary", + "replacement": "branch on the codes the batch surface actually speaks: a rolled-back atomic batch marks each row `errors[0].code = ROLLED_BACK`, rows the abort never reached `NOT_ATTEMPTED`, and the causal row keeps its own error — all per row, at HTTP 200, both codes ledger-registered. Delete any branch on the three retired spellings outright: it never fired, because nothing ever emitted them", + "migrationId": "standard-error-code-batch-members-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove applied to the error vocabulary. No producer has ever emitted any of the three — measured when a sweep of the error catalogue found these three entries publishing no HTTP status: outside the enum declaration the only occurrences in the whole repo were two spec tests using them as arbitrary fixture strings, and `git log -S` shows they never had a producer since ADR-0112 introduced the vocabulary. A catalog member no producer can speak teaches an AI author a branch that can never fire; after removal the wrong spelling fails parse at authoring time instead. This is a WIRE vocabulary, not stored metadata — no `sys_metadata` row exists for the D2 chain to rewrite, so (like `driver-sql-upsert-cross-row-identity-merge-refused`) this entry is the notification channel. No mechanical rewrite exists: a dead branch has no correct mechanical target — the per-row codes carry strictly more information than the envelope code the branch expected. Maintainer ruling 2026-08-18: option A, retire all three from `StandardErrorCode`. ADR-0112, ADR-0049." + }, + { + "surface": "error.code value CONCURRENT_LIMIT_EXCEEDED — a StandardErrorCode member retired from the closed catalogue, so constructing or parsing an error with it now refuses at every catalogue door (StandardErrorCode, ErrorCode / ApiErrorSchema.code, makeApiErrorSchema) with the removal prescription", + "replacement": "delete any branch on `CONCURRENT_LIMIT_EXCEEDED` — a branch on a catalogue code no producer emits has nothing to match. For request pacing branch on `RATE_LIMIT_EXCEEDED` (HTTP 429; wait `retryAfterSeconds` before retrying). A service that enforces its own concurrency limit registers a code for it in its own error-code ledger rather than reusing the retired spelling. `QUOTA_EXCEEDED`, its catalogue neighbour, is unchanged.", + "migrationId": "standard-error-code-concurrent-limit-exceeded-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove applied to the ADR-0112 error catalogue. Ruling A of 2026-09-13 (maintainer 「同意」) retired both producerless 429 members; the closure-review ruling of 2026-09-24 (letter 留·收窄, maintainer 「其他同意」) narrowed it to this code alone after `QUOTA_EXCEEDED` was found emitted by a hosted AI agent route and read by the console chatbot plugin. The ledger doctrine in error-code-ledger.zod.ts names a producerless row with no card behind it as the registered-but-unemittable retirement class, and a catalogue member no producer speaks teaches an author a branch that cannot fire; after removal the stale spelling fails parse with its prescription instead. An error code is WIRE vocabulary, not a metadata key, so there is no authored source for a D2 conversion to rewrite and this entry is the notification channel, as it was for `standard-error-code-batch-members-retired`. No mechanical rewrite exists: a dead branch has no correct mechanical target." + }, + { + "surface": "the startup-ORCHESTRATION surface of kernel/startup-orchestrator.zod.ts and contracts/startup-orchestrator.ts — 3 emitted defs and 8 exported names: StartupOptionsSchema / StartupOptions / StartupOptionsParsed, HealthStatusSchema / HealthStatus, StartupOrchestrationResultSchema / StartupOrchestrationResult, and the IStartupOrchestrator interface (orchestrateStartup / rollback / checkHealth / startWithTimeout). The startup RESULT survives, re-declared: PluginStartupResultSchema and PluginStartupResult stay on both entries", + "replacement": "(removed — there is no declarative replacement, because nothing ever implemented the interface or parsed the schemas. Plugin startup is the kernel own boot loop: ObjectKernel.start() calls startPluginWithTimeout() per plugin, which races that plugin start() against PluginMetadata.startupTimeout and, when KernelConfig.rollbackOnFailure is set, destroys the already-started plugins and rethrows the original error as the new error cause. So: instead of StartupOptions.timeoutMs declare startupTimeout on the plugin; instead of StartupOptions.rollbackOnFailure set rollbackOnFailure on the kernel config; instead of StartupOrchestrationResult.results read the per-plugin durations through ObjectKernel.getPluginStartupDurations(). StartupOptions.healthCheck and HealthStatus have NO replacement at all — no startup probe system exists, and one returns only through the enforce route of ADR-0049 with a new ADR, the probe first and the vocabulary second. StartupOptions.parallel and StartupOptions.context likewise: the kernel starts plugins sequentially and passes its own PluginContext)", + "migrationId": "startup-orchestrator-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-09-06, option 3: keep a startup-result contract re-declared as the shape the kernel ships, and retire the rest. The module declared an orchestration design that never landed, and the spec and the kernel had already drifted into disagreement about the one shape that did: PluginStartupResultSchema described a plugin object, a required durationMs and a health member, while @objectstack/core shipped pluginName, an optional durationMs and timedOut. The ruling keeps a startup-result contract that describes what the kernel actually produces, and retires the rest. Re-measured on this card: zero implementers and zero consumers of the four retired surfaces in this repository and in the pinned objectui checkout, with lit same-corpus controls (defineStack, ManifestSchema); every remaining reference was a generated artifact or a released CHANGELOG.md. healthCheck and HealthStatus are the sharpest of the four: they name a per-plugin health probe the runtime has never had, the shape of the plugin sandboxing / integrity / approval config that was never wired to anything, which an AI author (ADR-0033) reads as proof the capability exists. With no authored document carrying any of the three defs there is no seam for a D2 conversion and no author to tombstone for: route 3, the shape of the dynamic plugin-loading family's removal and the advanced plugin-lifecycle config's retirement — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. The two keys of the SURVIVING result schema that leave (plugin, health) are tombstoned instead, and registered in RETIRED_KEYS_BY_MAJOR, because that def keeps emitting and its type is imported by @objectstack/core. A third key arrives on the spec surface only to leave it: core deprecated startTime alias, which held the same elapsed milliseconds as durationMs under a name that promises an instant. The re-declaration had to either mirror it or tombstone it, and mirroring is refused by check:duration-unit-keys (ruling B on duration-shaped number keys: the unit lives in the key name) since it is an elapsed number whose key name carries no unit and matches neither of that rule two schema-declared exemptions. So the L1 window closes here and the kernel stops populating it in the same change." + }, + { + "surface": "StrategyContext.executeAggregate aggregations[].method (contracts/analytics-service.ts, exported from @objectstack/spec/contracts) - the parameter type, declared as bare string", + "replacement": "AggregationFunction (count | sum | avg | min | max | count_distinct, data/query.zod.ts) - the same closed vocabulary IDataEngine.aggregate already declares for the identical slot (AggregationNodeSchema.function; the analytics bridge renames method to function and forwards). A caller filling method from a string-typed value narrows the value to the enum - typing it AggregationFunction, or parsing with the spec's own AggregationFunction zod enum where the value enters from data. Values outside the six were never served: the bridge has parsed-and-refused them at runtime since it stopped declaring its own engine type and began parsing the method with the spec enum, and that refusal stays as defence in depth", + "migrationId": "strategy-context-aggregation-method-narrowed", + "toMajor": 18, + "rationale": "Maintainer ruling 2026-08-28 (option A, census-first): one slot, one declaration. Two spec-declared surfaces described the same value and disagreed about its type: IDataEngine.aggregate's aggregations[].function is the closed six-value AggregationFunction enum while StrategyContext.executeAggregate declared the same slot aggregations[].method: string, so nothing on the analytics side of that seam was compile-checked against the engine's vocabulary - an author, very often an AI (ADR-0033), writing an analytics strategy got no compile-time help and could carry any method name all the way to the bridge's runtime refusal. One slot now has one declaration. Bookkeeping: this is a TYPE narrowing on a runtime TS interface member - no authorable metadata key, no wire shape and no walked-shape def changed, so nothing lands in RETIRED_KEYS_BY_MAJOR / RETIRED_DEFS_BY_MAJOR and the surface ratchets are expected byte-identical. It is a SEMANTIC entry rather than a D2 conversion because there is no authored document or sys_metadata row for the chain to rewrite: the only consumers are TypeScript call sites, and the compile error is the channel that reaches them. In-repo census at the ruling (hard precondition, measured before the narrowing landed): every implementor and every call site filling method is legal under the enum - ObjectQLStrategy.resolveMeasureAggregation emits only the six once it refuses a custom-SQL measure up front, the two literal producers write count, and every test fixture is implementor-side and stays assignable by contravariance." + }, + { + "surface": "The BODY of every ADR-0031 structured region — `loop.config.body`, each `parallel.config.branches[]`, and `try_catch.config.try` / `.catch` — at every depth the flow parse walks. Two node populations become undeclarable there: a node whose TYPE parks the run on EVERY execution (`screen`, `wait`, `approval`, `approval_revise`) and an `end` node, whatever its `outcome`. ⛔ `subflow` and `map` are NOT in the population, although their executors also declare `supportsPause: true`: they pause exactly when the child flow their `config.flowName` names pauses, which is a DIFFERENT metadata record and is not in hand while this flow is parsed. Refusing them by type would also refuse `loop { map(synchronous child) }`, a shape that runs correctly today, so a region-nested `map` or `subflow` still parses and is met at RUN time instead.", + "replacement": "Move the node onto the TOP-LEVEL graph and route the region's exit to it. For an `end`: delete it from the body, give the region a normal exit, and put the terminator (with its `outcome` / `message`) on the top-level graph — `loop { body: [ …, end ] }` becomes `loop { body: [ … ] } → end`. For a pausing node: hoist it out of the container — `loop { body: [ try_catch { try: [ approval ] } ] }` becomes a top-level `approval` with the loop fanning out around it, or the pausing half of the branch is split into a `subflow` the top-level graph calls; where the repetition is genuinely needed, make the TOP-LEVEL graph the repeating construct with the pause on it rather than nesting the pause inside a region. A `try_catch` whose only purpose was to contain the region's refusal has nothing left to contain and is deleted with it.", + "migrationId": "structured-region-body-pause-and-end-refused", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-09-17, verbatim and untranslated: 「同意,其他也同意」, carrying the presented option C (a durable pause inside a structured region is refused at authoring time); extended the same day by a second ruling, which attached the `end` half (an `end` node inside a region body is refused as well) and ruled 禁 on building durable pause into structured regions — structured regions do not support durable pause and a region body cannot terminate the run, so this is that limit's authoring-time enforcement rather than an interim. The POPULATION was then fixed by the 2026-09-18 ruling, letter D (maintainer 「其他同意」): 「inside `loop` / `parallel` branch / `try_catch` (try and catch) bodies at any depth, the node types `screen`, `wait`, `approval`, `approval_revise` and `end` are refused by `FlowSchema.superRefine` … `map` and `subflow` are ⛔ not refused by type.」 A parse-time rule refuses what is STATICALLY wrong; refusing `map` / `subflow` by type would refuse a correct working shape on a guess about another record. The refusal already existed AT RUN TIME and said nothing an author could act on: the engine converts a suspension raised inside a region into an error at the region boundary, AFTER the executor has written its progress state into the enclosing scope, so a `try_catch` that contains that error leaves residue the next entry reads back as progress. Measured on a real `AutomationEngine`, `loop { try_catch { map(pausing child) } }` over 3 iterations x 2 items: not one item's subflow ever completed, only two of three iterations reached the catch, and iteration 3 read `started === collection.length`, ran nothing, and returned SUCCESS with `summary.failed = 0`. ⚠️ Read that measurement for the MECHANISM: the shape it was taken on is a `map`, which this parse rule deliberately does not reach — making the run-time refusal of a region-contained node that durably suspends LOUD is the second half of ruling D and ships as its own `domain:services` change. ⛔ NOT losslessly convertible: hoisting a node out of a region is a GRAPH REWRITE — new edges, a changed exit, sometimes a deleted container — and which of several shapes the author meant is an intent no artifact records, so a transform that picked one would be inventing the design. That leaves D3, a structured TODO naming each node to edit. ⚠️ Two further boundaries this refusal deliberately does NOT reach, because a parse cannot: a pausing node type contributed by a PLUGIN (ADR-0018 left the node-type namespace open and a parse has no registry), and a region nested past `MAX_REGION_DEPTH` (32), where the walk stops. For both, the engine's run-time refusal is still the only one — unchanged by this step, not fixed by it." + }, + { + "surface": "`sys_account.issuer` — the column, its `{ fields: ['issuer', 'account_id'], unique: true }` index, its label in the four generated translation bundles, and the `@objectstack/plugin-auth` symbols that existed only to serve it (`backfillAccountIssuer`, `CREDENTIAL_ISSUER`, `oauthIssuerFor`, `ResolvedSocialProvider`, `BackfillAccountIssuerOptions`, `BackfillAccountIssuerResult`). The `accounts.list()` client type loses `issuer` with the route that stopped returning it.", + "replacement": "nothing — account identity is `(provider_id, account_id)`, which `sys_account` has declared UNIQUE since the object was created. A caller that read `account.issuer` reads nothing in its place: the authority is `sys_sso_provider.issuer`, resolved through the account's `provider_id`, which is unique per environment. A host that called `backfillAccountIssuer` on its own schedule deletes the call; there is no successor pass. Existing deployments run the ceremony below before the column is dropped.", + "migrationId": "sys-account-issuer-retired", + "toMajor": 18, + "rationale": "better-auth 1.7.3 removed the issuer-scoped account identity outright: `createLocalAccountIssuer` is deleted, `accountSchema.issuer` is gone, `AccountKey` is `(providerId, accountId)` again, and the `account.issuer` column and its unique index are gone from `get-tables`. There is no drop-in replacement. Maintainer ruling 2026-09-10: adopt the rollback rather than own a fork of an identity model the vendor abandoned — a permanent fork on the authentication library was refused, and staying pinned was refused as the durable answer (the exact pin to 1.7.2, taken after a floating 1.7.3 broke a fresh seeded boot, was the stopgap and has done its job). The column was a net liability in its own right: a credential row whose `issuer` was not the local credential issuer was invisible to `findAccountByKey`, so sign-in failed `INVALID_EMAIL_OR_PASSWORD` behind a \"User not found\" warn pointing at the `sys_user` row rather than at the account — four checklist items rediscovered that independently. Its discriminating power here was near zero: `sys_sso_provider` declares `{ fields: ['provider_id'], unique: 'global' }`, so `provider_id → issuer` is a function within an environment." + }, + { + "surface": "sys_audit_log.organization_id — the injected organization column left the compliance ledger (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts, which now declares systemFields.tenant false); the organization a row is about stays in the attribution field tenant_id, and an organization reader is scoped on it by a platform row policy", + "replacement": "`sys_audit_log.tenant_id`, the attribution field every writer stamps. Rewrite any authored filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_audit_log` to name `tenant_id`. A row about a deployment-level action leaves it empty. Under an organization wall an organization reader is scoped to the rows about its active organization by the platform row policy `sys_audit_log_org`, and a platform administrator reads every row", + "migrationId": "sys-audit-log-organization-column-retired", + "toMajor": 18, + "rationale": "ADR-0131 D7: the audit ledger may hold rows about deployment-level actions, so the organization a row is about becomes a plain attribution field under a name the tenant-field resolver does not claim, never the tenancy anchor, and the object is governed by object permission, not by the wall. Writer census at commit 3ae59661dc of this repository's main branch: the record mirror, the record-view writer and the sign-in writer in plugin-audit, and the settings change writer in service-settings, stamp tenant_id and stamped the injected column with the same value; the platform-admin standing writer in plugin-security stamps both NULL, by ruling; the two administrative user writers in plugin-auth stamp neither. So the attribution field already carries every organization the column did. Under a walled posture the tenant wall compared the column to the caller organization, which hid every row about no organization from every reader, platform administrators included. The read scope moves to the security layer, where the engine computes it once: the platform row policy tenant_id equal to the caller organization, shipped in organization_admin, member_default and viewer_readonly and stripped when no wall is enforced, plus an explicit organization_admin entry for the ledger without viewAllRecords or modifyAllRecords, because the wildcard superuser bypass would otherwise skip the policy on an object with no tenant column and hand each organization administrator every organization's rows. Per-tenant retention windows partition on tenant_id. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; once its values are confirmed equal to tenant_id, the operator drops it with os migrate apply --allow-destructive, and any row where they differ is reported rather than dropped." + }, + { + "surface": "sys_flow_dispatch.organization_id — the injected organization column left the flow trigger dispatch claim ledger (packages/services/service-automation/src/sys-flow-dispatch.object.ts, which now declares systemFields.tenant false), and reading the table now requires the manage_platform_settings capability", + "replacement": "nothing on this table — `sys_flow_dispatch` is deployment-level state (ADR-0131 D7) and no organization owns a row. Delete any authored filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_flow_dispatch`. A principal that must read the table needs the `manage_platform_settings` capability, which platform administrators hold", + "migrationId": "sys-flow-dispatch-organization-column-retired", + "toMajor": 18, + "rationale": "ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level and loses the column; the writer decides membership, not the name. Writer census at commit e67ba80049 of this repository's main branch: the sole writer is ObjectStoreFlowDispatchStore in @objectstack/service-automation: two write sites (the claim insert and the settle update), each under a system context whose row is a dispatch key and its outcome, naming no organization. So the injected column only ever held NULL. The census is the same procedure that reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the caller organization and hid every row from every reader, platform administrators included; with no column there is no wall, and the table is governed by object permission instead (D7). The capability gate is part of the same change, not a follow-up: the organization_admin grant carries the superuser bits on every object, so without it a walled deployment would hand each organization administrator every other organization's rows. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, the remedy the drift report names." + }, + { + "surface": "sys_job.organization_id — the injected organization column left the platform background-job catalogue (packages/platform-objects/src/audit/sys-job.object.ts, which now declares systemFields.tenant false), and reading the table now requires the manage_platform_settings capability", + "replacement": "nothing on this table — `sys_job` is deployment-level state (ADR-0131 D7) and no organization owns a row. Delete any authored filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_job`. A principal that must read the table needs the `manage_platform_settings` capability, which platform administrators hold", + "migrationId": "sys-job-organization-column-retired", + "toMajor": 18, + "rationale": "ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level and loses the column; the writer decides membership, not the name. Writer census at commit e67ba80049 of this repository's main branch: the sole writer is DbJobAdapter in @objectstack/service-job: four write sites (the update and insert arms of the schedule upsert, the active toggle and the run summary bump), each under a system context whose row literal names no organization. So the injected column only ever held NULL. The census is the same procedure that reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the caller organization and hid every row from every reader, platform administrators included; with no column there is no wall, and the table is governed by object permission instead (D7). The capability gate is part of the same change, not a follow-up: the organization_admin grant carries the superuser bits on every object, so without it a walled deployment would hand each organization administrator every other organization's rows. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, the remedy the drift report names." + }, + { + "surface": "sys_job_queue.organization_id — the injected organization column left the durable job and message queue (packages/platform-objects/src/audit/sys-job-queue.object.ts, which now declares systemFields.tenant false), and reading the table now requires the manage_platform_settings capability", + "replacement": "nothing on this table — `sys_job_queue` is deployment-level state (ADR-0131 D7) and no organization owns a row. Delete any authored filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_job_queue`. A principal that must read the table needs the `manage_platform_settings` capability, which platform administrators hold", + "migrationId": "sys-job-queue-organization-column-retired", + "toMajor": 18, + "rationale": "ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level and loses the column; the writer decides membership, not the name. Writer census at commit e67ba80049 of this repository's main branch: the sole writer is DbQueueAdapter in @objectstack/service-queue: nine write sites (the publish insert and the worker update and delete paths), each under a system context whose row literal names no organization. So the injected column only ever held NULL. The census is the same procedure that reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the caller organization and hid every row from every reader, platform administrators included; with no column there is no wall, and the table is governed by object permission instead (D7). The capability gate is part of the same change, not a follow-up: the organization_admin grant carries the superuser bits on every object, so without it a walled deployment would hand each organization administrator every other organization's rows. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, the remedy the drift report names." + }, + { + "surface": "sys_job_run.organization_id — the injected organization column left the platform job run history (packages/platform-objects/src/audit/sys-job-run.object.ts, which now declares systemFields.tenant false), and reading the table now requires the manage_platform_settings capability", + "replacement": "nothing on this table — `sys_job_run` is deployment-level state (ADR-0131 D7) and no organization owns a row. Delete any authored filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_job_run`. A principal that must read the table needs the `manage_platform_settings` capability, which platform administrators hold", + "migrationId": "sys-job-run-organization-column-retired", + "toMajor": 18, + "rationale": "ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level and loses the column; the writer decides membership, not the name. Writer census at commit e67ba80049 of this repository's main branch: the sole writer is DbJobAdapter in @objectstack/service-job: two write sites (the run start insert and the run finish update), each under a system context whose row literal names no organization, including for a job that declares the organization it runs as, whose stamp reaches the job data writes and never this ledger. So the injected column only ever held NULL. The census is the same procedure that reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the caller organization and hid every row from every reader, platform administrators included; with no column there is no wall, and the table is governed by object permission instead (D7). The capability gate is part of the same change, not a follow-up: the organization_admin grant carries the superuser bits on every object, so without it a walled deployment would hand each organization administrator every other organization's rows. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, the remedy the drift report names." + }, + { + "surface": "sys_migration_journal.organization_id — the injected organization column left the migration run journal (packages/platform-objects/src/system/sys-migration-journal.object.ts, which now declares systemFields.tenant false), and reading the table now requires the manage_platform_settings capability", + "replacement": "nothing on this table — `sys_migration_journal` is deployment-level state (ADR-0131 D7) and no organization owns a row. Delete any authored filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_migration_journal`. A principal that must read the table needs the `manage_platform_settings` capability, which platform administrators hold", + "migrationId": "sys-migration-journal-organization-column-retired", + "toMajor": 18, + "rationale": "ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level and loses the column; the writer decides membership, not the name. Writer census at commit e67ba80049 of this repository's main branch: the sole writer is the @objectstack/core migration runner: one append site, under a system context or under the transaction it opened with one, and the row contract MigrationJournalEventSchema has no organization field to carry. So the injected column only ever held NULL. The census is the same procedure that reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the caller organization and hid every row from every reader, platform administrators included; with no column there is no wall, and the table is governed by object permission instead (D7). The capability gate is part of the same change, not a follow-up: the organization_admin grant carries the superuser bits on every object, so without it a walled deployment would hand each organization administrator every other organization's rows. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, the remedy the drift report names." + }, + { + "surface": "sys_migration.organization_id — the injected organization column left the deployment data-migration flag ledger (packages/platform-objects/src/system/sys-migration.object.ts, which now declares systemFields.tenant false), and reading the table now requires the manage_platform_settings capability", + "replacement": "nothing on this table — `sys_migration` is deployment-level state (ADR-0131 D7) and no organization owns a row. Delete any authored filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_migration`. A principal that must read the table needs the `manage_platform_settings` capability, which platform administrators hold", + "migrationId": "sys-migration-organization-column-retired", + "toMajor": 18, + "rationale": "ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level and loses the column; the writer decides membership, not the name. Writer census at commit e67ba80049 of this repository's main branch: eleven write sites in six files (the platform-objects migration flag helpers, the ObjectQL lax-deviation and boot-admission revocation writes, and the seed-tenancy, membership-backfill and flow-credential receipts), each under a system context, and the row contract DataMigrationFlagSchema has no organization field to carry. So the injected column only ever held NULL. The census is the same procedure that reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the caller organization and hid every row from every reader, platform administrators included; with no column there is no wall, and the table is governed by object permission instead (D7). The capability gate is part of the same change, not a follow-up: the organization_admin grant carries the superuser bits on every object, so without it a walled deployment would hand each organization administrator every other organization's rows. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, the remedy the drift report names." + }, + { + "surface": "sys_presence.organization_id — the injected organization column left the realtime presence table (packages/services/service-realtime/src/objects/sys-presence.object.ts, which now declares systemFields.tenant false), and reading the table now requires the manage_platform_settings capability", + "replacement": "nothing on this table — `sys_presence` is deployment-level state (ADR-0131 D7) and no organization owns a row. Delete any authored filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_presence`. A principal that must read the table needs the `manage_platform_settings` capability, which platform administrators hold", + "migrationId": "sys-presence-organization-column-retired", + "toMajor": 18, + "rationale": "ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level and loses the column; the writer decides membership, not the name. Writer census at commit e67ba80049 of this repository's main branch: nothing writes the table through ObjectQL at all (presence travels the realtime path, and the generic data door exposes reads only), and a person present in several organizations is one person. So the injected column only ever held NULL. The census is the same procedure that reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the caller organization and hid every row from every reader, platform administrators included; with no column there is no wall, and the table is governed by object permission instead (D7). The capability gate is part of the same change, not a follow-up: the organization_admin grant carries the superuser bits on every object, so without it a walled deployment would hand each organization administrator every other organization's rows. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, the remedy the drift report names." + }, + { + "surface": "sys_setting.scope global — the settings cascade global rung left the tenant-scoped settings table: a value for a key declared at global scope is stored in the new tenant-less object sys_platform_setting (packages/platform-objects/src/system/sys-platform-setting.object.ts), and the global option of sys_setting.scope is retired", + "replacement": "`sys_platform_setting`, one row per `(namespace, key)` for the deployment, with the same `value`, `value_enc`, `encrypted`, `locked`, `locked_reason` and `updated_by` columns and no `scope`, `user_id` or `organization_id`. Write it only through the settings door (`/api/settings/:namespace`), which routes a global-scope key there. Reading it through the generic data API requires the `manage_platform_settings` capability. Delete any authored filter, list-view column or seed that names `scope = global` on `sys_setting`", + "migrationId": "sys-setting-global-rung-moved", + "toMajor": 18, + "rationale": "ADR-0131 D7: deployment-level runtime settings leave the tenant-scoped table, and a tenant-less object holds the values an operator must change without a restart. A census of every manifest at commit 51290bca2c of this repository's main branch found seven namespaces whose keys sit at the global rung (ai, auth, knowledge, mail, sms, storage and the ObjectQL lifecycle defaults), every one edited live in Setup, so none of them moves to boot configuration. The settings service is the only writer of a global row, it writes under a system context, and the row names no organization, so on sys_setting the injected organization column only ever held NULL there and a walled posture hid the row from every reader. The resolver reads the rung from the new object alone and excludes scope global from its sys_setting reads, so a row a pre-v18 database still holds there is not a second source; no write path produces one any more, which is why the select option retires rather than staying a declared value no write can reach. The cascade order, the lock semantics, SpecifierScopeSchema and the global resolution source are unchanged. An encrypted value moves without re-encryption: the ADR-0128 AAD binds the settings scope, namespace and key, never the holder object or an organization, so a sys_secret handle copied into the new row opens as it did. Existing databases: nothing moves automatically (ADR-0131 D14). The v18 upgrade ceremony moves each sys_setting row at scope global into sys_platform_setting by namespace and key, value_enc handle included; until it runs, those values read as their next rung or the manifest default." + }, + { + "surface": "the sys_view_definition platform object (SysViewDefinitionObject, exported by @objectstack/metadata-core and re-exported by @objectstack/platform-objects and its metadata subpath), its registration by MetadataPlugin and by the metadata protocol assembly, its name in PLATFORM_OBJECTS_BY_PACKAGE (@objectstack/spec system constants), its kernel:ready active-row index migration and that migration's exports from @objectstack/metadata-protocol (ensureViewDefinitionActiveIndex, resolveIndexExec, buildActiveIndexSql, VIEW_DEFINITION_TABLE, VIEW_ACTIVE_INDEX_NAME, VIEW_ACTIVE_PROBE_INDEX_NAME, VIEW_ACTIVE_INDEX_COLUMNS and the EnsureViewIndex types), and its idx_sys_view_def_active entry in the os migrate duplicates runtime-index pre-flight", + "replacement": "nothing replaces the table — a runtime-authored view is a `view` metadata item in `sys_metadata`, written through `PUT /api/v1/meta/view/` (the client's `meta.saveItem` for type `view`), which is what every framework and Studio view door already does. Delete any import of the removed symbols; `classifyIndexFailure` and the `IndexExec` type are still exported by `@objectstack/metadata-protocol`, from the shared index-migration module. A stack that names `sys_view_definition` (a lookup target, a flow trigger, a permission entry, a platform-global declaration) removes the reference: the name no longer resolves to a platform object", + "migrationId": "sys-view-definition-retired", + "toMajor": 18, + "rationale": "ADR-0131 D13: an object no framework code writes or reads is inert and retires. Census at commit 41d0d4038c of this repository's main branch, run with the glob pathspec over packages/**/src (41 files; control word sys_metadata 769) and repo-wide (65 files): no framework writer of the table's rows and no reader of them — the only statements that touched its rows were the active-row index migration's own presence and duplicate probes and the os migrate duplicates pre-flight's copy of the latter. The sibling Studio repository never referenced it (0 hits against 93 for sys_metadata, at its main branch and at the pinned console commit): its view create, update and list doors write the ADR-0005 view overlay through the metadata API. The only way a row could ever have reached the table was a caller using the generic data door on the object by name. Keeping it registered kept an API-enabled table, a boot-time index migration and a pre-flight probe alive for no consumer, and kept the name resolving as a real platform object for authored metadata that named it." + }, + { + "surface": "the two cache durations whose name carried no unit: CacheTier.ttl and CacheAvalanchePrevention.circuitBreaker.resetTimeout (system/cache.zod.ts)", + "replacement": "ttlSeconds and resetTimeoutSeconds — rename each key; both values, the 300 TTL default and the 30 reset default are unchanged", + "migrationId": "system-cache-durations-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. These two are one entry because they are one file and one authoring session: a cache tier and the avalanche-prevention block that protects it. Each sat beside a number in a DIFFERENT unit with nothing at the authoring site to separate them — CacheTier.ttl (seconds) beside maxSize (megabytes), and circuitBreaker.resetTimeout (seconds) beside lockout.lockTimeoutMs (milliseconds) on the very same schema. That last pair is the sharpest case on this file: one shape already carried both conventions, and the suffixed one was the honest half. Both are retiredKey() tombstones; neither shape is strict, so a bare deletion would strip in silence and the unknown-key error could not carry the rename. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no cache collection, and neither a cache tier nor an avalanche-prevention block is a registered metadata kind stored as a sys_metadata row, so the conversion chain has no seam that would see one. ADR-0087." + }, + { + "surface": "the two collaboration-session durations whose name carried no unit: CollaborationSessionConfig.idleTimeout and CollaborationSessionConfig.snapshot.interval (system/collaboration.zod.ts)", + "replacement": "idleTimeoutMs and snapshot.intervalMs — rename each key; both values and the 300000 idle-timeout default are unchanged", + "migrationId": "system-collaboration-durations-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. idleTimeout is the collision that got this whole population ruled rather than merely noted: it is MILLISECONDS here, while the tenant surface carried its own idleTimeout in SECONDS at the same time — so the identical bare name meant five minutes on one shape and three and a half days on the other, a 1000x divergence no parse could catch because both readings are positive integers. The tenant half was already renamed, in the same change that landed the duration gate itself; this is the half that remained. snapshot.interval rides in the same entry because it is the same object graph and the same authoring session — leaving one bare beside the other would have preserved exactly the ambiguity the rename removes. Both are retiredKey() tombstones; the shapes are not strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no collaboration collection, and a session config is a runtime call argument rather than a stored sys_metadata row, so the conversion chain has no seam that would see it. ADR-0087." + }, + { + "surface": "FailoverConfig.healthCheckInterval, the disaster-recovery health-check period whose name carried no unit (system/disaster-recovery.zod.ts)", + "replacement": "healthCheckIntervalSeconds — rename the key; the value and the 30 default are unchanged", + "migrationId": "system-failover-health-check-interval-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. It stands alone because its file has exactly one offender left — and because the key directly beside it is the counter-example that shows where the line falls. FailoverConfig.dns.ttl is also a bare-named duration in seconds, and it is NOT renamed: it carries an externalVocabulary marker because it mirrors the DNS resource-record TTL field (RFC 1035 section 4.1.3), spelled ttl by every provider API the value is forwarded to (Route 53, Cloudflare). healthCheckInterval mirrors nothing outside this repo, so the exemption does not reach it. Tombstoned with retiredKey(); the shape is not strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no disasterRecovery collection and a failover config is host configuration, never a stored sys_metadata row. ADR-0087." + }, + { + "surface": "the five remaining metrics durations whose unit lived in a source JSDoc only: MetricDefinition.summary.maxAge, ServiceLevelObjective.errorBudget.burnRateWindows[].window, MetricExportConfig.interval, MetricsConfig.collectionInterval and MetricsConfig.retention.period (system/metrics.zod.ts)", + "replacement": "summary.maxAgeSeconds, errorBudget.burnRateWindows[].durationSeconds, intervalSeconds, collectionIntervalSeconds and retention.durationSeconds — rename each key; every value is unchanged", + "migrationId": "system-metrics-jsdoc-durations-unit-in-key", + "toMajor": 18, + "rationale": "This entry FINISHES what system-metrics-window-durations-unit-in-key started on this file, and the two are meant to be read as a sequence — this one does not amend that record, which stays a true account of what the system-directory duration round did. That round renamed the three metrics window and period lengths whose describe named no unit, and recorded that the error-budget burn-rate window was \"outside this rename, not outside the gate population\", naming the JSDoc-channel gap as where it would be settled. That gap is now ruled and this is its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer's 「同意」, which keeps the refusal of a duration key whose JSDoc names a unit its describe does not, remediates the 21-row JSDoc-channel population per file, and lands that widened gate last, into a tree already clean. ⚠️ One consequence for readers of the older entry: its acceptanceCriteria says the burn-rate window keeps its name and that a sweep renaming it has over-applied the rule. That sentence was true of that round and is superseded here, by the ruling it itself pointed at; the other key it names, the exporter batch size, is a COUNT of records and still does not move. All five keys here share one defect: the unit (seconds) was stated in the JSDoc above the key, a channel check:duration-unit-keys does not read — it reads .describe() and .meta({ description }) — and four of the five carried no describe at all while the fifth read \"Window size\". So the reader who most needs the unit, the reader of the published reference page, got a bare integer: 600, 3600, 60, 15 and 604800 are each a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. Each key is renamed and its describe corrected in the same stroke, because under the duration-unit rule (the unit lives in the key name or a unit-carrying value, never in the describe prose alone) moving the unit into the describe alone is itself a violation. Three of the five spellings are not the mechanical suffix, and each departure has a reason this file already supplied: burnRateWindows[].window becomes durationSeconds, not windowSeconds, because the enclosing array is already called burnRateWindows so the key would stutter — the objection the system-directory round recorded against window.windowSeconds — and because on this tree windowSeconds is not an authorable key at all, its only key-position occurrence being an alias-map entry in ServerRateLimitConfigSchema that maps the spelling AWAY to windowMs; retention.period becomes durationSeconds, not periodSeconds, because period is calendar vocabulary elsewhere in this spec (ServiceLevelObjective.period.type selects rolling or calendar, PluginRegistryEntry.pricing.billingPeriod is monthly or yearly) so periodSeconds would keep the ambiguous half of the name; and collectionInterval keeps its qualifier as collectionIntervalSeconds so it stays distinct from the MetricExportConfig.intervalSeconds this same card creates one def over. The two mechanical spellings are attested: maxAgeSeconds is the token AccessControlConfig.maxAgeSeconds already carries after this same rule renamed it on system/object-storage.zod.ts, and it keeps the age stem the sibling ageBuckets counts buckets of; intervalSeconds is the token four seconds-valued cadences already carry. Counted in key position across packages/spec/src at fc28c1d38, the base of this change, the seconds suffixes run Seconds 40, Sec 1 (maxExecutionTimeSec) and S 0 — the two bare S keys on that corpus, maxCommitTimeMS and enableRLS, are a millisecond spelling and a boolean — so Seconds is the family; this change takes Seconds to 45 at 9b62f54671. All five are retiredKey() tombstones; none of the five enclosing shapes is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no metrics collection, and none of a metric definition, an SLO, an export config or a metrics config is a registered metadata kind stored as a sys_metadata row — the same reading the system-directory round recorded for the three keys it renamed. Measured on fc28c1d38: no in-repo code consumer reads any of the five — outside packages/spec the only occurrences of every distinctive key on these shapes (burnRateWindows, errorBudget, downsampling, collectionInterval, cardinalityLimits, maxLabelCombinations, ageBuckets) are in the generated content/docs/references/system/metrics.mdx, which this rename regenerates, against a lit control of 1195 defineStack occurrences on that same corpus at fc28c1d38 (1195 again at 9b62f54671); and the objectui checkout this repo builds against — this is the pin, `.objectui-sha` = `f0268ad784854568aa58a2aa791f6a7502259186`, re-read from this tree — spells all six metrics def names and both distinctive keys 0 times across 8234 tracked files at that sha, against lit controls window 4430, timeout 1658, period 249, interval 213 and metrics 404 on that same corpus and sha (0 across 7754, against 4255 / 1431 / 247 / 200 / 401, at a58626c88, 0 across 7650, against 4194 / 1360 / 238 / 195 / 401, at 0abd4f9f8, 0 across 7632, against 4193 / 1360 / 238 / 195 / 401, at 9dfaca654, 0 across 7579, against 4175 / 1351 / 238 / 195 / 374, at 2e818d0b5, 0 across 10267, against 4044 / 1348 / 231 / 196 / 354, at ab1879721, 0 across 10071, against 4002 / 1331 / 231 / 196 / 355, at 89cad75d5, 0 across 9912, against 3916 / 1303 / 234 / 196 / 354, at 31971ff1e, 0 across 9800, against 3873 / 1293 / 233 / 196 / 352, at e420df310, 0 across 9546, against 3772 / 1197 / 228 / 196 / 341, at db11afd49, 0 across 9283, against 3681 / 1172 / 183 / 176 / 340, at dd3f7e1be, 0 across 8512, against 3581 / 1096 / 171 / 179 / 326, at f8a9d0fb0, and 0 across 8303, against 3526 / 1086 / 170 / 179 / 324, at 62597c588), so no pin bump is owed. ADR-0087." + }, + { + "surface": "the three metrics window/period lengths whose name carried no unit: MetricAggregationConfig.window.size, ServiceLevelIndicator.window.size and ServiceLevelObjective.period.duration (system/metrics.zod.ts)", + "replacement": "window.durationSeconds, window.durationSeconds and period.durationSeconds — rename each key; every value is unchanged", + "migrationId": "system-metrics-window-durations-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. The three are one entry because they are one measurement expressed three times on one file: how long a window or period is. The new name is deliberately NOT the mechanical sizeSeconds the gate prints. size means a byte or row count everywhere else in this spec — CacheTier.maxSize is megabytes, RegistryConfig.cache.maxSize is bytes, and this very file spells a batch row count size — so sizeSeconds would have kept the misleading half of the name and bolted a unit onto it, leaving a reader to decide whether a window is measured in bytes-per-second or in time. windowSeconds was rejected for a plainer reason: the parent key is already window, so it would read window.windowSeconds. durationSeconds names what the number IS, and the file itself supplied the precedent — ServiceLevelObjective.period already called its length a duration, so after the rename all three read alike instead of one borrowing byte vocabulary. All three are retiredKey() tombstones; the shapes are not strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no metrics collection, and none of an aggregation config, an SLI or an SLO is a registered metadata kind stored as a sys_metadata row. ADR-0087." + }, + { + "surface": "the two object-storage durations whose name carried no unit: AccessControlConfig.maxAge and StorageConnection.timeout (system/object-storage.zod.ts)", + "replacement": "maxAgeSeconds and timeoutMs — rename each key; both values are unchanged", + "migrationId": "system-object-storage-durations-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. AccessControlConfig.maxAge is the one key in this stack where the two structural exemptions and the rename look alike from a distance, so the reasoning is recorded rather than assumed. It was CONSIDERED for an externalVocabulary marker and demoted on evidence: every bucket-CORS standard the value is forwarded to spells the field WITH its unit — S3 MaxAgeSeconds, GCS maxAgeSeconds, Azure MaxAgeInSeconds — so marking it would have exempted a DEVIATION from the cited standard rather than a mirror of it, which is the opposite of what the marker declares. Its twin shared/CorsConfig.maxAge DID get the marker and keeps its bare name, because the Fetch response header that one mirrors, Access-Control-Max-Age, genuinely carries no unit token. Two maxAge keys on opposite sides of the same line; the asymmetry is the point and must not be harmonised. StorageConnection.timeout rides along as the plain case on the same file. Both are retiredKey() tombstones; the shapes are not strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no objectStorage collection, and neither shape is a registered metadata kind stored as a sys_metadata row. ADR-0087." + }, + { + "surface": "the three package-registry durations whose name carried no unit: RegistryUpstream.syncInterval, RegistryUpstream.timeout and RegistryConfig.cache.ttl (system/registry-config.zod.ts)", + "replacement": "syncIntervalSeconds, timeoutMs and cache.ttlSeconds — rename each key; every value, the 30000 timeout default and the 3600 TTL default are unchanged", + "migrationId": "system-registry-config-durations-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. The three are one entry because they are one file and, for the first two, one object: RegistryUpstream declared a SECONDS interval and a MILLISECONDS timeout twenty-five lines apart, both bare. That pair carries the clearest demonstration in this card of why a bound is no substitute for a name — timeout is min(1000), which reads as one second under the right unit and as sixteen minutes under the wrong one, and both readings satisfy the validator. The cache TTL is the same defect one schema over, beside a maxSize measured in bytes. All three are retiredKey() tombstones; the shapes are not strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no registry collection, and a registry config is host configuration read at startup rather than a stored sys_metadata row, so the conversion chain has no seam that would see it. ADR-0087." + }, + { + "surface": "the four tracing-configuration durations whose unit lived in a source JSDoc only: OpenTelemetryCompatibility.exporter.timeout, OpenTelemetryCompatibility.exporter.batch.exportTimeout, OpenTelemetryCompatibility.exporter.batch.scheduledDelay and TracingConfig.performance.exportInterval (system/tracing.zod.ts)", + "replacement": "timeoutMs, exportTimeoutMs, scheduledDelayMs and exportIntervalMs — rename each key; all four values (milliseconds) and their 10000 / 30000 / 5000 / 5000 defaults are unchanged", + "migrationId": "system-tracing-otel-exporter-durations-unit-in-key", + "toMajor": 18, + "rationale": "Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the maintainer's 「同意」: a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands — the duration-unit rule (the unit lives in the key name or its value type, never in prose alone) executed file by file. It follows system-tracing-span-duration-unit-in-key on this same file and does not amend it: that entry retired Span.duration under ruling B, whose population was the describe channel, and these four keys were never in it — they are the JSDoc-only channel that finding opened, which is why one file carries two rounds. Each key named milliseconds in its JSDoc — \"Timeout in milliseconds\", \"Export timeout in milliseconds\", \"Scheduled delay in milliseconds\", \"Background export interval in milliseconds\" — and the JSDoc above a key is NOT what content/docs/references/** renders; .describe() is. Measured on this tree: all four carried NO .describe() at all, so the published reference row for each was a bare integer with no unit anywhere on the page — a strictly worse channel than the unit-in-prose shape the duration-unit rule already refuses, since here the reference reader had no prose to misread. The magnitudes make the guess plausible in both directions: 10000, 30000, 5000 and 5000 are all defensible as seconds and as milliseconds, and an operator who reads seconds sets an exporter deadline 1000x short. The suffix is the family spelling, counted in key position at 98bd7986fe over packages/spec/src *.ts (reproduce with git grep -hoE on that ref): 281 *Ms declarations over 42 distinct names, timeoutMs 65 of them and intervalMs 14, against 0 key-position timeoutSeconds and 77 *Seconds of any name; the Delay-plus-Ms pairing is likewise already attested on that same ref (maxDelayMs 9, initialDelayMs 9, maxRetryDelayMs 5, debounceDelayMs 2, delayMs 2, retryDelayMs 1) with 0 occurrences of any competing exportTimeout, scheduledDelay or exportInterval spelling, suffixed or Seconds. Note this file is milliseconds throughout and its own landed precedent is Span.duration to durationMs, the opposite of the sibling metrics card whose rows were seconds. exporter.timeoutMs and exporter.batch.exportTimeoutMs are deliberately allowed to sit one nesting level apart: the pair pre-exists the rename — the batch sub-object is the OpenTelemetry batch span processor's own four knobs (max batch size, max queue size, scheduled delay, export timeout) beside the exporter's own request deadline — so renaming either to something more distinctive would depart from the vocabulary the shape mirrors, and the nesting already disambiguates every read point (exporter.timeoutMs vs exporter.batch.exportTimeoutMs). All four old spellings are retiredKey() tombstones: neither OpenTelemetryCompatibilitySchema nor TracingConfigSchema nor any object nested inside them is .strict(), so a bare deletion would be a SILENT STRIP (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — and the stripped value lands on an export deadline and a background export period. Why a semantic entry and not a D2 conversion: the conversion chain walks a normalized STACK, and neither def is an authorable surface — stack.zod.ts declares no tracing collection, no metadata-type binding or manifest embed carries either, and a tracing configuration is never a stored sys_metadata row — so a conversion would be a transform with no seam that ever runs. That is the same disposition system-tracing-span-duration-unit-in-key recorded for the other key on this file. Measured at 98bd7986fe: NO in-repo reader exists outside packages/spec — OpenTelemetryCompatibility, TracingConfig and all three batch key names occur 0 times across the whole tree at that ref excluding packages/spec and content/docs/references, against a lit control of 18920 Schema occurrences on exactly that corpus and ref — both counts from one git grep -o over 98bd7986fe with those two pathspec exclusions — and a dark control of 0; inside packages/spec the only occurrences are tracing.zod.ts, its test, and the generated rows in content/docs/references/system/tracing.mdx, which this rename regenerates. And the pinned objectui checkout — `.objectui-sha` = `f0268ad784854568aa58a2aa791f6a7502259186` — names none of it: all 37 exports of tracing.zod.ts and each of the four key names occur 0 times across the 8234 files tracked at that sha (the 517 Span and 57 SpanSchema hits are objectui's own HTML text-span component, TextSpanSchema, an unrelated name, plus colSpan and prose), against two lit controls on that same corpus and sha: 17956 hits for the bare token objectstack, and 7522 for the package specifier @objectstack/spec (at a58626c88: 0 across 7754, Span 509, 17468 and 7246; at 0abd4f9f8: 0 across 7650, Span 508, 17390 and 7209; at 9dfaca654: 0 across 7632, Span 508, 17313 and 7186; at 2e818d0b5: 0 across 7579, Span 505, 17227 and 7134; at ab1879721: 0 across 10267, Span 491, 16377 and 6665; at 89cad75d5: 0 across 10071, Span 489, 16044 and 6461; at 31971ff1e: 0 across 9912, Span 486, 15691 and 6206; at e420df310: 0 across 9800, Span 486, 15352 and 6024; at db11afd49: 0 across 9546, Span 486, 14704 and 5545; at dd3f7e1be: 0 across 9283, Span 485, 13745 and 5466; at f8a9d0fb0: 0 across 8512, Span 488, 13347 and 5123; at 62597c588: 0 across 8303, Span 486, 13125 and 5043)." + }, + { + "surface": "Span.duration, the emitted trace-span length whose name carried no unit (system/tracing.zod.ts)", + "replacement": "durationMs — rename the key; the value is unchanged", + "migrationId": "system-tracing-span-duration-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. It stands alone because it is the only offender on its file and the only one in this card that is a pure runtime-emitted measurement: a span is written by an exporter and read by a backend, never authored by hand. That is also why it is a rename and not an externalVocabulary mirror, which is the exemption a tracing shape would most plausibly claim: OpenTelemetry, whose model this schema follows, carries span length as a start/end nanosecond PAIR and declares no key named duration at all, so there is no external spelling for the marker to point at. The shape already spells its two instants startTime and endTime, so the bare duration was the one measurement on the span that did not say what it was. Tombstoned with retiredKey(); the shape is not strict, so a bare deletion would strip in silence and an exporter emitting the old spelling would lose the value without an error. Why a semantic entry and not a D2 conversion: an emitted span is never a stack collection member and never a stored sys_metadata row — the same disposition every runtime-emitted measurement in this stack has taken. ADR-0087." + }, + { + "surface": "QueueConfig.rateLimit.duration, the worker rate-limit window whose name carried no unit (system/worker.zod.ts)", + "replacement": "durationMs — rename the key; the value is unchanged", + "migrationId": "system-worker-queue-rate-limit-duration-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. It stands alone because it is the only offender left on its file, and the file itself is what makes it a drift rather than a convention: TaskResult.durationMs, declared ninety lines earlier in the SAME source, already spelled the identical measurement with its unit. One file, one unit, two spellings, and the correct one was already there — so this rename removes an internal inconsistency rather than imposing an external one. Tombstoned with retiredKey(); the shape is not strict, so a bare deletion would strip in silence and a queue would fall back to no rate limit at all without an error. Why a semantic entry and not a D2 conversion: stack.zod.ts declares jobs, not queues, so a QueueConfig is worker host configuration rather than a stack collection member or a stored sys_metadata row, and the conversion chain has no seam that would see it. ADR-0087." + }, + { + "surface": "SchemaLevelIsolationStrategy `performance.schemaCacheTTL` (system/tenant.zod.ts)", + "replacement": "`performance.schemaCacheTtlSeconds` (default 3600) — rename the key; the value (seconds) is unchanged", + "migrationId": "tenant-schema-cache-ttl-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling A, 2026-09-11: the gate that reads a duration key's JSDoc lands last, after its offenders are fixed file by file — so this entry executes, per file, the rule that a duration number key carries its unit in its name. The key carried its unit (seconds) in a source JSDoc only — \"Schema cache TTL in seconds\" — while `.describe()`, the text `content/docs/references/**` publishes, said \"Schema cache TTL\" and named no unit at all. So the reader who most needs the unit, the reader of the published reference page, was the only reader who never saw it: 3600 is a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. Under that rule's gate, moving the unit into the describe alone is itself a violation (unit in prose, none in the name), so the key is renamed and the describe is corrected in the same stroke. Spelled `Ttl` and not `TTL`: counted on this tree, the suffixed family already spells it that way in every member (`cacheTtlSeconds` 11, `ttlSeconds` 3, `defaultCacheTtlSeconds` 1) and no key-position `TtlSeconds` variant spells it otherwise. Tombstoned with `retiredKey()` because the nested `performance` object is not strict, so a bare deletion would silently strip the key. Why a semantic entry and not a D2 conversion: `stack.zod.ts` declares no tenancy collection and a tenant isolation strategy is not a stored metadata row (it describes cloud tenancy configuration), so the chain has no seam that runs on it — the same reading `tenant-timeouts-unit-in-key` recorded for the two sibling keys on this file. Measured on bd25e897dc: no in-repo runtime reads the key — outside `packages/spec/src/system/tenant.zod.ts` and its test the only occurrences are the four generated rows in `content/docs/references/system/tenant.mdx`, which this rename regenerates; and the pinned objectui checkout — `.objectui-sha` = `f0268ad784854568aa58a2aa791f6a7502259186` — spells it 0 times across 8234 tracked files, against lit controls `TTL` 184 and `tenant` 1338 on the same corpus (0 across 7754, against 184 and 1319, at a58626c88; 0 across 7650, against 182 and 1318, at 0abd4f9f8; 0 across 7632, against 182 and 1318, at 9dfaca654; 0 across 7579, against 182 and 1317, at 2e818d0b5; 0 across 10267, against 180 and 1238, at ab1879721; 0 across 10071, against 181 and 1237, at 89cad75d5; 0 across 9912, against 181 and 1237, at 31971ff1e; 0 across 9800, against 181 and 1235, at e420df310; 0 across 9546, against 181 and 1200, at db11afd49; 0 across 9283, against 181 and 1185, at dd3f7e1be; 0 across 8512, against 156 and 1034, at f8a9d0fb0; 0 across 8303, against 156 and 987, at 62597c588)." + }, + { + "surface": "DatabaseLevelIsolationStrategy `connectionPool.idleTimeout` / TenantSecurityPolicy `accessControl.sessionTimeout` (system/tenant.zod.ts)", + "replacement": "`connectionPool.idleTimeoutSeconds` (default 300) and `accessControl.sessionTimeoutSeconds` (default 3600) — rename each key; the values (seconds) are unchanged", + "migrationId": "tenant-timeouts-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling 2026-09-02, B: a duration number key carries its unit in its name, enforced by a gate with no grandfathered baseline — folding in the finding that these two descriptions named no unit. Both keys carried their unit (seconds) in a source JSDoc only; `.describe()` — the text `content/docs/references/**` publishes — said \"Idle pool timeout\" and \"Session timeout\" with no unit at all. So the one reader who most needs the unit, the reader of the published reference page, was the only reader who never saw it: 300 is a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. That finding proposed adding the unit to the two descriptions; under the ruled gate that exact fix is a violation (unit in prose, none in the name), so the keys are renamed instead — one breaking change per key, and the tree never passes through a state the gate refuses. Both are retiredKey tombstones (the nested objects are not strict). Why a semantic entry and not a D2 conversion: neither schema is a stack collection member or a stored row (they describe cloud tenancy configuration), so the chain has no seam that runs on them (the `kernel/Manifest:loading` precedent). Measured on ca46f8f12: no in-repo runtime reads either key." + }, + { + "surface": "a literal `defaultValue` with a `Z` or a UTC offset on a `time` field, or on an action param typed `time`", + "replacement": "the wall clock itself, `HH:MM` or `HH:MM:SS` with no zone, or a `datetime` field when the value is an instant. The conversion drops a `Z` or a zero offset, which names the same wall clock. It does not touch a non-zero offset (`08:00+08:00`): whether that meant 08:00 or the UTC 00:00 only the author knows, so rewrite it by hand", + "migrationId": "time-default-zone-refused", + "toMajor": 18, + "rationale": "A `time` value is a zone-less wall clock (ADR-0053 D-C1), and the record validator already refuses a zone-suffixed time of day on write. The stored form still admitted one, so a field default such as `10:00Z` parsed clean and every insert that fell back to it was then refused `invalid_time` on a field the caller never sent, and an action param default or submitted value passed the dispatcher. The stored form now refuses the zone, so the field and action-param default gates refuse it when it is authored and the dispatcher refuses it at submit." + }, + { + "surface": "`TimeUpdateInterval` — the `/analytics/query` body's `timeDimensions[].granularity` and an analytics cube dimension's `granularities[]`. The three sub-day members `second`, `minute` and `hour` are retired; `day`, `week`, `month`, `quarter` and `year` are unchanged and parse byte-identically", + "replacement": "the coarsest declared interval that still answers the question — `day` is the finest bucket the platform labels. A caller who wants raw per-instant rows drops `granularity` entirely, which groups on the unbucketed timestamp deliberately rather than by accident. There is no mechanical replacement that preserves a sub-day bucket, because no backend ever produced one", + "migrationId": "time-update-interval-sub-day-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove — the spec-side narrowing promised by the fix that made driver-memory's analytics face bucket by its declared granularity. The rest of the contract never carried these three: `DateGranularity` (`data/query.zod.ts`) — the vocabulary a `groupBy` entry and every driver's bucket expression are typed by — declares five, `@objectstack/core`'s `BUCKET_GRANULARITIES` labels the same five, and `DriverCapabilitiesSchema.supports.queryDateGranularity` is a `z.record(DateGranularity, boolean)`, so a driver could not advertise sub-day bucketing even if it had one. Measured on the shipped faces before the narrowing: `driver-memory`'s analytics face answered NOT_IMPLEMENTED/501, `driver-mongodb`'s bucket builder answered NOT_IMPLEMENTED/501, and the engine's in-memory aggregation — the fallback every SQL/ObjectQL analytics query carrying a granularity lands on, since `NativeSQLStrategy` declines on a granularity — answered 200 with one group per distinct timestamp, echoing the raw instant back as its own bucket label. Two honest refusals and one silently wrong answer, and no third behaviour anywhere. ⚠️ This retires the NAMES, not the idea: offering sub-day analytics means widening `DateGranularity`, the `queryDateGranularity` record, the canonical bucket-key vocabulary and every driver's bucket expression together — new capability, decided as such" + }, + { + "surface": "training duration and deadline keys: `TrainingCourse.durationMinutes` / `validityDays`, `TrainingPlan.recertificationIntervalDays` / `gracePeriodDays` / `reminderDaysBefore`", + "replacement": "nothing to re-declare — delete the keys. No training-management engine exists on the platform: nothing schedules or times a course, computes a certification expiry, re-assigns training on an interval, escalates an expired certification or sends a reminder, so there is no live mechanism to declare a duration or deadline to", + "migrationId": "training-deadline-keys-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on the unread deadline keys (ruled A: retire per family). Five minute/day-shaped keys sat on the published authorable surface and in the generated reference docs — an author could write `validityDays: 365` and reasonably expect a certificate to expire — and read by NOTHING: the schemas are exported from `@objectstack/spec/system`, mounted by no stack key, registered as no metadata type, absent from the 2026-06 liveness ledgers, and the reader census over every package outside `packages/spec` (tests and changelogs excluded) and over objectui at the pinned sha returned zero hits for every key. Three of the five carried defaults (365, 30 and 14 days) that were materialized into every parsed plan without ever being consulted. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections; none of these schemas is either, so a conversion would be a transform with no seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent)." + }, + { + "surface": "the training family, retired whole: the five defs system/TrainingCategory, system/TrainingCompletionStatus, system/TrainingCourse, system/TrainingPlan and system/TrainingRecord, and every name system/training.zod.ts exported from @objectstack/spec/system (the five *Schema consts, their z.input aliases and the two *Parsed aliases)", + "replacement": "nothing to re-declare — no training-management engine exists on the platform, so there is no working configuration to migrate to. Nothing assigned a course, tracked a completion, sent a reminder or expired a certification; a training record the organisation keeps is ordinary object data, declared as an object with its own fields and enforced by the object engine. If training management becomes a product capability it re-declares fresh, through the enforce route of ADR-0049 — the engine first, the vocabulary second", + "migrationId": "training-family-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 on the families' remaining keys and defs (ruled A: retire the three compliance-shaped families whole via RETIRED_DEFS_BY_MAJOR, the integration/ErrorMappingConfig precedent; not roadmapped). Five defs and roughly twenty-five declared keys sat on the exported surface and in the generated reference docs, and were read by NOTHING: the schemas were exported from `@objectstack/spec/system`, mounted by no `stack.zod.ts` key, registered as no metadata type, absent from the 2026-06 liveness ledgers, and the reader census over every package outside `packages/spec` (tests and changelogs excluded), over `examples/**` and `skills/**`, and over objectui at the pinned sha returned zero hits for every exported name, with a lit control. `TrainingCourse.mandatory`, `TrainingPlan.trackCompletion` and `TrainingPlan.sendReminders` were boolean capability claims of exactly the shape ADR-0049 names: an author could write them, parse clean, and get no behaviour and no diagnostic. Tagging the family `[EXPERIMENTAL — not enforced]` was the fallback the ruling did not take (a human-only signal). The deadline-key tombstones of the 2026-09-02 per-family ruling (five sites, `RETIRED_KEYS_BY_MAJOR[18]`, D3 `training-deadline-keys-retired`) leave with their defs' source; their registry entries stay as history. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections; none of these schemas is either, so a conversion would be a transform with no seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent), and with no carrier key there is no shape on which a tombstone could sit." + }, + { + "surface": "translation.pages..components..submitLabel — the component-copy key of the retired element:form", + "replacement": "The live form surface's submit copy: `submitText` on the `object-form` component, an I18nLabel localized at its own authoring site.", + "migrationId": "translation-component-submit-label-retired", + "toMajor": 18, + "rationale": "The D2 conversion `translation-component-submit-label-removed` deletes `submitLabel` from every translation bundle and stored translation item, and the delete is lossless: the key's only declarer, `element:form`, retired whole, so no resolver has overlaid the string since and it was read by nothing. What the delete drops is translation WORK. A translator who localized a submit button for each locale did so because a user was meant to read it; if the page's form now lives on `object-form`, its submit copy is `submitText`, and that key is not filled by moving the old strings mechanically — the component ids differ, and a retired `element:form` may have no successor on the page at all. Only the author can say which form each string belonged to and whether it still exists." + }, + { + "surface": "stack.translations[]..settings and translation.settings — the settings group on the per-app bundle and on the registered `translation` item", + "replacement": "Delete the group from the per-app bundle and from every `translation` item. There is no application-side replacement key: settings copy is not application-authorable at either door. `settings` is keyed by `SettingsManifest.namespace`, and only platform code declares a manifest (`packages/services/service-settings/src/manifests/*.manifest.ts`), so the only namespaces an application could ever address were the platform’s own. Platform settings copy is translated in the PLATFORM bundle — `@objectstack/service-settings`’s `settingsBuiltinTranslations`, typed `PlatformTranslationData` — which is where a correction to a platform string belongs. An application’s own copy goes in the 11 groups the per-app bundle and the `translation` item still declare, in the order they declare them: `objects`, `picklists`, `apps`, `messages`, `globalActions`, `dashboards`, `datasets`, `pages`, `flows`, `metadataForms`, `settingsCommon`. Note `settingsCommon` among them: it IS on both faces, so the Settings UI shell strings an application may translate (the source badges, under `settingsCommon.sourceLabels`) are NOT what is being removed here — only the per-namespace manifest copy under `settings` is.", + "migrationId": "translation-per-app-settings-platform-only", + "toMajor": 18, + "rationale": "Not losslessly convertible, and NOT because the content was inert: what it did differs by door, and both effects are visible on screen. THE PER-APP BUNDLE — measured on this tree before the split: `AppPlugin.loadTranslations` hands each `stack.translations` bundle entry WHOLE to `II18nService.loadTranslations`, the adapter deep-merges it into the one per-locale tree, and every platform plugin contributes into that same tree — so `settings` from an app bundle and `settings` from `@objectstack/service-settings` land in one place. `resolveSettingsTitle` and the rest of the `resolveSettings*` family read it (`pickSettingsEntry` → `pickData(bundle, locale)?.settings`), and so does the console's `useSettingsLabel`, which scans every namespace carrying a `settings` branch. ORDER decides the rest, and it runs against the application: `AppPlugin` loads the app’s bundles in its own `start()` (kernel Phase 2), `SettingsServicePlugin` contributes the platform’s settings translations from a `kernel:ready` hook (Phase 3), and `deepMerge` gives the LATER source the leaf — `AppPlugin`’s own comment says as much (“the platform bundles have not arrived yet at this point in the lifecycle”). So the platform won every key both bundles defined, and what a per-app bundle actually had was a GAP FILLER on a namespace it does not own: the entry rendered only where the platform bundle carried no string for that key and locale (the platform ships en / zh-CN / ja-JP / es-ES), silently, with no way for the author to tell a filled gap from an ignored override. Dropping it takes those gaps back to the manifest’s own literal — the `?? fallback` every `resolveSettings*` helper ends in, which is English. THE `translation` ITEM went further: a stored item is not loaded into the static tree at all but into the runtime-authored layer (`authored-translation-sync` → `replaceAuthoredTranslations`), and both i18n adapters read that layer OVER the shipped bundles (`deepMerge(static, authored)`), whatever order they loaded in. So an item’s `settings` OVERRODE the platform’s own copy for its locale — a published item could rewrite a platform Settings screen — which is exactly what the ownership ruling says an application must not do. Dropping it takes each overridden key back to the platform bundle’s string, and each key it had filled back to the manifest literal. A mechanical notice reading \"(removed)\" conveys neither. The two bundles are separate namespaces from this major on (ruling of 2026-09-13, letter ②: a platform bundle schema and a per-app bundle schema, `settings` absent from the per-app one), and the item door follows the file door (ruling of 2026-09-22, letter B: the file door and the item door are two authoring surfaces for ONE app metadata type, so they accept one shape; an admin override of platform copy, if ever wanted, is a platform-level feature, not app metadata). ADR-0049 enforce-or-remove supplied the question, not the answer — `settings` stays a LIVE platform key. No deprecation window: both doors refuse the key by name from this major, with the prescription on the rejection." + }, + { + "surface": "translation.dashboards..widgets..subCaption — the metric sub-caption overlaid onto a widget's options.description", + "replacement": "The widget's one authored description, `widget.description`, rendered as the card-header subtitle and translated by `dashboards..widgets..description`.", + "migrationId": "translation-widget-sub-caption-retired", + "toMajor": 18, + "rationale": "The D2 conversion `translation-widget-sub-caption-removed` deletes `subCaption` from every translation bundle and stored translation item, and `translateDashboard` no longer overlays anything onto a widget's `options`. The sub-caption was the string under a metric's value; the dashboard schema never declared `options.description` and no authored widget wrote it, so a translated sub-caption existed only because this key put it there. What the delete drops is translation WORK: a translator who wrote a caption per locale meant a user to read it. The conversion cannot move those strings to `description`, because `description` already translates the card-header subtitle — a different string a widget may also carry — and only the author can say whether the caption's wording belongs in that subtitle or is no longer needed." + }, + { + "surface": "a retry policy carrying a key it does not declare (a typo such as maxRetry, a key borrowed from another retry vocabulary such as baseDelayMs or maxAttempts, or a key nothing reads), wherever the policy is written: a job retryPolicy, and the retry block of a try_catch flow node; and a try_catch flow node whose config carries a key beside try, catch, errorVariable and retry that its executor contract does not declare. Never a key on the try or catch region object or on its nodes and edges (the region check at registration owns those). Reachable wherever a job or a flow is authored or stored: defineStack sources, defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved from the Studio flow designer, and a flow row already sitting in sys_metadata", + "replacement": "the key the policy declares, or no key: maxRetries (retries after the first attempt), backoffMs (the base delay), backoffMultiplier, maxRetryDelayMs and jitter. Rename a typo to the declared key the refusal's did-you-mean names; write a delay borrowed from another vocabulary as backoffMs or maxRetryDelayMs; write a count of total attempts as maxRetries one lower (maxAttempts 3 is maxRetries 2); delete a key nothing reads. A retryDelayMs is still answered by its own tombstone: rename it to backoffMs", + "migrationId": "try-catch-and-retry-policy-undeclared-keys-refused", + "toMajor": 18, + "rationale": "`RetryPolicySchema` was a plain `z.object`, which strips a key it does not declare. Its defaults are opt-in (`maxRetries` 0, `backoffMultiplier` 1), so a stripped key falls back to \"no retry\" or to a flat delay: a `job.retryPolicy` with `maxRetry: 3` parsed, deployed and never retried, and nothing said so. On a `try_catch` node the strip also kept the flow parse from judging the node's keys: its descriptor closes `retry` to five keys, so `registerFlow`'s undeclared-key walk (`validateNodeConfigKeys`) refused a key the contract would have accepted, and `flow-builtin-node-config-undeclared-keys-refused` left `try_catch` to that walk. A `retry.maxRetry` typo or a `bogusKey` beside `try` therefore passed `objectstack validate` and `objectstack compile` and was refused only when the flow registered. The policy is now a `strictObject`: an undeclared key is refused at parse, naming the key, with a did-you-mean for a near miss. Measured before closing it: every writer of either parser in this repository and in the pinned objectui writes only declared keys, so it is closed on the shared schema. The one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first), `objectstack validate` and the metadata save door share (`flowNodeConfigRefusals`) now judges `try_catch` keys like every other builtin's, as `node-config-refused-by-contract` anchored at the key (`nodes.N.config.retry.maxRetry`), and the descriptor walk stands aside for it, so it keeps plugin node types only. The descriptor's declared key sets equal the contract's at every position the walk descends to, so registration refuses what it refused before. ⚠️ The one key the walk refused that the contract declares is the `retryDelayMs` tombstone. The `retry-policy-converged` conversion renames it before every door that converts first, but keeps it beside a `backoffMs` holding a different value, and leaves it when it is `null`. The key arm refuses what survives at `nodes.N.config.retry.retryDelayMs`, in the tombstone's own words, so registration widens nowhere. A `script` node's retired keys keep the scope they had. ⚠️ No D2 conversion: the platform cannot know what an undeclared key was meant to be. ⚠️ Where such a node already sits, the whole flow is refused, as registration already refused it: from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, while the flows beside it register; a `defineStack` source throws `StackSchemaInvalidError`; a save from Studio answers 422 naming the key. A job whose `retryPolicy` carries such a key is new to refusal (no door judged one before): its `defineStack` source throws `StackSchemaInvalidError` at `jobs.N.retryPolicy`, and an artifact carrying it is refused whole at load. ADR-0087, ADR-0031." + }, + { + "surface": "data.TursoConfig (a turso / libsql datasource.config) and the TursoDriver constructor of @objectstack/driver-turso — mode local beside a non-empty syncUrl is now refused, on mode at authoring and at construction. The published TursoConfigSchema mirror of @objectstack/driver-turso carries the same text for parity but declares no mode key and strips an authored one, so it cannot see a forced mode and still accepts the config as a replica", + "replacement": "the configuration the author meant. An embedded replica drops mode: keep the file: url and syncUrl, for example url file:./data/replica.db with syncUrl naming the remote, and the url and syncUrl select the replica. A plain local database drops syncUrl and sync: a file: url (or :memory:) with no syncUrl is a local database, with or without mode local", + "migrationId": "turso-config-forced-local-with-sync-url-refused", + "toMajor": 18, + "rationale": "A syncUrl names the remote an embedded replica syncs with, and the turso driver syncs whenever it is set on a local engine, whatever mode says. The triage ruling of 2026-09-29 weighed refusing this shape against honouring mode local by skipping the sync, and refused it: honouring it would ignore a declared syncUrl, the same defect with the keys swapped, and a loud contradiction is the author's to resolve. A forced mode local beside a syncUrl parsed clean at authoring, and the driver built it with a local transport label and then ran it as a replica: it synced on connect, started the sync interval and answered true to the sync-enabled check, exactly as the same config with no mode did (measured on the driver source). A declared mode the runtime ignores is the declared-but-not-enforced shape ADR-0049 does not ship, so the datasource contract and the constructor now refuse it together, with one message, which names both ways out. The sibling refusals keep their order: a forced local mode on a remote url or a bare path meets its url refusal first. An empty syncUrl is unset and is not refused. Stored datasource rows are not re-parsed when they load, so a stored row in this shape now fails when its driver is built: the connection service records it as failed-degraded, a test connection answers ok false, and under ADR-0062 D5 the boot fails fast when objects bind to that datasource, unless OS_ALLOW_DRIVER_CONNECT_FAILURE is set. Measured on this tree at the change: no example, template, published skill or hand-written doc authors the shape, and no host default or environment variable sets mode or syncUrl. ADR-0049 / ADR-0087 / ADR-0112." + }, + { + "surface": "data.TursoConfig (a turso / libsql datasource.config) and the TursoDriver constructor of @objectstack/driver-turso — mode replica with no syncUrl (or an empty one) is now refused, on mode at authoring and at construction. The published TursoConfigSchema mirror of @objectstack/driver-turso carries the same text for parity but declares no mode key and strips an authored one, so it cannot see a forced mode and still accepts the config as a local file", + "replacement": "the configuration the author meant. An embedded replica names the remote it replicates from: keep the file: url and set syncUrl to the libsql or https Turso endpoint, for example url file:./data/replica.db with syncUrl naming the remote. A plain local database drops mode: a file: url with no syncUrl and no mode is a local database", + "migrationId": "turso-config-forced-replica-without-sync-url-refused", + "toMajor": 18, + "rationale": "An embedded replica is a local file kept in sync with the remote named in syncUrl, so a replica is defined by its remote. The ruling of 2026-09-28 weighed refusing this shape against documenting a replica with no remote as a local mode, and refused it: with no remote there is no replica mode to document, only a declaration nothing honours. A forced mode replica with no syncUrl parsed clean at authoring, and the turso driver built it as a replica that never synced: no sync client was created, no sync interval started, the sync call did nothing and the sync-enabled check answered false, while every read and write went to the local file (measured on the built driver). A declared mode the runtime never runs is the declared-but-not-enforced shape ADR-0049 does not ship, so the datasource contract and the constructor now refuse it together, with one message, which names both ways out. The sibling refusals keep their order: a forced replica on a remote url, an in-memory url or a bare path meets its url refusal first, and one with sync meets the sync refusal first. Stored datasource rows are not re-parsed when they load, so a stored row in this shape now fails when its driver is built: the connection service records it as failed-degraded, a test connection answers ok false, and under ADR-0062 D5 the boot fails fast when objects bind to that datasource, unless OS_ALLOW_DRIVER_CONNECT_FAILURE is set. Measured on this tree at the change: no example, template, published skill or hand-written doc authors the shape, and no host default or environment variable sets mode. ADR-0049 / ADR-0087 / ADR-0112." + }, + { + "surface": "datasource.config.timeout on the turso driver — the per-request time limit", + "replacement": "`timeoutMs` — the same limit, in milliseconds, beside the sibling `sync.intervalSeconds` that already spelled its unit.", + "migrationId": "turso-config-timeout-unit-in-key", + "toMajor": 18, + "rationale": "The D2 conversion `turso-config-timeout-to-timeout-ms` renames `config.timeout` to `config.timeoutMs` on every datasource whose driver resolves to turso (a stored `libsql` spelling included) and leaves every other driver's `timeout` alone; the rename is lossless because the key always meant milliseconds. The judgment is whether each value was written in that unit. Two keys above it, `sync.intervalSeconds` spelled SECONDS, so one config block carried both conventions, and the unit of `timeout` lived only in a description and a title no parse reads: `timeout: 30` meant as thirty seconds became a thirty-millisecond limit, short enough to fail a remote request, and the rename keeps 30. Only the author can say which unit they meant. Code that builds a turso driver config in TypeScript is outside the chain's reach." + }, + { + "surface": "data.TursoConfig (a turso / libsql datasource.config) and the published TursoConfigSchema mirror of @objectstack/driver-turso — combinations of url, syncUrl, mode and timeoutMs that are now refused at parse: a remote url (libsql, https, http, wss, ws, any letter case) beside syncUrl or under a forced local or replica mode; in a local or replica mode, a url that is none of a file: url, :memory: or a remote url (a bare path, another scheme, :MEMORY:, a blank url); a replica on an in-memory url; timeoutMs beside a wss or ws url in remote mode; and syncUrl under a forced remote mode. The driver mirror also refuses sync with no syncUrl, as the spec contract already did", + "replacement": "the configuration the author meant, spelled the way the driver runs it. A remote database is the remote url alone (drop syncUrl and sync, and drop a forced local or replica mode or set it to remote). An embedded replica is a local file written as a file: url beside syncUrl, for example url file:./data/replica.db with syncUrl naming the remote. A local database is a file: url (file:./data/app.db, never the bare path ./data/app.db) or :memory: for a throwaway one. A remote database that needs timeoutMs spells its url libsql or https, or drops timeoutMs. Each refusal names the key it sits on (url, syncUrl or timeoutMs) and prints the spellings above", + "migrationId": "turso-config-transport-mismatch-refused", + "toMajor": 18, + "rationale": "Each key parsed on its own, so the contract accepted configurations the turso driver refuses when it is built (VALIDATION_ERROR / 400 from the constructor, since the fixes that stopped a remote url beside a syncUrl from writing to process memory and an unrecognised url scheme from falling through to an in-memory local engine) — a datasource published clean and then failed at boot or at test connection. One more it built and then ignored until the constructor was taught to refuse it as well: syncUrl under a forced remote mode, where the remote client was created without it, no sync ever ran and the sync call failed as not supported while the driver reported sync as enabled (measured on the built driver). Authoring now refuses exactly the constructor's refused set — the same predicates, a scheme matched in any letter case, the url read trimmed as both datasource loaders hand it over — plus that key, refused at authoring first as the declared-but-not-enforced shape ADR-0049 does not ship, and by the constructor too since that later fix. Nothing the constructor accepts is refused (when authoring first refused that key it was the one exception; since the constructor refuses it too there is none): a forced remote mode keeps its url unjudged, as the constructor does. Stored datasource rows are not re-parsed when they load, so a stored row still reaches the constructor as written; the constructor refuses the first four shapes there already and, since that later fix, also refuses syncUrl under a forced remote mode and sync with no syncUrl when the datasource boots. What changes here is that creating, testing or editing its config through the datasource admin service, defineStack or os validate is refused at the key. Measured on this tree at the change: no example, template, published skill or hand-written doc authors a refused combination. ADR-0049 / ADR-0087 / ADR-0112." + }, + { + "surface": "page action:group and action:menu components — a member of properties.actions whose type is not api, and whose params is not an array", + "replacement": "Write `params` as the list of inputs to collect from the user, an `ActionParam[]` array. To run an action with static parameter values, author it as its own `action:button` node, whose `params` object carries them; for a `type: 'api'` member's request body write `bodyExtra`. A member that needs neither drops the key.", + "migrationId": "ui-action-group-menu-member-params-array-only", + "toMajor": 18, + "rationale": "An `action:group` or `action:menu` runs each member itself and forwards an array `params` as the input list. It forwards any other `params` value only for a `type: 'api'` member, as its request payload; for every other `type`, an absent one included, it drops the value, with a development-build warning only. The member declared `params` as any value, so an object `params` on such a member passed the component-props gate and then had no effect: no error and no static values. `params` carries one shape, the input list, and no second value-bag key is declared; a member's `properties.params` is already refused, so static parameter values are not part of the inline action vocabulary at all, and the action that needs them is its own `action:button` node. The member now refuses a non-array `params` on a non-`api` type at the gate, at `actions.N.params`, with that prescription. The `api` member's object `params` is unchanged. It is read where every page component's props are: the component-props gate reports the refusal as an advisory `component-props-invalid` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: the static values belong on a different node, and the census found no writer. Deployed metadata NOT MEASURED." + }, + { + "surface": "page `action:group` and `action:menu` components — each member of `properties.actions` (whose keys used to pass unjudged)", + "replacement": "an inline action with `action:button`'s keys, its executor spelled `type`: `{ name?, label?, icon?, type?, variant?, visible?, disabled?, tags?, params?, description?, target?, openIn?, method?, bodyExtra?, bodyShape?, operation?, patch?, confirmText?, successMessage?, errorMessage?, refreshAfter?, locations?, toast?, resultDialog?, onSuccess?, objectName? }`, plus `size?` on an `action:group` member. Write `actionType` as `type`, `endpoint` (and `url` / `path` / `href`) as `target`, `enabled` as `disabled` with the condition inverted, and `outcomeMessages` as one `successMessage`; drop a member `className`, `properties`, `autoTrigger`, `undoable`, `recordIdField` and an `action:menu` member's `size`.", + "migrationId": "ui-action-group-menu-members-typed", + "toMajor": 18, + "rationale": "An `action:group` or `action:menu` draws and runs each member itself: it draws `label` (or `name`), `icon`, `variant`, `tags` and, on a group's inline buttons, `size`; gates the member on `visible` and `disabled`; places it by `locations`; and forwards its `type` and the rest of `action:button`'s keys to the action runner. The page-component rows declared each member an open record, so a misspelled key, a node-style `actionType` or an `endpoint` no `api` handler reads passed the component-props gate, and the container drew and ran the member without it. The rows now take a closed member: `action:button`'s keys by `type`, with the rows' prescriptions; the keys the rows leave undecided — `outcomeMessages`, a member `className`, a member `properties.params` — are refused, and `outcomeMessages` stays undeclared on all four action blocks as one decision. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and the authored census found no working member to respell. Deployed metadata NOT MEASURED." + }, + { + "surface": "`action` documents declaring `undoable: true` on a shape no runtime fulfils — `type: 'script'` (the default route) and `type: 'url'`, plus the dormant `type: 'flow'` / `'modal'` / `'form'`, in each case WITHOUT `operation: 'update'`", + "replacement": "either of the two fulfilled shapes — `operation: 'update'` with a `patch`, where the framework runtime snapshots the prior value of every field in the merged write bag, or `type: 'api'`, where the pinned console builds the undo envelope — or no `undoable` at all. ⛔ NOT mechanically convertible: which of the two the author meant is an intent no artifact records (a `script` action with an inline handler and an api action calling an endpoint are different dispatches, not two spellings of one), and dropping the flag silently would remove an Undo the author asked for. The refusal names both shapes and the drop, and the author chooses", + "migrationId": "ui-action-undoable-unfulfillable-refused", + "toMajor": 18, + "rationale": "The key was a plain optional boolean read by no refinement, so every combination parsed clean while only two of them ever produced an Undo — the declared-but-inert shape ADR-0078 refuses at author time. ⚠️ The obvious repair, requiring `operation: 'update'`, was MEASURED WRONG and is deliberately not what this entry records: the pinned console's two readers gate the undo envelope on `action.undoable` alone with zero reads of `action.operation`, and those same two files are the entire recorded evidence for this package's own liveness verdict `action/undoable: live`. A blanket requirement would therefore have refused the published `ReassignLeadAction` skill example (`type: 'api'` + `undoable: true`, no `operation`) at import time, since `defineAction` IS `ActionSchema.parse`, and every console api action with undo along with it. So the accepted set is closed to the two shapes some runtime fulfils rather than to the one the framework runtime fulfils. Stating \"`type: 'api'` is fulfilled by the console\" in the contract is the point, not a leak: the spec is the contract for every runtime including the console, and a closed table of fulfillable combinations is what the declared-is-delivered rule asks for." + }, + { + "surface": "page.component.ai:chat_window — the component node, with every key its props bag declared (`mode`, `agentId`, `context`, `aria`), in regions, named slots and nested containers alike", + "replacement": "Delete the component node and put nothing in its place: AI chat is not a page element, and the floating chat overlay the console mounts on every page is the supported entry point. To choose which platform agent the overlay answers with — what `agentId` reached for — set the app's `defaultAgent` (a platform agent: `ask`, or `build` on an authoring surface). `mode`, `context` and `aria` have no counterpart on the page: none of them was ever read, and the overlay is not configured per page", + "migrationId": "ui-ai-chat-window-retired", + "toMajor": 18, + "rationale": "No renderer for `ai:chat_window` ever shipped in objectui, framework or cloud, and none is wanted: the console leaves it unregistered on purpose so that a page naming it fails loudly, and Studio's page palette excludes it. So the element and its four keys were a capability claim nothing kept — a page that placed one validated clean and drew \"Unknown component type\" in front of an end user. Zero producers were measured in objectstack, cloud and hotcrm (one comment naming it as dropped). The name is now refused at `PageComponentSchema.type`, its `ComponentPropsMap` row refuses every props bag with the same prescription, and the enum no longer lists it; `ai:suggestion` is unchanged. No conversion is registered, because the only edit is deleting the node, and which region closes up, holds something else, or keeps its slot is the author's judgment about a page they composed — this entry is that delegation" + }, + { + "surface": "a list view's `bulkActionDefs[].params[]` entry (`BulkActionParamSchema`) — undeclared keys, which this shape accepted and forwarded while it was `.passthrough()`", + "replacement": "the declared shape, now closed to match its single-record twin `ActionParamSchema`: `{ name, type }` plus `label`, `help`, `required`, `default`, `options`, `object`, `labelField`, `multiple`, `placeholder` and — new in this release — `dependsOn`. Every rejection names the surface, echoes the offending key and carries a rename or a prescription: the action-param spellings rename onto this surface's words (`helpText` → `help`, `defaultValue` → `default`, `reference` → `object`, `displayField` → `labelField`); the keys that belong one layer out are pointed there (`visible` and a capability gate belong on the DEF, `visibleWhen` belongs on an `options[]` entry, `field` / `objectOverride` / `carryOver` / `defaultFromRow` / `requiresFeature` are field-backed ACTION-param contracts the bulk surface does not implement); and the widget-config family (`min`, `max`, `step`, `precision`, `scale`, `rows`, `accept`, `maxSize`, and the picker knobs `lookupFilters` / `lookupColumns` / `lookupPageSize` / `descriptionField` / `picker` / `subtitle` / `avatarField` / `idField` / `allowCreate`) is answered with one prescription naming `FieldSchema` as the shape those keys are real on. `dependsOn` needs NO edit — it is declared, in the same shape the field-level key takes (`['parent']`, or `[{ field, param }]` when the remote filter key differs).", + "migrationId": "ui-bulk-action-param-unknown-keys-refused", + "toMajor": 18, + "rationale": "The accept was a NULL READING, and that is what makes this a contract fix rather than a preference. Measured against installed spec 17.4.0, three parses per schema in one process: `BulkActionParamSchema` accepted `zzz_nonsense_key_that_no_producer_emits_8755` in the SAME RUN that it accepted `dependsOn`, while `ActionParamSchema` one surface over refused both with `unrecognized_keys`. A shape that examines nothing cannot license anything — so \"the bulk schema accepts it\" was never evidence a key was authorable, and every misspelling and every invented key shipped silently. Not losslessly convertible for the reason the majors-15/16/17 strictness entries give: an arbitrary unknown key has no mapping target and auto-deleting it would be the silent data loss ADR-0078 bans, so each occurrence needs an author's decision. ⚠️ Two halves of this are worth knowing before you upgrade. (1) `dependsOn` was already LIVE on this surface and is kept: `bulkParamToField` does not destructure it out, so it rides the adapter's spread onto the field bag, where the option widgets read it through `useCascadingOptions` and the reference-bearing pickers lower it into a candidate filter; an ablation removing it from that spread reddened 7 of 12 cases in the consuming repo, so retiring it was measured off the table. (2) the widget-config family rode the same spread and really was honoured by whichever widget read it — those keys are refused now rather than forwarded, which is the accepted cost of closing the shape (maintainer ruling, letter A, 2026-09-17: 「Breaking for authored metadata」, one-shot, no grace window and no dual spelling). ⛔ Do not read their rejection as \"the renderer ignores them\", and ⛔ do not answer it by declaring the key on the object's FIELD: the bulk surface has no field-backed param route, so that value does not reach this dialog either. A census of authored bulk params taken at registration time over the two repositories reachable from that session found ZERO carrying an undeclared key (objectstack@176b03582e: 7 param literals; objectui@3e4f6324f7: 3), so no in-corpus configuration is known to break. ⚠️ That census did NOT cover hotcrm, which was unreachable from the session that took it — that leg is UNMEASURED, not clean, and an upgrader with their own metadata corpus should run the check below rather than inherit this result." + }, + { + "surface": "page `cloud-connection:panel` / `marketplace:installed-list` components — `properties` (any key at all: both widgets declare no props)", + "replacement": "an empty `properties` bag (`{}`), or omit `properties` entirely. Neither widget reads any prop: the console registrations discard the schema node (`() => `) and the components take no arguments, so there is no declared key to move to — a key authored on either widget configures nothing and is removed, not renamed. Node-level keys (`visibleWhen`, `id`, `style`, …) stay on the component node, where the page runtime reads them.", + "migrationId": "ui-cloud-connection-widgets-unknown-keys-refused", + "toMajor": 18, + "rationale": "These were two more instances of the class already closed for `record:reference_rail` and then for `record:alert` / `record:quick_actions` / `record:history`, each by declaring a strict `ComponentPropsMap` row measured from the renderer's read points: console-registered widgets on `@objectstack/cloud-connection`'s published Setup pages, reachable through the component type union's open string arm, with registered renderers but no `ComponentPropsMap` row — so the props gate's dispatch (it parses `properties` against the type's row at publish and lint time, and skips a type with no row because the type union is open) skipped them as unregistered and any authored key rode through every validator in silence. The new rows are strict and EMPTY, measured from the renderers' actual read points at the objectui pin (not from the registrations' declared-input lists): both registrations ignore the component node entirely, so the widgets accept no configuration at all, and an authored key is now a publish-time refusal naming the surface instead of a silent no-op." + }, + { + "surface": "form-view field row `maxLength` / `minLength` declarations (`FormFieldSchema`, the rows inside `FormView.sections[].fields[]`) — `0`, negative or non-integer values", + "replacement": "a positive-integer bound (>= 1), or no declaration at all (\"no minimum\" is expressed by OMITTING `minLength`, never by `minLength: 0`). The row-level key is a per-form override that can only NARROW what the referenced object field already declares (the object field surface tightened first, to a positive integer, by maintainer rulings) — so a malformed row value is deleted, and a bound that was actually wanted is re-declared as a positive integer, or dropped in favour of the object field's own authoritative declaration", + "migrationId": "ui-form-field-length-malformed-refused", + "toMajor": 18, + "rationale": "The form-field row still carried the object field's old shape — bare `z.number()` — after that surface converged (`maxLength` by the maintainer's 2026-08-24 ruling, `minLength` by the 2026-08-25 one, which refused zero too: both `z.number().int().min(1)`). The row keys are LIVE, measured in objectui: the spec bridge (`packages/react/src/spec-bridge/bridges/form-view.ts` mapField, which maps every spec key or explains why it does not, so none is dropped in silence) and plugin-form (`sectionFields.ts` normalizeSectionField) both copy them onto the runtime field, the console FormPage merges `override.maxLength ?? def.maxLength` onto the rendered input (fixed so that a form's own bound wins over the object's, as its docstring promised), and the fields package builds react-hook-form validation rules from `minLength`/`maxLength` — so `maxLength: 0` on a form row reached the DOM as an input that accepts nothing, and the public-form resolve route (`GET /forms/:slug`) serves the rows verbatim to anonymous renderers. The schema now refuses the malformed values at parse (`z.number().int().min(1)`, ADR-0078 declared=enforced). Unlike the object-field twins there is NO type-conditional gate: a form row references its object field by name and usually omits `type`, so the referenced field's type is invisible at parse time — value shape is checkable on this surface, key placement is the object field's own schema's job." + }, + { + "surface": "form-view field row `precision` / `scale` declarations (`FormFieldSchema`, the rows inside `FormView.sections[].fields[]`) — non-integer or negative values (`scale: 2.5`, `precision: -1`)", + "replacement": "a non-negative integer digit count, or no declaration at all. The row-level key is a per-form override of the referenced object field's own declaration (that surface tightened first, to a non-negative integer) — a malformed row value is deleted, and a count that was actually wanted is re-declared as a non-negative integer (`scale: 2.5` was probably `2` or `3`)", + "migrationId": "ui-form-field-precision-scale-integer-refused", + "toMajor": 18, + "rationale": "The form-field row still carried the object field's old shape — bare `z.number()` — after that surface converged on `z.number().int().min(0)` for both digit counts. The row keys are LIVE, measured in objectui: the spec bridge (`form-view.ts` mapField, which maps every spec key or explains why it does not) and plugin-form (`sectionFields.ts`) copy them onto the runtime field, `ObjectForm` derives the number input's step from `precision`, and the `NumberField` widget reads `scale` — so a malformed count flowed into rendering arithmetic (`Math.pow(10, -precision)`) with no defined meaning. The schema now refuses non-integer and negative values for both keys at parse time (ADR-0078 declared=enforced). Same no-type-gate rationale as the length pair entry (`ui-form-field-length-malformed-refused`): the row usually omits `type`, so only value shape is checkable on this surface. ⚠️ The timeline view's `scale` enum (`TimelineConfigSchema.scale`) is a different surface and is unchanged; the gantt view has no `scale` key at all — its own granularity key is `viewMode`. `CurrencyConfigSchema.precision` was also a different surface — retired in this same protocol major by `currency-config-precision-removed`, not enforced here." + }, + { + "surface": "form `layout` — the `object-form` page component (`ObjectFormPropsSchema.layout`) and the form view (`FormViewSchema.layout`: `view.form`, `view.formViews.*`, a form view item's `config`, a flattened form overlay): the `inline` and `grid` arms (REMOVED)", + "replacement": "`layout: 'vertical' | 'horizontal'`, or no `layout` at all ('vertical' is the renderer default). A multi-column form is `columns` (e.g. `columns: 2`), which the renderer honours under either layout — it was never a layout value. 'grid' → 'vertical' and 'inline' → 'vertical', with any `columns` beside them kept as authored.", + "migrationId": "ui-form-layout-inline-grid-retired", + "toMajor": 18, + "rationale": "Both surfaces declared `vertical | horizontal | inline | grid`, and no renderer ever gave `inline` or `grid` a behaviour of its own. Measured at the objectui pin `f8a9d0fb0`: the simple `object-form` arm folds both to `vertical` under a comment saying exactly that, the drawer and modal arms pass only `vertical` / `horizontal` through, and the tabbed, split and wizard sub-forms hard-code `vertical` — so both values parsed green at the spec door and rendered as the default. The spec admitted them from two declarations (the designer palette and the registry inputs), never from a read. The maintainer's ADR-0049 family criterion asks whether mainstream platforms have the capability — if they do, build the consumer once, correctly; if they do not, retire the key — and not whether anything in this repository reads it. Multi-column, the capability `grid` names, is one they have, and this spec already carries it under another key, `columns`; `inline` is a toolbar / filter-row pattern, not a record-form layout. So the two arms are redundant vocabulary rather than a missing consumer, and are retired with no alias window. The mechanical rewrite is the ADR-0087 D2 conversion `form-layout-inline-grid-to-vertical` (retired from the load path — both enums refuse the two values at parse with a per-value prescription; stored rows and assembled artifacts replay clean). It is behaviour-preserving: the rewritten form renders exactly as before. What it cannot decide is whether an author who wrote `grid` without `columns` wanted a multi-column form they never got — that form always rendered single-column, and only the author knows whether that was the intent." + }, + { + "surface": "form-view predicates naming the `features.*` scope root — section-level `visibleWhen`, field-level `visibleWhen` at any nesting depth, and per-option `visibleWhen` authored inline in the form view (`FormViewSchema`, including the flattened runtime form overlay and the deprecated `visibleOn` alias spellings)", + "replacement": "gate by record state (`record.*` in runtime forms, `data.*` in metadata forms), or move the feature-gated surface onto an app page component or action — the predicate surfaces where `features.*` stays bound and stays legal. No rewrite is mechanical: a feature-flag gate and a record-state gate answer different questions, so the author chooses which surface the gate belongs on.", + "migrationId": "ui-form-view-predicate-features-root-refused", + "toMajor": 18, + "rationale": "Ruled by the maintainer on 2026-08-27 (option B — vocabulary narrowing: a form view may not name `features.*` in a predicate, and the authoring door refuses it loudly): one authored form view is served on two kinds of route, and a `features.*` predicate got two verdicts from the same text. Inside an app (`/apps/:appName/*`) the root resolves against the real auth-config flags; on the standalone form routes (`/forms/:name`, public `/f/:slug`) no app context exists, the root is UNBOUND, the predicate faults — and `visibleWhen`'s fault fallback is visible, so the field or section a feature flag was meant to hide is shown to everyone (fail-open, on an access-shaped key). Measured before ruling and re-verified at dispatch (2026-08-28): ZERO authored `features.*` form-view predicates exist across objectui apps/examples/content, against an 18-hit positive control on authored `visibleWhen` predicates — so the vocabulary is narrowed at the authoring door instead of building an auth-config fetch plus pre-load semantics on a route with zero consumers. App-context predicate surfaces (page components, actions, bulk-action eligibility) keep `features.*` unchanged." + }, + { + "surface": "kind:'html' page source (and its deprecated kind:'jsx' alias) in a project with no sdui.manifest.json of its own — the div tag, and any other tag or prop the SDUI component manifest shipped in @objectstack/console does not declare", + "replacement": "`box` for a plain wrapper — the one drop-in swap: the same element, your `className` verbatim, the same children, and no layout of its own. Reach for `card`, `flex`, `container`, `stack` or `grid` only where you want their layout. For any other tag or prop the command names, a component and prop the manifest declares; the file is `dist/sdui.manifest.json` inside `@objectstack/console`.", + "migrationId": "ui-html-page-div-refused", + "toMajor": 18, + "rationale": "An html page's source is a JSX string, not a keyed document: `objectstack migrate meta` rewrites stored metadata by key and cannot rewrite a tag inside authored source, so the move is by hand, and which wrapper keeps a page's layout is the author's call. `objectstack validate`, `objectstack compile` (which `dev` and `start` run before they boot only when the artifact is missing or `--compile` is passed, and `dev`'s watch mode when a watched file changes) and `objectstack lint` check that source against an SDUI component manifest: the `sdui.manifest.json` in the directory the command runs in, then the copy `@objectstack/console` ships. The second lookup asked for a file the console package does not export, so it always failed, and a project without its own manifest had its html pages checked at parse level only — syntax and structure, never which components and props they use. It now reaches the shipped copy. That manifest declares the html tier's intrinsic tags but not `div`: the maintainer ruled (2026-09-27) that an html page may author the intrinsic tags its renderer registers and that the published manifest declares that set, while `div` stays deprecated in favour of `box`, and the console's own html-page compile refuses `div` the same way. A `div` in such a page, which used to pass unchecked, now fails the command with `jsx-forbidden-tag` and `jsx-unknown-component`. A project that keeps its own `sdui.manifest.json` is checked against that file, as before. The runtime save door now holds pages to the same manifest: a server that `objectstack serve` runs (`dev` and `start` run it too) resolves the deployment's manifest the same way, from the `sdui.manifest.json` beside the served config and then the copy `@objectstack/console` ships, and the metadata save door compiles an html page's source against it on every publish. A `div` page saved from Studio or through the metadata API is refused with a `422` under the same rule ids, and a draft is stored as written and refused at its publish. A server that resolves no manifest says so once at boot and stores html pages unjudged, as before. Pages already stored are not rewritten; each is judged the next time it is saved." + }, + { + "surface": "list-view group-by field names — `kanban.groupByField` (`KanbanConfigSchema`, REQUIRED), `gantt.groupByField` and `timeline.groupByField` (`GanttConfigSchema` / `TimelineConfigSchema`, both optional) — values carrying leading or trailing whitespace", + "replacement": "the field name written with no leading and no trailing whitespace — the same spelling the object declares and the server answers under. A padded value is RE-AUTHORED, never trimmed on the author's behalf: `' stage'` becomes `'stage'`. The refusal names the offending spelling verbatim, so the whitespace an author cannot see in an editor is visible in the message, next to the name to write instead.", + "migrationId": "ui-list-view-groupbyfield-padded-refused", + "toMajor": 18, + "rationale": "The same padded-name defect the grouping-level narrowing (`ui-list-view-grouping-field-padded-refused`) refused, on the axis that one scoped out by name, and given the same refusal. All three keys were a bare `z.string()`, so a padded group-by name was valid authored metadata all the way to the renderers. The name is a LOOKUP KEY on every row, measured in objectui at `dda8f3815`: the kanban board resolves its lane as `laneField = groupByField || groupField || detectStatusField(objectDef)` and buckets cards by `card[laneField]`; `ObjectGantt`'s `groupByAccessor` splits the name on `.` and walks the backing record (`resolvePath(task.data, field)`); the timeline groups its rows the same way. The server answers under the unpadded name, so every per-row lookup reads `undefined` and the board collapses into one `Uncategorized` lane — the gantt and the timeline into one ungrouped bucket — holding every record. That is a silent wrong answer that reads as a true statement about the data: one giant bucket is indistinguishable from a dataset where the field genuinely is empty, which is why nothing weaker than a parse refusal is honest here. `packages/lint`'s `validate-list-view-field-refs` already grades this position `error` for the same consequence, but it only runs where an app is validated against its object definitions; the producer accepted the value regardless. ⛔ NOT a `.trim()`: a trimming schema makes `' stage'` and `'stage'` silently equivalent, the consumer-tolerance direction the contract-first rule refuses (fix the metadata, not the renderer: off-spec metadata is refused where it is authored, never coerced into working) — and on the REQUIRED kanban key the author cannot withdraw the value by omitting the key, so a normalising producer would be their only feedback channel and it would say nothing. The narrowing is non-padded ONLY and deliberately not the snake_case machine-name grammar `/^[a-z_][a-z0-9_]*$/` this package spells inline for object/field/tool NAMES: a `groupByField` is authored as a field REFERENCE and a dotted relationship path (`owner.name`) is an in-tree spelling of one. Ships at once, no deprecation window (2026-08-27 maintainer ruling 「短期不考虑渐进」)." + }, + { + "surface": "list-view grouping level names — `grouping.fields[].field` (`GroupingFieldSchema`, the rows inside `ListView.grouping.fields[]`) — values carrying leading or trailing whitespace", + "replacement": "the field name written with no leading and no trailing whitespace — the same spelling the object declares and the server answers under. A padded value is RE-AUTHORED, never trimmed on the author's behalf: `' business_unit '` becomes `'business_unit'`. The refusal names the offending spelling verbatim, so the whitespace an author cannot see in an editor is visible in the message.", + "migrationId": "ui-list-view-grouping-field-padded-refused", + "toMajor": 18, + "rationale": "Ruled by the maintainer on 2026-09-10 (「其他同意」): refuse at the producer. `field` was a bare `z.string()`, so a padded grouping level was valid authored metadata all the way to the renderers. Measured on objectui (M1-M11 with live controls): the projection harvester `collectGroupingFieldRefs` TRIMS the name when it builds `$select`, while THREE renderers bucket rows by the RAW name — plugin-grid `usableGroupingFields`, plugin-list `ObjectGallery.groupedItems`, plugin-kanban `effectiveSwimlaneField`. The server therefore answers under `business_unit` while every per-row lookup asks for `' business_unit '`, reads `undefined`, and the view collapses into ONE `(empty)` group (grid, gallery) or ONE `Uncategorized` lane (kanban) holding every record — a silent wrong answer that reads as a true statement about the data, which is why nothing weaker than a parse refusal is honest here. ⛔ NOT a `.trim()`: a trimming schema makes `' a '` and `'a'` silently equivalent, the consumer-tolerance direction the contract-first rule refuses (fix the metadata, not the renderer: off-spec metadata is refused where it is authored, never coerced into working). objectui's harvester trim stays as defence-in-depth; nothing is removed there. The narrowing is non-padded ONLY and deliberately not the snake_case machine-name grammar `/^[a-z_][a-z0-9_]*$/` this package spells inline for object/field/tool NAMES: a grouping level is authored as a field REFERENCE and a dotted relationship path (`owner.name`) is an in-tree spelling of one. The blank name is unchanged here — it is already refused loudly one layer down by `compileListViewGroupQuery`'s `grouping_field_blank`, and this narrowing exists for the SILENT case. Ships at once, no deprecation window (2026-08-27 maintainer ruling 「短期不考虑渐进」)." + }, + { + "surface": "page `mcp:connect-agent` component — `properties` (any key at all: the widget declares no props)", + "replacement": "an empty `properties` bag (`{}`), or omit `properties` entirely. The widget reads no prop: the console registration discards the schema node (`() => `) and the component function takes no parameters — every value it renders comes from `/discovery`, i18n and its own state — so there is no declared key to move to; a key authored on it configures nothing and is removed, not renamed. Node-level keys (`visibleWhen`, `id`, `style`, …) stay on the component node, where the page runtime reads them.", + "migrationId": "ui-mcp-connect-agent-unknown-keys-refused", + "toMajor": 18, + "rationale": "This was a third instance of the class closed for the `record:*` blocks by declaring a strict `ComponentPropsMap` row measured from the renderer's read points (the strict, empty `cloud-connection:panel` / `marketplace:installed-list` rows closed the previous two): a console-registered widget on `@objectstack/mcp`'s plugin-shipped Setup page, reachable through the component type union's open string arm, with a registered renderer but no `ComponentPropsMap` row — so the props gate's dispatch (it parses `properties` against the type's row, and skips a type with no row because the type union is open) skipped it as unregistered, any authored key rode through every validator in silence, and door 3 of the canonical-envelope gate `@objectstack/mcp` was given for its shipped page had to carry a standing exemption for the type. The new row is strict and EMPTY, measured from the renderer's actual read points at the objectui pin (not from the registration's declared-input list): the registration ignores the component node entirely, so the widget accepts no configuration at all, and an authored key is now a publish-time refusal naming the surface instead of a silent no-op." + }, + { + "surface": "the grouping property of the object-grid and object-kanban page blocks (ComponentPropsMap['object-grid' | 'object-kanban'].grouping), which was z.unknown and therefore accepted any value: a padded field name such as { fields: [{ field: ' business_unit ' }] }, a number, a bare field-name string, an empty fields list, or keys the grouping config does not declare", + "replacement": "the grouping config a list view carries, `GroupingConfigSchema` from `@objectstack/spec/ui`: `{ fields: [{ field, order?, collapsed? }, ...] }` with at least one entry, each `field` naming the record field exactly as it is stored, with no leading or trailing whitespace, `order` one of `asc` / `desc` and `collapsed` a boolean. A padded name is rewritten unpadded (`' business_unit '` becomes `'business_unit'`); a bare string `'business_unit'` becomes `{ fields: [{ field: 'business_unit' }] }`; an empty `fields` list, a number, or any other value is deleted, since it never grouped anything. On `object-kanban`, `swimlaneField` still wins when both are authored, and deleting `grouping` is the whole migration there when `swimlaneField` is set", + "migrationId": "ui-object-block-grouping-config-typed", + "toMajor": 18, + "rationale": "Both blocks' renderers read the list view's grouping shape and nothing else: the grid groups its rows by every `grouping.fields[i].field` (its server-side group header query and its row projection) and reads `order` and `collapsed` per level, and the kanban board takes `grouping.fields[0].field` as its swimlane field when no `swimlaneField` is authored, looking that raw name up on every card. The list view has refused a padded grouping field name since protocol 17.5, and a list view's `grouping` is a closed shape; these two doors declared the same prop as `z.unknown`, so the same value that list view refuses validated green here and rendered wrong with no error — the grid showed one `(empty)` group holding every row, the board one swimlane holding every card. The prop is kept, not retired: the board's fallback is a live reader of the grouping config. The rewrite is left to the author on purpose: a trimming rule would make a padded and an unpadded name silently equivalent, which is the consumer tolerance the contract refuses, and a bare string, a number or an empty list has no mapping that says what grouping was meant. Metadata AT REST is left exactly as stored — `properties` on a page component is not parsed on the save path, so a stored page keeps loading and renders as it does today; the component-props gate reports such a value as an advisory `component-props-invalid` finding at the offending path on `os validate`, `os build` and `os lint`, a padded name with the received value and the unpadded name to write. ADR-0049 / ADR-0087." + }, + { + "surface": "page `object-form` components — `properties.customFields` (which used to accept any value)", + "replacement": "a list of closed inline form fields `{ name, label?, type?, required?, options?, … }` — the members the form draws, in camelCase, each option `{ label, value, description?, visibleWhen? }` with `value` a string, a number or a boolean. Write a `visibleOn` (or a legacy `condition`) as `visibleWhen`, move a member's `defaultValue` into the block's `initialValues`, drop `id`, and leave the `grid` widget's snake_case keys (`min_rows`, `allow_add`, …) out until the widget reads a camelCase spelling.", + "migrationId": "ui-object-form-custom-fields-typed", + "toMajor": 18, + "rationale": "The form merges `customFields` over the fields it generates from the object's metadata — a member naming a declared field replaces that field's whole definition, any other is added — and draws each member as it was written, handing it to the field widget as its metadata. The page-component row declared it `z.unknown()`, so `42`, a member with no `name`, or a misspelled member passed the component-props gate, and the form drew the field without it. The row now takes a closed runtime form field of the members the form draws, keyed by `name`, each typed to its read — by reference where this package already declares the member (the object field's metadata members, the evaluated predicates). An option is the runtime option the form's option controls draw — `label`, `value`, `description`, `visibleWhen` — and its `value` is any string, number or boolean, kept as written: an inline field binds no object column, so a stored field's lowercase identifier rule does not apply to it. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and the authored census, with every inline option list evaluated, found no working member to respell. Deployed metadata NOT MEASURED." + }, + { + "surface": "page `object-form` and `object-master-detail-form` components — `properties.fields` (whose entries used to accept any value)", + "replacement": "a list of bare field names, in the order the form draws them. Write a `{ name: 'email' }` entry as `'email'` — the form only ever drew its name — and move a `label` or `required` override onto a `sections[].fields` entry (`type` is always the object field's); write a `{ field: 'email' }` entry as `'email'`, or move it into a section's `fields`, the vocabulary it belongs to.", + "migrationId": "ui-object-form-fields-names-typed", + "toMajor": 18, + "rationale": "The form reads its top-level `fields` as the names of the fields to draw, in order, selecting from the object's fields and from `customFields`; the master-detail form hands its own to the parent form verbatim. objectui declares the member `string[]`, but the page-component rows declared it `z.array(z.unknown())` while the form drew a `{ name }` entry by that name — the shape objectui's page-builder guide taught, with a `label`, `type` and `required` the form silently dropped. objectui has since retired that entry from every authoring face — the guide and its fixtures name the fields — keeping only a STORED one readable; so both rows now take field names, and refuse an object entry with what to write instead: a `{ name }` entry is its bare name, and a `{ field }` entry — the `sections[].fields` vocabulary, which the form skips at the top level with a console warning — is its bare name or belongs in a section. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, the form already draws a stored `{ name }` entry by its name, and an override written beside it has no rewrite that keeps it — moving it onto a section is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED." + }, + { + "surface": "page `object-form` components — `properties.contentLayout`, `.submitBehavior`, `.navigateOnSuccess` and `.mobile` (which used to accept any value)", + "replacement": "the shape the form reads: `contentLayout` `'simple'` or `'tabbed'`; `submitBehavior` the form view's own block — `{ kind: 'thank-you', title?, message? }`, `{ kind: 'redirect', url, delayMs? }` with a relative `url`, `{ kind: 'continue' }` or `{ kind: 'next-record' }`; `navigateOnSuccess` a relative path string; `mobile` `{ stickyActions?, stepper?, stepperMinFields?, stepperFieldsPerStep?, fullscreenLongText? }`, with `stepper` `true`, `false` or `'auto'` and the two counts positive integers. Write a `submitBehavior` `kind` as one of the four; move a `redirect` destination to a relative path; write `heading` as `title`.", + "migrationId": "ui-object-form-members-typed", + "toMajor": 18, + "rationale": "The form reads these members with one shape, and the page-component row declared them `z.unknown()`, so any value passed the component-props gate and the form answered an off-shape one with a silent default: a `submitBehavior` `kind` it does not know fell through to the thank-you panel; a misspelled `contentLayout` such as `'tabs'` stacked the sections; a `navigateOnSuccess` that is not a string threw after the record was written, so the submit reported a failure; and a `mobile` member it does not read, or a `stepper` outside `true` / `false` / `'auto'`, was ignored. The row now takes the form view's own `submitBehavior` by reference — the block the renderers already judge a redirect `url` through — so one value is judged the same way on the form view and the block, and the measured shape for the other three. The form's `fields` and `sections` and the master-detail form's two stay open, because the form draws a `{ name }` field entry and an inline runtime field inside a section, which the typed shapes would refuse; and `customFields` stays open until the spec declares the runtime form field its entries are. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and an off-shape value has no rewrite that both keeps what the form shows today and honours what the author wrote — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED." + }, + { + "surface": "page `object-form` and `object-master-detail-form` components — `properties.sections` (whose entries used to accept any value)", + "replacement": "closed sections `{ name?, label?, description?, collapsible?, collapsed?, visibleWhen?, columns?, pane?, fields }` (or `{ group, columns?, pane? }`), each `fields` entry a field name, the form view's `{ field, … }` entry or an inline form field `{ name, type, … }`. Write a section or field `visibleOn` as `visibleWhen`, a string `columns: '2'` as the number `2`, and a section `label` (or a field entry's `label` / `placeholder` / `helpText`) as a plain string.", + "migrationId": "ui-object-form-sections-typed", + "toMajor": 18, + "rationale": "The form reads a section's heading, collapse pair, `visibleWhen`, `columns`, `pane`, `group` and `fields` — the key set of the form view's section — and draws three kinds of field entry: a name, the form view's `{ field }` entry overriding that object field, and an inline runtime form field drawn as it stands. The page-component rows declared each section `z.unknown()`, so a misspelled key passed the component-props gate and the form drew the section without it; a form view's deprecated `visibleOn` and string `columns`, which a form view folds at parse, reached the form raw — a page block's `properties` is never parsed on the way — and were dropped. Both rows now take one section shape of their own, the stored form view unchanged: the form view's section keys plus the three entry arms, canonical spellings only, a label a plain string because the form draws it as it stands, and the form view's group-reference rule. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and the authored census, with every inline option list evaluated, found no working section to respell. Deployed metadata NOT MEASURED." + }, + { + "surface": "page `object-gantt` components — `properties.markers` (whose entries used to accept any value)", + "replacement": "a list of `{ date, label?, color? }`: `date` an ISO date or date-time string (required), `label` the text drawn against the line, `color` any CSS colour. Write a marker `title`, `text` or `name` as `label`, and a `colour` as `color`; give every marker a string `date`.", + "migrationId": "ui-object-gantt-markers-typed", + "toMajor": 18, + "rationale": "The gantt reads each marker with one shape — `date` places the line, and a date that does not parse or falls outside the drawn range draws none; `label` is drawn against it; `color` paints it, the theme's primary colour when absent — and the page-component row declared the entries `z.unknown()`, because that contract was objectui's alone. So a marker with no `date`, a numeric `date` or a misspelled member passed the component-props gate, and the chart drew no line, or drew it with no label and in the default colour. The spec now declares objectui's own authoring declaration of a marker, `{ date, label?, color? }` with `date` a string (authored metadata is JSON, which cannot carry a `Date`), closed as every element shape on that map is. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and a misspelled member has no rewrite that says which member the author meant — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED." + }, + { + "surface": "page `object-grid` components — `properties.columns` (whose entries used to accept any value)", + "replacement": "the list view's own `columns`: all field-name strings, or all column entries `{ field, label?, width?, align?, hidden?, sortable?, resizable?, wrap?, type?, pinned?, summary?, prefix?, link?, action? }`. Respell a column keyed `accessorKey` / `header` or `name` as `field` / `label`; write a list as all strings or all entries, never a mix; delete a column key the entry does not declare (`editable`, `options`, `reference`, `currency`, `precision`, …) — inline editing is the grid's own `editable`, and option labels, relational metadata and number formats are the object field's.", + "migrationId": "ui-object-grid-columns-typed", + "toMajor": 18, + "rationale": "The grid reads `columns` with one shape — all field-name strings or all column entries, decided by the first entry, drawing only an entry with a string `field` and reading the column entry's own members off it — and the page-component row declared it `z.array(z.unknown())`, so any entry passed the component-props gate and the grid answered an off-shape one in silence: a column keyed `accessorKey` / `header` or `name`, or one with no `field`, drew no column, a mixed list lost every entry the first one did not match, and a key the grid never reads off a column (`editable`, `options`, `reference`) was ignored. The member was held while the grid's group headers drew a column's `options` ahead of the field's; the renderer has since retired that read and takes the labels from the object field only, so the row takes the list view's own `columns` by reference — the column entry a list view already refuses an undeclared key on. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and a refused column has no rewrite that both keeps what the grid draws today and honours what the author wrote — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED." + }, + { + "surface": "page `object-grid` components — `properties.exportOptions` (which used to accept any value)", + "replacement": "the export options object a list view's `exportOptions` declares: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`, with `formats` drawn from `csv`, `xlsx` and `json`, `maxRecords` a non-negative integer, and `includeHeaders` / `streaming` booleans. Where a bare format array was written, write `{ formats: [...] }` to offer the formats you listed — the grid will now offer exactly those — or `{}` to keep the csv/json default the grid has been offering. Delete `pdf` from `formats`, and any key the object does not declare; delete an `exportOptions: null` (it never enabled the menu).", + "migrationId": "ui-object-grid-export-options-closed", + "toMajor": 18, + "rationale": "The grid reads one export options block — `exportOptions.formats`, `.maxRecords`, `.includeHeaders`, `.fileNamePrefix` and `.streaming` — the block a list view declares, but the page-component row declared the key `z.unknown()`, so any value passed the component-props gate. The trap was the list view's legacy spelling: a bare format array is legal on a list view, which lifts it to `{ formats }` at parse, and was accepted on the grid, which lifts nothing — the export menu appeared, offering the csv/json default, and the author's list was dropped without a report. The row now takes the list view's export options object itself rather than its union, so a legacy spelling does not spread to a surface that never read it: a bare array is refused with the object form named, a format outside the enum is refused at its index (`pdf` with its retirement text), and a key the object does not declare is named. It is read where every page component's props are: the component-props gate reports these as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape; a bare array has no rewrite that both keeps what the grid shows today and honours what the author wrote, which is the judgment this entry leaves to the upgrader; and the authored census found nothing to respell. Population measured at the change, on origin/main f148852752: zero `object-grid` blocks authoring `exportOptions` in the examples, the package fixtures, the documentation and the published skills, against ten authored `object-grid` blocks through the same matcher (nine in TypeScript, one in a YAML documentation example) and four list-view `exportOptions` authorings as the key's control. Deployed metadata NOT MEASURED." + }, + { + "surface": "page `object-grid` components — `properties.fields`, `.selection`, `.selectable`, `.rowActions`, `.bulkActions` and `.batchActions`; page `object-kanban` components — `properties.columns`; page `object-calendar` components — `properties.calendar` (which used to accept any value)", + "replacement": "the shape each block reads, the list view's own where it has one: `object-grid` `fields` field-name strings; `selection` `{ type }` with `none` / `single` / `multiple`; `selectable` `true`, `false`, `'single'` or `'multiple'`; `rowActions`, `bulkActions` and `batchActions` action-name strings. `object-kanban` `columns` all lanes `{ id, title, cards?, limit?, className?, collapsed? }` or all bare value strings (never mixed), a lane `id` a string. `object-calendar` `calendar` `{ startDateField, endDateField?, titleField?, colorField?, allDayField? }`. Move an object entry of `fields` to `columns`; move a `{ name }` entry of `bulkActions` to `bulkActionDefs` or write the bare name; style a lane with `className` instead of `color`; rename `dateField` / `endField` to `startDateField` / `endDateField`.", + "migrationId": "ui-object-grid-kanban-calendar-list-members-typed", + "toMajor": 18, + "rationale": "Each renderer reads these members with one shape, and the page-component rows declared them `z.unknown()`, so any value passed the component-props gate and the block answered an off-shape one with a silent default: an object entry of `fields` named no field; a `{ name }` entry of `bulkActions` was skipped; a kanban lane list mixing objects and strings drew a blank lane and swept its records into the trailing lane; and a calendar block without `startDateField` placed no event. The rows now take the list view's own `selection`, `rowActions`, `bulkActions` (for `batchActions` too, the spelling the grid reads first) and `calendar` members by reference, and the measured shape for the grid's `fields` and `selectable` and the kanban lane, so one value is judged the same way on every door that carries it. The grid's `columns` is not narrowed: its group-header labels read an authored column's `options`, which the list view's column entry does not declare, so it stays open until that read is ruled. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and an off-shape value has no rewrite that both keeps what the block shows today and honours what the author wrote — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED." + }, + { + "surface": "`object-grid` page-component page sizes (`ComponentPropsMap['object-grid']` — `pagination.pageSize`, each `pagination.pageSizeOptions[]` entry, and the flat `pageSize` shorthand) — zero, negative and non-integer values (`pagination: { pageSize: 0 }`, `pageSize: 25.5`)", + "replacement": "a positive integer, or no declaration at all. A page size of `0` has no defined meaning on this surface and never had one: delete the key to take the renderer's own default, or write the page size that was meant (`pageSize: 0` authored to mean \"no paging\" is `showPagination: false` with no `pagination` bag, since the bag's PRESENCE is what enables paging)", + "migrationId": "ui-object-grid-page-size-positive-integer-refused", + "toMajor": 18, + "rationale": "This door still carried the shape it was given when the `object-*` blocks first got `ComponentPropsMap` rows measured from their read points — `pagination: z.unknown()` and `pageSize: z.number()` — after the view arm converged on `z.number().int().positive()`. So the SAME authored member carried two accept sets and renderers read the looser one: `PaginationConfigSchema` (`view.zod.ts`) refuses `pageSize: 0` and pins that refusal by name, and every other `pageSize` the package declares is bounded with its own throwing pin (`kernel/metadata-plugin.zod.ts`, `marketplace/marketplace.zod.ts`) — the component arm was the only one that accepted `0`. The value is LIVE: an objectui grid measurement found that an authored `pagination.pageSize: 0` reached `ObjectGrid`, went out on the wire as `$top: 0` and rendered ZERO ROWS, with no grouping needed to trigger it, and it reached the renderer through this arm. objectui's grid plugin repaired the consumer half — it now refuses a non-positive page size at all three read points (one resolver, fail-soft, one loud diagnostic); this is the declaration half, and it is not a prerequisite for that repair. ⚠️ The `pagination` bag itself stays OPEN (`z.looseObject`): only the two members whose value is a page size are bounded, and sibling keys parse and pass through exactly as before. `PaginationConfigSchema` on the view arm is a closed shape and is unchanged by this entry." + }, + { + "surface": "page `object-grid` components — `properties.rowHeight`, `.rowColor`, `.navigation`, `.conditionalFormatting`, `.bulkActionDefs`, `.aggregations` and `.operations` (which used to accept any value)", + "replacement": "the shape the grid reads, the list view's own where it has one: `rowHeight` one of `compact` / `short` / `medium` / `tall` / `extra_tall`; `rowColor` `{ field, colors }`; `navigation` `{ mode?, size?, openNewTab?, preventNavigation? }`; `conditionalFormatting` `[{ condition, style }]` with a CEL `condition` and a CSS `style` map; `bulkActionDefs` the list view's bulk-action defs; `aggregations` `[{ field, type }]` with `type` one of `count`, `sum`, `avg`, `min`, `max`, `count_distinct`; `operations` `{ create?, update?, delete?, export? }` booleans. Rewrite an objectui-native formatting rule `{ field, operator, value, backgroundColor }` as `{ condition: \"record.FIELD == VALUE\", style: { backgroundColor } }`; delete `operations.read` and `operations.import`, which nothing reads.", + "migrationId": "ui-object-grid-row-members-typed", + "toMajor": 18, + "rationale": "The grid reads each of these members with one shape, and the page-component row declared them `z.unknown()`, so any value passed the component-props gate and the grid answered an off-shape one with a silent default: an off-preset `rowHeight` such as `42` rendered as `compact`, a `rowColor` of the wrong shape coloured no row, a `navigation` written as a bare mode string opened the record page whatever it named, an aggregation with an unknown function drew a zero nothing computed or no number at all, and an `operations` toggle nothing reads toggled nothing. The row now takes the list view's own schemas for the five members a list view declares, and the measured shape for `aggregations` and `operations`, so one value is judged the same way on both doors. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and an off-shape value has no rewrite that both keeps what the grid shows today and honours what the author wrote — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED." + }, + { + "surface": "page `object-kanban` components — `properties.conditionalFormatting` (which used to accept any value)", + "replacement": "the list view's own rules, `[{ condition, style }]`: a non-blank CEL `condition` over the card's `record.*` and a CSS `style` map of string values. Rewrite a native rule `{ field, operator, value, backgroundColor }` as `{ condition: \"record.FIELD == VALUE\", style: { backgroundColor } }`, an `expression` as `condition`, and move a colour written beside `condition` into `style`.", + "migrationId": "ui-object-kanban-conditional-formatting-typed", + "toMajor": 18, + "rationale": "The board reads `conditionalFormatting` as an ordered list of `{ condition, style }` rules, through the evaluator the grid's rows use, and paints a card with the `style` of the first rule whose condition holds; objectui declares exactly the list view's rule as the member's only dialect. The page-component row declared it `z.unknown()`, so `42`, a bare string or a rule with no `style` passed the component-props gate and the board painted no card for it. The row now takes the list view's own member, by reference, as `object-grid` does, so one rule is judged the same way on every door. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and the authored census found no working rule to respell. Deployed metadata NOT MEASURED." + }, + { + "surface": "page `object-map`, `object-gantt` and `object-tree` components — `properties.navigation` (which used to accept any value)", + "replacement": "the list view's navigation block `{ mode?, size?, openNewTab?, preventNavigation? }`, `mode` one of `page`, `drawer`, `modal`, `split`, `popover`, `new_window`, `none` — the block `object-grid`, `object-kanban`, `object-calendar` and `object-timeline` already take. Rewrite a bare mode string such as `navigation: 'drawer'` as `navigation: { mode: 'drawer' }`.", + "migrationId": "ui-object-map-gantt-tree-navigation-typed", + "toMajor": 18, + "rationale": "Each of the three renderers hands `navigation` to the shared navigation hook, which reads `navigation.mode`, falls back to `page` when it finds none, and types its mode union as the list view's `NavigationConfigSchema`. The rows declared the member `z.unknown()`, so any value passed the component-props gate and an off-shape one was answered with a silent default: `navigation: 42` and a bare mode string such as `'drawer'` both opened the record page, whatever they named. The three rows now take the list view's schema by reference, so one value is judged the same way on every door that carries it. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and an off-shape value has no rewrite that both keeps what the block shows today (the record page) and honours what the author wrote — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED." + }, + { + "surface": "page `object-master-detail-form` components — `properties.details[]` (each detail entry, which used to accept any value) and `properties.details[].columns[]` (its inline grid columns), including `scale` on a column that declares no `type` and whose `name` is a `currency` field of the entry's `childObject`", + "replacement": "each entry is `{ childObject, relationshipField?, columns?, formFields?, inlineMode?, amountField?, totalField?, title?, minRows?, maxRows?, addLabel? }` — the keys the renderer reads — with `inlineMode` one of `grid` / `form`. Each column is the strict, name-keyed inline grid column a relationship field's `inlineColumns` takes — `{ name, label?, type?, … }`, where `{ name }` alone hydrates the rest from the child object's field. Write `childObject` on every entry; write `name` where a column said `field` (or `fieldName`, `key`) or was a bare field-name string; delete `scale` from a column that renders as a currency column, whether it declares `type: 'currency'` or takes it from a `currency` child field — nothing replaces it, the currency's ISO 4217 minor unit decides; delete any key neither shape declares.", + "migrationId": "ui-object-master-detail-form-details-closed", + "toMajor": 18, + "rationale": "The block draws one inline grid per detail entry, hydrating an authored column list with the same rule and into the same grid as the other two carriers of the inline grid column, but nothing judged its entries: a key the renderer does not read was ignored in silence, and a column carrying a key the grid does not read, or `scale` on a currency column — refused on the other carriers under the maintainer's rulings of 2026-09-23 (option B, `scale` retired from the currency type) and 2026-09-24 (option 乙 — a currency's ISO 4217 minor unit decides its display) — went through `objectstack validate` green. The entry is now a strict shape and its `columns` references the column schema, so every rule that schema holds applies here too, with its own prescription. The entry half is read where every page component's props are: the component-props gate reports a failing entry or column as an advisory `component-props-unknown-key` / `component-props-invalid` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. The identity-only half is `defineStack`'s cross-reference check, which already judged the other two carriers: it now reaches the block wherever a page carries it and refuses an identity-only column over a `currency` child field that carries `scale`, with the column schema's own message; reach: the child object must be declared in the same stack, and a column the column schema refuses on its own is left to the component-props gate. No conversion is registered: nothing on the load path refuses the shape, and the authored census found nothing to respell. Population measured at the change, on origin/main ebdb6f2aca: one authored block in the examples (the showcase project workspace, one entry `{ title, childObject, addLabel }`, no columns), one documentation example whose three columns were bare field-name strings (rewritten as `{ name }` columns in the same change), and zero `field`-keyed detail columns, against one authored `inlineColumns` block as the control. Deployed metadata NOT MEASURED." + }, + { + "surface": "page `object-metric` components — `properties.aggregate` and `.trend` (which used to accept any value)", + "replacement": "the shape the tile reads: `aggregate` `{ field?, function, groupBy? }`, with `function` one of the engine's `count`, `sum`, `avg`, `min`, `max` or `count_distinct`, a `field` for every function but `count`, and `groupBy` the chart aggregate's own union — a field name or a `{ field, dateGranularity?, alias? }` date-bucket node — here optional; `trend` `{ value, label?, direction? }`, with `value` a number, `label` a string or an inline locale map and `direction` `up`, `down` or `neutral`. Write a string `aggregate` as an object (`'count'` → `{ function: 'count' }`); move `dateGranularity` inside `groupBy`; write a bare trend direction as `{ value, direction }`.", + "migrationId": "ui-object-metric-aggregate-trend-typed", + "toMajor": 18, + "rationale": "The tile reads these members with one shape, and the page-component row declared them `z.unknown()`, so any value passed the component-props gate and the tile answered an off-shape one in silence: a string `aggregate` or a function the engine does not have asked the server for a measure it does not have, so the tile showed an error or, on the client-side fallback, a sum it was not asked for; `groupby` for `groupBy` drew one ungrouped number; and a `trend` with no `value` painted a lone `%`, with a misspelled member or direction simply not drawn. The row now takes the query AST's own aggregation functions — the six the tile forwards to the engine — and the chart aggregate's `groupBy` union by reference, and the badge's measured shape for `trend`. The aggregate is not the chart's whole: the chart requires `groupBy` and five functions, while a metric paints one number over every row and draws a `count_distinct` wherever the analytics service answers it. `drillDown` and `compareTo` stay open: the chart's drill-down declares a `filter` the tile never reads and refuses a `report` it draws, and the dashboard widget's comparison declares a `dimension` this path never reads, so each waits on a ruling between the reference and the read. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and an off-shape value has no rewrite that both keeps what the tile shows today and honours what the author wrote — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED." + }, + { + "surface": "page `object-metric` components — `properties.compareTo` (which used to accept any value)", + "replacement": "the shape the tile reads: `{ kind }`, with `kind` the dashboard widget comparison's own vocabulary, `previousPeriod` or `previousYear`. Write a bare kind string as an object (`'previousYear'` → `{ kind: 'previousYear' }`), and delete a `dimension`: the tile shifts the date macros in its own `filter`, so state the window there.", + "migrationId": "ui-object-metric-compare-to-typed", + "toMajor": 18, + "rationale": "The tile reads `compareTo` with one shape — `kind` alone, dispatching on `previousYear` and treating every other value as `previousPeriod` — and the page-component row declared it `z.unknown()`, so any value passed the component-props gate and the tile answered an off-shape one in silence: a bare `'previousYear'` or a kind outside the two compared against the previous period, and a `dimension` was carried and never read, because this inline tile shifts the date macros in its own `filter` while only a dashboard widget's dataset path hands `dimension` to the analytics executor. The row now takes `{ kind }`, with `kind` the dashboard widget comparison's own member by reference, and refuses `dimension` by name with that prescription rather than accepting a key the tile ignores. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and a `dimension` has no rewrite that keeps the window the author meant — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED." + }, + { + "surface": "page `object-metric` components — `properties.drillDown.report` (which used to accept any value)", + "replacement": "a report definition, the same shape as `reports[]` (`ReportSchema`): `{ name, label, dataset, values, … }`, or a `joined` report whose every block binds a `dataset`. Write a bare report name, a `{ name }` reference or the retired `objectName` / `columns` form as the dataset-bound report itself.", + "migrationId": "ui-object-metric-drill-down-report-typed", + "toMajor": 18, + "rationale": "The metric tile hands `drillDown.report` to the shared drill drawer, which draws it as a report — with the metric's filter joined into the report's own `runtimeFilter` — when it is dataset-bound (a non-empty `dataset`, or a `joined` report with a block that binds one), and lists the records for any other value. The page-component row declared it `z.unknown()`, so a report with no `dataset`, a misspelled report key, a bare report name or a `{ name }` reference passed the component-props gate, and the drawer quietly listed the records instead. The row now takes `ReportSchema` by reference — the declaration objectui already names for the member — and, since a joined report refuses a block that binds no `dataset`, every report it admits is one the drawer draws. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and the authored census found no drawn report to respell. Deployed metadata NOT MEASURED." + }, + { + "surface": "page `object-metric` components — `properties.drillDown` (which used to accept any value)", + "replacement": "the shape the tile reads: `{ enabled?, title?, target?, columns?, maxRows?, report? }`, the first five the chart drill-down's own members — `enabled` a boolean, `title` a string, `target` `drawer`, `dialog` or `navigate`, `columns` field names, `maxRows` a positive whole number — and `report` still open. Delete a drill `filter` and scope the metric with its own `filter`, one level up; delete a `mode`, since a metric always lists the records behind its number.", + "migrationId": "ui-object-metric-drill-down-typed", + "toMajor": 18, + "rationale": "The tile reads `drillDown` with one shape — `enabled`, `title`, `target`, `columns`, `maxRows` and `report`, scoping the drilled list by the metric's own `filter` — and the page-component row declared it `z.unknown()`, so any value passed the component-props gate and the tile answered an off-shape one in silence: a drill `filter` or a `mode` was carried and never read, a misspelled member was simply not applied, and a non-numeric page size reached the drilled list. The row now takes the five list members the chart drill-down declares, by reference, and refuses `filter` and `mode` by name: a metric tile has no click event for a drill filter to resolve against, and no row for `mode` to open as a record. The chart's shape is not taken whole, because it declares `filter`. The drill `report` stays open: the tile draws a dataset-bound report through the shared drawer, but no spec drill shape declares a `report` member yet. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and a drill `filter` has no rewrite that keeps the scope the author meant — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED." + }, + { + "surface": "page `object-timeline` components — `properties.items` (whose entries used to accept any value)", + "replacement": "the entry kind the block's `variant` selects: on `vertical` (the default) or `horizontal`, a feed entry `{ time?, title, description?, variant?, icon?, content?, className? }`; on `gantt`, a gantt row `{ label, items? }` whose bars are `{ title?, startDate?, endDate?, variant? }`, each date a string or epoch milliseconds. Write a feed entry's `date` as `time` and its `color` as `variant` (`default`, `success`, `warning`, `danger`, `info`); move a gantt row to `variant: 'gantt'`, or a feed entry off it.", + "migrationId": "ui-object-timeline-items-typed", + "toMajor": 18, + "rationale": "The timeline rail draws `items` as authored, ahead of every record source, and each branch of its renderer reads only its own kind of entry: the feed branches read `time`, `title`, `description`, `variant`, `icon`, `content` and `className`; the gantt branch reads a row's `label` and its bars' `title`, `startDate`, `endDate` and `variant`. The page-component row declared each entry `z.unknown()`, so a misspelled key, a feed entry with no `title`, or a gantt row on a feed timeline passed the component-props gate, and the rail drew an empty, unlabelled entry. The row now takes objectui's two ruled kinds, closed, and pairs each entry with the kind its `variant` selects; a feed entry's `content` (child components) is held unjudged until a writer appears. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and the authored census found no drawn entry to respell. Deployed metadata NOT MEASURED." + }, + { + "surface": "page `object-timeline` components — `properties.mapping` (which used to accept any value)", + "replacement": "the binding record the rail reads: `{ title?, date?, description?, variant? }`, each a field name. Write `titleField`, `dateField` / `startDateField`, `descriptionField` and `variantField` inside `mapping` as `title`, `date`, `description` and `variant`; write a bare field name as the member it binds (`mapping: { title: 'subject' }`).", + "migrationId": "ui-object-timeline-mapping-typed", + "toMajor": 18, + "rationale": "The timeline rail reads `mapping` as four field names — `title` and `date` between the `timeline` block's own member and the flat fallback, `description` ahead of `descriptionField`, and `variant`, the field whose value picks each entry's marker colour and the one binding with no other spelling — and the page-component row declared it `z.unknown()`, because that contract was objectui's alone. So a bare field name, a non-string binding or a misspelled member passed the component-props gate, and the rail bound nothing for it and drew the default field. The spec now declares objectui's own declaration of the binding record, four optional field names, closed as every element shape on that map is. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and the authored census found nothing to respell. Deployed metadata NOT MEASURED." + }, + { + "surface": "`kind:'react'` page source — `` and `` (the react-tier overlay aliases published as deprecated when the react tier converged on the metadata-tier vocabulary)", + "replacement": "`` — ListViewSchema's own `data` data source and `type` view kind, the same two keys a metadata list view authors. `objectName=\"x\"` → `data={{ provider: 'object', object: 'x' }}`; `viewType=\"kanban\"` → `type=\"kanban\"`. A `` with no `data` at all is refused too: on a react page no host stamps the object, so the data source is the required binding there.", + "migrationId": "ui-react-list-view-binding-aliases-retired", + "toMajor": 18, + "rationale": "A react page's source is a JSX string, not a keyed document: `objectstack migrate meta` rewrites stored metadata by key and cannot rewrite props inside authored source, so the move is by hand. The contract deprecated both aliases in favour of the metadata-tier spelling (maintainer ruling 2026-08-23: the react tier converges on the metadata-tier vocabulary, deprecating first) while objectui's ListView still read only `objectName`, so the canonical spelling validated green and rendered an empty list. The consumer fold has landed (objectui `normalizeListViewSchema`, console pin a472b071: `data.provider === 'object'` → `objectName`, and the author's `type` read for the view kind), and the maintainer ruled the aliases retired with no deprecation window (2026-09-07). Writing either alias is now a publish-time `react-prop-retired` error carrying this prescription — never a silent pass on a key the renderer happens to still read." + }, + { + "surface": "page `record:alert` / `record:quick_actions` / `record:history` / `record:discussion` components — `properties` (undeclared keys, notably a typo'd `severty`, quick_actions' inline `actions`, history's host-channel `entries` / `loading`, and any key at all on `record:discussion`)", + "replacement": "the declared shapes the renderers read. `record:alert`: `severity?`, `title?` / `body?` (string or inline locale map), `visible?` (boolean | CEL string | `{ dialect, source }`), `icon?`, `action?` `{ actionName, label?, variant? }`, `dismissible?`, `dismissKey?`. `record:quick_actions`: `actionNames?`, `requiredPermissions?`, `location?` (the spec's own action-location vocabulary), `align?`, `inline?`, `variant?` / `size?` (the Button primitive's vocabulary). `record:history`: `limit?`, `emptyText?` / `unknownUserText?` (literal strings). `record:discussion`: `record:chatter`'s own row — one schema for the pair. Every rejection carries the surface, the offending key and a prescription (`actions` → `actionNames`; `entries` / `loading` → omit, the block self-fetches `sys_activity`; `aria` on quick_actions → not declared until the renderer reads the contract spelling; `visibleWhen` / `visibility` on the alert → `visible`; a locale map as history text → a literal string)", + "migrationId": "ui-record-blocks-unknown-keys-refused", + "toMajor": 18, + "rationale": "These were the four `record:*` components the component-props unknown-key gate (an authorable surface refuses a key it does not declare; for a component's `properties`, by parsing them against the type's `ComponentPropsMap` row) could not reach after the rail was given its strict row: each had a registered objectui renderer (and, bar `record:discussion`, a `PageComponentType` entry and a console palette slot) but no `ComponentPropsMap` row, so the props gate's dispatch skipped them as unregistered and every authored key rode through. A typo'd `severty` on the platform's own banner surface parsed, typechecked, validated, built and shipped as a silent no-op while sibling components in the same file drew loud diagnostics. The rows declare the shapes the renderers actually read (measured from read points at the objectui pin, not from the registrations' declared-input lists — quick_actions' registration claims an empty-bar fallback the renderer does not implement, and omits the `aria.label` read that exists but under a spelling the shared ARIA shape refuses), so an undeclared key is now a publish-time refusal instead of a silent no-op." + }, + { + "surface": "page `record:line_items` components — `properties` (which used to accept any key) and `properties.columns[]` (its inline grid columns)", + "replacement": "the declared shape the renderer reads: `{ childObject?, relationshipField, columns, parentObject?, parentId?, recordId?, amountField?, totalField?, title?, readonly?, minRows?, maxRows?, filter?, sort?, limit? }`, with `filter` the ViewFilterRule array, `sort` the SortItem array and `limit` a positive integer; `childObject` may come from the component-level `dataSource` binding instead. `columns` is required and holds at least one column, each the strict, name-keyed inline grid column a relationship field's `inlineColumns` takes — `{ name, label?, type?, options?, … }`. Write `name` where a column said `field` (or `fieldName`, `key`); declare `label`, `type` and `options` on the column, because this block draws a column exactly as declared and hydrates nothing from the child object's field; delete `scale` from a column declaring `type: 'currency'`; delete `addLabel`, `formFields` and `inlineMode`, which belong to an `object-master-detail-form` detail entry and are not read here, `sortField`, which no block takes (the detail entry derives the line-position field from the child object), and any other key the shape does not declare.", + "migrationId": "ui-record-line-items-props-closed", + "toMajor": 18, + "rationale": "The block draws one inline grid of the record's child rows, through the same objectui grid as the other three carriers of the inline grid column, but it had no `ComponentPropsMap` row: it was the one entry on the string-arm registration ledger, so the component-props gate skipped it as unregistered and every authored key rode through. The showcase project page keyed all five of its columns `field`, the spelling the grid retired, and published green; the grid binds a column by `name`, so every cell rendered empty. The row is measured from the renderer's read points at the objectui pin, not from the registration's declared-input list, and its `columns` references the column schema, so every rule that schema holds applies here too, with its own prescription. It is read where every page component's props are: the component-props gate reports a failing key or column as an advisory `component-props-unknown-key` / `component-props-invalid` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. `defineStack`'s identity-only column check does not reach this block: the panel hands its columns to the grid as authored, so there is no hydrated type to judge. No conversion is registered: nothing on the load path refuses the shape, and the one `field`-keyed producer was respelled in the same change. Population measured at the change, on origin/main 1ecb871beb: one authored block in the examples (the showcase project detail page, five `field`-keyed columns, respelled `name`), zero in the documentation, against eight authored `record:*` blocks of other types through the same matcher as the control. Deployed metadata NOT MEASURED." + }, + { + "surface": "page `record:reference_rail` components — `properties` and each `entries[]` item: undeclared keys (notably a per-entry `filter`, an entry `icon`, and an inline locale map as `title`)", + "replacement": "the declared shape the renderer reads: `entries[]` of `{ objectName, relationshipField, title?, limit?, displayField? }` plus a component-level `hideEmpty`. Every rejection carries the surface, the offending key and a prescription (`filter` → remove it, or use `record:related_list` whose `filter` is real; `icon` → remove it, no render path reads it; entry-level `hideEmpty` → move it up beside `entries`; `items` / `related` → `entries`; `object` → `objectName`; `label` → `title`; a `title` locale map → a literal string, or omit it to keep the localized object label)", + "migrationId": "ui-reference-rail-unknown-keys-refused", + "toMajor": 18, + "rationale": "The rail was the `record:*` component the component-props unknown-key gate (an authorable surface refuses a key it does not declare; for a component's `properties`, by parsing them against the type's `ComponentPropsMap` row) could not reach: it had a registered renderer and a `PageComponentType` entry but no `ComponentPropsMap` row, so the props gate's dispatch skipped it as unregistered and every authored key rode through. Measured on 17.0.0 GA end to end: a planted entry `filter` passed tsc, `objectstack validate` and `objectstack build`, shipped verbatim in the artifact, and the rendered rail kept counting and listing unfiltered rows — while the same build loudly reported `record:related_list` keys in the same file. The row declares the shape the renderer actually reads (measured from its read points, not its TS interface — the interface's `icon` is read by nothing and is refused, not declared), so an undeclared key is now a publish-time refusal instead of a silent no-op." + }, + { + "surface": "reports[].blocks[].dataset on a report whose type is joined: a block that binds no dataset (the joined arm of the ReportSchema refinement)", + "replacement": "Bind the block to a dataset: set the block's `dataset` to the dataset whose measures (`values`) and dimensions (`rows`) it shows. A block with nothing to show can be deleted instead, as long as the report keeps at least one block.", + "migrationId": "ui-report-joined-block-dataset-required", + "toMajor": 18, + "rationale": "ADR-0021 single-form, enforced (ADR-0049 enforce-or-remove, the enforce arm). A `joined` report carries its data on `blocks`, each an independent query over that block's own `dataset`, and the container selects nothing: a container `dataset` is refused. `ReportSchema`'s refinement comment and the reports guide both said each block is dataset-bound, but the joined arm required only that `blocks` be non-empty, and a block's `dataset` is optional on its shape, so a block with no `dataset` parsed, passed `objectstack validate` and every save door, and drew nothing. Measured at this repo's `.objectui-sha` pin `ab187972159583b595facdcae3c73b50f6f312e9`: the joined renderer hands each block's `dataset` to its table, whose query hook goes idle on an empty name, so an unbound block draws an empty table and issues no query; a report whose blocks all lack one fails the dataset-report guard and falls through to the pre-9.0 presentation bridge, which issues no query either, and a dashboard drill-down that opens that report lists the records instead of drawing it. Studio's report inspector authors blocks through the spec form's `blocks` repeater, which adds a blank row and requires no column of it, so a block saved with only a name reached the store with no error. The joined arm now refuses each such block at `blocks[i].dataset`, naming the block, with the prescription to bind it to a dataset. `dataset` stays optional on the block shape itself: `blocks` is read only on a `joined` report, and a block on any other report type is ignored, as before. Ships at once, with no deprecation window: there is no window in which an unbound block draws anything, and there is no mechanical rewrite, because only the author knows which dataset the block was meant to show." + }, + { + "surface": "`report.blocks[].chart` (REMOVED from the joined report block shape) and `report.chart` on a report whose `type` is `joined` (REFUSED by `ReportSchema`'s refinement) — a chart anywhere on a joined report", + "replacement": "nothing on the joined report: a joined report draws each block as a table and has no chart channel at either level. Delete the `chart`. If the chart was wanted, give the slice it was meant to plot a report of its own — `type` `tabular`, `summary` or `matrix`, binding the same `dataset` the block bound, selecting the dimension and measure the chart names in its `rows` and `values` — carry the `chart` over to that report's top level, where `xAxis` names a dataset dimension and `yAxis` a measure exactly as before, and reach it from the app navigation beside the joined report.", + "migrationId": "ui-report-joined-chart-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove. Nothing ever drew a chart on a joined report: the renderer's joined branch draws each block as a table and returns before its one read of the report's `chart`, and no renderer reads a block's `chart` at all — measured at this repo's `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52` (`DatasetReportRenderer.tsx`, joined branch at lines 1462-1524, the only chart read at 1557). So both coordinates parsed, passed the `validate-chart-bindings` lint (which resolved their axes as if they would plot), and showed tables only. The D2 conversion `report-joined-chart-removed` already REPAIRS THE DATA: it strips both from authored sources on a chain replay and from stored `sys_metadata` rows at rehydration, a lossless delete because neither value ever rendered. What it cannot repair is intent. Deleting the key leaves the report looking exactly as it always did — which is the problem when the author believed a chart was there: they were reading a chart that never existed, and only they know whether they wanted one. A walker cannot move it anywhere either: a joined report has no chart channel, and creating a new report, choosing its type and placing it in navigation are authoring decisions, not rewrites. The Studio report form offered a block chart input until this change, so a stored row carrying one is a real shape, not a hypothetical. Ships at once, no deprecation window: there is no window in which a key the renderer never reads does anything. `chart` on every non-joined report is unchanged — it is that report's live embedded chart." + }, + { + "surface": "report selection keys on a `joined` container — a top-level `dataset`, or a NON-EMPTY top-level `rows` / `columns` / `values` list, on a report whose `type` is `joined` (`ReportSchema`'s refinement)", + "replacement": "the same key on the `blocks[]` entries that need it — each block binds its own `dataset` and selects its own `rows` / `columns` / `values` — or DELETE it. Deleting changes nothing that renders: the container value was never read. The refusal lands at the key's own path and says both, the way the container `order` refusal beside it always has, and that `order` refusal is unchanged.", + "migrationId": "ui-report-joined-container-selection-refused", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove, the enforce arm: the four keys stay declared (they are the selection of every non-joined report), and the one report type that never reads them now refuses them. A `joined` report selects nothing itself, and the refinement already said so for `order` alone — it refused a container `order` with a pointer onto `blocks[]` while the four selection keys beside it parsed green. Measured at this repo's `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52`: `DatasetReportRenderer`'s joined branch (`DatasetReportRenderer.tsx:1462`) reads `blocks`, plus the container `runtimeFilter` and `drilldown` resolved above it, and returns before the top-level reads of `columns` / `dataset` / `rows` / `values` begin (line 1529 onward) — so each was accepted by the metadata layer and dropped by the renderer without a word. The alias tables made it reachable: `fields` / `measures` / `metrics` route to `values`, `groupings` / `groupBy` / `dimensions` to `rows`, and `objectName` / `object` / `dataSet` / `source` to `dataset`, on a joined report as on any other. Studio's report inspector hides the top-level binding for a joined report (`ReportDefaultInspector.tsx:328`) but its type picker patches only `type`, so a report bound first and switched to `joined` second carries the keys invisibly. An empty list is NOT refused: it selects nothing, which is what a joined container selects — the container `order` refusal's own threshold. Ships at once, no deprecation window: there is no window in which a key the renderer never reads does anything." + }, + { + "surface": "ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, or present and undefined, on equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before or after (an alias spelling of any of them included), on every carrier of ViewFilterRuleSchema", + "replacement": "the value the rule compares against — value: \"open\" on equals, value: \"2026-01-01\" on after. A rule that meant \"the field has no value\" becomes one of the four operators that take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction from their name and still parse with or without a value. A rule that was an unfinished row is deleted. The list operators (in / not_in) and the range operator (between) refused an absent value before this change and still do, in their own words", + "migrationId": "view-filter-rule-absent-value-refused", + "toMajor": 18, + "rationale": "The value key's own published description has declared, since the value was first shaped by its operator, that every operator outside the list, range and unary sets takes a scalar, and that only the unary operators ignore the key; the refinement implementing the coupling returned early on an absent value for every operator, so a rule with no value parsed green on all thirteen scalar operators. The query path refuses the same rule: both lowerings of a stored rule — the console's and the REST lookup-picker route's — emit it as the two-element [field, operator] node, which the filter-AST lowering reads as an undefined comparand and refuses with INVALID_FILTER / 400, measured for all thirteen operators. Nothing between storage and the query drops the rule, so one such rule failed every query that read its view, the view's other rules included. The first-party producer does not write the shape: the console filter builder drops a row whose operator takes a value and whose value is missing before it saves, and the drill-down save-as-view path checks each rule against this schema before persisting it (read at the pinned objectui commit). Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: there is no value to infer, and writing a value, switching to a unary operator and deleting the rule are three different predicates only the author can choose between. The read path does not re-validate stored rows (the reading the sibling entry view-filter-rule-scalar-operator-array-refused records), so a stored view keeps loading — and keeps failing its queries, as it did before this change; what changes is that RE-SAVING it is refused at the value path, naming the operator and the field. ADR-0049 / ADR-0087 / ADR-0112." + }, + { + "surface": "ui.ViewFilterRule operator — the TypeScript INPUT type of a view filter rule, on every carrier of ViewFilterRuleSchema (ListView.filter, a view tab filter, Page.filterBy, the related-list, record-picker and object-* block filter doors)", + "replacement": "the canonical operator id, a member of ViewFilterOperator (VIEW_FILTER_OPERATORS). A typed rule written operator: \"eq\" becomes operator: \"equals\"; every legacy spelling maps to exactly one canonical id, and VIEW_FILTER_OPERATOR_ALIASES is that map (ne and neq to not_equals, gt to greater_than, gte to greater_than_or_equal, nin and notIn to not_in, isNull to is_null, and the rest). A value that is not yet known to be an operator — read from storage, a URL or user input — is typed unknown and handed to ViewFilterRuleSchema.safeParse, or folded with normalizeFilterOperator first; the schema stays the judge", + "migrationId": "view-filter-rule-operator-input-canonical", + "toMajor": 18, + "rationale": "The operator key is a z.preprocess over the alias fold, and zod types a preprocess's INPUT from its function's parameter. That parameter was unknown, so ViewFilterRule (a z.input) typed operator as unknown: a rule with operator: 42, or any string at all, compiled on every carrier and was refused only when the door parsed it. The typed input is now the canonical ViewFilterOperator, the vocabulary the alias table's own contract says new producers emit. The RUNTIME does not move: the door still folds every spelling it folded before to canonical and still refuses a non-string with the enum's own issue at operator, so a stored sys_metadata row, a YAML or JSON body, and a plain-JS producer that carries an alias keep parsing exactly as before, and os validate answers as before. Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: what narrows is only what TypeScript source may write. The exported normalizeFilterOperator keeps its unknown parameter on purpose — it exists to fold untyped stored metadata, and its callers pass raw strings by design. ADR-0087 / ADR-0122." + }, + { + "surface": "ui.ViewFilterRule value on a SCALAR operator — an ARRAY where the operator takes one value (equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before, after), on every carrier of ViewFilterRuleSchema", + "replacement": "one scalar — a string, number, boolean or null. A rule written value: [\"won\"] on equals becomes value: \"won\"; a rule that really did mean membership of a list becomes operator: \"in\" with the array unchanged. The list operators (in / not_in) and the range operator (between) are untouched and still take their arrays. The unary operators (is_empty / is_not_empty / is_null / is_not_null) are untouched too: they take their direction from the operator NAME and their value position is discarded, so whatever sits there still parses, array included. An omitted value is still an omitted value", + "migrationId": "view-filter-rule-scalar-operator-array-refused", + "toMajor": 18, + "rationale": "Closing the protocol half of the maintainer's ruling C-prime of 2026-09-20 on objectui's render-time filter converter — the protocol is the only refusal set, so a document it accepts never throws at render time — verbatim, untranslated: 「the differences are the protocol's to close」. The value key's own published description has declared this rule since the value was first shaped by its operator — 「every other operator takes a scalar」 — and the refinement that implements the coupling returned early for every operator that is neither a list operator nor between, so the entire scalar class was declared and, from then until this change, not judged. ⚠️ This REVERSES a reading recorded in the sibling entry view-filter-rule-value-shaped-by-operator, which listed a scalar operator carrying an array as deliberately accepted because it 「lowers to a deep-equality comparand」. The backends a lowered view rule reaches at this release do not agree, so each is named rather than generalised. The SQL family REFUSES: the lowered node reaches driver-sql's bare field-value loop, which asserts the comparand against its own SCALAR_COMPARAND_OPERATORS set; an array is none of the six accepted comparand types the platform declares in ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE, so the comparand is refused with the withheld INVALID_FILTER / 400 envelope — and with it the driver-turso and driver-sqlite-wasm drivers built on driver-sql, and turso's remote transport. driver-memory REFUSES the same shape in the same envelope (its assertFilterConditionShape throws on an array in the implicit-equality position). driver-mongodb ANSWERS: its translateFilter passes the array through unchanged and the engine's shared comparand doors (normalizeFilterComparandTypes, assertListComparandShapes) both pass the shape, so the server applies MongoDB's equality rule for an array operand — a row matches when its stored array equals the value or holds the value as one of its elements, and a row storing the scalar does not match. That MongoDB reading is taken at the driver's compile face, at those engine doors and through mingo 7.2.4, which applies that rule; a live mongod instance was NOT measured. Method: driver-sql on SQLite, driver-memory, driver-mongodb's translateFilter and mingo were each run on the lowered node beside a scalar and an $in control; MySQL and a live Turso server were NOT measured. Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: a SemanticMigration converts nothing by its own type, and the stored-row pass replays D2 conversions only. Coercing at load would be the platform guessing intent — an array of two on equals has no honest single value, and picking the first is a different predicate. The read path does not re-validate stored rows, so a stored view keeps loading; what changes is that RE-SAVING it is refused at the value path. ADR-0049 / ADR-0087 / ADR-0112." + }, + { + "surface": "A top-level `options` bag on a `view` item RECORD (`{ name, object, viewKind, config }`) saved through the metadata write door (`PUT /api/v1/meta/view/:name`, the Studio / MCP save): `options.kanban`, `options.calendar`, `options.timeline` and every other key in it, on either `viewKind`.", + "replacement": "The same per-kind blocks under the record's `config` — `options.kanban` becomes `config.kanban`, `options.timeline` becomes `config.timeline` — where each is judged by its kind's own block schema, and a key that block does not declare is re-spelled or deleted as its refusal says. A key `config` already sets wins; the bag's copy is deleted. The flattened list overlay (no `config`) keeps its legacy `options` bag, judged key by key, as before.", + "migrationId": "view-item-options-bag-refused", + "toMajor": 18, + "rationale": "The record member re-opens its top level with a strip so the console's round-trip keys survive, and that strip dropped a top-level `options` bag from the parse without looking inside it, while the save stored the request body. The console reads a record's body from `config` on the object page but spreads the whole stored record on the interface page, so the same saved view rendered two ways. Now that the save stores the parsed body, the bag would instead vanish silently on the next save. The maintainer's ruling of 2026-09-30 refuses it by name with the prescription to write `config.KIND`; declaring it would have kept a second spelling of one block on a second member. No console write puts the bag on a record. Not convertible: which of two spellings of one block the author meant, where both are set, is the author's call." + }, + { + "surface": "view.owner / view.hidden on the view item record — the per-user owner and the switcher-hidden flag", + "replacement": "(removed — no per-user view scope and no switcher filter exists.) A view is listed to everyone who can read its object. A view that must not be listed is deleted; per-user view scoping is a parked direction, not a shipped mechanism.", + "migrationId": "view-item-owner-hidden-retired", + "toMajor": 18, + "rationale": "The D2 conversion `view-item-owner-hidden-removed` deletes both keys from every view item RECORD — in `views` (stack sources and stored rows) and in the assembled-manifest view item channel (package export, environment artifacts) — and the delete is lossless: both switcher read paths filter on the view kind and object and sort on `order`, so `hidden: true` hid nothing and no scope ever read `owner`. The judgment is about exposure. A view an author marked as one user's, or hid from the switcher, has always been listed to every user who can read the object — its name, its columns, its filters and its sort. Whether anything in such a view was meant to stay private, and whether it should now be deleted rather than kept, is the author's call. A flattened view overlay's own `owner` and `hidden` are a separate family on a different door, with their own D2 conversion `view-overlay-owner-hidden-removed` and their own D3 entry `view-overlay-owner-hidden-retired`." + }, + { + "surface": "A flattened `view` overlay saved through the metadata write door (`PUT /api/v1/meta/view/:name`, the Studio / MCP save) whose `viewKind` names one family while the body was judged by the other: a column-less `viewKind: \"list\"` body, which only the form overlay member used to accept (its list keys `sort`, `searchableFields`, `timeline`, `sharing` and the rest stripped unread), and a `viewKind: \"form\"` body carrying list `columns`, which only the list overlay member used to accept.", + "replacement": "Each overlay is judged by the member its `viewKind` names. A column-less list overlay is a patch on the view it shadows and carries list keys the list view schema accepts: a `sort` array of `{ field, order }` (the bare string clause was retired in 17.5.0), no `timeline.metaFields` (the timeline block has no such key), an array `searchableFields`, and the list `sharing` block (`{ type, lockedBy }`), not the form public-link block. A column-less list overlay names no `type`; one that does is a full inline config and lists its `columns`. A form overlay's `columns` is its body-column count (an integer); a field list means the body is a list view (`viewKind: \"list\"`) or belongs in `sections: [{ fields }]`.", + "migrationId": "view-overlay-judged-by-viewkind-arm", + "toMajor": 18, + "rationale": "Both overlay members shared one `viewKind: list | form` enum. The list member required `columns`, so it refused the column-less list patch the console writes on every toolbar save (the ruled patch-only storage shape, maintainer ruling: 「`persistViewPatch` 只存 patch,不存 merged base」); the union then tried the form member, which requires no list key and strips every one, and accepted it — so a retired `sort` string or a `timeline.metaFields` the list schema refuses by name was saved with `success: true` and stored as sent. Measured on `origin/main` @ `4df101c3` and again at `ce70876e` (after the `options`-bag door landed) through the real save. Ruled route C-prime: each member admits one `viewKind`, the list member judges a column-less patch (`columns` optional there only; the authoring list view keeps it required), and a column-less body that names a `type` stays refused at `columns`. Not convertible: whether a refused value was a typo or a stale capability is the author's call." + }, + { + "surface": "The legacy `options` bag on a flattened `view` overlay saved through the metadata write door (`PUT /api/v1/meta/view/:name`, the Studio / MCP save): `options.kanban`, `options.calendar`, `options.gantt`, `options.gallery`, `options.timeline`, `options.chart`, `options.map` and `options.tree` on a list overlay, any other key in the bag, and the bag on a form overlay.", + "replacement": "Each `options.KIND` block carrying only keys the top-level `KIND` block declares, with values that block accepts — or, preferred, the same keys moved to the top-level `KIND` block, which wins per key where both set one. A key the block does not declare is deleted or re-spelled to the declared key the refusal names (`options.kanban.groupField` becomes `groupByField`, `options.calendar.dateField` becomes `startDateField`); `options.timeline.metaFields` has no declared successor and is deleted. Any other key in the bag is deleted, and a form overlay carries no bag at all.", + "migrationId": "view-overlay-options-bag-judged", + "toMajor": 18, + "rationale": "The list overlay member re-opens its top level with a strip so the console's round-trip keys survive, and that strip dropped the `options` bag from the parse without looking inside it. The save stores the request body, not the parse output, and objectui's interface page forwards a stored view's `options` into the list renderer, which merges `options.KIND` under the top-level block — so a key the strict block refuses by name (`timeline.metaFields`) was saved and rendered when spelled `options.timeline.metaFields`. Measured on `origin/main` @ `8d1f7ab` through the real save. Ruled direction A (the maintainer's ruling of 2026-09-24): judge each `options.KIND` with the kind's strict schema and refuse an out-of-contract key by name, as the direct spelling is; refusing the bag whole was ruled out because the legacy `options.map` path is live and pinned. Judged key by key, because the renderer reads the bag as a per-key underlay of the top-level block: a bag that carries only the keys the top-level block leaves to it is legal and stays accepted. Not convertible: whether a refused key was a typo of a declared one or a retired capability is the author's call." + }, + { + "surface": "view.owner / view.hidden on a flattened view overlay — the lean PUT /api/v1/meta/view/:name body with no config that the console saves for a view it personalizes", + "replacement": "(removed — no per-user view scope and no switcher filter exists.) A view is listed to everyone who can read its object. A view that must not be listed is deleted, or no longer shipped from source; per-user view scoping is a parked direction (ADR-0017), not a shipped mechanism.", + "migrationId": "view-overlay-owner-hidden-retired", + "toMajor": 18, + "rationale": "The D2 conversion `view-overlay-owner-hidden-removed` deletes both keys from every flattened overlay (a view body with no `config` and no container slot) — in `views` (stack sources, and every stored row, replayed on each read before it is served or badged) and in the assembled-manifest view item channel — and the delete is lossless: both switcher read paths filter on the view kind and object and sort on `order`, so an overlay saved with `hidden: true` hid nothing and no scope ever read `owner`. The judgment is about exposure, the same one the view item record's retirement leaves. A view someone hid or marked as one user's through its overlay has always been listed to every user who can read the object. The delete is lossless but does not always close the row: an overlay row that held nothing but its identity and these keys is left identity-only, a body the view door refuses, so that row needs its author (see the acceptance criteria). Measured writers in this repository and its sibling UI: zero (no source, example or skill, and objectui at its pinned commit and at main writes neither key on an overlay; the HotCRM app writes neither). NOT MEASURED: clients outside this repository, and production stored rows — the write door accepted and stored both until this release, and no deployment store is reachable from here." + }, + { + "surface": "ui.PaginationConfig.pageSize — an OMITTED page size on a view", + "replacement": "nothing, to take the platform display page size of 50. To keep the old 25 rows per page on a view, write it: `pagination: { pageSize: 25 }`", + "migrationId": "view-pagination-page-size-default-50", + "toMajor": 18, + "rationale": "A RULED behaviour change on a default, so there is nothing to rewrite and nothing to refuse: the maintainer's ruling of 2026-09-24 set the platform display page size to 50, declared once in the protocol, and the declared default of `PaginationConfigSchema.pageSize` moved from 25 to 50. A `pagination` block that omits `pageSize` now parses to 50 — 50 rows per page on a paged view, and a fetch ceiling of 50 on a view with no pager (kanban, gallery, timeline). A view with no `pagination` block at all parses with none on either side; its page size reaches it through the renderer, which is ruled to read the spec default rather than keep its own number (an earlier ruling on the grid's page size, which the page-size ruling restated). Not losslessly convertible because the question is intent, not text: a mechanical pass that wrote `pageSize: 25` into every silent view would preserve the old number and defeat the ruling, and one that wrote 50 would add nothing the default does not already do. Only the deployment knows which silent views were relying on 25. The accept set is unchanged — a positive integer — and every authored `pageSize` parses exactly as before." + }, + { + "surface": "`VISIBILITY_STRICT_OPTIONS` (const) on `@objectstack/spec/shared` — the shared `strictObject` options of the visibility-carrying view/page shapes (ADR-0089 D3a)", + "replacement": "(removed from the public surface — no replacement export. It was an internal option bag for this package's own schemas; the visibility contract it configures is unchanged and still published through the schemas that use it — `FormFieldSchema`, `FormSectionSchema` and the page component — together with `normalizeVisibleWhen` and `VISIBILITY_ALIAS_KEYS`, which stay exported.)", + "migrationId": "visibility-strict-options-unexported", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove applied to an export. The const was barrel-exported while its type, `StrictObjectOptions`, is deliberately unpublished, so no consumer could annotate it, spread it into a typed option bag or name it in a parameter — a published value with no usable contract and zero measured pull outside this package. Publishing the type instead was weighed and not adopted: no consumer ever asked for it, and it would turn the strict-object template's internals into public API." + }, + { + "surface": "The `waitEventConfig` block of every `type: 'wait'` flow node, and the `boundaryConfig` block of every `type: 'boundary_event'` node — the BLOCK, not a key inside it. `eventType` has been required INSIDE each block since protocol 17, so the contract already refused `waitEventConfig: {}`; what it also accepted was the block missing entirely, which is the state a freshly created node is in. Two documents, two verdicts, and the accepted one was the silent one. Also narrowed one level down: under `eventType: 'timer'`, `timerDuration` is now required and may not be blank. ⚠️ That second narrowing sits on the BLOCK and is NOT gated on `type: 'wait'`, so it reaches any node type that carries a `waitEventConfig` at all — a `start` node spelled `waitEventConfig: { eventType: 'timer' }` parsed before and is refused now. Inert in practice, because no executor but the wait one reads the block, but a stack that spells it elsewhere must be edited too, so scan for the KEY and not only for the node type.", + "replacement": "Declare what resumes the node, on the node: `waitEventConfig: { eventType: 'timer', timerDuration: 'PT1H' }` for a delay — QUOTE a bare number, the key is a string and a numeric string is read as milliseconds, so '60000' is the same 60s wait as 'PT1M' — or `{ eventType: 'signal' | 'webhook' | 'manual' | 'condition', signalName: '' }` when an external producer resumes the run. For `boundary_event`, `boundaryConfig: { attachedToNodeId: '', eventType: 'error' | 'timer' | 'signal' | 'cancel' }`. ⛔ There is deliberately NO default for either `eventType`: a required key has no \"unset behaves as\", and an indefinite park — if one is ever wanted — is its own declared `eventType`, never the absence of configuration. ⚠️ `boundary_event` has no executor in the runtime at all (a flow reaching one fails with NO_EXECUTOR), so a stored boundary node is an authoring-surface repair: the native construct for error handling is a `try_catch` region (ADR-0031).", + "migrationId": "wait-node-event-config-required", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-09-13, the clause of the reply that covers this item, verbatim and untranslated: 「其他同意」 — carrying the presented option: the protocol is the source of truth; a designer never invents a default the protocol does not apply; a default the protocol should have is declared by the protocol; a required key has no \"unset behaves as\". ⛔ NOT losslessly convertible, and the reason is that the missing value is an INTENT no artifact records: a block-less wait node does not say whether its author meant a delay (and for how long) or a named signal (and which one), and a transform that picked one would be inventing the very default this ruling forbids. What the old runtime picked was 'timer' with no duration, which is not a wait at all: measured through a real `engine.execute()` run, such a node answered `{ success: true, suspend: true }`, scheduled no wake-up job THOUGH A JOB SERVICE WAS ANSWERING, persisted no `waitUntil` for a later boot's re-arm pass, and emitted not one log line at any level — the run parked forever and reported success. So the conversion layer (D2) cannot hide this break and the tombstone channel cannot carry it either (nothing was renamed or retired; a key that was optional became required), which leaves D3: a structured TODO naming each node that must be edited. The alternative considered and NOT taken was to warn and keep parsing — a warning on the authoring path an AI agent drives is read by nobody, and the agent reports \"done\" over a flow that hangs." + }, + { + "surface": "the four WebSocket configuration durations whose name carried no unit: WebSocketConfig.reconnectInterval, WebSocketConfig.pingInterval, WebSocketConfig.timeout and WebSocketServerConfig.heartbeatInterval (api/websocket.zod.ts)", + "replacement": "reconnectIntervalMs, pingIntervalMs, timeoutMs and heartbeatIntervalMs — rename each key; every value is unchanged, and so is every default (1000, 30000, 5000, 30000)", + "migrationId": "websocket-durations-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B (2026-09-02, extended on 2026-09-05 to runtime-emitted durations): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. What makes this shape worth one entry rather than four is the neighbour: on both configs a bare duration sits directly beside a bare COUNT — maxReconnectAttempts on the client, reconnectAttempts on the server — so `reconnectInterval: 5` and `maxReconnectAttempts: 5` read as the same kind of number and are not. Suffixing the durations separates the two families at the authoring site; the counts keep their names, because a count has no unit to carry. All four are retiredKey() tombstones (neither shape is strict, so a bare deletion would strip in silence). Why a semantic entry and not a D2 conversion: a WebSocketConfig is a client CONNECTION argument and a WebSocketServerConfig is a server CONSTRUCTION argument — neither is a stack collection member and neither is ever stored as a sys_metadata row, so the conversion chain has no seam that would see one. The same disposition the epoch-instant renames on this file took (epoch-instant-keys-renamed), and what ruling B prescribes for a key that is not authorable metadata. ADR-0087." + } + ], + "removed": [] + }, + "perMajor": [ + { + "from": 16, + "to": 17, + "added": [], + "converted": [ + { + "surface": "action.execute", + "to": "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)", + "conversionId": "action-execute-to-target", + "toMajor": 17 + }, + { + "surface": "field.conditionalRequired", + "to": "field key 'conditionalRequired' → 'requiredWhen' (the deprecated predicate alias, folded into the canonical key so no reader picks its own precedence)", + "conversionId": "field-conditionalRequired-to-requiredWhen", + "toMajor": 17 + }, + { + "surface": "agent.tools", + "to": "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)", + "conversionId": "agent-tools-to-skills", + "toMajor": 17 + }, + { + "surface": "sharingRule.accessLevel", + "to": "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)", + "conversionId": "sharing-rule-access-level-full-to-edit", + "toMajor": 17 + }, + { + "surface": "flow.node.config.objectName", + "to": "CRUD flow-node config key 'object' → 'objectName' (the last alias in the executors' `readAliasedConfig` shim graduates into this layer, and the shim is deleted)", + "conversionId": "flow-node-crud-object-alias", + "toMajor": 17 + }, + { + "surface": "flow.node.notify.config", + "to": "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)", + "conversionId": "flow-node-notify-config-aliases", + "toMajor": 17 + }, + { + "surface": "flow.node.wait.waitEventConfig", + "to": "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)", + "conversionId": "flow-node-wait-event-config-lift", + "toMajor": 17 + }, + { + "surface": "flow.node.connector_action.connectorConfig", + "to": "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)", + "conversionId": "flow-node-connector-config-lift", + "toMajor": 17 + }, + { + "surface": "flow.node.map.config.flowName", + "to": "map flow-node config key 'flow' → 'flowName' (an undeclared spelling the executor accepted through a bare fallback; it graduates into this layer)", + "conversionId": "flow-node-map-flow-alias", + "toMajor": 17 + }, + { + "surface": "flow.node.subflow.config.flowName", + "to": "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)", + "conversionId": "flow-node-subflow-flow-alias", + "toMajor": 17 + }, + { + "surface": "flow.node.script.config", + "to": "script flow-node config keys 'functionName' → 'function', 'input' → 'inputs' (executor `??` fallbacks, graduated into this layer)", + "conversionId": "flow-node-script-config-aliases", + "toMajor": 17 + }, + { + "surface": "permission.rowLevelSecurity.priority", + "to": "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)", + "conversionId": "permission-rls-priority-removed", + "toMajor": 17 + }, + { + "surface": "tool.category / tool.permissions / tool.active / tool.builtIn", + "to": "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)", + "conversionId": "tool-inert-authoring-keys-removed", + "toMajor": 17 + }, + { + "surface": "app.version / app.aria / app.objects / app.apis / app.sharing / app.embed / app.mobileNavigation / app.contextSelectors.includeAll / app.contextSelectors.placement / app.homePageId / app.areas.order", + "to": "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)", + "conversionId": "app-dead-authoring-keys-removed", + "toMajor": 17 + }, + { + "surface": "app.areas.visible / app.areas.requiredPermissions", + "to": "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)", + "conversionId": "app-area-fail-open-gates-removed", + "toMajor": 17 + }, + { + "surface": "action.shortcut / action.bulkEnabled", + "to": "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)", + "conversionId": "action-inert-keys-removed", + "toMajor": 17 + }, + { + "surface": "flow.active / flow.template / flow.nodes[].outputSchema / flow.errorHandling.fallbackNodeId", + "to": "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)", + "conversionId": "flow-inert-keys-removed", + "toMajor": 17 + }, + { + "surface": "view.list.responsive / view.list.performance / view.form.defaultSort / view.form.aria", + "to": "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)", + "conversionId": "view-inert-keys-removed", + "toMajor": 17 + }, + { + "surface": "view.list.striped / view.list.bordered / view.list.virtualScroll", + "to": "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)", + "conversionId": "view-list-passthrough-keys-removed", + "toMajor": 17 + }, + { + "surface": "view.list.exportOptions / view.listViews.*.exportOptions", + "to": "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)", + "conversionId": "view-export-options-pdf-removed", + "toMajor": 17 + }, + { + "surface": "dashboard.aria / dashboard.performance / dashboard.widgets[].performance", + "to": "dashboard keys 'aria'/'performance' and widget 'performance' removed (inert, removed under ADR-0049 enforce-or-remove: no renderer applied any of them)", + "conversionId": "dashboard-inert-keys-removed", + "toMajor": 17 + }, + { + "surface": "dashboard.widgets[].responsive", + "to": "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)", + "conversionId": "dashboard-widget-responsive-removed", + "toMajor": 17 + }, + { + "surface": "dashboard.widgets[].actionUrl / dashboard.widgets[].actionType / dashboard.widgets[].actionIcon / dashboard.widgets[].aria", + "to": "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)", + "conversionId": "dashboard-widget-action-aria-removed", + "toMajor": 17 + }, + { + "surface": "dashboard.widgets[].compareTo", + "to": "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)", + "conversionId": "dashboard-widget-compareto-converged", + "toMajor": 17 + }, + { + "surface": "agent.knowledge", + "to": "agent key 'knowledge' removed (inert, removed under ADR-0049 enforce-or-remove: declaring sources/indexes never scoped retrieval; restrict at the knowledge-service level)", + "conversionId": "agent-knowledge-removed", + "toMajor": 17 + }, + { + "surface": "skill.triggerPhrases", + "to": "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)", + "conversionId": "skill-trigger-phrases-removed", + "toMajor": 17 + }, + { + "surface": "stack.api.requireAuth", + "to": "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", + "conversionId": "stack-api-require-auth-removed", + "toMajor": 17 + }, + { + "surface": "flow.node.waitEventConfig", + "to": "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", + "conversionId": "flow-node-wait-timeout-keys-removed", + "toMajor": 17 + }, + { + "surface": "datasource.readReplicas", + "to": "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)", + "conversionId": "datasource-read-replicas-removed", + "toMajor": 17 + }, + { + "surface": "datasource.capabilities", + "to": "datasource key 'capabilities' removed (eleven flags no code read; pushdown comes from the driver's own supports.*, and `readOnly` never made anything read-only)", + "conversionId": "datasource-capabilities-removed", + "toMajor": 17 + }, + { + "surface": "datasource.retryPolicy / datasource.healthCheck / datasource.external.label / datasource.external.requirePermission", + "to": "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)", + "conversionId": "datasource-inert-blocks-removed", + "toMajor": 17 + }, + { + "surface": "mapping.extractQuery / mapping.errorPolicy / mapping.batchSize", + "to": "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)", + "conversionId": "mapping-inert-keys-removed", + "toMajor": 17 + }, + { + "surface": "book.translations / book.groups.translations", + "to": "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", + "conversionId": "book-translations-removed", + "toMajor": 17 + }, + { + "surface": "job.id", + "to": "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)", + "conversionId": "job-id-removed", + "toMajor": 17 + }, + { + "surface": "translation.validationMessages", + "to": "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.._validations..message`, which the write path resolves (17.3.0, a translation key shipped together with its reader)", + "conversionId": "translation-validation-messages-removed", + "toMajor": 17 + }, + { + "surface": "datasource.config", + "to": "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)", + "conversionId": "datasource-config-driver-key-aliases", + "toMajor": 17 + }, + { + "surface": "datasource.driver", + "to": "datasource driver id 'mongo' → 'mongodb' — the canonical id both boot hosts, the driver package and the published DRIVER_CATALOG already used, so the id that selects a driver and the id that selects its config contract are one string with no mapping between them", + "conversionId": "datasource-driver-mongo-to-mongodb", + "toMajor": 17 + }, + { + "surface": "flow.node.script.config.actionType / flow.node.script.config.template / flow.node.script.config.recipients / flow.node.script.config.variables / flow.node.script.config.script", + "to": "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", + "conversionId": "flow-node-script-branch-keys-removed", + "toMajor": 17 + }, + { + "surface": "flow.errorHandling.retryDelayMs / flow.node.config.retry.retryDelayMs / job.retryPolicy.maxRetries / job.retryPolicy.backoffMultiplier", + "to": "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", + "conversionId": "retry-policy-converged", + "toMajor": 17 + }, + { + "surface": "object.managedBy", + "to": "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)", + "conversionId": "object-managed-by-system-to-system-data", + "toMajor": 17 + }, + { + "surface": "object.enable.trash / object.enable.mru", + "to": "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)", + "conversionId": "object-enable-trash-mru-removed", + "toMajor": 17 + }, + { + "surface": "hook.body.capabilities / action.body.capabilities", + "to": "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)", + "conversionId": "hook-body-crypto-hash-removed", + "toMajor": 17 + }, + { + "surface": "dataset.measures[].aggregate", + "to": "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)", + "conversionId": "dataset-measure-array-string-agg-removed", + "toMajor": 17 + }, + { + "surface": "connector.rateLimitConfig", + "to": "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)", + "conversionId": "connector-rate-limit-config-removed", + "toMajor": 17 + }, + { + "surface": "connector.fieldMappings[].transform / externalLookup.fieldMappings[].transform", + "to": "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)", + "conversionId": "field-mapping-transform-removed", + "toMajor": 17 + }, + { + "surface": "theme.typography.fontSize / theme.typography.fontWeight / theme.typography.lineHeight / theme.typography.letterSpacing / theme.typography.fontFamily.heading / theme.typography.fontFamily.mono / theme.animation / theme.zIndex", + "to": "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)", + "conversionId": "theme-inert-token-scales-removed", + "toMajor": 17 + }, + { + "surface": "page.component.page-header.description", + "to": "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)", + "conversionId": "page-header-subtitle-alias", + "toMajor": 17 + }, + { + "surface": "object.indexes[].type / object.indexes[].partial", + "to": "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)", + "conversionId": "object-index-type-partial-removed", + "toMajor": 17 + }, + { + "surface": "page.component.element:record_picker.displayField", + "to": "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)", + "conversionId": "record-picker-display-field-to-label-field", + "toMajor": 17 + }, + { + "surface": "page.component.element:record_picker.searchFields / page.component.element:record_picker.multiple", + "to": "record-picker component props 'searchFields'/'multiple' removed (the control is a plain single-select with no search box; neither key had a reader)", + "conversionId": "record-picker-inert-keys-removed", + "toMajor": 17 + }, + { + "surface": "page.component.page:card.body", + "to": "page:card component prop 'body' → 'children' (one composition key across every container; the card renderer already reads both)", + "conversionId": "page-card-body-to-children", + "toMajor": 17 + }, + { + "surface": "page.component.element:button.action.params", + "to": "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)", + "conversionId": "inline-action-api-params-to-body-extra", + "toMajor": 17 + }, + { + "surface": "page.component.page:tabs.type", + "to": "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)", + "conversionId": "page-tabs-type-to-tab-style", + "toMajor": 17 + }, + { + "surface": "page.component.page:header.icon / page.component.page:card.actions", + "to": "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)", + "conversionId": "page-structure-inert-keys-removed", + "toMajor": 17 + }, + { + "surface": "page.component.record:details.layout", + "to": "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)", + "conversionId": "record-details-layout-removed", + "toMajor": 17 + }, + { + "surface": "app.hidden", + "to": "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)", + "conversionId": "app-hidden-to-unpublished", + "toMajor": 17 + }, + { + "surface": "action.locations[]", + "to": "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: []`)", + "conversionId": "action-global-nav-location-removed", "toMajor": 17 + } + ], + "migrated": [ + { + "surface": "ActionDescriptor.isAsync (the descriptor an executor publishes via `registerNodeExecutor` / `defineActionDescriptor`)", + "replacement": "nothing to re-declare — delete the key. Suspension is `execute()` RETURNING `suspend: true`, and permission to suspend is `supportsPause: true` on the same descriptor (with the `resumeAuthority` its pauses need)", + "migrationId": "action-descriptor-is-async-retired", + "toMajor": 17, + "rationale": "ADR-0049 enforce-or-remove. `isAsync` declared \"this action suspends the flow awaiting an external reply\" and NOTHING read it: a fresh three-repo measurement (taken when the key was filed for retirement, and re-run at pickup) found zero property reads across objectstack, objectui and cloud — every hit was the declaration itself, a generated baseline, one of five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. So declaring it never made a node suspend and omitting it never stopped one, which is the silently-inert declaration ADR-0049 exists to end. It was always a second, weaker spelling of the capability `supportsPause` states, and the two diverged in exactly the way a duplicated declaration does: `screen` declared both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling — `AutomationEngine` now refuses a suspension whose type does not declare `supportsPause: true` — so the capability this key gestured at is now a real, enforced fact under one name. This one had no consumer to grow into and takes the remove leg. Why D3 semantic and not a D2 conversion: an ActionDescriptor is published from an executor's TypeScript, never stored in stack metadata — no stack, example or template carries the key — so there is no source for the chain to rewrite and `os migrate meta` cannot reach it. The schema tombstones it via `retiredKey()` and descriptor authors delete the key themselves; that rejection (a `tsc` error at the authoring site, and a parse error inside `defineActionDescriptor`) is the channel a third-party plugin author actually meets. The `EnhancedApiError.fieldErrors` disposition, one layer down." + }, + { + "surface": "automation.ActionDescriptor.resumeAuthority — an OMITTED value on a pausing node descriptor (supportsPause: true, or any executor whose execute() returns suspend: true)", + "replacement": "an explicit resumeAuthority: 'any' on the descriptor, for a pausing node whose pauses really are meant to be continued through the generic resume route (POST /automation/:name/runs/:runId/resume) — a screen-style collected-input pause, or a signal wait an external producer resumes. Declare 'service' instead if continuing is the tail of a decision your own service must authorize and record first. Either value is a one-line addition; only the silence changed meaning", + "migrationId": "action-descriptor-resume-authority-default-flip", + "toMajor": 17, + "rationale": "A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as protocol 12's `rest-requireauth-default-flip`, and it is registered here for the same reason: whether a given pause is genuinely open to the generic route is a trust judgment no transform can make. The generic resume route's authorization gate keys on the SUSPENDED NODE, and `ActionDescriptor.resumeAuthority` used to default to `'any'`, so a pausing node type shipped raw-resumable unless its author remembered the field. It now resolves to `'service'` when absent: an unclaimed pause is refused on the generic route with `PERMISSION_DENIED` / 403 until its descriptor states who may continue it. The revise-window incident decided the direction — ADR-0044 pointed an approval's revise edge at a generic `wait`, `wait` is legitimately `'any'`, and the pause standing in a service-owned position inherited a fail-open value nobody chose; the demonstrated cost was an unaudited resubmit plus a destroyed remote run. The two possible mistakes are asymmetric, which is the whole argument: guessing `'any'` walks past a decision nothing recorded and is silent, while guessing `'service'` returns a refusal naming the missing field. ⚠️ The surface is a DESCRIPTOR FIELD set in plugin CODE, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — the disposition `data-driver-find-stream-retired`, `storage-service-list-retired` and `actor-user-roles-to-positions` already carry. It differs from those in one way a reader should not have to infer: nothing is REMOVED, so tsc reports nothing at all — the field was already optional after step one and an omission still compiles. The enforced channels are all run-time: a registration warning naming the node type (once per type per engine), the refusal message on the resume itself, and `check:resume-authority-declared` for executors living in this repo. For a third-party plugin the generated upgrade guide is the only channel that arrives BEFORE a user hits a run that will not continue. In-tree the flip moves nothing: all six shipped pausing types (screen, wait, subflow, map, approval, approval_revise) declare their authority explicitly. ADR-0044 amendment (2026-07-28) and its 2026-08-08 landing section, and the 2026-07-28 resume-seam addendum to ADR-0019 (approval as a flow node)." + }, + { + "surface": "ui.actionSession.roles", + "replacement": "ui.actionSession.positions (an action body reads `ctx.session.positions`)", + "migrationId": "action-session-roles-to-positions", + "toMajor": 17, + "rationale": "The MIRROR-IMAGE sibling of `actor-user-roles-to-positions`, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. The maintainer ruled contract-first on 2026-08-06 (\"C skeleton + A semantics\": declare the shape as it stands first, then rename on the typed face): phase 1 declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it; the producer emits both for one deprecation window (the runtime half of the same ruling), after which `roles` is removed on the path the v16 session-alias removal already walked (the hook session's `tenantId` alias: deprecated first, removed in the next major). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` / `activationEvents` / `hook-context-session-roles-retired` shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087." + }, + { + "surface": "action body / AI route: ctx.user.roles (req.user.roles)", + "replacement": "ctx.user.positions (an AI route handler reads `req.user.positions`) — the same array, under the one spelling ADR-0090 D3 sanctions", + "migrationId": "actor-user-roles-to-positions", + "toMajor": 17, + "rationale": "The THIRD face of the ADR-0090 `roles` → `positions` rename, and the only one whose surface the spec never declared. `ActorUser` (`packages/runtime/src/security/actor-user.ts`) is the ONE producer of the `user` envelope handed to an action body as `ctx.user` and to an AI route handler as `req.user`; it declared `positions` and `roles` side by side and filled them from a SINGLE assignment (`roles: core.positions`), so the two keys were verbatim identical on every dispatch — a second spelling of the vocabulary ADR-0090 D3 reserves and bans, published straight into author-written code. The maintainer ruled it closed IMMEDIATELY (2026-08-06 14:49Z, on the finding that this alias had no closing date): no deprecation window, no dual-emit, the alias simply gone in 17. ⚠️ Do not read this entry across to its sibling `action-session-roles-to-positions`: `action-session-roles-to-positions` governs `ctx.session`, a DIFFERENT object reached through the same `ctx`, and that one KEEPS its one-window dual-emit. Same word, same dispatch, two faces, two schedules — `ctx.user.roles` is absent in 17 while `ctx.session.roles` still answers for the length of its window. What makes this entry different in KIND from both session-side siblings: `ctx.user` has no spec schema and never had one. It is a runtime TS interface, so unlike `HookContext.session.roles` (tombstoned on a deliberately non-strict `HookContextSchema` once it had no producer and no consumer left) and unlike `ActionSessionSchema` (declared contract-first, as it stood, as the first stage of the session rename, precisely so its key could be renamed), there is no schema key here to tombstone and no `retiredKey()` prescription that could reach anybody — nothing ever ran an `ActorUser` through a `.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it reports at the READ site inside the author's own body; for an untyped or sandboxed body there is no enforced channel at all, which is exactly why this ledger entry has to exist — `spec-changes.json` and the generated upgrade guide are the ONLY way such a reader learns of the rename. It is the `findStream` (no caller) / `IStorageService.list` (no consumer) disposition — a TS/API contract, no stored source, no tombstone, tsc at the call site — applied to a surface that lives one layer further out than either: those two are at least DECLARED in `packages/spec/src/contracts`, this one only in `packages/runtime`. Why it is a D3 semantic TODO and not a D2 conversion, on the same two independent grounds as its session sibling: FIRST, there is no source to convert — an `ActorUser` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key (the `openApi31` / `activationEvents` / `hook-context-session-roles-retired` shape). SECOND, the only place the key is ever SPELLED is inside an action body or an AI route handler: author-written JS/TS, or a sandboxed script. A declarative transform cannot safely rewrite an identifier inside free-form code — the same reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. The removal's hard precondition was met before it landed, and the result is recorded here because the ledger is where an upgrading consumer meets it: the declaration's own comment claimed the alias was \"kept for the REST/AI shapes\", and that claim was DISPROVEN face by face against `origin/main` — repo-wide `user.roles` was 4 hits, all of them in the pins the removal flipped; the four `ActorUser` construction sites build server-side envelopes that never enter a response body; objectui's `.roles` reads belong to two unrelated producers (the better-auth session, and the `/auth/me/permissions` payload). The `cloud` repo was NOT reachable in that session and is the one consumer face left unverified — this entry, and the changeset's FROM/TO prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087." + }, + { + "surface": "data.query.aggregations[].distinct", + "replacement": "the `count_distinct` aggregation FUNCTION for a deduplicated count — the one deduplicating spelling every face computes, lowered to `COUNT(DISTINCT field)` on both SQL faces since it took the enforce leg of the 2026-08-07 ruling that kept it declared and retired `array_agg` / `string_agg`. `SUM(DISTINCT …)` / `AVG(DISTINCT …)` get no replacement: no backend ever computed them here, and a per-row measure that needs deduplicating before summing is a modelling problem to fix in the data", + "migrationId": "aggregation-node-distinct-retired", + "toMajor": 17, + "rationale": "A DIVERGENCE, not an inert declaration — which is why it outlived the sweep of the `QueryAST` members no executor runs, the one that dispositioned every other `data.query.*` member. That sweep asked which keys no executor reads; this one HAD an executor, exactly one out of six. The engine's in-memory fallback (`objectql/src/in-memory-aggregation.ts`) deduplicated the values before applying the function, while `SqlDriver.aggregate`, the Turso `RemoteTransport.aggregate`, `driver-mongodb`'s `buildAggregationStage`, `driver-memory`'s `computeAggregate` and service-analytics' `AGGREGATE_SQL` all ignored the key. So `{ function: 'sum', field: 'amount', distinct: true }` answered a deduplicated sum when the engine fell back in memory and an ordinary sum on every SQL datasource: one query, two numbers, chosen by which backend happened to serve it — and unlike the divergences closed earlier on the same axis (an aggregate function name the remote Turso face compiled and the local face refused, and an unsupported function thrown as a bare error with no code on both SQL faces), the wrong answer here is a plausible NUMBER rather than a refusal, so nothing surfaced it to the author. Measured blast radius inside the fallback: `sum` and `avg` only — `count` returned from its own branch before reaching the dedupe, `count_distinct` fed the values into a Set (dedupe-then-Set is Set), and dedupe does not move `min`/`max`. ENFORCE was weighed and rejected (maintainer ruling 2026-08-09): `count_distinct` already covers the only spelling anyone has measured demand for, and lowering `SUM(DISTINCT …)` across five faces — two of them, driver-memory and driver-mongodb, then under the maintainer's 2026-08-05 investment freeze, a freeze lifted 2026-08-11, after this ruling — buys a shape that is near-universally a modelling mistake. A REQUEST surface — `QueryAST` is the client SDK builder's output and the `POST /data/:object/query` body, never stored in stack metadata — so there is no source for the chain to rewrite and callers move their own queries: the disposition that sweep gave `joins`/`cursor`/`distinct`/`windowFunctions`, applied verbatim one level down. ADR-0049." + }, + { + "surface": "api.analyticsQueryRequest.query", + "replacement": "bare AnalyticsQuery body (top-level cube/measures/dimensions/where/...)", + "migrationId": "analytics-query-request-envelope-retired", + "toMajor": 17, + "rationale": "The { cube, query: {...} } envelope was an HTTP-wire dialect of the retired degraded analytics shim (the fallback that answered /analytics/query when no analytics service was installed, and dropped the caller's identity and its `where` filter at the door), never stored in stack metadata — there is no source for the chain to rewrite. Callers of POST /analytics/query and /analytics/sql must move the query.* fields to the body top level themselves." + }, + { + "surface": "api.analyticsQueryRequest.format", + "replacement": "(removed — responses are always the JSON envelope; use the export surface for CSV/XLSX)", + "migrationId": "analytics-query-request-format-retired", + "toMajor": 17, + "rationale": "The `format` key was declared but never implemented (declared ≠ enforced): every response is the JSON envelope regardless of the requested value, so there is no behaviour to preserve and nothing stored to rewrite." + }, + { + "surface": "PUT /api/v1/meta/api/{name} (runtime-authored `api` endpoints, draft and active alike)", + "replacement": "Declare the endpoint as a stack artifact (`**/*.api.ts`, or `defineStack({ apis })`) and ship it through `publishPackage`", + "migrationId": "api-runtime-create-withdrawn", + "toMajor": 17, + "rationale": "The `api` registry entry declared `allowRuntimeCreate: true` and the runtime never honoured it. Measured on a real showcase boot: `PUT /api/v1/meta/api/e8_backdoor` answered 200 with `{\"success\":true,…,\"message\":\"Saved …\"}`, and the declared route then answered 404 forever — with NO `[EndpointMatcher] … EXCLUDED` line, because the endpoint was never in the index to be excluded from. The serving criterion belongs to `IMetadataService.matchEndpoint` -> `EndpointMatcher` -> `MetadataManager.listForIndex('api')`, which reads the manager's registry plus its registered loaders (`[\"filesystem\",\"memory\"]` on dev/serve); a runtime write lands in `sys_metadata`, which is in neither. A declared capability the runtime does not honour is ADR-0049 false compliance, and a write that answers \"Saved\" and then 404s forever is its most dangerous shape for the AI authors ADR-0033 targets. The maintainer ruled REMOVE on 2026-08-07 rather than converge the read path, because making the matcher read `sys_metadata` re-opens cache, invalidation, tenancy and the ADR-0110 D3 miss-vs-outage distinction on a new read path, and there is no business pull for Studio-authored endpoints today (zero `.api.*` artifacts author them at runtime; showcase uses the artifact route, and its declared endpoints serve live). There is NO D2 conversion, for the reason this list exists: nothing in an authored source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an authorable one, and the artifact route it points authors toward is untouched — a `**/*.api.ts` file valid before this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, so it is one semantic TODO for operators and Studio callers rather than a stack conversion — the same disposition `BatchOptions.validateOnly` takes. Consequently `gateApiDraftsForPublish` is retired with it: it gated a promotion into a state the matcher can never read, and with the inlet closed no `api` draft can exist for it to judge. Re-entry is recorded in the ruling: if the Studio metadata-coverage work promotes `apis` to a registered type WITH A REAL CONSUMPTION PATH, the flag flips back then — implementation first, declaration second. The same refusal closes the direct-active write too, which had been a third path past the endpoint namespace and duplicate-path gates. ADR-0049 / ADR-0121." + }, + { + "surface": "data.object.enable.apiMethods (the eight legacy non-primitive values)", + "replacement": "the six primitives only — `get` / `list` / `create` / `update` / `delete` / `bulk`: replace each legacy value with the primitives it derives from, de-duplicate, and delete the key entirely if the result names all six", + "migrationId": "apimethod-enum-shrink", + "toMajor": 17, + "rationale": "The authored `enable.apiMethods` enum is now exactly the six primitives. The eight legacy values — `upsert`, `aggregate`, `history`, `search`, `restore`, `purge`, `import`, `export` — are no longer authorable, because they are DERIVED effective operations resolved by the server's single derivation table, and an enum that lets an author name both a primitive and something derived from it has two spellings for one fact. The FROM → TO is a table rather than a rename: `upsert` → `create` + `update`; `import` → `create` + `update`; `export`, `aggregate` and `search` → `list`; `history` → `get`; and `restore` / `purge` map to NOTHING — they never derived, because `enable.trash` was retired with the other dead `enable.*` flags in the 11.0 ADR-0049 removal of dead author-facing properties, so the value is deleted outright. That last row is why this is a semantic entry and not a mechanical conversion, and the reason is a security one: the mapping WIDENS. An allowlist naming `history` was granting read of one record's audit trail; rewritten to `get` it grants ordinary record reads, and an allowlist naming `search` becomes a grant of full `list`. A transform that applied the table silently would broaden real API permissions without anyone reading the diff, so the rewrite is delegated to the author with the widening flagged. The reporter codemod exists for exactly that shape: `node scripts/codemod/apimethods-legacy-to-primitives.mjs` scans, reports the exact replacement per site, and FLAGS the allowlists the mapping would widen so the edit stays reviewable — it reports, it does not rewrite. Stored metadata keeps parsing (permanent tolerance, narrowing only), so nothing breaks at rest; what changes is what an author may newly write. Registered late, by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger: the enum shrink (phase 2 of the programme that made UI action buttons agree with the `apiMethods` allowlist) predates the gate that makes a breaking changeset state its ledger disposition. ADR-0087." + }, + { + "surface": "automation.ApprovalEscalation.enabled — an OMITTED value inside an approval node's escalation block", + "replacement": "nothing, for the common intent (escalate on timeout): an escalation block carrying timeoutHours is live by default. To declare an SLA OFF while keeping its configuration, write enabled: false explicitly — which is now the spelling the escalation sweep actually reads", + "migrationId": "approval-escalation-enabled-default-flip", + "toMajor": 17, + "rationale": "A DECLARED-DEFAULT CORRECTION plus the enforcement that makes the key real (maintainer ruling 2026-08-27, which moved the declared default to what the sweep had always done) — the same category as protocol 17's `import-run-automations-declared-default-corrected`: the schema promised `enabled` defaults to `false` (SLA off) while the plugin-approvals sweep never read the key at all — any escalation block with a positive `timeoutHours` escalated, and with `action: 'auto_approve'` that silently approved requests their author had declared off the clock. The flip moves the default to `true` and, in the same change, the sweep starts honouring an explicit `enabled: false`. The feature-level switch is whether an `escalation` block exists at all; within a block carrying `timeoutHours`, escalation is on unless explicitly turned off. Deployed metadata that OMITS `enabled` does not change behaviour: it escalated before (the sweep ignored the key) and escalates after (the parse materializes `true`). Stored request snapshots written before the flip carry a MATERIALIZED `enabled: false` (the approval-node executor parses config through the old schema before snapshotting), so the sweep keeps a read-side legacy window keyed on the snapshot's `created_at`: pre-flip snapshots keep escalating exactly as they do today, and the window retires itself as those pending requests drain. What DOES change is that an explicit `enabled: false` finally binds — a flow that authored it (e.g. the console toggle switched off after a timeout was set) stops escalating on requests opened after the upgrade, which is the declared intent being honoured." + }, + { + "surface": "sys_audit_log.action — the values 'export' and 'permission_change' left the select enum declared by plugin-audit (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts). The same two values also left the shipped list-view filters on that object: 'permission_change' from the auth_events view and 'export' from the config_changes view", + "replacement": "nothing, for either value — both are removed rather than renamed, because neither named an event this platform records. For permission changes, read the ordinary `create` / `update` rows on the permission objects themselves: a grant or binding write is an ordinary record write and the generic audit writer already ledgers it, so a second semantically-duplicate row was never minted. For `export` there is no replacement and nothing is lost: no export feature ever wrote an audit row. A consumer filtering `sys_audit_log` on either value was reading an empty result set on every deployment, and still is — what changed is that the contract no longer promises otherwise", + "migrationId": "audit-log-action-enum-retired", + "toMajor": 17, + "rationale": "Maintainer ruling 2026-08-12 on the audit log's writerless actions, the retirement half of a two-half verdict: the cheap writers get built (`login` / `logout` on the auth session hooks, `config_change` from the settings service) and the enum values with no feature behind them are retired. 原则记录:空 widget + 永远查不到东西的过滤器是可见产品缺陷;审计面宁窄勿谎. The defect was false compliance on a COMPLIANCE surface, which is the sharpest form of ADR-0049 declared-≠-enforced: an auditor reading the action enum believed the platform captured permission changes and data exports, and the shipped list views and dashboard widgets showed them a filter and a tile for exactly those events. Both were permanently empty. Measured by enumerating every `sys_audit_log` writer in the repo — there are exactly two: plugin-audit`s generic hook writer, whose `actionFor` maps afterInsert/Update/Delete to create/update/delete and nothing else, and plugin-auth`s admin user-import. Neither has ever emitted `export` or `permission_change`. This is an enum-VALUE retirement, so the bookkeeping differs from a key retirement in the two ways `hook-body-crypto-hash-removed`, `dataset-measure-array-string-agg-removed` and `action-global-nav-location-removed` already record: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) and the four surface ratchets are expected to be byte-identical (no def changed). It differs from all three in being a SEMANTIC entry rather than a D2 conversion, and the reason is that there is no source to rewrite: `sys_audit_log` is a platform-owned, append-only object whose every field is `readonly: true`. Nobody authors an audit row and nobody authors this enum — the values appear only in rows the runtime writes and in queries consumers send. A conversion rewrites authored metadata or a stored `sys_metadata` row; this surface is neither, so the disposition is the one `BatchOptions.validateOnly` and the notification cursor already take in this major. ⚠️ Historical ROWS are deliberately untouched. A deployment that somehow holds a row with either value keeps it, and keeps reading it back: the enum is not enforced on this object at all (`validateRecord` skips `readonly` fields, and every field here is readonly), so nothing rejects stored history and no backfill is required or wanted. Deleting audit history to satisfy a schema narrowing would be the one genuinely destructive reading of this change. ADR-0049 / ADR-0087." + }, + { + "surface": "sys_audit_log.action — the value 'restore' left the select enum declared by plugin-audit (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts). It also left the shipped writes_only list-view filter on that object, and the generated option label in all four plugin-audit translation bundles", + "replacement": "nothing — the value is removed rather than renamed, because it never named an event this platform records. There is no undelete or restore capability to point at: deletes are hard deletes, and the record-level audit writer maps the ObjectQL lifecycle to `create` / `update` / `delete` only. A consumer filtering `sys_audit_log` on this value was reading an empty result set on every deployment, and still is — what changed is that the contract no longer promises otherwise. If you were counting on a restore trail, the capability itself is the missing piece (an undelete / purge permission lifecycle and a soft-delete recycle bin, neither built yet), not this enum row", + "migrationId": "audit-log-action-restore-retired", + "toMajor": 17, + "rationale": "The same maintainer ruling as `audit-log-action-enum-retired`, carried to the one value that ruling's own survey did not name (triage 2026-08-13). 原则记录:空 widget + 永远查不到东西的过滤器是可见产品缺陷;审计面宁窄勿谎. `restore` is the least ambiguous member of the family: the record-level writer could not have produced it even by accident, because `actionFor()` in audit-writers.ts is typed `'create' | 'update' | 'delete' | null` and its caller early-returns on null. A tree-wide search finds no other producer. What made it a card rather than a tidy-up is that TWO shipped declarations asserted the opposite, so a declaration-reading audit scored the action as covered: the `writes_only` list view offered it as a filter value, and the module docblock of auth-event-audit.ts named it among the actions the writer emits. The comment is the ADR-0049 declared-≠-enforced shape in its purest form (the shape a credential-storage audit had to settle by re-measuring two \"hashed at rest\" comments) — a sentence next to a mechanism, contradicted by the type signature of that very mechanism, with nothing in CI able to tell. Both declarations are corrected in one change, and the invariant behind the comment (every declared action has a writer) now has a pin test under it rather than prose. Bookkeeping is identical to the sibling entry, for the same reasons: an enum-VALUE retirement puts nothing in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) and leaves the four surface ratchets byte-identical (no def changed), and it is a SEMANTIC entry rather than a D2 conversion because there is no source to rewrite — `sys_audit_log` is a platform-owned, append-only object whose every field is `readonly: true`, so nobody authors an audit row and nobody authors this enum. ⚠️ This is a statement about the WRITER, not a product stance against undelete. Soft delete/restore is parked, not rejected: the undelete / purge lifecycle and the recycle bin are both held open, not declined. If that capability lands, this value returns WITH its writer — the emission point, its tests, and the view that surfaces it — never as a bare enum row again. ⚠️ Historical ROWS are deliberately untouched, exactly as for the sibling entry: the enum is not enforced on this object at all (`validateRecord` skips `readonly` fields), so any stored row keeps parsing and reading back, and no backfill is required or wanted. Deleting audit history to satisfy a schema narrowing would be the one genuinely destructive reading of this change. ADR-0049 / ADR-0087." + }, + { + "surface": "api.authConfig.features.passkeys / api.authConfig.features.magicLink", + "replacement": "(removed — no replacement flag; the capabilities are not advertised)", + "migrationId": "auth-config-unadvertised-reserved-features", + "toMajor": 17, + "rationale": "Both flags were served by `GET /api/v1/auth/config` from introduction and read by no client: no login UI anywhere renders a passkey or magic-link affordance off them, so the payload advertised two sign-in methods a user could never reach, and a deployer setting `plugins.passkeys` / `plugins.magicLink` flipped a switch with no observable effect (ADR-0049 enforce-or-remove; the maintainer ruling of 2026-08-11 chose remove over keep-as-reserved, so that a deployer cannot flip a flag that does nothing anywhere). The two are not equally empty: nothing at all is wired behind `passkeys`, whereas `magicLink`'s better-auth endpoints are live and only their advertisement was withdrawn. This is a RESPONSE surface — nobody authors or persists an `AuthFeaturesConfig` — so there is no source for the chain to rewrite; the schema tombstones both keys via retiredKey() and consumers drop their read. The withdrawal is conditional: both return to the payload in the change that ships the login UI (flag-gated passkey and magic-link entry points, which objectui defers until the maintainer schedules them). ADR-0049." + }, + { + "surface": "the protocol-17 authoring schemas closed against undeclared keys by the unknown-key strictness wave — `automation/` (flow and its six nested blocks, control-flow, state-machine, webhook, time-relative trigger, flow function), `security/` (permission sets, RLS policies, sharing rules) and `identity/position`, `ui/` (responsive, theme, chart, `AriaProps`, fifteen `view` sub-blocks, `ViewItem`, `userFilters`) — plus the `view` write-path identity precondition one level above them", + "replacement": "declared keys only. Each rejection names the surface, echoes the offending key and — where the word is recognisable — gives the canonical spelling, a retired-key tombstone, or a prescription where a rename would be wrong. A `view` body must additionally carry at least one key some union member declares, discounting the identity keys the write path stamps itself (`VIEW_WRITE_PATH_IDENTITY_KEYS`)", + "migrationId": "authoring-schemas-strict-unknown-keys", + "toMajor": 17, + "rationale": "zod's default `.strip` discarded any key these schemas did not declare and let the parse SUCCEED, so the author — increasingly an AI — got a success envelope and shipped metadata that quietly ignored what they wrote. Closing them turns that into a loud parse error (ADR-0049 enforce-or-remove, ADR-0078 no-silently-inert). It is not losslessly convertible for the same reason the two precedent entries at majors 15 and 16 are not: an arbitrary unknown key has no mapping target, and auto-deleting it would be exactly the silent data loss ADR-0078 bans — so each occurrence needs the author to decide, fix the typo, move it to the layer that owns it, or delete dead metadata. The named renames the errors carry are a help, not a transform: a large share of this wave is PRESCRIPTIONS rather than renames precisely because renaming would be wrong (`inputSchema.optional` is the opposite polarity of `required`; `errorHandling.maxAttempts` counts the first attempt where `maxRetries` counts the ones after it; a `responsiveStyles` bucket written on `responsive` is a wrong-layer pointer, and the two breakpoint vocabularies sixteen lines apart cannot be bridged by edit distance; `aria.live` is real on exactly one renderer and `ariaLabelledBy` has nothing to rename to; `finally` on `try_catch` and `context` on a state machine have no key at all). The eleventh member is not an unknown-key close but the same defect one level up — the `view` union had an arm that both stripped and required nothing, so it matched every object and `saveMetaItem` persisted garbage as an ACTIVE view overlay that read back badged valid. This is ONE entry for the whole major by the maintainer's 2026-08-12 ruling that the wave is registered one entry per major, not one per batch, mirroring the registry's only two precedents of this shape; the eleven batches it folds are the changesets `unknown-key-strictness-tier-a`, `-step2`, `-automation-batch11`, `-ui-batch13`, `-ui-batch15`, `-ui-batch16`, `strict-automation-control-flow-state-machine`, `view-subblock-strictness-batch18`, `rare-jars-shave`, `user-filters-allow-add-tab-promote-and-close` and `view-union-identity-precondition`, each carrying its own FROM → TO table in `CHANGELOG.md`. One batch promoted a key before closing its block: `userFilters.allowAddTab` was already read by objectui, so the maintainer's 2026-08-04 ruling declared it in the spec rather than let a correct-looking refusal tell authors to delete a working capability. The entry was registered in the backfill of the v17 train's breaking changesets, which had never been compared against the ledger. ADR-0049 / ADR-0078 / ADR-0087." + }, + { + "surface": "api.batchOptions.validateOnly", + "replacement": "(removed — no dry-run today; open an issue to design a no-commit batch preview)", + "migrationId": "batch-options-validate-only-retired", + "toMajor": 17, + "rationale": "The `validateOnly` key promised a dry-run (\"validate records without persisting\") but no batch surface ever read it — updateManyData / deleteManyData / batchData persist regardless. There is no behaviour to preserve and nothing stored to rewrite (it only ever appeared in an HTTP request body). Callers must stop sending it." + }, + { + "surface": "api.batchOperationResult — the per-row `results` entries of BatchUpdateResponse (`POST /data/:object/batch`, `/updateMany`, `/deleteMany`)", + "replacement": "`errors: ApiError[]` (was `error: string` — read `row.errors?.[0]?.message`, branch on `row.errors?.[0]?.code`), `data` (was `record`), and `index` (new — the row's position in the request array)", + "migrationId": "batch-row-result-schema-shape", + "toMajor": 17, + "rationale": "The rows the three bulk-write endpoints emitted had drifted from the schema that declared them: `BatchOperationResultSchema`, the client SDK's exported `BatchOperationResult` type and the reference docs all said `errors: ApiError[]` / `data` / `index`, while the wire carried `error: string` / `record` and never sent `index` at all. A TypeScript consumer written against the published type compiled, validated and read `undefined` at runtime — the declared-but-not-delivered shape this registry exists to close, on the response envelope (ADR-0119 D4 deferred the reconciliation off a bug fix; this is that tracked change, shipped in the 17 major window). The ADR-0119 rollback marking, which the fix making `deleteManyData` and `updateManyData` honour `atomic` carried to those two endpoints, is structured in the same move: the `ROLLED_BACK:` / `NOT_ATTEMPTED:` message-string prefixes become registered `ApiError.code` values (message keeps the human-readable cause and causal row index), so \"attempted and undone\" vs \"never ran\" is machine-readable instead of a regex convention. A RESPONSE surface — nothing stored in stack metadata carries a batch row, so there is no source for the chain to rewrite; consumers of the legacy keys move their reads themselves. Off-contract readers only: the legacy keys were never in the schema or the SDK types, so a typed consumer needs no change. Ruled 2026-08-03: the implementation moves to the schema's shape as a hard cut in the 17 major, with no dual-emit transition." + }, + { + "surface": "client.DeleteDataResult.deleted (the return of `client.data.delete()`)", + "replacement": "`success` — `r.deleted` → `r.success`. Same call, same wire body, declared name", + "migrationId": "client-delete-result-success", + "toMajor": 17, + "rationale": "`DeleteDataResult` carried the comment `Spec: DeleteDataResponseSchema` above a declaration that contradicted it: the interface declared `deleted: boolean` while `DeleteDataResponseSchema` declares `{ object, id, success }`. `deleted` has never been declared by any schema and no server path has ever returned it on `/data/:object/:id`. Both delete surfaces — `client.data.delete()` and the project-scoped `client.project(id).data.delete()` — are pure `unwrapResponse` / `_unwrap` passthroughs, so the interface is a CLAIM about the wire, never a rewrite of it, and the claim was false in the one direction that matters: the compiler endorsed the wrong spelling. `if (r.deleted)` compiled, read `undefined` at runtime, and the branch was never taken; `if (r.success)` was rejected by the compiler and correct on the wire. So this rename REVEALS a defect rather than breaking working code — every reader of the old key was already reading `undefined`, on every deployment and not just some, because the protocol path has always answered `success`. It is registered as a semantic entry rather than a mechanical conversion for the reason the rewrite itself does not capture: the key is one token, but a call site that branched on `r.deleted` has been taking the FALSE branch unconditionally since it was written, and whatever that branch did — or skipped — is what actually has to be re-read. There is no authored source for the chain to rewrite either; this is a published TypeScript surface whose enforced channel is tsc at the call site, and for an untyped JS caller there is no constrained channel at all, which is why the ledger entry is the only notification that reaches them. ⛔ Do not write `r.success ?? r.deleted`: there is one producer shape, and a consumer accepting two spellings is what contract-first exists to prevent (the same rule already moved the runtime's ObjectQL fallback from answering `deleted: true` to the declared `success`, on the producer side). No deprecated `deleted?: boolean` transition key ships, for the same reason — a transition period is for keys that WORKED, and this one never did. Registered by the stock reconciliation of the v17 train's breaking changesets, which had never been compared against the ledger. ADR-0087." + }, + { + "surface": "connector.authentication on AUTHORED entries (defineStack `connectors:`, `PUT /meta/connector/:name`) — previously refused only on provider-bound instances (ADR-0097 §3), now refused on catalog descriptors too", + "replacement": "a catalog descriptor drops `authentication` (or sets `{ type: \"none\" }`) and documents the auth scheme in `description`; a dispatchable instance declares `provider` and references its credential with `auth: { type, credentialRef }` (ADR-0097 §3). Runtime `registerConnector` calls are unaffected — the runtime shape still carries resolved secrets inline.", + "migrationId": "connector-inline-authentication-publish-refused", + "toMajor": 17, + "rationale": "A published connector row lands whole in `sys_metadata`, so an inline `token` / `key` / `password` / `clientSecret` is cleartext at rest, readable through the data API (the class a credential-persistence survey measured: any authored artefact whose schema permits an inline credential lands it there). No mechanical rewrite exists: whether the entry should become a `none` descriptor or a provider-bound instance with a `credentialRef` — and which secret store receives the credential — is a judgment about the connector, not a rename." + }, + { + "surface": "dashboard.widgets[].compareTo: { offset: '7d' | '1M' | … } (every duration except '1y')", + "replacement": "compareTo: { kind: 'previousPeriod' } plus an explicit window on the widget's own `filter`", + "migrationId": "dashboard-widget-compareto-offset", + "toMajor": 17, + "rationale": "The widget declared three comparison arms; the analytics executor implements one shape, `{ kind, dimension? }`, with no `offset` concept in it at all. On the ADR-0021 dataset path — the spec's single author-facing analytics shape — `{ offset }` was forwarded verbatim into that contract and threw `compareTo requires a timeDimension \"undefined\"`, taking the widget down; the arm ever only ran on the legacy inline chart path (measured when all three declared arms were found dead on the dataset path: two silently dropped, this one throwing). The conversion rewrites `{ offset: '1y' }`, which IS `previousYear` by definition. Every other duration has NO faithful target: `previousPeriod` shifts by the length of whatever window the widget's filter resolves to, which equals `7d` only when that window happens to be seven days long. Rewriting mechanically would silently change which rows the comparison column counts — a wrong number rather than a missing one, which is strictly worse and exactly the class this convergence exists to end. Re-stating the intended window is a judgment about the presentation, not a transform." + }, + { + "surface": "contracts.IDataDriver.findStream / data.DriverInterfaceSchema.findStream", + "replacement": "find() with limit/offset — the paged read whose determinism IS enforced (IDataDriver.find, data/pagination-conformance.ts)", + "migrationId": "data-driver-find-stream-retired", + "toMajor": 17, + "rationale": "`findStream` was a REQUIRED contract method documented as \"optimized for large datasets to avoid memory overflow\", and in two of its three implementations it delivered the opposite: `SqlDriver` and `InMemoryDriver` both awaited `find()` for the ENTIRE result set and then yielded it row by row, so the peak memory a caller was promised protection from was already reached before the first yield. The third (`MongoDBDriver._findStream`) did walk a cursor, but it was the one read path in that driver never routed through `buildFindOptions`, so it hardcoded `projection: { _id: 0 }` and silently discarded `query.fields`. None of it was ever observed, because the method had NO caller in either repository: the engine exposes no stream entry, and the REST export, import and bulk-read paths all go through `find()`. The ~20 driver test doubles that existed only to satisfy a required method almost all threw `not implemented`, and nothing ever noticed — which is the proof, not the anecdote. Being REQUIRED, it also taxed every new driver and every test double with an implementation of a capability the platform does not have. Rather than build a caller to justify three implementations, the method is retired; a real cursor-based read should return WITH the caller that needs it (ADR-0049 enforce-or-remove). This is a TS/API contract surface — a driver is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone either: nothing ever ran a driver object through `DriverInterfaceSchema.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it points at callers. ADR-0049 / ADR-0078." + }, + { + "surface": "contracts.IDataDriver query parameter — find / findOne / count / updateMany / deleteMany / explain", + "replacement": "`DriverQuery` (`Omit`): delete the redundant `object:` key from the query literal at the call site — the object name is already the FIRST argument", + "migrationId": "data-driver-query-omit-object", + "toMajor": 17, + "rationale": "Every one of these methods takes the object name as its first argument, and then required a `QueryAST` that lists `object` as mandatory — the same fact demanded twice, with two places for it to disagree. The layers above had already paid for that ambiguity: the objectql engine deliberately writes its key order as `{ ...query, object }` so a smuggled `query.object` cannot override the resolved name, and the wire layer spends a named 400 (`QUERY_OBJECT_MISMATCH`) refusing the inconsistency. The driver side paid in blanket casts: a direct caller holding only a `where` could not name the type, wrote `as any`, and switched off checking for `where` / `orderBy` / `fields` along with it — 20 such sites were measured in the downstream cloud codebase, and a `$like` the type layer would have caught reached runtime there through exactly that hole. This is a TS contract surface with no authored source for the chain to rewrite, which is why it is a semantic entry and not a D2 conversion; for a typed caller the compiler names every site (TS2353 `'object' does not exist in type 'DriverQuery'`), and for an untyped JS caller there is no constrained channel at all, which is exactly why this ledger entry must exist. Neither side is forced to move: a caller holding a `QueryAST` VALUE passes it unchanged (excess properties are only rejected on fresh literals), and an implementation still declaring `query: QueryAST` keeps compiling under parameter bivariance. What an implementation may no longer do is READ `query.object` — callers are now entitled to omit it. Registered by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger. This change was that audit's CONTROL sample, drawn to show that not every flagged candidate is an omission, and it was one: the seven `IDataDriver` hits in the ledger are all prose inside other entries, and the only subject-level hit on this interface is `data-driver-find-stream-retired` — a DIFFERENT member. Two later, smaller driver call-parameter changes both registered, and both cite this narrowing as background; the larger sibling they derive from never got its own entry. ADR-0087 (backfilled by that reconciliation)." + }, + { + "surface": "contracts.IDataEngine.batch / data.DataEngineBatchRequestSchema", + "replacement": "`IObjectQLEngine.transaction(cb)` for in-process multi-write atomicity; the metadata protocol's `batchData` with `options.atomic: true` for a batch over one object; `POST {basePath}/batch` on the wire", + "migrationId": "data-engine-batch-retired", + "toMajor": 17, + "rationale": "`batch?` was declared on `IDataEngine` for as long as that contract existed and was never implemented by any engine: `ObjectQL` has no `batch` method and there is no other engine in the tree. It also had no caller — `DataEngineRequest` was imported by exactly one file, the contract declaring the member. Its entire specification was a three-word doc comment (\"Batch Operations (Transactional)\"), which settles nothing about partial failure, ordering, cross-object references, rollback scope, or what `transaction: false` was supposed to mean — the questions a batch API exists to answer. Contrast its neighbours `getDefaultDriverName?` / `getDriverByName?`, whose optionality is evidenced: each names its implementer and its probing caller. The tell that nobody ever designed against it is in the schema: `DataEngineBatchRequestSchema.requests` nested the request union RECURSIVELY, so a batch could contain batches, with no statement anywhere about what that meant for ordering or rollback. The only test was a type pin — an ad-hoc object literal carrying a `batch` property, asserting the property was defined — which could not fail while the declaration existed and would have passed unchanged for the member's whole life with no engine implementing it. What it claimed is now covered by members that are real, so the removal deletes a false affordance rather than a capability: ADR-0119 D1 made `transaction` reachable through the contract and D4 made `batchData`'s `atomic` honest, while the wire batch has always validated with `CrossObjectBatchRequestSchema` / `BatchUpdateRequestSchema` from `api/batch.zod.ts` — a different schema entirely, untouched here. TS/API surfaces only: an engine is CODE, never stack metadata, so there is no source for the chain to rewrite. Deliberately no schema tombstone either — nothing ever parsed `DataEngineBatchRequestSchema`, so a `retiredKey()` prescription would have no one to reach; its three `authorable-surface.json` baseline lines and its `json-schema.manifest.json` entry are dropped in the same change, deliberately. The enforced channel is tsc. ADR-0049 / ADR-0078." + }, + { + "surface": "api.DataEventType 'data.field.changed'", + "replacement": "the `data.record.updated` event, whose payload already carries the per-field detail: `changes` (the changed fields), plus `before` / `after`", + "migrationId": "data-field-changed-event-retired", + "toMajor": 17, + "rationale": "`data.field.changed` was declared in `DataEventType` and emitted by nothing — the engine's `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since multi-record predicate writes were given events of their own) `data.records.{updated,deleted}`, and no other producer exists in either repository. A subscriber that switched on it was waiting on an event no producer sends: the branch never ran, and because the surrounding `switch` still compiled, nothing anywhere reported the gap (ADR-0078's silently-inert declaration, on the event vocabulary). `DataEventSchema` could not have carried the semantics even if something had emitted it — the payload is record-shaped (`recordId`, `changes`, `before`, `after`) with no `field` / `oldValue` / `newValue` slot — so the member promised a granularity the contract has no room for. Per-field detail is therefore not lost: it has always ridden on `data.record.updated` as `changes`, which is one event per write rather than N events on a wide table. This is a runtime EVENT surface — no stack, example or template authors an event name (webhooks subscribe through the separate authorable `WebhookTriggerType`, whose vocabulary was already trimmed to producers that exist) — so there is no source for the chain to rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a retiredKey() fix-it error the way an authorable object key can (the same limit the sharing-rule `full` retirement `owd-full-alias-removed` hit). The enforced channels are tsc, which fails any consumer still naming the value in a `DataEventType` position, and the enum parse, which now rejects the name instead of accepting an event that never arrives. A genuine per-field stream, if one is ever wanted, gets its own honest contract the way bulk writes were given theirs. ADR-0049 / ADR-0078." + }, + { + "surface": "datasource.config.password (postgres / mysql / mongo) and datasource.config.authToken (turso)", + "replacement": "the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` secrets-store reference", + "migrationId": "datasource-config-inline-credential-refused", + "toMajor": 17, + "rationale": "A datasource artefact is persisted whole into `sys_metadata`, which is served back by the ordinary data API — an inline credential is cleartext at rest. The maintainer ruled on 2026-08-12 to close that per artefact: each schema that admitted an inline credential refuses it at publish and points at the secret mechanism the artefact already had, rather than a heuristic guard at the `sys_metadata` write. There is no mechanical rewrite: moving the value requires ENCRYPTING it into a `sys_secret` row through a running secret binder and deleting the cleartext, which a source-file transform cannot do — auto-deleting the key alone would silently drop a live credential instead." + }, + { + "surface": "connection-material string keys of the built-in driver configs — postgres/mysql/mongo `url`/`host`/`database`/`username`, postgres `schema`/`applicationName`, mongo `authSource` and the `options` passthrough (judged deep), turso `url`/`syncUrl`/`encryptionKey`, sqlite/sqlite-wasm `filename` — values containing `${…}` placeholder syntax", + "replacement": "the literal value. For secret material, the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` reference. For environment-driven connections, the runtime environment itself: `OS_DATABASE_URL` and friends are translated into driver config by the boot hosts and never pass through the publish door", + "migrationId": "datasource-config-placeholder-refused", + "toMajor": 17, + "rationale": "A `${…}` placeholder in authored datasource config is resolved by NOTHING — it is stored verbatim in `sys_metadata` and handed verbatim to the database client at connect (measured by the credential census while the inline-credential refusal was being built), so the connection fails, or connects somewhere unintended, with no error naming the unresolved placeholder — the masked-failure shape. The syntax looked supported: it parsed green, stored fine, and failed at a distance; two shipped refusal messages (the inline-credential refusal and the URL-userinfo refusal) had to warn \"do NOT substitute a placeholder\" around the broken escape. The maintainer ruled on 2026-08-13 for the second of two directions: refuse the syntax loudly at publish; implementing real resolution was explicitly rejected — a new capability with an env-exfiltration security surface and zero measured pull for actual substitution. There is no mechanical rewrite: the placeholder names a value that exists only in the author's intended deployment environment, which a source-file transform cannot know — substituting anything would invent a connection target." + }, + { + "surface": "datasource.config.url (postgres / mysql / mongo / turso) and datasource.config.syncUrl (turso) — the URL userinfo password segment (`user:password@host`)", + "replacement": "the same URL with its userinfo password removed (a bare `user@host` stays legal), plus the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` secrets-store reference", + "migrationId": "datasource-config-url-userinfo-refused", + "toMajor": 17, + "rationale": "The inline-credential closure refused the credential KEYS, and building it measured that `config.url` still accepted the identical secret one syntax over — `postgresql://user:password@host/db` landed in `sys_metadata` cleartext exactly as `config.password` did, and the key refusal itself steered authors there. The maintainer ruled on 2026-08-12 (Option A) to refuse the URL userinfo password at publish, through one value-level parse the driver schemas share. Runtime-environment DSNs (`OS_DATABASE_URL` and friends) never pass through the publish door and are unaffected by construction. There is no mechanical rewrite, for the same reason as the sibling entry `datasource-config-inline-credential-refused`: moving the value requires ENCRYPTING it into a `sys_secret` row through a running secret binder and stripping the cleartext, which a source-file transform cannot do — auto-stripping the userinfo alone would silently drop a live credential instead. Do not substitute a `${…}` placeholder into the URL: placeholders in authored metadata are resolved by nothing and reach the database client verbatim (measured when the inline-credential refusal was built)." + }, + { + "surface": "stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)", + "replacement": "the same declarations, re-read as LIVE HTTP routes: `path` moved under `/api/v1/apps//`, and every entry that declares `authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying `rateLimit: { enabled: true, … }`", + "migrationId": "declarative-apis-endpoints-live", + "toMajor": 17, + "rationale": "This is the one protocol-17 entry that turns metadata ON rather than off, so read it as a SECURITY review item and not as a rename. Before 17 the declarative endpoint surface executed NOTHING: no route was mounted for a declared `path`, no matcher existed, and every key — `authRequired` included — parsed green and gated nothing (which is why the maintainer's 2026-08-04 ruling refused a non-empty `apis:` outright until an executor existed). Protocol 17 ships that executor and narrows the refusal to a per-endpoint publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as soon as the stack is published. So an `apis:` block written against an older major — or one restored from a source older than that refusal, or authored from a doc that predates the refusal — changes meaning without changing a byte: what used to be inert documentation becomes an execution entry point into the data and automation pipelines. Nothing about that transition can be applied mechanically, because the judgment it needs is \"did the author of this endpoint mean for the internet to reach it?\" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT `authRequired: false` is the only thing that opens anonymous access, and under ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key defaults to `false`, so a budget written without it meters nothing) or the stack refuses to publish. ⚠️ If you author endpoints in TypeScript, annotate them with `ApiEndpoint` — the AUTHOR state — so that omitting `authRequired` compiles: `const e: ApiEndpoint = { name, path, method, type, target }` is legal and is the safe shape this paragraph prescribes. `ApiEndpointParsed` is the POST-parse type (defaults materialized, ADR-0122), where `authRequired` is required — annotating a declaration with it forces you to write the key out, and being made to think about a key whose only unrecoverable value is `false` is the one thing this entry is trying to avoid. Hold a parse RESULT with `ApiEndpointParsed`; write declarations as `ApiEndpoint`. Grep every `apis:` entry for `authRequired: false` before you upgrade, delete the ones that were never meant to be public, and arm a budget on the ones that were. The path move is the mechanical-looking half and is still yours: ADR-0121 D1/D2 confine a declared path to your own namespace carve-out (`/api/v1/apps//…`), the namespace comes from an explicit `manifest.namespace` with no derivation fallback, and the subpath is the only part you name — rewriting it for you would silently change a URL third parties call." + }, + { + "surface": "a `beforeDelete` handler on a BY-ID `delete()` assigning `ctx.input.id` a DIFFERENT id, to move the delete onto that row", + "replacement": "delete the other row explicitly — `ctx.ql.delete(object, otherId)` / `ctx.api` — and let the addressed delete proceed or `throw` from the handler to stop it; to delete MANY rows, have the CALLER pass `{ multi: true, where: … }`. Writing the SAME id back is unaffected and stays legal.", + "migrationId": "delete-by-id-before-hook-repoint-retired", + "toMajor": 17, + "rationale": "The by-id target of an `update()` or `delete()` is now IMMUTABLE inside a `before*` handler, on both verbs, cleared or rebound. `delete()` was the last cell of that table still answering differently: it HONOURED a repoint, re-resolving the new target by re-reading its pre-image and rebinding `previous` (the fix that first made a single-row delete bind `previous` at all), so `afterDelete` and the roll-up recompute saw the row actually deleted. It now refuses with `HookTargetRebindError` / `ERR_HOOK_TARGET_REBIND`, `path: 'by-id'`, exactly as the `update()` twin and both per-row paths (ADR-0058 Amendment II.1 / D4) already did.\n\nRead this as a RULING, not a defect report — that distinction is the reason the entry is worth its length. That re-resolution was internally CORRECT and nothing stale ever leaked from it; the case that retires a rebind on `update()` (the write landing on a row whose pre-image, `readonlyWhen` locks and validation rules were never evaluated) simply did not apply to it. The engine change that dispatches `before*` hooks per matched row on a bulk write therefore left the asymmetry standing on purpose rather than folding a behaviour removal into an ordering change, and filed it as a finding of its own. The 2026-08-09 maintainer ruling on that finding closed it on three measured axes instead: compatibility cost zero (a repository-wide grep for assignments into a hook's `input.id`, re-run on the implementing PR's base, found six sites and ALL SIX are this family's own pins — no consumer anywhere repoints); one rule across both verbs beats two individually-correct rules an author has to memorize, since the justification for the split lived in an ADR rather than at the call site; and \"a hook silently redirects which row gets deleted\" is a top-grade footgun for authored — especially AI-authored — handlers however correctly the redirect is implemented. Correctness of a mechanism does not justify the surface it exposes. Aligning the other way, by building `update()` the same re-resolution, stays excluded by the recorded ruling that extended per-row hook semantics to `before*` hooks on bulk writes (\"do not silently pick re-resolution instead\").\n\nWhy this is a D3 semantic TODO and not a D2 conversion, on the same two grounds as `hook-register-empty-object-target-refused` and `hook-context-session-roles-retired` at this step: FIRST, there is no source to convert — a `HookContext` is constructed per write and never persisted, so no `sys_metadata` row, example or template can carry the assignment. SECOND, the only place it is ever SPELLED is inside a handler body: author-written JS/TS, or a sandboxed script whose context is `unknown`. A declarative transform cannot safely rewrite an assignment inside free-form code, and the intent is not recoverable anyway — only the author knows whether the repoint meant \"delete that row INSTEAD\" or \"delete that row TOO\".\n\nWhat makes this one cheaper to meet than its two siblings, and worth saying because it bounds the work: the removed capability has an ENFORCED channel at run time. The refusal throws before anything is written and its message NAMES the retired capability and the three replacement routes, so a handler that still repoints fails loudly and self-describingly on its first execution rather than going quiet. This ledger entry is the channel that reaches an upgrader BEFORE that first execution. ADR-0058 Amendment II.2." + }, + { + "surface": "driver aggregate() call argument — query.aggregate and aggregations[].func", + "replacement": "query.aggregations and aggregations[].function — the spellings QueryASTSchema and AggregationNodeSchema have always declared", + "migrationId": "driver-aggregate-undeclared-key-aliases-removed", + "toMajor": 17, + "rationale": "`SqlDriver.aggregate` and `RemoteTransport.aggregate` each read two aliases the Query Protocol has never declared: `query.aggregations || query.aggregate` and `agg.function || agg.func`. \"Never declared\" is measured, not assumed — `git log -S` over `data/query.zod.ts` finds no commit that ever introduced either name, there is no `retiredKey()` tombstone and no alias-table entry for them (the file's only alias table is `SortNode`'s `direction` → `order`), and neither appears in any upgrade guide or release note. So this entry does not record a declared surface being withdrawn; it records a LENIENCY being withdrawn, which is why it is here rather than behind a tombstone. The only writers in this repository were the two driver packages' own fixtures — the family of the org-axis red-line gate that read only rejected aliases while its own fixtures spelt them, so its tests stayed green and the rule stayed dead: a fixture spelling the alias keeps the tolerant limb green forever and no test in existence can go red on its deletion — so ADR-0049 enforce-or-remove applies once those are re-spelt. ⚠️ Do NOT read this across to `dashboard`/`page` measures: `aggregate` IS the canonical key there and `func` IS a declared, loudly-suggesting alias (`DatasetMeasureSchema`, ui/dataset.zod.ts). That neighbouring vocabulary is untouched, and it is the most likely reason an off-repo caller ever wrote these keys on a QUERY — one habit, two surfaces, only one of which declared it. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone: nothing ever ran a query through `QueryASTSchema.parse()` on this path. The enforced channel is tsc at the call site, once the parameter is `DriverQuery` — and for an untyped JS caller there is no enforced channel at all, which is exactly why this ledger entry has to exist: the generated upgrade guide is the only way such a reader learns of the rename. Same disposition, and the same reason, as `data-driver-find-stream-retired` (`IDataDriver.findStream`, removed with no tombstone because nothing parses a driver object), `storage-service-list-retired` (the zero-consumer `IStorageService.list`, whose two adapters answered differently and both incompletely) and `actor-user-roles-to-positions` (the `ctx.user` `roles` alias, closed at once on the maintainer's word rather than given a window). The removal ran in a fixed order — the fixtures re-spelt first, the two alias branches deleted second, the parameter narrowed to `DriverQuery` last — because the reverse order yields red nobody can explain. ADR-0049 / ADR-0087." + }, + { + "surface": "data.DriverCapabilities.create / data.DriverCapabilities.read / data.DriverCapabilities.update / data.DriverCapabilities.delete / data.DriverCapabilities.bulkCreate / data.DriverCapabilities.bulkUpdate / data.DriverCapabilities.bulkDelete / data.DriverCapabilities.transactions / data.DriverCapabilities.savepoints / data.DriverCapabilities.isolationLevels / data.DriverCapabilities.queryFilters / data.DriverCapabilities.queryAggregations / data.DriverCapabilities.querySorting / data.DriverCapabilities.queryPagination / data.DriverCapabilities.queryWindowFunctions / data.DriverCapabilities.querySubqueries / data.DriverCapabilities.queryCTE / data.DriverCapabilities.joins / data.DriverCapabilities.fullTextSearch / data.DriverCapabilities.jsonQuery / data.DriverCapabilities.geospatialQuery / data.DriverCapabilities.streaming / data.DriverCapabilities.jsonFields / data.DriverCapabilities.arrayFields / data.DriverCapabilities.vectorSearch / data.DriverCapabilities.schemaSync / data.DriverCapabilities.migrations / data.DriverCapabilities.indexes / data.DriverCapabilities.connectionPooling / data.DriverCapabilities.preparedStatements / data.DriverCapabilities.queryCache", + "replacement": "(removed — delete the keys. A driver advertises a capability by implementing the corresponding IDataDriver method; the three bits that survive because method presence cannot carry the signal are `queryDateGranularity`, `autonumber` and `batchSchemaSync`)", + "migrationId": "driver-capabilities-inert-bits-removed", + "toMajor": 17, + "rationale": "Retiring `IDataDriver.findStream` (it had no production caller, and two of its three implementations read the whole result set into memory before yielding a row) left `DriverCapabilities.streaming` pointing at a capability the contract no longer declares, and the follow-up audit checked every bit in the record the same way, across objectstack and cloud (objectui confirmed clean): of 34 declared bits, THREE have a decision-making reader — `queryDateGranularity` (engine aggregate dispatch + checkDateBucketParity), `autonumber` (engine defers generation to the driver), `batchSchemaSync` (engine ANDs it with method presence, because a subclass can inherit `syncSchemasBatch` from a base whose transport batches while its own cannot) — and THIRTY-ONE were written by every driver and read by nothing. Their `.describe()` strings promised engine adaptation (\"if false, ObjectQL will filter/sort/paginate in memory\") that was never built, and zero readers let the values go WRONG unnoticed: SqlDriver declared `streaming: false` while implementing `findStream`; InMemoryDriver declared `streaming: true` over a full-table read (ADR-0078 false affordance, on the capability record itself). The real mechanism everywhere else is METHOD presence: transactions gate on `driver.beginTransaction`, aggregate pushdown on `typeof driver.aggregate`, schema sync on `typeof driver.syncSchema`, and the REQUIRED CRUD/bulk methods are called unconditionally. A driver is CODE, never stack metadata — `supports` literals live in driver classes and `DriverConfig.capabilities` is plugin TS configuration, neither ever a `sys_metadata` shape (the stack-tree neighbour, `datasource.capabilities`, was retired separately, as a whole block nothing read) — so there is no source for the D2 chain to rewrite and this entry is the D3 record. The keys are tombstoned rather than deleted because `DriverCapabilitiesSchema` is not `.strict()` and IS parsed (DriverConfigSchema / SQLDriverConfigSchema / NoSQLDriverConfigSchema embed it): a plain delete would silently strip a vendor's authored bit, replacing one silent no-op with another. `batchSchemaSync` also drops its `.default(false)` for `.optional()` — absence already meant false at both readers, and the default forced every capability object to spell out 30+ bits. ADR-0049 / ADR-0078." + }, + { + "surface": "SqlDriver.distinct() third argument — any value", + "replacement": "a bare FilterCondition (@objectstack/spec/data) — the same value find() carries under query.where, never a query envelope", + "migrationId": "driver-sql-distinct-bare-filter-typed", + "toMajor": 17, + "rationale": "This entry records a TYPE being added, not a surface being withdrawn, and it says so up front because the distinction decides who has to do anything. `distinct` is not declared on `IDataDriver`, so neither the narrowing of `IDataDriver`'s query parameters to `DriverQuery` nor the follow-through that brought five drivers' implementations in line ever reached it, and it kept `filters?: any` while its body said something far more specific — `applyFilters(builder, filters)` is handed the ARGUMENT ITSELF, never a `.where` off it. ⚠️ RUNTIME BEHAVIOUR IS UNCHANGED by this entry's change: not one statement moved, so no upgrade breaks at run time and nothing that answered correctly stops. What the annotation removes is a compile-time hole, measured rather than assumed: a truthy NON-OBJECT third argument — `distinct('orders', 'product', 'completed')` — used to type-check and resolve the UNFILTERED set, because `applyFilters` emits no predicate at all for a truthy non-object, non-array filter. A call meaning \"which products among completed orders\" answered with EVERY product, silently. That spelling is now TS2345 at the call site. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone, the disposition `data-driver-find-stream-retired`, `storage-service-list-retired`, `actor-user-roles-to-positions` and `driver-aggregate-undeclared-key-aliases-removed` already carry. ⚠️ It differs from those four in ONE measured way a reader should not have to infer: because nothing changed at run time, an untyped JS caller is not affected BY THE UPGRADE at all. The entry is here for a different reason — such a caller is exactly the one tsc can never reach, and the silent widening above is a defect they may ALREADY be sitting on, before and after this major. The generated upgrade guide is the only channel that reaches them, which is why the fix is written down rather than left to the compiler. ⛔ The reverse mismatch is NOT closed and no type can close it: `FilterCondition` is an open map (`[key: string]: any`) because a filter key IS a field name, so a query envelope `{ object, where }` is structurally a valid filter — one constraining columns named `object` and `where` — and so is a FilterArray. Both reach `distinct` type-checked and are refused at run time, loudly, with INVALID_FILTER / 400. `driver-memory`'s opposite half — where the BARE spelling returns the unfiltered set in silence — stayed open under the maintainer's 2026-08-05 investment freeze on driver-memory, which was lifted on 2026-08-11; it is still open, now unexcused rather than deferred (the measurement that found the two drivers reading this argument differently split the fix: the sql half is this entry, and the memory half was held back by that freeze). ADR-0087." + }, + { + "surface": "engine.find(object, { fields }) and engine.findOne(object, { fields }) carrying a dotted entry (`account.name`) — the direct engine path, not the REST ingress", + "replacement": "read the related record with `expand` (`{ expand: { account: { object: '', fields: ['name'] } } }`), keeping the reference column itself in `fields` — the relation is carried by that column and projecting it away leaves expansion nothing to resolve, the same silent no-op a nested projection omitting the related `id` produced before the engine began keeping that join key itself; or denormalise the value onto the queried object (a stored field, written when the source changes) and name that — the same remedy the REST ingress prescribes when it refuses a dotted projection, and the sort axis when it refuses a dotted sort", + "migrationId": "engine-dotted-projection-refused", + "toMajor": 17, + "rationale": "The REST ingress closed the PROJECTION axis' dotted leg first, refusing a dotted entry instead of widening the response to every field (`assertProjectionFieldsExist`, `400 INVALID_FIELD`), which covers everything reaching `findData`. A caller reaching `engine.find()` / `engine.findOne()` DIRECTLY passed through none of it, and that caller set was measured, not assumed: a flow `get_record` node's authored `fields: ['name', 'account.name']` parses (`GetRecordConfigSchema` restricts nothing), travels verbatim into `data.find(...)`, cleared the engine's head-only projection filter on its head segment (`account` IS a field), and reached the driver as a projection column — where SQL renders `\"account\".\"name\"` against a table that was never joined, the DB answers `no such column`, and the driver's unknown-column recovery ladder — there so an unknown column never reads as \"no rows\" — retries `select('*')`. The caller asked to narrow and silently received EVERY field, byte-identical to no projection at all, pointing away from both FLS and data minimisation.\n\nRuled by the maintainer on 2026-08-12: a dotted entry the engine cannot resolve is refused loudly at the engine's own head-only projection filter, covering every caller that reaches the engine. The check it replaces was justified by a comment claiming the engine resolves relationship paths \"via populate\"; a measurement found that NO populate step exists — once the spec and docs stopped prescribing a dotted `fields` path, that comment was the last place in the repo asserting dotted-path resolution does — so what was removed is not a working feature but a path to widening, kept alive by a false premise. The unknown-PLAIN-column tolerance is explicitly KEPT by the same ruling (an unknown plain name still drops silently; an all-unknown projection still falls back to `*`), a registry-less host gets no verdict (the driver-side recovery ladder remains its documented backstop, and a driver-side carve-out is measured-need only), and a dotted `fields` inside a nested `expand` degrades to an observable warning rather than a refusal — `expandRelatedRecords`' pre-existing graceful-degradation `catch` swallows every expand failure, the same posture the formula-sort refusal (`engine-find-formula-order-by-refused`) records for the same catch.\n\nThis is a CODE-path API, not stored metadata, so — like `engine-find-formula-order-by-refused` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and the ledger entry is the notification channel. No mechanical rewrite exists: the platform cannot decide between `expand` and denormalisation for the caller, and it must not resolve the path itself — no driver ever did, and inventing a join here is a feature decision, not a migration — the line analytics already takes for a relation-traversing dotted measure, refused with a 400 naming the caller's spelling rather than computed against the wrong column. ADR-0112." + }, + { + "surface": "a `where` / filter naming a `formula` field — at BOTH doors: the REST ingress (`assertFilterFieldsExist`, covering everything that reaches `findData`) and the engine seam itself (`engine.find` / `findOne` / `count` / `aggregate` / `update` / `delete`), which saved reports, flows and dashboard widgets reach directly", + "replacement": "denormalise the value onto the object (a stored field, written when the source changes) and filter that — deliberately the same remedy, in the same words, the SORT axis prescribes when it refuses a formula sort, at the ingress and at the engine, and the SEARCH axis prescribes when it refuses a formula search field; `summary` and `autonumber` fields need NO action, because both get real maintained columns and filter correctly", + "migrationId": "engine-find-formula-filter-refused", + "toMajor": 17, + "rationale": "`formula` is the one field type no driver materialises a column for, and FILTER was the last of the three query axes still fail-open on it: SORT refuses it (at the REST ingress and at the engine) and SEARCH refuses it by name, while a `where` on a `formula` field cleared every gate precisely BECAUSE the object declares the field, reached a driver with no column behind it, and answered 200 with zero rows. Measured on a real `ObjectQL` with `is_open` a `formula` over the stored `status` column: `where {is_open: true}` and `where {is_open: false}` each returned 0 rows with NO error, while the controls `where {status: 'open'}` returned 4 rows and `where {subtask_total: 5}` (a `summary`, which HAS a column) returned 1 row.\n\nBOTH directions are wrong and the `false` one is the dangerous one: the same predicate against a STORED boolean returns every matching row, so a filter meaning \"not yet done\" silently became \"no records at all\" — a row SET changed under a 200, which no amount of inspecting the response can reveal, and the formula READS correctly in that very same response, so the field is visibly populated and simultaneously unfilterable. That is strictly worse than the sort axis it mirrors: a refused sort returns the same rows in a different order, a refused filter changes which rows exist.\n\nBoth doors now refuse it with `400 INVALID_FIELD`, naming the offending key path and carrying the remedy sentence — the ingress gate (`assertFilterFieldsExist`, `@objectstack/metadata-protocol`) for everything reaching `findData`, and `assertFilterIsMaterializable` (`@objectstack/objectql`, `filter-comparand-shape.ts`) at the engine's own filter seam, which every caller-supplied `where` passes through whichever verb it arrived by. Both judge the field by the SAME `@objectstack/spec/data` predicate the SEARCH axis uses (`isVirtualSearchField` / `SEARCH_VIRTUAL_TYPES`, which holds `formula` and nothing else), so gate and drivers cannot disagree about which types have a column: a gate widened to the spec's `COMPUTED_VALUE_TYPES` (the WRITE contract) would refuse two working types. DOTTED filter paths are deliberately not judged on this axis at either door.\n\nThis is a CODE-path API, not stored metadata, so — like `engine-find-formula-order-by-refused` and `engine-dotted-projection-refused` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and this ledger entry is the notification channel. No mechanical rewrite exists in either direction: the platform cannot invent the stored column the remedy prescribes, and it must not filter post-hoc instead — `driver.find` has already applied `limit` / `offset`, so a predicate applied after the formulas are evaluated would filter an ARBITRARY PAGE, which looks correct on small result sets and is wrong the moment pagination is involved.\n\nAUTHOR-REACHABLE SURFACES are why this is not merely a code-side note. A saved report's `query.filter` (`sys_saved_report`) is forwarded VERBATIM into `engine.find` by `plugin-reports` (`report-service.ts`, `where: q.filter`), bypassing the ingress gate entirely; flow node `config.filter` and dashboard widget filters are author-written the same way. A report or flow authored to filter on a formula field used to run and quietly return the wrong row set; it now fails loudly, with the remedy in the message.\n\nRegistered on the ruling inherited from the SORT axis — its engine refusal was registered in this ledger although no stored row needs rewriting, because the ledger is the one channel that carries its rewrite instructions to the author — re-affirmed for this axis at triage on 2026-08-13: the shape is identical to the sort axis and the consequence here is larger. ADR-0112." + }, + { + "surface": "engine.find(object, { orderBy }) and engine.findOne(object, { orderBy }) naming a `formula` field — the direct engine path, not the REST ingress", + "replacement": "denormalise the value onto the object (a stored field, written when the source changes) and sort by that — the same remedy the REST ingress prescribes when it refuses a dotted or formula sort; a `summary` field is unaffected and still sorts, because it gets a real maintained column", + "migrationId": "engine-find-formula-order-by-refused", + "toMajor": 17, + "rationale": "The SORT axis is closed at the REST ingress for an unknown field, a dotted path and a `formula` field alike (`assertSortFieldsExist`, `400 INVALID_SORT`), which covers everything reaching `findData`: the list route, `POST /data/:object/query`, the export route and the RPC dispatcher. A caller reaching `engine.find()` / `engine.findOne()` DIRECTLY passed through none of it, and a `formula` ORDER BY there was dropped in silence. Measured on a real driver: `asc` and `desc` came back BYTE-IDENTICAL, in insertion order, under a success, with the rows carrying the very values they were asked to be ordered by. No column exists to order by (a formula is computed on read, so no driver materialises one), so the ORDER BY reached the driver, found nothing, and the unknown-column backstop returned the rows unordered.\n\nRuled by the maintainer on 2026-08-10: an ORDER BY the engine cannot apply is a 4xx with guidance prose at the public boundary, never a silent drop — the same direction as the analytics dataset refusal envelope and the ingress sort hint's stored-field prescription. The engine's documented internal-caller tolerance (`assertProjectionFieldsExist`'s docblock) was to survive only behind a pinned internal path, and only if a MEASURED internal call site relied on it. The sweep of every in-tree `orderBy` reaching the engine directly — hooks, flows, reports, queue/job adapters, sharing, metadata loaders, expand sub-reads — found NONE: every hardcoded internal sort names a real stored column (`created_at`, `updated_at`, `version`, `priority`, `scheduled_for`, `started_at`, `next_run_at`, `recorded_at`, `id`), and no shipped object in the repo declares a `formula` field at all. So no internal path shipped, and there is no flag to opt back into the drop.\n\nThis is a CODE-path API, not stored metadata, so — like `hook-register-empty-object-target-refused` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and the ledger entry is the notification channel. No mechanical rewrite exists in either direction: the platform cannot invent the stored column the remedy prescribes, and it must not sort post-hoc instead — `driver.find` has already applied `limit` / `offset`, so re-sorting after the formulas are evaluated would reorder an ARBITRARY PAGE, which looks correct on small result sets and is wrong the moment pagination is involved.\n\nONE AUTHOR-REACHABLE SURFACE reaches this indirectly and is why it is not purely a code-side note: a saved report's `query.orderBy` (`sys_saved_report`) is forwarded verbatim into `engine.find` by `plugin-reports`, bypassing the ingress gate. A report authored to sort by a formula field used to run and return rows in an arbitrary order; it now fails loudly, with the remedy in the message. One further path is deliberately NOT a refusal: a nested `expand` sort raises this refusal inside `expandRelatedRecords`, whose pre-existing graceful-degradation `catch` swallows every expand failure and retains the raw foreign keys — so that path moves from silent to OBSERVABLE (a warning naming the field and the fix) rather than refusing. Reversing that backstop is a separate decision on all expand failure modes. ADR-0112." + }, + { + "surface": "data.engine.update options.upsert", + "replacement": "(removed — never implemented; express create-if-absent explicitly: `findOne` first, then `insert` or `update` on what you find)", + "migrationId": "engine-update-upsert-retired", + "toMajor": 17, + "rationale": "The `upsert` flag promised insert-if-absent on `engine.update()` but no engine or driver path ever read it: the key was declared on both update-options schemas and allowlisted by the unknown-option gate, yet `ObjectQL.update()` never referenced it and it was not a driver pass-through key — `{ upsert: true }` was accepted and silently dropped and the update stayed a plain update (ADR-0049 declared-but-unenforced). There is no behaviour to preserve and nothing stored to rewrite (it only ever appeared in a call-time option bag). Any future first-class upsert must reconcile with the engine's not-found gate — a by-id update whose id names no row throws RECORD_NOT_FOUND rather than inserting — which is why the flag is removed rather than implemented here." + }, + { + "surface": "api.enhancedApiError.fieldErrors", + "replacement": "fields", + "migrationId": "enhanced-api-error-field-errors-renamed", + "toMajor": 17, + "rationale": "The wire has always carried `fields` — the validators, import coercion, validation-failure.ts, @objectstack/client and the console's field-error extractor all say `fields`, and nothing ever emitted `fieldErrors`, so a reader keying on it was reading a field no server sent (ADR-0078's silently-inert declaration, on the error envelope). This is a RESPONSE surface: no stack, example or template carries the key, so there is no source for the chain to rewrite — the schema tombstones it via retiredKey() and consumers move their read themselves. ADR-0114 D4 (the field-level error code catalog)." + }, + { + "surface": "automation.etlPipeline / automation.etlPipelineRun / automation.etlSource / automation.etlDestination / automation.etlTransformation (the whole L2 layer of automation/etl.zod.ts, its four enums and the `ETL` factory — 9 defs, 27 exported names)", + "replacement": "(removed — no protocol surface replaces it, deliberately. Layer by layer: connector-attached synchronisation is `ConnectorSchema.syncConfig` (`integration/connector.zod.ts`), which is PARSED AND VALIDATED but NOT EXECUTED — a declared shape, not a running sync. `AutomationEngine.registerConnector` runs `ConnectorSchema.parse` and stores the parsed definition; nothing reads `syncConfig` back off it, and the key has no reader outside `packages/spec` at all — the same measurement that deleted `syncConfig.schedule` in @objectstack/spec 17 under ADR-0049, with the other cron-typed positions nothing reads. What the platform DOES execute on a connector is its `actions`: a flow's `connector_action` node resolves the registered handler and awaits it, so an author who needs data actually moved drives it from there. Per-field value transformation on import is `shared/mapping.zod.ts`, whose `transform` is applied row by row by the REST import path and recorded key by key in `packages/spec/liveness/mapping.json`; scheduling is `system/job.zod.ts`. What has NO replacement is multi-source, multi-stage movement with joins and aggregations — because it never had an implementation either. It returns through the ENFORCE route: the engine first, the vocabulary second)", + "migrationId": "etl-pipeline-layer-retired", + "toMajor": 17, + "rationale": "The reading the spec dual-source cleanup used to retire L1 `DataSyncConfig` (its automation copy deleted as dead), re-measured one layer up and identical: narrative-only. No engine ever parsed, scheduled or executed an `ETLPipeline`. Measured on origin/main immediately before the removal: the only non-spec references in this repo are two fumadocs-generated documentation sources (`apps/docs/.source/*.ts`), not executors; objectui has no reference at all; there is no `liveness/etl.json` or `pipeline.json`, so no ADR-0049 gate ever had a reading on it — while the same file family's EXECUTED half does have one (`liveness/mapping.json`), which is the contrast that makes the absence meaningful rather than an oversight. The `etl` string in this registry was the one untested link the finding named, and it is not a loader path: it was the id of the retry-vocabulary entry for `ETLPipeline.retry` (a third retry-policy vocabulary the retry convergence had not covered), absorbed here. The layer was ADR-0078's asymmetry in its purest form — an author could write a complete ten-stage pipeline, get no error, and get no execution. It was also advertised: `packages/spec/docs/SYNC_ARCHITECTURE.md` named `ETLPipeline` as the recommended destination for authors displaced by the L1 retirement and listed ten transformation types with copyable examples down to `script | Custom JavaScript/Python`. That document is rewritten in the same change; a retirement whose own doc still recommends the retired layer is self-contradictory, and forwarding L1's authors to a second layer with no executor was the defect compounding rather than closing. ⚠️ `etl-retry-converged-onto-retry-policy` is SUBSUMED here, the way the `activationEvents`, dynamic plugin-loading and widget / i18n retirements each let an earlier tombstone go with the shape that carried it: both land in the unreleased protocol 17, so composed, a rename of `retry.maxAttempts` on a shape that does not survive the major has no observable effect — and keeping both would tell an upgrader to rewrite a key on a schema the same upgrade deletes. The `maxAttempts` `retiredKey()` tombstone goes with the shape that carried it, which is strictly stronger than the tombstone: there is no longer a `retry` block to author the key into. Route 3 — no carrier key, no parse site, so no D2 conversion and no tombstone; RETIRED_DEFS_BY_MAJOR plus this entry are the declaration. ADR-0049, ADR-0078." + }, + { + "surface": "security.permissionSet.objects[].allowExport (ABSENT — a permission set that never declared the key)", + "replacement": "an explicit `allowExport: true` on the object entry (or the `*` wildcard) of every permission set whose holders are meant to keep exporting", + "migrationId": "export-axis-opt-in", + "toMajor": 17, + "rationale": "A secure-default FLIP, not a shape change — the same class as `rest-requireauth-default-flip` (ADR-0056 D2, protocol 12) and `action-descriptor-resume-authority-default-flip`, and it is registered for the same reason those are: the metadata is UNCHANGED and still parses, so no gate anywhere will tell an upgrader that what it MEANS has inverted. Before 17, `allowExport` unset inherited read; from 17 it denies. Reading a record and taking a bulk machine-readable copy of the whole table are different privileges — Salesforce \"Export Reports\", Dynamics \"Export to Excel\", NetSuite \"Export Lists\" and SAP `S_GUI` 61 all separate them — and the axis now says so. This cannot be a mechanical conversion in either direction: writing `allowExport: true` wherever the key is absent would preserve today's behaviour while silently defeating the entire point of the flip, and writing `false` would revoke a capability the deployment may legitimately want. Whether a given set's holders SHOULD be able to take a bulk copy is exactly the segregation-of-duties judgement the axis exists to make explicit, and it belongs to the operator. Two details decide who is actually affected: package-shipped sets are re-seeded on upgrade, so the built-ins are handled — at 17.0.0 `admin_full_access` and `organization_admin` carried the grant explicitly, ON A `*` WILDCARD, and protocol 18 REMOVES it (see `admin-export-wildcard-removed`: the wildcard made the axis undeniable for an org admin, so from 18 an admin exports only what an app set grants — do not read this clause as a standing promise that the built-ins keep exporting) — while ENVIRONMENT-AUTHORED sets are not and must be edited by hand. `member_default` deliberately does NOT carry the grant, so ordinary authenticated users lose export until an admin grants it; that is the point of the flip, not an oversight. Merge semantics are unchanged and most-permissive, exactly like the CRUD bits: any set granting `true` grants export, and `false` is authoring intent rather than a veto, because permission sets are additive capability containers (ADR-0090). The super-user bits no longer confer it: `viewAllRecords` / `modifyAllRecords` are \"may see all data\", not \"may take a bulk copy\". Registered (backfilled) by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger: the export axis, and its extension to the CSV attachments scheduled reports mail out, both predate the gate that makes a breaking changeset state its ADR-0087 disposition. ADR-0087." + }, + { + "surface": "@objectstack/rest: ExportFieldMeta.required / .system / .readonly / .hasDefault / .min / .max / .minLength / .maxLength (the map built by `buildFieldMetaMap`, reached as `PreparedImport.metaMap` from `prepareImportRequest`)", + "replacement": "the object schema you already hold — read `fields[name].required` / `.system` / `.readonly` / `.defaultValue` / `.min` / `.max` / `.minLength` / `.maxLength` off the same `ObjectSchema` you passed to `buildFieldMetaMap`, which is where the ENGINE reads them and therefore the only copy that cannot drift", + "migrationId": "export-field-meta-constraints-retired", + "toMajor": 17, + "rationale": "ADR-0049 enforce-or-remove. These eight were never a source of truth: `buildFieldMetaMap(schema)` DERIVED each one from the very `schema` its caller passed in, so the map carried a second copy of facts the caller already held. They existed for exactly one consumer — the import dry run's hand-copied pre-check mirror (`firstMissingRequiredField` / `firstConstraintViolation`, added when the dry run was found skipping the field-level validation the real write ran) — and the maintainer's 2026-08-06 ruling D (a validate-only protocol operation, so the dry run's prediction is the engine's verdict by construction) retired that mirror: the dry run now asks `DataProtocol.validateData` for the engine's verdict, which reads the object's own schema. That left all eight computed on every import and read by NOTHING, which is the declared-and-unread shape ADR-0049 exists for; a constraint vocabulary standing next to the presentation one with no enforcer behind it is precisely the thing an AI-authored consumer mistakes for a contract. Verified zero-reader before removal, per key and by type, across this repo (`packages/rest` itself, and all five in-repo dependents of `@objectstack/rest`: runtime, cli, verify, plugin-auth, plugin-dev) and the `objectui` sibling; plugin-auth's identity import forwards `prepared.metaMap` into `runImport` but reads only the presentation keys through `coerceRow`. Why this needs a ledger entry despite that sweep: it is the `findStream` / `IStorageService.list` / `actor-user-roles-to-positions` disposition — a published TS surface with NO spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry a prescription, and the ledger is the only channel that reaches an upgrader. It is if anything blinder than those three: the keys shipped in a FINAL release (`@objectstack/rest` 14.5.0) and have been published in every release since, and because they were OPTIONAL keys on an interface that itself survives, a JavaScript consumer reading `meta.required` after the upgrade gets `undefined` with no error at all — tsc reports at the read site only for a typed consumer. Why D3 semantic and not a D2 conversion: there is nothing to convert. No authored or stored metadata changes shape — `required` / `min` / `maxLength` and the rest remain fully authorable on a field definition and fully enforced by the engine, which is where they always lived. The only place these eight are ever spelled is inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach them. ADR-0049 / ADR-0087; this is the removal the dry-run change deliberately deferred to a sweep of its own." + }, + { + "surface": "data.externalLookup / data.externalDataSource / data.externalFieldMapping (the whole of data/external-lookup.zod.ts — 3 defs, 8 exported names) and system.messageQueue (the whole of system/message-queue.zod.ts — MessageQueueConfig, MessageQueueProvider, TopicConfig, ConsumerConfig, DeadLetterQueue — 5 defs, 14 exported names)", + "replacement": "(removed — there is no replacement key, because there was never a key: neither family was reachable from any metadata-type binding, stack collection or /meta door, so no document could carry either. For external data: `object.external` (`ObjectExternalBindingSchema`, ADR-0015/0062) names a datasource by reference and connection credentials live in the datasource config — never inline in object metadata; `data/external-catalog.zod.ts` is that federated path's catalog surface and is untouched. For message queues: the LIVE surface is `kernel/events/integrations.zod.ts`'s `EventMessageQueueConfig` (`EventBusConfig.messageQueue`), which deliberately carries NO credential field — broker connection and SASL credentials are runtime deployment configuration, not authorable metadata. Either capability returns via the ENFORCE route of ADR-0049 through a new ADR — the executor / broker admin service first, the vocabulary second)", + "migrationId": "external-lookup-message-queue-families-retired", + "toMajor": 17, + "rationale": "Both families are the verdict of the 2026-08-12 census of spec schemas that permit inline credentials (fork (b): no `sys_metadata` door reaches them; accepted 2026-08-12): security-shaped declared surface with inline-credential sinks and ZERO consumers. `ExternalDataSourceSchema.authentication.config` is a record of unknown whose own docblock example wrote `\"clientSecret\": \"...\"` inline, and `MessageQueueConfigSchema.sasl.password` was a required inline broker credential — the class of the `sys_metadata` cleartext-sink finding (cleartext-at-rest credential sinks), except that unlike its two measured surfaces (driver config and connector `authentication`) nothing ever persisted these: no metadata-type binding (kernel/metadata-type-schemas.ts imports neither module), no stack collection, no object/field embedding (`object.external` binds `ObjectExternalBindingSchema` — remoteName/remoteSchema/writable/columnMap, no authentication), and zero imports outside packages/spec repo-wide, with the corpus-reach control (`DatasourceSchema` under identical exclusions) returning hits in the same run. The consumed MQ near-namesake `kernel/EventMessageQueueConfig` deliberately has no credential key, so the consumed shape had no credential and the credential-bearing shape had no consumer. A dead schema minus one field is still a dead schema, so the whole declarations go, not just the credential faces (the lesson of the plugin sandboxing config that was never wired to anything: an exported schema with no consumer reads as a capability to whoever finds it — here it read as an invitation to author secrets in cleartext). With no carrier key there is nothing to tombstone and no source or `sys_metadata` row for a D2 conversion to rewrite: route 3, the shape of the earlier removals of the dynamic plugin-loading family, the `ui/` interaction configs, the widget / i18n shapes and the sweep of five declared-but-inert surfaces — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. ⚠️ The `data/ExternalFieldMapping:transform` tombstone of the field-mapping transform retirement (one of that retirement's three spellings; the whole transform union left because no runtime ever executed any of its five members) is SUBSUMED by the def retirement, the WidgetManifest.performance way: it goes with the shape that carried it. The base `shared/FieldMapping` tombstone and the `integration/ConnectorFieldMapping` spelling are untouched and still reject `transform` with that retirement's prescription. ⚠️ The reopen trigger of the maintainer's 2026-08-12 ruling on the cleartext sink — it closed each artefact's contract (Option A) and parked the class-level `sys_metadata` write-boundary guard (Option B) until \"a third measured artefact-type surface\" — is NOT met by this census: that guard stays parked; this is the ADR-0049 leg of the fork the triage pre-agreed." + }, + { + "surface": "PUT /api/v1/meta/field/{object}.{name} (runtime-authored standalone `field` items)", + "replacement": "Author the field inside its object and write the whole object — PUT /api/v1/meta/object/{object} with the new field in `fields` — or declare it in the object source (`**/*.object.ts`) and redeploy", + "migrationId": "field-runtime-create-withdrawn", + "toMajor": 17, + "rationale": "The `field` registry entry declared `allowRuntimeCreate: true` and the platform never built a read path for it. Measured end-to-end through the real HttpDispatcher -> ObjectStackProtocolImplementation -> SysMetadataRepository: `PUT /api/v1/meta/field/showcase_task.zz_probe` answered 200 with {\"success\":true,\"state\":\"active\",\"message\":\"Saved field …\"}, the row persisted, and `GET /api/v1/meta/object/showcase_task` then listed fields = [title, status] with zz_probe ABSENT — forever. The row is even self-readable by name (`GET /meta/field/showcase_task.zz_probe` -> 200, `_diagnostics.valid: true`), which makes it well-formed and universally inert rather than malformed. The seam is that `field` is the ONE declared type with no standalone existence: fields are authored inside the object (`ObjectSchema.fields`), a `field` write mints a SEPARATE row keyed ('field','.'), and nothing composes fragment rows into their parent — `applyRegistryWriteThrough` routes only `type === 'object'`, and `filePatterns` (`**/*.field.ts`) match nothing in any app. A declared capability the platform cannot honour is ADR-0049 false compliance, and the maintainer ruled REMOVE on 2026-08-12 rather than build the read path, which is a feature spanning at least three packages (a composition step that does not exist, ~20 `gate.fields` call sites, physical schema/migrations, and cold boot via `loadMetaFromDb`); if ever wanted it is a separate card — implementation first, declaration second. ⚠️ This is NOT the `api` withdrawal's rationale reused (`api-runtime-create-withdrawn`): that ruling rested on \"zero business pull\", and \"add a field\" is the opposite — a core Studio/CRM operation. The justification here is that the operation REMAINS AVAILABLE on the route that actually composes: `object` keeps `allowRuntimeCreate: true`, so what is withdrawn is a second, broken SPELLING of adding a field, not the ability to add one. There is NO D2 conversion, for the reason this list exists: nothing in an authored source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an authorable one, and no authored source changes — an `**/*.object.ts` file valid before this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, so it is one semantic TODO for operators and Studio callers rather than a stack conversion — the same disposition `api` and `BatchOptions.validateOnly` take. ADR-0049 / ADR-0087." + }, + { + "surface": "data.filter $regex / $options — in a STORED filter (dashboard widget filter and globalFilters, report runtimeFilter, page and component filter, solution-blueprint filter), and equally in the where clause of a query request", + "replacement": "$icontains for the case-insensitive substring match this was almost always used for, or $contains for a case-sensitive one — a pattern that genuinely needs a regular expression has no filter-level replacement", + "migrationId": "filter-regex-options-retired", + "toMajor": 17, + "rationale": "Like `driver-aggregate-undeclared-key-aliases-removed` and `driver-sql-distinct-bare-filter-typed`, this entry records a LENIENCY being withdrawn rather than a declared surface: `$regex` was never in `FILTER_OPERATORS` and never a key on `StringOperatorSchema`. That is measured, not assumed — `git log -S'$regex'` over `packages/spec/src` returns only doc comments describing how `$contains` LOWERS to MongoDB (`Contains substring - SQL: LIKE %?% | MongoDB: $regex`), plus the retirement's own contract-half change, which added the name solely as `RETIRED_FILTER_OPERATORS` prescription data. ⚠️ But it differs from those two in the one way that decides the disposition, so a reader should not have to infer it: those were driver CALL ARGUMENTS, code and never stack metadata, whereas a filter IS stored metadata. `FilterConditionSchema` is an OPEN RECORD (`z.record(z.string(), z.unknown())`) because a filter key is a field name, so a stored `{ name: { $regex: 'acme.*' } }` parses GREEN and always will — a `retiredKey()` tombstone cannot exist on an open map, which is exactly why the ledger has to carry this. What such a stack used to get was four different answers from four backends: `driver-sql` and Turso's remote transport compiled it to a LIKE-escaped SUBSTRING (so `a.b` matched only the literal `a.b` and the regex was silently never a regex), `driver-memory` and objectql's `having` ran it as a real `RegExp` (so the same filter also matched `axb`, and an INVALID pattern was caught and answered `false` — zero rows, in silence), and `driver-mongodb` refused it with a bare `Error` carrying no `code` and no `status`. It is now refused everywhere with INVALID_FILTER / 400 naming the replacement. There is deliberately NO D2 conversion and this sits in `semantic` rather than among the mechanical transforms: rewriting `$regex` to `$icontains` is NOT lossless in either direction — a regex metacharacter becomes a literal — so an auto-applied rewrite would silently change which rows a dashboard, report or permission filter selects, a wrong number rather than a missing one. Choosing the substring the pattern MEANT is a judgment about the query, not a transform. ⚠️ This entry covers BOTH HALVES of the maintainer's 2026-08-06 ruling (option B: retire `$regex` loudly and add `$icontains`, rather than make five backends agree on one regex dialect), not just the driver one: the contract half (the `$icontains` declaration, the `$contains` family pinned case-sensitive, and the `RETIRED_FILTER_OPERATORS` prescriptions) landed before the gate that makes a breaking changeset state its ADR-0087 disposition existed, and so was never asked for a ledger entry; the driver half is where the refusal became executable. One surface, one entry, registered from the half that made it observable. ADR-0049 / ADR-0087." + }, + { + "surface": "flow.errorHandling.maxRetries (under strategy: 'retry')", + "replacement": "an explicit count >= 1 (e.g. maxRetries: 3), or strategy: 'fail'", + "migrationId": "flow-retry-max-retries-required", + "toMajor": 17, + "rationale": "maxRetries had two defaults — FlowSchema `.default(0)` and the engine's `maxRetries ?? 3` — so an unstated count retried 0 times through the schema and 3 times through a hand-built definition. With the engine's copy removed the unstated count is unambiguously 0, and retrying zero times is exactly `strategy: 'fail'`, so the schema now refuses the combination instead of it silently doing nothing. There is no lossless rewrite: 0 preserves the behaviour a parsed flow got but contradicts what its author wrote, and any positive count is a NEW decision about re-running the whole flow with its side effects. That choice is the author's." + }, + { + "surface": "data.hookContext.session.roles", + "replacement": "(removed — gate on `session.userId` / `session.isSystem`; for PRIVILEGE ask the security service, which reads `permissions` / `positions` / posture off the execution context, ADR-0095 D3)", + "migrationId": "hook-context-session-roles-retired", + "toMajor": 17, + "rationale": "Declared on the runtime hook context, read by exactly two consumers, produced by nobody. The two readers were the approvals record lock and the delegation write guard, each opening with `session.roles?.includes('admin')`; ObjectQL's `buildSession()` builds the session field by field and has never written `roles`, and nothing else feeds a HookContext in objectstack, cloud or objectui (cloud's hook consumers read `hookContext?.session?.userId`; objectui's `roles` are the `/auth/me` user payload, a different surface; an ACTION body's `ctx.session` is a different untyped object that does carry `roles`, tracked apart and unaffected). Both branches were therefore dead on every real engine path — an authorization decision in shape only, and a second admin dialect competing with the one ADR-0090 D3 / ADR-0095 D3 sanction. An earlier fix removed both readers, returning the record lock and the delegation guard to the one permission vocabulary; this removes the declaration, per ADR-0049 enforce-or-remove. This is a RUNTIME context, not stored metadata: the engine builds a HookContext per operation and nothing persists one, so no `sys_metadata` row, example or template can carry the key and there is no source for the D2 chain to rewrite — the `openApi31` / `activationEvents` shape, one semantic TODO rather than a stack conversion. The key IS tombstoned (`HookContextSchema` is deliberately not `.strict()` — a plain delete would strip it silently, as a removed field key was measured to be, ADR-0104), so a consumer that parses a context it was handed still meets the prescription. ADR-0049." + }, + { + "surface": "engine.registerHook(event, handler, { object: '' | [] | [''] }), and a scope whose `excludeObjects` cancels its `object` entirely", + "replacement": "name the object(s) — `object: 'account'` / `object: ['account', 'contact']` — or, for a global hook, `object: '*'` or no `object` key at all; for a cancelled scope, widen `object` or drop the overlapping names from `excludeObjects`", + "migrationId": "hook-register-empty-object-target-refused", + "toMajor": 17, + "rationale": "An earlier breaking fix established that an empty hook target is not \"no target\" and closed the shape at the two METADATA doors — `HookSchema.object`'s refine and `hook-binder.ts`'s `normalizeObjects`. `engine.registerHook`, the CODE door, goes through neither, so all three spellings still registered, each producing a defect the author did not write: `''` is FALSY, so the allow face was skipped entirely and the entry became a GLOBAL hook (that fix's headline failure mode — blank intent taking the broadest possible blast radius); `[]` and `['']` are truthy but admit no object name, so the entry could never fire. The later `excludeObjects` face (a hook global except for the objects it names) then brought a fourth shape reached by arithmetic rather than by one bad name: an `object` list every member of which is also excluded admits nothing, so that entry can never fire either. All four are ADR-0078 silently-inert declarations, and all four are now refused at REGISTRATION.\n\nNo mechanical rewrite exists, in either direction. The refused values carry no recoverable intent — `object: ''` could have meant `'*'` (what it actually did) or a specific object name the author forgot to fill in, and those are opposite registrations; choosing between them is a judgment the chain cannot make. Nor could the MATCHING read be changed instead: teaching the matcher that `''` is an unmatchable name would silently convert a hook firing on every object into one firing on none — the same class of defect pointing the other way, which is why the `excludeObjects` change declined to do it in passing.\n\nThis is a RUNTIME registration API, not stored metadata, so — like `hook-context-session-roles-retired` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and the ledger entry is the notification channel. One metadata surface reaches it INDIRECTLY and is the reason this is not purely a code-side note: a `record-change` flow's start node forwards `config.objectName` verbatim into `registerHook` (`RecordChangeTrigger.start`), so a flow authored with a blank `objectName` used to bind a trigger to EVERY object in the tenant. It now fails to bind instead, loudly — the automation engine's per-flow bind guard warns and the `kernel:bootstrapped` binding audit re-reports it — which is the correct end state, but it is an observable change for that flow. ADR-0078." + }, + { + "surface": "observability.SEMCONV.httpRequestErrorsTotal (the published metric name http_request_errors_total{method,route}, and its emission from the runtime dispatcher's per-route wrapper)", + "replacement": "the 5xx rate is `http_requests_total{status=~\"5..\"}` — the TRANSPORT emits that family through the `IHttpServer.afterResponse` seam, so it covers every inbound surface; unhandled-exception rate specifically, which is the one thing the retired counter uniquely reported, is the `errorReporter` (Sentry / Datadog / your adapter), which still fires on every 5xx throw", + "migrationId": "http-request-errors-total-retired", + "toMajor": 17, + "rationale": "ADR-0049 enforce-or-remove, on a DECLARED-not-enforced metric name. `SEMCONV` published `http_request_errors_total` as part of a stable namespace declared \"so hosts can wire alerts/dashboards against it\", but the only emitter was `@objectstack/runtime`'s `instrumentRouteHandler`, applied only by the dispatcher's own route Proxy — so the series never saw auth's `getRawApp()` mount, the REST data API via `RouteManager`, or any other inbound surface. Its two siblings in the same family were moved to the transport seam (the request counter, then the latency histogram, both through the response-observing hook the transport was given) and this one could not follow: `HttpResponseObservation` carries `{method, routePattern, status, elapsedMs}` and NO throw signal of any kind, so every transport-side shape would have counted a DIFFERENT population rather than the same one more widely. The divergence was measured in both directions — the dispatcher answers its own errors through `errorResponseBase`, which sets a status and does not re-throw, so the old counter MISSED those, while its `catch` incremented unconditionally, so a thrown 4xx WAS counted as an error. And `http_requests_total` already carries a `status` label, so a status-class error counter would be fully derivable from data the transport already publishes. Maintainer ruling 2026-08-20 (option C of four presented, over B \"move it to the transport as a status class\" and D \"keep it dispatcher-scoped and rename it\"): RETIRE. A metric NAME is a RESPONSE surface, not authorable metadata — no stack, example or template carries it, so there is no source for a D2 conversion to rewrite and no schema to tombstone; a host names the series in its own dashboard or alert file, outside this repo. That is exactly why this entry exists: for an operator whose Grafana keys on the string, the ledger is the only notification channel there is. Same disposition, and the same reason, as `runtime-httpserver-wrapper-retired` and `enhanced-api-error-field-errors-renamed`. ADR-0049 / ADR-0087." + }, + { + "surface": "system.serverEvent / system.serverEventType / system.serverCapabilities / system.serverStatus (the lifecycle-event, capability-report and status vocabulary of system/http-server.zod.ts — 4 defs, 8 exported names)", + "replacement": "(removed — there is no replacement key, because there was never a key. Server lifecycle is the transport plugin's own start/stop seam; per-request and per-server observability is `system/metrics.zod.ts` and `system/logging.zod.ts` (plus `OS_SERVER_TIMING` for timings), and liveness is the `/health` endpoint. What a transport plugin can DO it states by implementing the kernel plugin contract — the seams it registers are the capability statement, and a self-described capability record can only disagree with them. Server-level configuration that IS authorable lives on `defineStack({ server })` / `StackServerConfigSchema`, which is unaffected)", + "migrationId": "http-server-runtime-vocabulary-retired", + "toMajor": 17, + "rationale": "The second and final ADR-0049 pass over `system/http-server.zod.ts`. The first removed the CONFIG half (`HttpServerConfigSchema`, nine keys, zero readers, zero authoring entry); this removes the RUNTIME half — a 7-member lifecycle event union with a timestamped envelope, an eight-boolean capability report, and a five-state status record with connection and request counters. Nothing ever emitted, consumed or parsed any of them. This card was HELD for four days rather than queued, on a specific and legitimate doubt: a response/capability vocabulary can be a REFERENCE surface for host implementers, so \"zero consumers in this repo\" is weaker evidence for one of those than for an authorable key (the CSS-variable rebuttal). The hold was lifted by measuring the reference reader itself rather than by re-running the same grep: `plugin-hono-server`, the one in-tree host implementation, neither implements nor reports any of the three — it names no capability record, no status shape and no event union, and what it registers is routes and middleware through the kernel plugin contract. A declaration-site grep put every declaration in this one file, a quoted-name sweep across objectstack and objectui found no reader outside it, and the control passed in the SAME run: `MiddlewareConfig`, declared twelve lines away, resolves to `packages/runtime/src/middleware.ts`. So the sweep could see a reader in this file when there was one. With no carrier key there is nothing to tombstone, and with no author there is no source or `sys_metadata` row for a D2 conversion to rewrite: RETIRED_DEFS_BY_MAJOR plus this entry are the declaration — route 3, the same shape as the config half's removal in this very file and the earlier removals of the dynamic plugin-loading family, the `ui/` interaction configs and the widget / i18n shapes. If host-implementer conformance becomes a real requirement it returns through the ENFORCE route: an adapter contract with a checker behind it, vocabulary second. ADR-0049." + }, + { + "surface": "api.ImportRequest runAutomations — the declared default of the key on BOTH import bodies, POST /api/v1/data/:object/import (ImportRequest) and its async twin POST /api/v1/data/:object/import/jobs (CreateImportJobRequest, which IS the same schema object). It was declared default(false) and described as \"off by default for bulk\"; it is now default(true), which is what the server has always done", + "replacement": "an explicit runAutomations: false on any import request that is meant to load rows without firing triggers/hooks. That spelling is unchanged and has always been the only one the server read — what changes is that omitting the key now DECLARES what it already DID. Callers who want automations on need write nothing", + "migrationId": "import-run-automations-declared-default-corrected", + "toMajor": 17, + "rationale": "A DECLARATION corrected to match a runtime that did not move — the inverse of a behaviour flip, and registered here for the reason protocol 12's `rest-requireauth-default-flip` and this major's `action-descriptor-resume-authority-default-flip` are: whether a given import was meant to fire triggers is a judgment no transform can make, so the prescription is a TODO rather than a rewrite. The server decides in import-prepare.ts with `body?.runAutomations !== false`, i.e. an omitted flag runs automations, and has since the flag was first honoured — automations always ran on import historically (the engine ignored the flag entirely before then), so opt-out was made the explicit act, matching platform convention. The schema said the opposite in both machine-readable and human-readable form, and both SHIPPED: `.default(false)` in `@objectstack/spec`'s JSON Schema, and the describe prose in the published reference tables for both defs. ⚠️ Nothing in this repo reconciled the two and NO deployed caller changes behaviour: no request path parses an import body through this schema — the route reads the raw body, and the sole reference to `CreateImportJobRequestSchema` is the declarative `ImportJobApiContracts` catalog entry, a declaration and not a parse. That is exactly why this needed a ruling rather than a docs edit: the divergence was unobservable in-tree and observable only to a consumer OUTSIDE it. A client or SDK that validated its request through the published schema materialised `runAutomations: false` from the declared default and sent it explicitly, and the server honoured it — so the same request body produced opposite behaviour depending on whether the caller validated before sending, with the validating caller silently losing its triggers. Nothing rejected it, nothing warned, and the reference page told an author the wrong thing in the other direction. There is deliberately NO schema tombstone and no D2 conversion: no key is removed, and an HTTP request body is neither authored nor persisted — the same disposition `notification-list-cursor-retired` takes for the sibling default on this major, and `batch-options-validate-only-retired` before it. The declared move itself is recorded mechanically, per key, in DEFAULT_CHANGES_BY_MAJOR[17] — the per-key default fingerprint added once a flipped default was found invisible to every gate — whose `from`/`to` fingerprints are re-derived on every build. Maintainer ruling 2026-08-09, disposition A: the spec follows the runtime. ADR-0049 / ADR-0078." + }, + { + "surface": "job.retryPolicy.maxRetries (> 10) / job.retryPolicy.backoffMultiplier (< 1)", + "replacement": "maxRetries <= 10, and backoffMultiplier >= 1", + "migrationId": "job-retry-policy-constraints-tightened", + "toMajor": 17, + "rationale": "The RetryPolicy converged onto one declaration from its automation and system copies keeps the automation side's bounds, which the job side never had: `maxRetries` is capped at 10 and `backoffMultiplier` floored at 1. Neither has a lossless rewrite. Clamping `maxRetries: 20` to 10 would halve a retry budget its author chose, and a `backoffMultiplier` below 1 describes a delay that SHRINKS on each attempt — retrying a failing dependency ever faster, which is the opposite of backoff and was never a shape the engine meant to offer. Both now fail at parse time with the bound named, rather than being silently reinterpreted. Choosing the replacement count (or accepting the cap) is the author's call." + }, + { + "surface": "api.listNotifications cursor — the key on BOTH halves of GET /api/v1/notifications (ListNotificationsRequestSchema and ListNotificationsResponseSchema) and the cursor argument of the client SDK call client.notifications.list(). The same entry covers the limit default: the request schema no longer declares default(20)", + "replacement": "a larger `limit` — the route answers the newest N notifications and has no page 2. There is no replacement for `cursor`, deliberately: nothing ever minted one, so no caller holds a value to carry over. Callers that looped on it were re-reading the first window and should read one window sized to what they display (the Console bell polls exactly this way). For the removed `limit` default, send the number you want explicitly if you were relying on 20 — omitting it takes the server window, which is 50 on the platform inbox and clamped into 1..200, and has been since before the declaration existed", + "migrationId": "notification-list-cursor-retired", + "toMajor": 17, + "rationale": "One capability, both halves, never half-deleted (maintainer ruling 2026-08-07, Option A, ruled jointly with the repair that made `unreadCount` really count the whole inbox). `cursor` was declared on the request and on the response and honoured on neither: the dispatcher domain reads `read` / `type` / `limit` and nothing else, and no emit site has ever written the response key. It was worse than inert because it had a shipped PRODUCER — the SDK appended it to the query string — so a caller paginating by the published contract looped on page 1 forever, with no error and no 400. Measured over a real boot with 60 unread before the removal: page2 === page1, both parsing green against the response schema, which is why no conformance gate could see it. This is `data.query.cursor` (`query-cursor-retired`) one layer up, with the same verdict for the same reason, down to deleting the SDK producer alongside the key. A first-class inbox cursor, if one is ever designed, will be a response-minted opaque token — a different API — so keeping this one preserved a wrong design rather than a roadmap. The `limit` default goes with it because the FICTION WAS THE MECHANISM, not the number: no request path parses a query string through this schema (the fix for request bodies never checked against their declared schemas wired the catalog's requestSchema to the real entry for BODIES only), so `.default(20)` never stamped anything onto anything, and the server has always applied its own 50. Re-spelling 20 as 50 — the other arm the ruling allowed — would have kept a declaration that does not execute and merely made it coincide with the implementation until someone moved the clamp; `.optional()` plus prose is true about both the schema and the server. No constraint (`.int()` / `.max(200)`) is declared either, because the service CLAMPS an out-of-range limit rather than refusing it, and declaring a rejection the wire does not perform is the same defect mirrored. Route 2, and the split is worth stating exactly because the two halves of the bookkeeping go different ways. There IS a tombstone: both schemas are non-strict, so a bare deletion would have made Zod SILENTLY STRIP whatever a caller kept sending — a clean parse and a parameter that never takes effect, which is this issue's own defect re-created one layer down (the silent strip measured when a field key pruned from a non-strict schema still parsed and simply vanished, ADR-0104). So `cursor` is `retiredKey()` on both halves, typed `never` for tsc and raising the prescription at any parse, and both keys are registered in RETIRED_KEYS_BY_MAJOR[17]. There is NO D2 conversion: a conversion rewrites an authored source or a stored `sys_metadata` row, and these two shapes are HTTP-only — nobody authors a `ListNotificationsRequest` and nothing persists one. Request AND response shapes: two semantic TODOs for API callers, no stack conversion — the same disposition `BatchOptions.validateOnly` (a declared dry-run that wrote for real) and the `AnalyticsQueryRequest` envelope keys already take in this major. The `limit` default is declared separately and mechanically, in DEFAULT_CHANGES_BY_MAJOR[17] (the table that closed the blind spot where a default or constraint change on an authorable key was recorded by no gate), whose `from`/`to` fingerprints are re-derived on every build. ADR-0049 / ADR-0078." + }, + { + "surface": "protocol.deletePackage({ packageId }) with no `organizationId` (and its transport, DELETE /api/v1/packages/:id)", + "replacement": "explicit `allTenants: true` for a cross-tenant uninstall, or an `organizationId` to scope it", + "migrationId": "package-uninstall-explicit-all-tenants", + "toMajor": 17, + "rationale": "An uninstall that named no organization matched EVERY organization's rows — measured at 5 of 5 deleted, including a foreign org's, while uninstall's orphaned-row defect was being repaired. That width was never chosen; it fell out of a missing argument, and the two transports of the same route disagreed because of it. In protocol 17 the call is REFUSED instead: neither `organizationId` nor `allTenants: true` answers 400 `TENANT_SCOPE_REQUIRED` and deletes nothing, as does supplying both (they are contradictory, not redundant). Whether a given caller meant \"this tenant\" or \"every tenant\" is an intent no transform can recover: `resolveActiveOrganizationId` is catch-wrapped, so an accidental org-less call and a deliberate environment-wide one are byte-identical at the call site — which is the whole reason the parameter had to become explicit rather than conventional. Nothing in authored metadata spells this: it is a runtime call-site contract, so it is one semantic TODO for operators and API callers rather than a stack conversion — the same disposition `rest-requireauth-default-flip` (protocol 12) takes for its own default flip." + }, + { + "surface": "kernel.dynamicLoadRequest.activationEvents / studio.studioPluginManifest.activationEvents", + "replacement": "(removed — delete the key. Every plugin activates immediately on load/registration, which is the only behaviour that has ever existed; `activate()` still runs at registration time. Lazy activation, if built, returns via the enforce route of ADR-0049 through a new ADR, with a vocabulary its executor actually honours)", + "migrationId": "plugin-activation-events-retired", + "toMajor": 17, + "rationale": "Both `activationEvents` keys — and the `ActivationEventSchema` trigger vocabulary they embedded (`onCommand` / `onRoute` / … / `onView`, once the kernel and studio copies had converged on the kernel's structured `{ type, pattern }` shape) — promised lazy plugin activation (\"plugins remain dormant until an activation event fires\") that no runtime in objectstack, cloud, cloud-v1 or objectui ever implemented: nothing anywhere read the key, every plugin activates immediately, and cloud-v1's own ROADMAP recorded lazy activation as unimplemented (planned v0.4.0). That is the ADR-0049 false-compliance shape in the semantically-lying direction: an author writing `activationEvents: [{ type: 'onMetadataType', pattern: 'flow' }]` expected deferral and got eager activation with a clean parse. Neither parent shape is stored metadata — `StudioPluginManifest` is TS configuration parsed by `defineStudioPlugin` (a root schema, never part of a stack tree) and `DynamicLoadRequest` is a runtime request shape with no caller — so no `sys_metadata` row can carry the key and there is no source for the D2 chain to rewrite; this entry is the D3 record. The kernel key is tombstoned via `retiredKey()` (its schema is not `.strict()`; a plain delete would strip an authored value silently), the studio key is rejected by the strict manifest parse with a guidance prescription (as are its former VS Code-flavoured aliases `activation` / `events` / `onActivate`), and the orphaned `ActivationEventSchema` / `ActivationEvent` exports are removed from `./kernel` and `./studio` with the keys (the lesson of the unwired plugin sandboxing / integrity / approval config removed before this: an exported schema with no consumer is read as a capability). Both keys took ADR-0049's REMOVE answer, not ENFORCE, while protocol 17 was still unreleased. SUPERSEDED ON THE KERNEL SIDE by the maintainer's REMOVE ruling on the rest of the plugin-runtime family (same unreleased major): the whole `DynamicLoadRequest` shape — and the rest of the plugin-runtime family with it — was removed, which took this key's `retiredKey()` tombstone with it. That is strictly stronger than the tombstone, not weaker: there is no longer a `DynamicLoadRequest` to author the key INTO, so the prescription an author needs is no longer \"delete this key\" but \"this request shape does not exist\" (see `plugin-runtime-family-retired`). The studio half of this entry is unaffected and still enforced by the strict manifest parse." + }, + { + "surface": "manifest.loading (the whole block: strategy / preload / codeSplitting / dynamicImport / initialization / dependencyResolution / hotReload / caching / sandboxing / monitoring)", + "replacement": "nothing to re-declare — delete the key. Plugins are composed at boot: `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder` in `packages/core/src/plugin-order.ts`). For the isolation `loading.sandboxing` appeared to configure, note that the plugin trust tier (`manifest.runtime`, ADR-0025 §3.6) does not supply it either: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the manifest permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation", + "migrationId": "plugin-manifest-loading-retired", + "toMajor": 17, + "rationale": "ADR-0049 enforce-or-remove; the maintainer ruled REMOVE on 2026-08-04, on condition that a bare-name sweep of cloud and objectui came back clean first. The block declared a complete plugin loading policy and NOTHING read it. A bare-name scan of all three repos — objectstack, cloud (measured 2026-08-09) and objectui (measured at pickup), each with a control probe proving the scan saw the tree — put every hit inside `packages/spec` itself: this module's own declaration, its own unit tests, the `Manifest.loading` embed and the generated artifacts. `manifest.loading.*` had zero readers in `packages/core`, `packages/runtime` and `packages/metadata`. So the key parsed, entered the manifest, and changed nothing — an exported schema with no consumer, read as a capability, at the scale of a whole block. What made it outrank ordinary inert-key cleanup is `sandboxing`: it declared process / vm / iframe / web-worker isolation, IPC transports and an `allowedServices` ACL, so an AI author (ADR-0033) reading that vocabulary concluded the platform isolates plugins, wrote the config, and received a clean parse and zero isolation. An inert security control is worse than an absent one because it is believed. Hot reload was additionally a TWO-SOURCE defect: the docs pointed at this dead `PluginHotReloadSchema` while the only implementation body, `HotReloadManager` (`packages/core/src/hot-reload.ts`), reads a different vocabulary — `HotReloadConfigSchema` in `plugin-lifecycle-advanced.zod.ts`. Ruling §2 converges on the surviving side: that schema is KEPT as the starting point for a future enforce decision (it has an implementation body but no runtime composes it yet), and enforcing it is deliberately a separate decision, not this retirement. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections. A package manifest is neither — `PLURAL_TO_SINGULAR` has no `packages` / `plugins` entry, so a manifest is not a stack collection member and a stored manifest row passes that seam through unchanged. A conversion would be a transform with no seam that ever runs." + }, + { + "surface": "kernel.dynamicLoadRequest / kernel.dynamicUnloadRequest / kernel.dynamicPluginResult / kernel.pluginSource / kernel.dynamicPluginOperation", + "replacement": "(removed — there is no replacement shape, because there is no operation to describe. Plugins are composed at boot: `defineStack` registers them and the kernel runs register → init → start; the set is fixed until the process restarts. Delete the import and the value. Runtime plugin loading, if it is ever built, returns via the enforce route of ADR-0049 through a new ADR — loader first, vocabulary second)", + "migrationId": "plugin-runtime-family-retired", + "toMajor": 17, + "rationale": "The five schemas declared the \"Dynamic Loading\" capability — runtime load / unload / reload of plugins without a kernel restart, with sandboxing, integrity hashes, drain strategies and dependent-cascade policy — and NOTHING implemented it. A bare-name scan of objectstack, cloud and objectui found zero references outside this package's own declaration, its unit tests and the generated artifacts: no runtime ever received a `DynamicLoadRequest`, performed a load/unload, or produced a `DynamicPluginResult`. That is the ADR-0049 false-compliance shape at its most inviting to an AI author (ADR-0033), who reads `DynamicLoadRequestSchema` in the published IDE bundle as proof the platform hot-loads plugins and constructs a request that parses clean and is received by nobody (an exported schema with no consumer is read as a capability). The earlier removal of this module's discovery/sandbox config island — plugin sandboxing, integrity and approval settings that nothing read — left these five in place explicitly: \"operation contracts, not security promises; the enforce-or-remove call on them is a design decision rather than a correction\" — but that suspension lived only in a changeset paragraph with no issue carrying it. The maintainer's ruling of 2026-08-03 is that decision, answered REMOVE: hot loading is a real future capability, but nothing is being built and nothing pulls it, and when it is built its vocabulary enters the schema with the implementation. `experimental` was considered and rejected: it is only `.describe()` prose and cannot stop an import, the weakest of the three ADR-0049 channels. None of the five is stored metadata — they are root request/result payload shapes embedded in no parent schema and parsed against no metadata document — so no `sys_metadata` row can carry one and there is no source for the D2 chain to rewrite; this entry is the D3 record. The removal also subsumes the kernel half of `plugin-activation-events-retired`: that tombstone goes with the shape that carried it. ADR-0049." + }, + { + "surface": "sys_position.permissions — the \"JSON-serialized array of permission strings\" textarea column left the platform position table declared by plugin-security (packages/plugins/plugin-security/src/objects/sys-position.object.ts), together with the clone_position copy entry that carried it between rows", + "replacement": "nothing on this table — delete the key from any authored `sys_position` seed row (stack `data` entries) or data-door write that still carries it. There are no direct position-level permission strings anywhere on the platform: capability reaches a position ONLY through permission-set bindings (`sys_position_permission_set` rows, created in Setup or by an app's kernel:ready binder) and is resolved from the position `name` at request time. A value that was recording intent as documentation belongs in `description`, which remains declared", + "migrationId": "position-permissions-column-retired", + "toMajor": 17, + "rationale": "Maintainer ruling 2026-08-20 on the finding that nothing writes or reads this column, ADR-0049 enforce-or-remove: REMOVE. The object-scoped census (all sys_position-naming files, with same-object positive controls resolving `active` / `delegatable` / `is_default` / `name` to real readers) measured the column at zero on both sides: the only row writers — the builtin and declared position bootstrappers — set label / description / managed_by / active / is_default, and position→grant resolution consults `sys_position_permission_set` rows plus the position `name`, never this column. Its only in-repo reference was the clone_position action copying it between rows — a copy of a value nothing writes. objectui was searched under the same discipline (evidenceScope closure): no console surface names the column — the position pickers and Setup views read name / label / id only, so a designer preview consumer does not exist either. That left a declared free-text grant catalogue on a security object that no runtime enforced: an author — human or AI — who filled it believed they granted permission strings directly on the position, and nothing refused or honoured the value. This is a platform-object COLUMN retirement, not a spec-key retirement, so the bookkeeping follows the ups-delegated-from-column-retired shape: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — PositionSchema never declared `permissions`, and the surface ratchets are expected byte-identical), no liveness-ledger row is added (the ledger walks PositionSchema's shape, which never carried the key — a row would be an orphan), and the disposition is a SEMANTIC entry rather than a D2 conversion: no conversion in the chain rewrites seed rows today and the measured author base is zero, while the loud channel already exists at runtime — the engine schema preflight refuses an undeclared field with 400 INVALID_FIELD before the driver or any hook runs — so this entry carries the prescription and the refusal carries the enforcement. The live-authoring half is the PositionSchema strict-parse guidance for `permissions`, which names the binding table in the rejection. ⚠️ Existing physical columns are deliberately untouched: schema sync is additive (ADR-0045), so a deployed database keeps the column; the platform stops declaring, projecting or accepting it. Zero producers means no rows are expected to carry a value; no backfill or destructive DDL is required or wanted. If position-level direct grants ever become a real need, the column is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce or do not declare." + }, + { + "surface": "data.query.aggregations[].function ('array_agg' / 'string_agg')", + "replacement": "an ordinary `fields` query, shaped in the caller — or a stored field that materialises the roll-up. For a deduplicated COUNT the live spelling is unchanged: `count_distinct` stays declared", + "migrationId": "query-array-string-agg-retired", + "toMajor": 17, + "rationale": "The stored half of this retirement is a conversion (`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. `QueryAST` is never stored in stack metadata — it is the client SDK builder's output and the `POST /data/:object/query` body — so there is no source for the chain to rewrite and callers move their own queries. Both values were declared-but-unlowered on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller following the schema against a SQL datasource got a refusal, not an array. They did run on `driver-mongodb` and on the engine's in-memory fallback, which is what makes this the one narrowing in the batch that removes reachable behaviour: an aggregation that worked on one backend and failed on another is exactly the unpredictability the ruling ended, and the maintainer's 2026-08-05 investment freeze on driver-memory and driver-mongodb had both of those backends frozen at the time (that freeze was lifted on 2026-08-11). `count_distinct` was deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049's enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049." + }, + { + "surface": "data.query.cursor", + "replacement": "a `where` predicate on the sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` (the documented manual-keyset pattern)", + "migrationId": "query-cursor-retired", + "toMajor": 17, + "rationale": "The `cursor` key promised keyset pagination and no driver implemented it: the cursor was accepted and ignored, so every page came back identical — a caller looping \"until hasMore is false\" never terminates. Worse than inert, it had a shipped public producer (`QueryBuilder.cursor()`, removed with the key). The caller-built `Record` shape also leaks sort/storage detail and squats on the reserved REST parameter set; a first-class cursor, if ever designed, will be a response-minted opaque token — a different API, so keeping this one preserved a wrong design rather than a roadmap. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078." + }, + { + "surface": "data.query.distinct", + "replacement": "`groupBy` for unique combinations; the `count_distinct` aggregation for deduplicated counts; the SQL/memory drivers' `distinct(object, field)` door for one column's values", + "migrationId": "query-distinct-retired", + "toMajor": 17, + "rationale": "The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND worse pagination metadata, and a side effect that \"confirmed\" the flag was doing something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with the key). The count suppression is deleted in the same change — `total` is truthful for those queries again. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078." + }, + { + "surface": "data.query.fields", + "replacement": "expand (`expand: { owner_id: { object: 'user', fields: ['name'] } }`), whose nested query selects the related record's own columns — keeping the foreign key in your own projection (`fields: ['title', 'owner_id']`), because the relation is carried by that column and projecting it away leaves expansion nothing to resolve. A dotted `fields` path is NOT a replacement: no driver ever resolved one, and the ingress refuses it (`400 INVALID_FIELD` — refused since a dotted projection was found silently widening the response to every field). Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes) — the same remedy the sort axis's refusal hint was corrected to prescribe, because a formula or rollup field materialises no column to sort or select by", + "migrationId": "query-field-node-object-form-retired", + "toMajor": 17, + "rationale": "The `FieldNode` union declared a nested-select object form `{ field, fields, alias }` that was inert end to end: no producer emitted it, and no consumer read `.fields` or `.alias` — objectql's formula projection and known-field filters, driver-sql's `select()` and driver-memory's projection all treat the list as `string[]`, driver-mongodb keyed its projection with the entry itself, and the REST ingress stringified it. Nested selection is `expand`, which the engine resolves via batch `$in` queries. This is a REQUEST surface — `QueryAST` is never stored in stack metadata (no view, dataset or report authors one), so there is no source for the chain to rewrite: the schema narrows to `z.string()` and callers move their own select lists. ADR-0049 / ADR-0078." + }, + { + "surface": "data.query.joins", + "replacement": "expand (`expand: { owner_id: { object: 'user', fields: ['name'] } }`), whose nested query selects the related record's own columns — keeping the foreign key in your own projection (`fields: ['title', 'owner_id']`), because the relation is carried by that column and projecting it away leaves expansion nothing to resolve. A dotted `fields` path is NOT a replacement: no driver ever resolved one, and the ingress refuses it (`400 INVALID_FIELD` — refused since a dotted projection was found silently widening the response to every field). Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes) — the same remedy the sort axis's refusal hint was corrected to prescribe, because a formula or rollup field materialises no column to sort or select by", + "migrationId": "query-joins-retired", + "toMajor": 17, + "rationale": "The `joins` array was declared-but-inert: no engine or driver read `query.joins` anywhere on the query path, so a query carrying it behaved exactly as if the key were absent — while the name squatted on the reserved REST parameter set. Related-record retrieval already has a live spelling (`expand`, resolved by the engine via batch `$in` queries), so the removal deletes the second, broken spelling rather than the capability, and the orphaned `JoinNode`/`JoinType`/`JoinStrategy` cluster goes with the key. A REQUEST surface — `QueryAST` is never stored in stack metadata — so there is no source for the chain to rewrite; callers move their own queries. ADR-0049 / ADR-0078." + }, + { + "surface": "data.query.windowFunctions", + "replacement": "`aggregations` + `groupBy` for request-level analytics; `SqlDriver.findWithWindowFunctions(object, query)` for embedders on a SQL datasource", + "migrationId": "query-window-functions-retired", + "toMajor": 17, + "rationale": "The `windowFunctions` array was declared-but-inert on the query path: `find()` never applied a window function, so every OVER clause a caller declared was silently dropped. The capability only ever ran behind `SqlDriver.findWithWindowFunctions()`, a driver-level door that is not on the `IDataDriver` contract and whose flat input shape (`{ function, alias, partitionBy?, orderBy? }`) the spec vocabulary never matched — `WindowFunctionNodeSchema` declared `field`/`over`/`frame` members the door never read, so that cluster is removed with the key rather than left as a false affordance. A REQUEST surface, never stored; no source to rewrite. ADR-0049 / ADR-0078." + }, + { + "surface": "ui.RecordDetailsProps.sections (the `record:details` page component)", + "replacement": "an OBJECT array — `sections: [{ label, columns, fields: [...] }]` — replacing the string-ID list; `label` gives the heading, `columns` its grid width, `name` makes the heading translatable, and the new sibling `hideFields` omits named fields from the body", + "migrationId": "record-details-sections-object-form", + "toMajor": 17, + "rationale": "`record:details` declared `sections` as a list of section IDs — `[\"overview\", \"financials\"]` — a shape nothing produced and nothing consumed, while every real page authored the object form. This is authorable metadata on the publish/parse path, so it is the D2 class exactly; it is registered as a SEMANTIC step rather than a mechanical conversion because a string ID carries no field list and the chain cannot invent one — only the author knows which fields the section named `overview` was meant to render. The measurement that made the type change safe is also what makes the prescription unambiguous: the ID-list form had zero read paths and zero producers. objectui's `RecordDetailsRenderer` maps every entry as an object (`s.name` / `s.label` / `s.title` / `s.fields`) with no string branch at all — a string entry spreads into a character map and renders nothing; `@object-ui/types`' `RecordDetailsComponentProps` mirror already declared `Array<{ name?, label?, fields, ... }>`; the Studio block designer can only author `{ label, columns, fields }`; `packages/lint` has modelled it as `nestedSections` all along; and every page in this repo — three showcase pages plus the `sys_user` platform page — authors the object form. So the break lands only on stored metadata written against a declaration nothing ever honoured, and it lands at publish time rather than rewriting data at rest. The same change DECLARED `hideFields`, which the `sys_user` platform page had been authoring undeclared. Registered late, by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger: the change that declared the object form predates the gate that makes a breaking changeset state its ledger disposition, so nothing asked it what it had done about the ledger, and the sibling key on the same def — `ui/RecordDetailsProps:layout`, retired in a neighbouring change — carries a tombstone while this face carried none. ADR-0087." + }, + { + "surface": "restServer.openApi31", + "replacement": "(removed — no replacement key exists. Delete the key; for a real outbound webhook use `Webhook` from `@objectstack/spec/automation`. Config-driven OpenAPI 3.1 webhooks/callbacks documentation returns, if ever, via the enforce route of ADR-0049 through a new ADR)", + "migrationId": "rest-server-openapi31-block-removed", + "toMajor": 17, + "rationale": "The `openApi31` block (`webhooks` / `callbacks` / `jsonSchemaDialect` / `pathItemReferences`, typed by `OpenApi31ExtensionsSchema` with `OpenApiWebhookEventSchema` and `CallbackSchema` under it) promised OpenAPI 3.1 document synthesis nothing delivered: the REST server's `normalizeConfig` forwards only `api`/`crud`/`metadata`/`batch`/`routes`, and the served /openapi.json is the pre-generated @objectstack/spec contract enriched with the live server URL and the registered objects — a webhook declared here never appeared in any served document (ADR-0049; the declared-but-unconsumed shape an earlier audit found in the connector webhook and event enums one layer up). There is no behaviour to preserve and nothing stored to rewrite: `RestServerConfig` is plugin TS configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), never a `sys_metadata` shape — the stack tree's `api` block declares only its four scoping/auth knobs. The three schemas are removed with the key (zero import-level consumers in objectstack / cloud / objectui); the key itself is tombstoned because the schema is not `.strict()` and a plain delete would strip it silently." + }, + { + "surface": "runtime.HttpServer (the exported delegating wrapper class)", + "replacement": "register an `IHttpServer` ADAPTER INSTANCE directly as the `http.server` service — `HonoHttpServer` or whatever adapter the host already builds — instead of wrapping one", + "migrationId": "runtime-httpserver-wrapper-retired", + "toMajor": 17, + "rationale": "`@objectstack/runtime` exported an `HttpServer` class that took an `IHttpServer` in its constructor, declared `implements IHttpServer`, and forwarded only the contract's REQUIRED members (`get` / `post` / `put` / `delete` / `patch` / `use` / `listen` / `close`). It forwarded none of the OPTIONAL ones — `getPort?()`, `getRawApp?()`, `setFallbackHandler?()`. `packages/spec/src/contracts/http-server.ts` instructs consumers to feature-detect exactly those members with `typeof server.X === \"function\"` and to degrade when absent, so wrapping a capable adapter made every probe answer false and the capability vanish with the adapter underneath providing it the whole time. The sharpest consequence is worth writing down before anyone reaches for a wrapper of the same shape: a host that wrapped `HonoHttpServer` and registered the wrapper as `http.server` would answer 404 to every endpoint its metadata declared, because `setFallbackHandler` — the ONLY entry path for declarative `apis:` endpoints since publish stopped refusing them and 17 began executing them — was never forwarded. This is a TS/API contract surface: an HTTP server adapter is CODE, never stack metadata, so there is no authored source for the chain to rewrite and deliberately no schema tombstone — nothing ever ran an adapter through a `.parse()`. That is precisely why this entry must exist: for an untyped JS host the ledger is the only notification channel there is, and for a typed one tsc reports at the construction site. Same disposition, and the same reason, as `storage-service-list-retired` and `data-driver-find-stream-retired`. Registered by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger, not by the original change: the wrapper's removal landed before the gate that makes a breaking changeset state its ledger disposition existed, so nothing ever asked it what it had done about the ledger. ADR-0049 / ADR-0087." + }, + { + "surface": "@objectstack/spec: the exported type `SharingExecutionContext` (`contracts/sharing-service`), and its re-export from @objectstack/plugin-sharing — the six-field context shape (`userId` / `tenantId` / `positions` / `permissions` / `systemPermissions` / `isSystem`) that sharing, approval and report enforcement signatures used to name", + "replacement": "`ExecutionContext` from `@objectstack/spec` — the complete `resolveAuthzContext` envelope the sharing, approval and report contracts have declared since they converged onto it. Every one of the retired type's six fields exists on it under the same name and type, so a value that satisfied the old type already satisfies the envelope: only the annotation is rewritten, never the value", + "migrationId": "sharing-execution-context-retired", + "toMajor": 17, + "rationale": "ADR-0049 enforce-or-remove, completing the maintainer's ruling of 2026-08-07 on the share-link context (enforcement adjudicates on the WHOLE envelope, never a per-site subset). This type was the declared context parameter of 36 signatures across three contracts — `ISharingService` / `ISharingRuleService`, `IApprovalService`, `IReportService` — and it omitted four fields those gates need: `accessible_org_ids` (under the `group` tenancy posture this IS the Layer 0 wall, ADR-0105 D2), `org_user_ids`, `posture` (ADR-0095 D2) and `tabPermissions`. Its damage ran in the MIRROR direction of the share-link twin, which that same ruling moved onto the whole context: nothing trimmed the VALUES — the engine middleware always handed the whole context down — it was the declared TYPE that was narrow, so an implementation could not READ what it had been given without casting out of its own contract (`const posture = (context as any).posture` in plugin-approvals' privileged-override gate). One change converged the contracts, two more re-annotated the four implementations (sharing and audit, then approvals and reports), and this change removes the now-unreferenced declaration, the deletion that split had deferred. Why this needs a ledger entry despite nothing in-repo referencing it: it is the `export-field-meta-constraints-retired` / `hook-context-session-roles-retired` disposition — a PUBLISHED TypeScript surface with no spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry the prescription, and the ledger is the only channel that reaches an upgrader. Why D3 semantic and not a D2 conversion: nothing authored or stored changes shape. The name is only ever spelled inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach it, and no `sys_metadata` row carries it. ADR-0049 / ADR-0087." + }, + { + "surface": "security.sharingRule.sharedWith.type `group` / `guest`, and owner-type rules (`type: owner` + `ownedBy`)", + "replacement": "`group` → `team` (the enforced runtime vocabulary); `guest` → delete the rule and expose the records through a public form or a share link; `type: owner` → rewrite as a `type: criteria` rule. `business_unit` is newly authorable for the single-unit case", + "migrationId": "sharing-rule-recipient-reconcile", + "toMajor": 17, + "rationale": "The authoring `ShareRecipientType` enum had drifted behind both the ADR-0090 D3 rename and the enforced runtime, in both directions at once. It still offered the pre-rename `group`, which the seed path silently SKIPPED, while omitting two recipients the runtime and bootstrap already enforced (`team` via `sys_team` / `sys_team_member`, and `business_unit`). It also offered `guest`, which had no runtime recipient mapping at all. Each of those is a rule that validated and then materialised nothing — the ADR-0078 shape, and on a SECURITY surface, where the failure is silent under-sharing: the author sees a valid rule and believes a set of people can reach the records, and no error ever contradicts them. Owner-type rules go for a different and sharper reason: they depend on live team / position membership, which the static materialiser cannot track, so they could not be made to work by fixing a name. They return as an enforced form only if membership-reactive re-materialisation is designed. This is a semantic entry rather than a mechanical conversion because only one of the three rewrites is a rename: `group` → `team` is mechanical, but `guest` and `type: owner` have no target — the author has to decide who was actually meant to reach those records and say so in a form the runtime enforces, and a transform that guessed would be inventing an access grant. After this change every authorable recipient and rule type on the SharingRule surface is enforced; the `queue` recipient stays runtime-reserved and deliberately non-authorable (there is no `sys_queue` yet). Note the two neighbouring conversions cover DIFFERENT faces of this schema and not this one: `sharing-recipient-role-to-position` is the ADR-0090 role → position rename and `sharing-rule-access-level-full-to-edit` is the access-level vocabulary. The change came out of the metadata property liveness audit, which found security properties parsed but never enforced, and was registered late, by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger. ADR-0078 / ADR-0090 D3 / ADR-0087." + }, + { + "surface": "data.query.orderBy[].direction (SortNode)", + "replacement": "`order` — `orderBy: [{ field: \"updated_at\", order: \"desc\" }]`. One word, same values (`asc` / `desc`)", + "migrationId": "sort-node-direction-rejected", + "toMajor": 17, + "rationale": "`SortNodeSchema` was a plain `z.object`, so zod's default `.strip` applied and a sort node spelling its direction `direction` lost the key silently. Measured on `main` before the change: `SortNodeSchema.parse({ field: \"updated_at\", direction: \"desc\" })` returned `{ field: \"updated_at\", order: \"asc\" }` — the key discarded and `order` falling back to its `asc` default, so the sort ran in the OPPOSITE direction and the request succeeded. Paired with `limit`, which is how a caller asks for \"the latest N\", that is not a reordered page but a DIFFERENT SET OF ROWS, returned under an ordinary 200 with nothing in the response to distinguish it from the answer that was asked for. `direction` is not a typo: it is the live vocabulary of a neighbouring contract, `IReportService.orderBy`, and `plugin-auth/objectql-adapter.ts` already translated between the two by hand — a translation known to be necessary and enforced nowhere, the ADR-0049 shape. Both doors closed in one change: `SortNodeSchema` is now a `strictObject` carrying `aliases: { direction: \"order\" }`, and `normalizeSortNodes` in `metadata-protocol` refuses `{ field, direction }` with `400 INVALID_SORT`. The alias is deliberate rather than left to the edit-distance fallback, because no edit distance bridges `direction` → `order` and a bare \"unrecognized key\" would leave the caller exactly where the silent strip did. This is registered as a semantic entry rather than a mechanical conversion for one reason worth stating: the rewrite itself is trivially mechanical, but a stored `direction: \"asc\"` is ambiguous evidence — the author may have written it meaning ascending and been silently GIVEN ascending, so the visible behaviour never contradicted them, and only they can say whether the sort they have been reading was the sort they asked for. Registered by the stock reconciliation of the v17 train's breaking changesets: the in-code alias tombstone shipped with the change that closed both doors (maintainer ruling 2026-08-03), but the ledger half never did, and a retirement needs both — the tombstone is the proof the removal was declared, the ledger entry is what `spec-changes.json`, the upgrade guide and `os migrate meta` project to consumers. ADR-0049 / ADR-0087." + }, + { + "surface": "type alias: the 102 XInput names of @objectstack/spec (ConnectorInput, AppInput, PageInput, ActionInput, ServiceObjectInput, ExecutionContextInput, TaskInput, … — 52 files across api/ automation/ data/ identity/ integration/ kernel/ security/ system/ ui/)", + "replacement": "the BARE name. ADR-0122 phase 2 moved the author state onto `X`, which makes `XInput` a character-for-character synonym of it — the permanent synonym D3 forbids. Drop the `Input` suffix: `ConnectorInput` -> `Connector`. Symmetrically, a consumer that held a PARSE RESULT under the bare name moves to `XParsed`, which phase 1 (16.x) already declared for every schema whose two shapes differ, so the target name has existed for a release. NINE `*Input` names are NOT retired and need no edit: `ExpressionInput`, `CronExpressionInput`, `TemplateExpressionInput` and `PredicateInput` are the bare aliases of their own `…InputSchema`, and `FormFieldInput`, `QueryInput`, `FieldInput`, `ObjectStackDefinitionInput` and `NavigationItemInput` are composed (recursive or `Partial`-shaped) types no bare alias denotes.", + "migrationId": "spec-type-alias-input-suffix-retired", + "toMajor": 17, + "rationale": "This entry exists for the reason `data-driver-find-stream-retired`, `storage-service-list-retired` and `actor-user-roles-to-positions` exist, and it is the same disposition: the surface is a TYPESCRIPT NAME, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — an `XInput` alias never had a carrier key, never emitted a def, and no `.parse()` ever saw it. Measured and verified rather than assumed: `json-schema/`, `json-schema.manifest/` and `authorable-surface/` are BYTE-IDENTICAL across this change, because those generators enumerate runtime `z.ZodType` exports and never read a type alias. So nothing left the published metadata surface and RETIRED_DEFS_BY_MAJOR is deliberately untouched — an entry there would falsely claim the metadata contract shrank. The enforced channel is tsc: the name is gone, so every consumer gets TS2724/TS2305 naming the import. That is loud but MUTE about the replacement — a compile error says `ConnectorInput` does not exist, not that `Connector` now means what it meant. The generated upgrade guide is the only channel that carries the second half, which is precisely the gap ADR-0087 registration exists to close — the `ctx.user` `roles` alias was removed with no ledger entry, and that entry had to land separately. ⚠️ Deliberately NOT registered alongside it: the 1384 bare aliases the same change FLIPPED from `z.infer` to `z.input`. Those names all still exist and still resolve; what moved is which of a schema's two shapes they denote, and only where the two differ (663 of 1384 — the rest are isomorphic and the flip is a no-op there, pinned as such). A consumer holding an authored literal is made MORE correct by it, silently; one holding a parse result gets a tsc error at the first defaulted key it reads. Registering that as a rename would misdescribe it — no name was retired — and the changeset carries its own FROM -> TO for it. ADR-0122 D8/D9 (its phase 2, which moved every bare name to the author state)." + }, + { + "surface": "contracts.IStorageService.list", + "replacement": "track the keys you wrote (sys_file / file-reference records, queryable through ObjectQL with real pagination) instead of enumerating the bucket — and where no such record exists, the cursor-shaped `list(prefix, { cursor, limit })` this entry reserved, restored since, once cloud proved to be the first-party caller this repository could not see", + "migrationId": "storage-service-list-retired", + "toMajor": 17, + "rationale": "`list(prefix)` was an OPTIONAL contract method documented as \"List files in a directory/prefix\", and the two shipped adapters answered the same call with two different semantics — both of them silently incomplete. `LocalStorageAdapter.list` was a single-level `readdir`, so a nested key `a/b/c` was invisible under `list('a')` (only `a/b` came back), and a subdirectory that `stat` succeeded on was pushed into the result as a file, yielding a `StorageFileInfo` whose `size` is a directory inode and which cannot be downloaded at all. `S3StorageAdapter.list` was RECURSIVE (`ListObjectsV2` matches the whole key) and read neither `IsTruncated` nor `ContinuationToken`, so past 1000 objects the \"all files\" a caller received was the first page, with no signal. One contract method, two dialects, both quietly incomplete — and the first feature that genuinely needed to enumerate a prefix (backup, orphan sweep, migration audit) would have got two different answers on two deployments without an error on either. The email plugin's large-attachment storage work was nearly that feature: it planned to drive attachment reclamation off `list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one level down, and switched to queue-driven deferred work instead. Nothing consumed it afterwards: the only in-repo call site was the `SwappableStorageService` pass-through (which itself rejects when the active adapter has no `list`), and REST, CLI and the storage routes never called it. Remove was chosen over align-and-tighten (maintainer ruling, 2026-08-05, on the finding that measured the two dialects): aligning would grow a conformance surface nobody walks, while a prefix listing that cannot paginate is the wrong signature to inherit — when a real caller needs enumeration it returns cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases (nested keys, directory entries, >1000 objects) proving both backends agree. This is a TS/API contract surface — a storage adapter is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription there would reach no one. The enforced channel is tsc, and it reports at the call site. Same disposition, and the same reason, as `data-driver-find-stream-retired`. ADR-0049 / ADR-0087." + }, + { + "surface": "ai.tool.requiresConfirmation", + "replacement": "put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — the flag the platform confirmation CONTRACT is written against, and that contract is ENFORCED. An AI-facing call on an action declaring the flag must carry the confirmation member `confirm: true` on the request and is REFUSED without it with `ACTION_CONFIRMATION_REQUIRED` (428), the refusal naming the action and the exact member to set. A gate, not a queue: nothing is parked, and a refused call did not run — no record was read and none was written. ⚠ Two bounds: the enforced set is the doors that enforce the author's `ai.exposed` opt-in, today the action door reached from the MCP `run_action` tool, while REST `/actions` is not `ai.exposed`-gated and sits outside the gate; and `confirm: true` is an unverifiable caller claim, so the gate makes forgetting loud without proving a human approved", + "migrationId": "tool-requires-confirmation-retired", + "toMajor": 17, + "rationale": "`ToolSchema.requiresConfirmation` accepted `true` and no execution path ever read it: not the LLM tool set (a tool reaches the model as name / description / parameters only), not `ToolRegistry.execute`, not `POST /ai/tools/:name/execute`, and not the MCP bridge, which derives `destructiveHint` from a hardcoded name list. Setting it on a destructive tool produced NO PAUSE. For an ordinary dead property that is untidy; for a SAFETY property it is false compliance, the case ADR-0049 exists for — an author gates a destructive tool, sees the flag accepted, and ships believing a human is in the loop. It is made worse by the near-miss: `action.ai.requiresConfirmation` carries the same name and DOES work, so the mistake reads as correct in review. This is registered as a semantic entry rather than a mechanical conversion because the rewrite is not a rename at all — the replacement lives on a different metadata object at a different layer, and deciding which action should carry the gate (or whether the operation should be an action at all) is a judgement the chain cannot make. Deleting the key mechanically would be the worst possible transform here: it would leave the metadata parsing green while silently completing the removal of a safety gate the author believed was in place. `ToolSchema` was made `.strict()` in the same change, which is load-bearing rather than tidying — removing a key from a non-strict schema swaps one silent no-op for another, so the retired key now REJECTS and the parse error carries the prescription, that being the one channel every consumer bumping `@objectstack/spec` is guaranteed to hit. Registered by the stock reconciliation of the v17 train's breaking changesets: the `retiredKey()` tombstone shipped with the change that executed ADR-0033's deletion of this unenforced key, and still stands in `ai/tool.zod.ts`, but the ledger half never did. A retirement needs both — the tombstone is the proof the removal was declared, this entry is what `spec-changes.json`, the upgrade guide and `os migrate meta` project to consumers. ADR-0033 §2 / ADR-0049 / ADR-0087." + }, + { + "surface": "ui.touchInteraction / ui.gestureConfig / ui.dndConfig / ui.keyboardNavigationConfig / ui.componentAnimation / ui.motionConfig / ui.pageTransition / ui.offlineConfig (the whole export surface of ui/touch.zod.ts, ui/dnd.zod.ts, ui/keyboard.zod.ts, ui/animation.zod.ts and ui/offline.zod.ts — 32 defs, 64 exported names)", + "replacement": "(removed — there is no replacement key, because there was never a key. Touch targets, drag-and-drop, focus management, keyboard shortcuts and motion are RENDERER BUILT-IN behaviour: the component library decides them, not a per-page metadata author. Offline is a platform capability, and its vocabulary belongs on the sync engine that owns the queue, the conflict policy and the cache — none of which exists yet. Delete the import and the value. Whichever of these earns real product pull returns WITH its own vocabulary and its executor — the way inbound rate limiting came back, as a new key carrying only what its executor consumes — not by un-retiring a declaration)", + "migrationId": "ui-interaction-config-family-retired", + "toMajor": 17, + "rationale": "Five `@objectstack/spec/ui` modules declared a full interaction-configuration vocabulary — 22 `z.object` sites across touch/gesture, drag-and-drop, focus/keyboard, animation/motion and offline/sync — and NOTHING in the protocol carried them. This is the ADR-0049 false-compliance shape in its most inviting form for an AI author (ADR-0033), and worse than the ordinary declared-but-unread defect: `authorable-surface.json` listed 109 keys under these defs and `content/docs/references/ui/{touch,dnd,keyboard,animation,offline}.mdx` rendered them as authoring tables, so the published documentation advertised a vocabulary with no carrier key anywhere. An author following `dnd.mdx` and writing a `dnd:` block onto a page component was rejected by `PageComponentSchema` for an unrecognized key — the docs and the schema disagreeing about the platform (Prime Directive #10). Three independent measurements, each with its controls passing in the same run: (1) no module under `packages/spec/src` imported any of the five except the `ui/index.ts` barrel, so no schema declared a carrier key; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` (25 roots, 4742 nodes) reached none of the 21 named object shapes, while `PageSchema`, `WebhookSchema` and `StateMachineSchema` all resolved `direct` and a synthetic carrier flipped all 21 — so unreachability was a fact about the graph, not a broken walker; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these modules' own unit tests. objectui holds TYPE re-exports and parity ratchets, never validators, and says so (its types package deliberately dropped the spec/ui zod-validator re-exports and keeps type-only ones). The 2026-08-04 ruling retired the family — touch, drag-and-drop, keyboard and motion are renderer built-in behaviour and offline belongs to a sync engine, none of it per-page metadata — and weighed wiring a carrier key (option B) and rejected it: that is a feature with a renderer behind it, not ledger clean-up. It also weighed tightening the shapes to `strictObject` and rejected that explicitly — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave \"a precisely validated dead slot, the more convincing lie\" (the lesson of the datasource capability flags: `readOnly` was precisely validated and read by nothing, while a shipped example called a datasource a read replica and wrote through it). Because there was no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, the same route 3 as `plugin-runtime-family-retired` (the kernel plugin-runtime family) and the `HttpServerConfig` retirement (seven keys no runtime read and no authoring door reached, retired with their container). ⚠️ Not to be confused with the theme-token retirement (theme-driven typography is not a near-term capability, so nine token groups nothing consumed were retired), which retired the THEME `animation` block — a different file, different defs, and that one did have a carrier key and therefore a tombstone. ADR-0049." + }, + { + "surface": "ui.notificationAction / ui.embedConfig", + "replacement": "(removed — there is no replacement shape, because there was never a key to write either into. Delete the import and the value. Notification presentation is still described by the surviving `NotificationType` / `NotificationSeverity` / `NotificationPosition` vocabulary; public access to a form is granted by the LIVE `FormView.sharing` block (`SharingConfig`), which is untouched. Notification action buttons as metadata, and iframe embedding, return via the enforce route of ADR-0049 through a new ADR — carrier key and renderer first, vocabulary second)", + "migrationId": "ui-notification-action-embed-config-retired", + "toMajor": 17, + "rationale": "Both shapes were published `@objectstack/spec/ui` vocabulary with NO AUTHORING DOOR. The v17 unknown-key strictness sweep, which measured each ui/ file for an authoring door before closing any shape, measured them three ways on 2026-08-03 and this retirement re-ran all three against `origin/main` before removing anything, each with a positive control that passed in the same run: (1) CARRIER — no schema in `packages/spec/src` declared a key of either type (`ui/notification.zod`'s only non-test importer was the barrel; `ui/sharing.zod`'s were the barrel and `ui/view.zod.ts`, which names its SIBLING `SharingConfigSchema`), measured by resolving specifiers rather than substring-matching, because the repo holds two `sharing.zod` modules and a substring test miscredits `stack.zod.ts` to the UI one; (2) REACHABILITY — a BFS from the 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema`, over `build-schemas.ts`'s own walk including its derived-clone bridge, never reached either, while `Page` / `Action` / `DashboardWidget` / `Webhook` and `SharingConfig` itself all resolved `root-graph` in the same run and an injected synthetic carrier flipped both; (3) PARSE — zero `.parse()` in objectstack, cloud or objectui outside their own unit tests. So nobody could author one and nothing ever validated one: the shape of the unwired plugin sandboxing config removed before it, an exported schema with no consumer read as a capability, and the ADR-0033 trap where an AI author takes `EmbedConfigSchema` in the published bundle as proof the platform serves iframes. Neither is stored metadata and neither has a carrier, so no `sys_metadata` row can hold one and there is no source for the D2 chain to rewrite; this entry is the D3 record. The sweep deliberately did NOT close them with `.strict()` — strictness is a property of a PARSE, and closing a shape nothing parses buys only \"a precisely-validated dead slot, the more convincing lie\" (the lesson of the datasource capability flags, whose `readOnly` was precisely validated and read by nothing) — and left the disposition to ADR-0049's enforce-or-remove, which came back REMOVE on 2026-08-04: a dead surface with no authoring door retires implementation-first, as three same-shape rulings that week had already decided. Each was orphaned by an earlier retirement one level up: `NotificationAction` lost its wrappers when the dual-source cleanup removed the `./ui` copies of `NotificationSchema` / `NotificationConfigSchema` (the same names declared differently on other entry points) — that retirement's published \"zero consumers\" evidence was later falsified for objectui, which re-exported both names, and is corrected on `ui/notification.zod`'s tombstone; the removal itself stands — and `EmbedConfig` lost its key at 17.0.0 when the 2026-06 liveness audit retired `App.embed` (no iframe route ever read it) — that key still stands as a `retiredKey()` tombstone in `app.zod.ts`, so an author who wrote the KEY already meets a prescription; this removes the value shape that outlived it. ⚠️ The retirement is per SCHEMA, not per file: `ui/sharing.zod` KEEPS `SharingConfigSchema`, a live door carried by `FormViewSchema.sharing` and read by `rest-server.ts` to mount the anonymous form routes, and `ui/notification.zod` keeps its three presentation enums. objectui consumed `NotificationActionSchema.shape.variant` as a VOCABULARY (never a parse) to pin its own hand-written `NotificationActionButton` interface — which is exactly why \"has a consumer\" never meant \"has an authoring door\" here; that pin is adapted objectui-side when it refreshes this dependency. ADR-0049." + }, + { + "surface": "ui.widgetManifest / ui.widgetLifecycle / ui.widgetEvent / ui.widgetProperty / ui.widgetSource / ui.i18nObject / ui.pluralRule / ui.numberFormat / ui.dateFormat / ui.localeConfig (the widget-registration vocabulary of ui/widget.zod.ts, and the five doorless shapes of ui/i18n.zod.ts — 10 defs, 26 exported names)", + "replacement": "(removed — there is no replacement key, because there was never a key. A custom field widget is still named the same way it always was: `field.widget` is a plain string naming a component the RENDERER has registered, and objectui's registry has always carried its own runtime manifest for that (`RuntimeWidgetManifest` / `RuntimeWidgetSource` in `@object-ui/types`, renamed off the spec's names under objectui's rule that a symbol named like a spec export must import it or take a name of its own), which models different keys and never derived from these. For localisation: write the default-language string on `label` / `description` — the framework generates the translation key at registration time from the naming convention — and put translations in translation files, which is the LIVE `system/translation.zod.ts` surface. Widget registration and locale formatting as authorable protocol metadata return via the ENFORCE route of ADR-0049 through a new ADR — the registry / loader / formatter first, the vocabulary second)", + "migrationId": "ui-widget-i18n-family-retired", + "toMajor": 17, + "rationale": "`ui/widget.zod.ts` published a complete widget-registration vocabulary — a manifest with lifecycle hooks, custom events, configurable properties and an npm/remote/inline implementation-source union — and `ui/i18n.zod.ts` published a structured-label, plural-rule and locale-formatting vocabulary. NOTHING in the protocol carried either. Three independent measurements, re-run on `origin/main` immediately before the removal with their controls passing in the SAME run: (1) no module under `packages/spec/src` imported `widget.zod` at all, and the only imports of `i18n.zod` anywhere name `I18nLabelSchema` / `AriaPropsSchema` (both KEPT), so no schema declared a carrier key — `field.widget` is a `z.string()` naming a registered component and has never referenced `WidgetManifest`; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` reached none of them, while `PageSchema` / `ObjectListViewSchema` resolved `direct` in the same run and a synthetic carrier flipped every one of them; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these files' own unit tests. `NumberFormat` / `DateFormat` DID have a carrier key (`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier was itself doorless, so the subtree was `no door` rather than `no gate` and goes whole — leaving the two leaves behind would strand exported schemas with no consumer, which an author reads as a capability. `I18nObjectSchema` was additionally superseded by its own file-neighbour: `I18nLabelSchema`'s documentation already says translation keys are generated at registration time and translations live in translation files, and the live translation surface is `system/translation.zod.ts`, which uses none of these shapes. The 2026-08-06 ruling weighed giving them a carrier (option B) and rejected it: that is a feature with a registry and a renderer behind it, not ledger clean-up. Tightening them to `strictObject` was rejected earlier and explicitly, by the batch of the v17 unknown-key strictness sweep that measured this file as having no authoring door — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave \"a precisely validated dead slot, the more convincing lie\" (the lesson of the datasource capability flags, whose `readOnly` was precisely validated and read by nothing). With no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, route 3, the same shape as `ui-interaction-config-family-retired`, `plugin-runtime-family-retired` and the `HttpServerConfig` retirement. ⚠️ `WidgetManifest.performance`'s own `retiredKey()` tombstone (left by the close-out sweep that removed the inert `performance` keys no renderer applied) is SUBSUMED here, the way the kernel `activationEvents` tombstone went with the removed plugin-runtime family: it goes with the shape that carried it, which is strictly stronger than the tombstone, because there is no longer a manifest to author the key INTO. ⚠️ One of the nine widget sites is deliberately NOT retired. `FieldWidgetPropsSchema` survives: it is a REACT PROPS CONTRACT rather than authorable metadata (it never appeared in `authorable-surface/` or `json-schema.manifest/` — its `onChange` is a `z.function()`), so \"zero parse\" is its design and not its defect, and it acquired a live cross-repo compile-time consumer one day before that sweep batch measured: an objectui fix of 2026-08-03, made to follow the spec, renamed `@object-ui/fields`' validation slot onto the spec's `error` with no alias, the form renderer began producing it, and `packages/fields/src/__tests__/spec-symbol-batch7.test.ts` pins the shape against `import type { FieldWidgetProps } from '@objectstack/spec/ui'` as an intentional tripwire. Re-verified on objectui `origin/main` 2026-08-07. ADR-0049." + }, + { + "surface": "sys_user_permission_set.delegated_from — the ADR-0091 D3 provenance column left the platform grant table declared by plugin-security (packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts). The sibling declaration on sys_user_position is untouched", + "replacement": "nothing on this table — delete the key from any authored `sys_user_permission_set` seed row (stack `data` entries) or data-door write that still carries it. Delegation semantics live on `sys_user_position`, where `delegated_from` remains declared AND runtime-enforced: the delegated-admin gate is what makes a position insert a delegation, and the explain engine attributes \"via delegation from X, until Y\". A permission-set grant that needs a provenance note keeps `reason` (free text), which remains declared on both grant tables", + "migrationId": "ups-delegated-from-column-retired", + "toMajor": 17, + "rationale": "Maintainer ruling 2026-08-18 on the finding that the delegation gate never reads this column on this object, ADR-0049 enforce-or-remove: REMOVE. The runtime delegation gate is structurally scoped to sys_user_position (`isDelegationWrite` returns false for every other object, so `assertSelfDelegation` is unreachable for this table), and the explain engine reads delegation provenance from sys_user_position rows only. On sys_user_permission_set the column was therefore declared and data-door-writable while NO runtime consumer read it — its only enforcement was an authoring-time lint (the D3 \"delegation row needs a reason\" rule), which a row written through the generic data door never meets. That is declared-but-unenforced in its pure form, on a security object: an author who stamped delegated_from on a permission-set grant believed they constrained delegation, and nothing refused or honoured it. Producers measured at zero — the only object literals naming both the table and the column were lint test fixtures. This is a platform-object COLUMN retirement, not a spec-key retirement, so the bookkeeping follows the audit-log-action-enum-retired shape: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — the surface ratchets are expected byte-identical), and the disposition is a SEMANTIC entry rather than a D2 conversion. A conversion over stack `data` seed records would be mechanically expressible, but no conversion in the chain rewrites seed rows today and the measured author base is zero; the loud channel already exists at runtime — the engine schema preflight refuses an undeclared field with 400 INVALID_FIELD before the driver or any hook runs — so this entry carries the prescription and the refusal carries the enforcement. ⚠️ Existing physical columns are deliberately untouched: schema sync is additive (ADR-0045), so a deployed database keeps the column; the platform stops declaring, projecting or accepting it. Zero producers means no rows are expected to carry a value; no backfill or destructive DDL is required or wanted. If delegation at permission-set granularity ever becomes a real need, the column is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce or do not declare." + }, + { + "surface": "ui.ViewFilterRule value — the third key of a view filter rule, on every carrier of ViewFilterRuleSchema: ListView.filter, a list view tab filter, Page.filterBy, a related-list component filter and a lookup picker filter. It accepted any declared scalar or array for EVERY operator; the accepted shape is now decided by the rule operator — in / not_in require an array, between requires exactly two bounds, and every other operator is unchanged", + "replacement": "an ARRAY for in / not_in (a single value becomes a one-element list: value: \"won\" becomes value: [\"won\"]), and a two-element [min, max] array for between. The empty list [] stays legal for in / not_in and keeps its meaning. Nothing else moves: a scalar operator carrying an array, a string operator carrying a number, and a unary operator carrying an ignored value all still parse", + "migrationId": "view-filter-rule-value-shaped-by-operator", + "toMajor": 17, + "rationale": "A publish-time gate catching up to a query-time one, not a new rule. An earlier fix closed the RUNTIME half: `assertListComparandShapes` (@objectstack/objectql, filter-comparand-shape.ts) refuses a lowered `{ stage: { $nin: \"won\" } }` with a named 400 INVALID_FILTER, and before that it was a 500. The authoring surface stayed silent, so the failure was two-stage: the view published cleanly and only broke when someone opened it. That file names this very schema as the reachable authoring source of the defect. The tightening MIRRORS that gate exactly — three constraints, one for one — and deliberately goes no further, because an earlier fix already settled the opposite error (the ordering operators' comparand widened to the strings the platform itself produces): a schema stricter than the runtime \"in ways the runtime deliberately allows\" was the WRONG side and was widened to match. So `in: []` is still accepted (a declared predicate both drivers implement), `equals: [\"a\",\"b\"]` is still accepted (it lowers to a deep-equality comparand), and `is_empty: \"\"` is still accepted (the null predicates take their direction from the operator NAME — convertComparison ignores the value position, and the ObjectUI client deliberately sends a truthy placeholder there). ⚠️ Metadata AT REST is deliberately NOT rewritten, and there is no D2 conversion. A D2 entry replays a shape the platform once WROTE and renamed; this shape was never written by any first-party producer (every in / not_in rule in this repo, in objectui and in the cloud repo already carries an array — measured) and has never EXECUTED, since it 400s on first render today. Coercing it at load would be the platform guessing intent rather than replaying a rename, and it cannot guess honestly: value: \"\" would become the predicate [\"\"] (a real filter on the empty string) rather than the \"not filled in yet\" a console row means, and between: 5 has no defensible second bound at all. The read path does not re-validate stored rows (applyConversionsToStoredItem never validates, by its own contract), so no stored view becomes unreadable; what changes is that RE-SAVING such a view is refused at the write gate naming `value`, instead of storing a filter that 400s. ADR-0049 / ADR-0078 / ADR-0112." + }, + { + "surface": "api.listViews / api.getView / api.createView / api.updateView / api.deleteView (the ViewProtocol interface and its ten Request/Response schemas in api/protocol.zod.ts — 10 defs, 25 exported names)", + "replacement": "the two view surfaces that are actually routed. For a view's STORED definition, the generic metadata methods with `type: 'view'` — `getMetaItem` / `getMetaItems` / `saveMetaItem` / `deleteMetaItem`, served at `/api/v1/meta/view/:name`. For the RESOLVED render-time view, `getUiView` (`GetUiViewRequest` / `GetUiViewResponse`), served at `/api/v1/ui/view/:object/:type`. Neither is addressed by a `viewId`, which is the one thing the retired surface offered and the one thing nothing implemented", + "migrationId": "view-management-protocol-retired", + "toMajor": 17, + "rationale": "A complete viewId-addressed CRUD surface — list (with a list/form filter), read, create, patch, delete — with none of the three things a protocol method needs. Measured on origin/main immediately before the removal: no implementation (`packages/metadata-protocol/src/protocol.ts` declares no `listViews` / `getView` / `createView` / `updateView` / `deleteView`; its only view resolver is `getUiView`), no route (`packages/rest/src/rest-server.ts` never mentions `viewId`, so nothing viewId-addressed is reachable over HTTP at all), and no caller (the only `ViewProtocol` mention outside its own file was the services checklist, which already recorded the five as declared-and-unrouted). The look-alike hits a bare-name grep turns up are all different contracts: `metadata-manager.ts`'s `getView(name: string)` is another class, and objectui's `getView(objectName, viewId)` resolves through `client.meta.getItem('view', …)`, i.e. the metadata route. What makes this worth a removal rather than a note is that the cost is already measured. A declared surface that is name-identical and semantics-adjacent to a real one is an attractive nuisance in every grep, and it mis-directed a decision once: The issue asking what `GET /ui/view/:object/:type` answers AND its 2026-08-07 maintainer ruling both read `GetViewResponseSchema` (zero implementations) as the contract of `GET /ui/view/:object/:type`, whose declared response is `GetUiViewResponseSchema` — one word apart, 250 lines up. That ruling's reasoning happened to survive the mix-up (\"nobody can consume `{object, view}` successfully today\" was true, though not for the stated reason), which is the luck this removal stops relying on. Route 3: none of the ten was a key on an authorable shape, nothing parsed them, so there is no tombstone and no D2 conversion — RETIRED_DEFS_BY_MAJOR plus this entry are the declaration. If reading and writing ONE view by id becomes a real requirement it returns implementation-first. ADR-0049, ADR-0087, maintainer ruling 2026-08-07." + }, + { + "surface": "CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / discovery routes.workflow / RestApiRouteCategory workflow", + "replacement": "the live mechanisms the slot only ever pointed at: `state_machine` validation rules for record state machines, approval flow nodes on the approvals runtime (ADR-0019) for approvals, lifecycle hooks + `record_change` flows (service-automation) for record-triggered automation", + "migrationId": "workflow-service-slot-retired", + "toMajor": 17, + "rationale": "The workflow slot was declared end to end and implemented nowhere: no code in either repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches were plugin-dev's retired stub probe and the generic discovery walk), no implementation of any WorkflowProtocol method ever existed, and no host ever mounted `/api/v1/workflow` (DEFAULT_DISPATCHER_ROUTES, before it was retired as a stale list, named it among routes that never existed). Every part of it was ADR-0078's silently-inert declaration: a CoreServiceName nothing filled, a contract nothing implemented, a protocol nothing served, a discovery route field no builder could truthfully populate. These are TS/API surfaces and a discovery RESPONSE field — never stored in stack metadata, so there is no source for the chain to rewrite; consumers of the deleted types move their imports themselves. ADR-0049 / ADR-0078." + } + ], + "removed": [] + }, + { + "from": 17, + "to": 18, + "added": [], + "converted": [ + { + "surface": "object.fields.*.scale / object.fields.*.precision", + "to": "malformed field 'scale'/'precision' declarations (non-integer or negative) are removed — they were silently unenforced; the schema now refuses them at authoring", + "conversionId": "field-malformed-scale-precision-removed", + "toMajor": 18 + }, + { + "surface": "page.component.record:chatter.position / page.component.record:discussion.position", + "to": "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)", + "conversionId": "record-chatter-position-vocabulary", + "toMajor": 18 + }, + { + "surface": "page.component.element:text_input.targetVariable / page.component.element:record_picker.targetVariable", + "to": "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)", + "conversionId": "element-input-target-variable-removed", + "toMajor": 18 + }, + { + "surface": "page.component.element:filter.object / page.component.element:filter.fields / page.component.element:filter.targetVariable / page.component.element:filter.layout / page.component.element:filter.showSearch / page.component.element:filter.aria", + "to": "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", + "conversionId": "element-filter-removed", + "toMajor": 18 + }, + { + "surface": "page.component.element:form.object / page.component.element:form.fields / page.component.element:form.mode / page.component.element:form.submitLabel / page.component.element:form.onSubmit / page.component.element:form.aria", + "to": "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", + "conversionId": "element-form-removed", + "toMajor": 18 + }, + { + "surface": "field.inlineColumns[].field / field.relatedListColumns[] object entries", + "to": "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)", + "conversionId": "field-column-lists-canonicalized", + "toMajor": 18 + }, + { + "surface": "analyticsCubes[].measures..filters", + "to": "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)", + "conversionId": "metric-filters-removed", + "toMajor": 18 + }, + { + "surface": "analyticsCubes[].dimensions..granularities", + "to": "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)", + "conversionId": "cube-sub-day-granularities-removed", + "toMajor": 18 + }, + { + "surface": "analyticsCubes[].joins..sql / analyticsCubes[].joins..relationship", + "to": "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..name` alone; the record KEY is the foreign-key field on the base object)", + "conversionId": "cube-join-sql-and-relationship-removed", + "toMajor": 18 + }, + { + "surface": "page.component.record:highlights.fields[].icon", + "to": "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)", + "conversionId": "record-highlights-field-icon-removed", + "toMajor": 18 + }, + { + "surface": "mapping.fieldMapping[].params.object / .fromField / .toField / .autoCreate", + "to": "mapping lookup params 'object'/'fromField'/'toField'/'autoCreate' removed (ADR-0049 — the import path never read them: `lookup` copies the cell through and reference resolution runs off the target field's own metadata. `autoCreate` never created anything — an unresolved reference fails the row either way. Implementing them instead would have added a second reference-resolution dialect to the import path)", + "conversionId": "mapping-lookup-params-removed", + "toMajor": 18 + }, + { + "surface": "translation.pages.components.submitLabel", + "to": "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)", + "conversionId": "translation-component-submit-label-removed", + "toMajor": 18 + }, + { + "surface": "page.components[].responsive", + "to": "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)", + "conversionId": "page-component-responsive-removed", + "toMajor": 18 + }, + { + "surface": "page.component.object-grid.defaultSort", + "to": "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)", + "conversionId": "object-grid-default-sort-removed", + "toMajor": 18 + }, + { + "surface": "page.component.object-kanban.quickAdd", + "to": "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)", + "conversionId": "object-kanban-quick-add-removed", + "toMajor": 18 + }, + { + "surface": "permission.objects..allowRestore / permission.objects..allowPurge", + "to": "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)", + "conversionId": "permission-allow-restore-purge-removed", + "toMajor": 18 + }, + { + "surface": "view.form.sections[].fields[].options[].default", + "to": "form-view per-option 'default' removed from the FormView vocabulary (ADR-0049 declared-but-unenforced — nothing on the form path read it: the insert-path default falls back to the OBJECT definition's option list, and no form renderer seeds a value from a form view's. The object field option's 'default' stays enforced; declare the pre-selected choice there — field-level 'defaultValue', or 'default: true' on that field's own options entry)", + "conversionId": "form-view-option-default-removed", + "toMajor": 18 + }, + { + "surface": "field.reference_to", + "to": "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)", + "conversionId": "field-reference-to-alias", + "toMajor": 18 + }, + { + "surface": "connector.errorMapping", + "to": "connector key 'errorMapping' removed (ADR-0049 — no engine ever mapped an external error through the rules, so the eleven nested keys configured nothing, and the rule-level `userMessage` shared its spelling with the live API-error channel while never being shown; deleting the block resolves that collision without a rename. The whole ErrorMappingConfig / ErrorMappingRule shape and the ConnectorErrorCategory enum went with it)", + "conversionId": "connector-error-mapping-removed", + "toMajor": 18 + }, + { + "surface": "connector.connectionTimeoutMs", + "to": "connector key 'connectionTimeoutMs' removed (ADR-0049 — the platform never applied it as a deadline and cannot at the site it names: a WHATWG `fetch` exposes one `AbortSignal` over the whole operation and never the connect phase. The value only travelled — onto the reported def and the materialization fingerprint. Use `requestTimeoutMs`, which `resilientFetch` applies as each attempt's deadline, and bound the connect phase at a provider or gateway that can separate the phases)", + "conversionId": "connector-connection-timeout-ms-removed", + "toMajor": 18 + }, + { + "surface": "hook.timeout", + "to": "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)", + "conversionId": "hook-timeout-to-timeout-ms", + "toMajor": 18 + }, + { + "surface": "job.timeout", + "to": "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)", + "conversionId": "job-timeout-to-timeout-ms", + "toMajor": 18 + }, + { + "surface": "apis[].cacheTtl", + "to": "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)", + "conversionId": "api-endpoint-cache-ttl-to-cache-ttl-seconds", + "toMajor": 18 + }, + { + "surface": "dashboard.refreshInterval", + "to": "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)", + "conversionId": "dashboard-refresh-interval-to-refresh-interval-seconds", + "toMajor": 18 + }, + { + "surface": "connector.health / connector.status / connector.webhooks", + "to": "connector keys 'health', 'status' and 'webhooks' removed (ADR-0049 — no connector health probe or circuit breaker ever ran, nothing read an authored status (the runtime reports a computed `state`), and a webhook nested in a connector was never registered or delivered. The ConnectorHealth / HealthCheckConfig / CircuitBreakerConfig, ConnectorStatus and WebhookConfig / WebhookEvent / WebhookSignatureAlgorithm shapes went with them)", + "conversionId": "connector-resilience-keys-removed", + "toMajor": 18 + }, + { + "surface": "connector.triggers", + "to": "connector key 'triggers' removed (ADR-0049 — a connector trigger never started anything: the automation engine registered a connector's actions only, no polling loop read an interval and no receiver was driven by a webhook trigger. The ConnectorTrigger shape went with it, including the `interval` spelling renamed to `intervalSeconds` earlier in this step. Start the work from a flow that calls the connector's action instead: an `api` flow for an external event, a `schedule` flow for a scheduled pull)", + "conversionId": "connector-triggers-removed", + "toMajor": 18 + }, + { + "surface": "datasource.config.persistence.autoSaveInterval", + "to": "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)", + "conversionId": "memory-persistence-auto-save-interval-to-ms", + "toMajor": 18 + }, + { + "surface": "datasource.config.timeout (turso)", + "to": "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)", + "conversionId": "turso-config-timeout-to-timeout-ms", + "toMajor": 18 + }, + { + "surface": "view.list / view.listViews.* — the list-view type 'page' and its pageName binding", + "to": "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)", + "conversionId": "view-page-mount-removed", + "toMajor": 18 + }, + { + "surface": "view.list.sort / view.listViews.*.sort — the bare string sort clause", + "to": "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)", + "conversionId": "list-view-sort-string-clause-to-array", + "toMajor": 18 + }, + { + "surface": "page.assignedProfiles", + "to": "page key 'assignedProfiles' removed (ADR-0090 D2 deleted the Profile concept it was named for, and no renderer, route or read door ever enforced it — the page stayed open to everyone; ADR-0049 enforce-or-remove)", + "conversionId": "page-assigned-profiles-removed", + "toMajor": 18 + }, + { + "surface": "dashboard.widgets[].chartConfig.aria / report.chart.aria / report.blocks[].chart.aria", + "to": "chart config key 'aria' removed (ADR-0049 enforce-or-remove — no chart renderer ever applied it on either face, so declared ARIA attributes silently did not reach the DOM; the accessible name that IS applied is the sibling 'description')", + "conversionId": "chart-config-aria-removed", + "toMajor": 18 + }, + { + "surface": "dashboard.widgets[].chartConfig.type / dashboard.widgets[].chartConfig.xAxis / dashboard.widgets[].chartConfig.yAxis / dashboard.widgets[].chartConfig.series", + "to": "dataset-bound dashboard widget chart-config keys 'type'/'xAxis'/'yAxis'/'series' removed (ADR-0021 — the dataset decides which series exist and which column each one reads; the widget's own 'type' is the chart family, and 'dimensions'/'values' are the selection, so an authored axis could only agree with the dataset or silently re-point a series at another column)", + "conversionId": "dashboard-widget-chart-config-structure-removed", + "toMajor": 18 + }, + { + "surface": "stack.translations[]..settings / translation.settings", + "to": "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", + "conversionId": "translation-per-app-settings-removed", + "toMajor": 18 + }, + { + "surface": "object.tenancy.organizationField", + "to": "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)", + "conversionId": "object-tenancy-organization-field-removed", + "toMajor": 18 + }, + { + "surface": "page.component.dataSource.filter / page.component.properties.filter (the object-* blocks, element:number, element:record_picker) / page.component.properties.defaultFilters (object-grid) — the record and single-level AST filter forms", + "to": "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)", + "conversionId": "page-component-filter-record-to-rule-array", + "toMajor": 18 + }, + { + "surface": "view.owner / view.hidden — on the view item record ({ name, object, viewKind, config })", + "to": "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)", + "conversionId": "view-item-owner-hidden-removed", + "toMajor": 18 + }, + { + "surface": "report.blocks[].chart / report.chart on a joined report", + "to": "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')", + "conversionId": "report-joined-chart-removed", + "toMajor": 18 + }, + { + "surface": "view.owner / view.hidden — on a flattened view overlay ({ name, object, viewKind, …, no config })", + "to": "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)", + "conversionId": "view-overlay-owner-hidden-removed", + "toMajor": 18 + }, + { + "surface": "page.component.object-form.layout / view.form.layout / view.formViews.*.layout", + "to": "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)", + "conversionId": "form-layout-inline-grid-to-vertical", + "toMajor": 18 + }, + { + "surface": "object.fields.*.currencyConfig.precision", + "to": "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)", + "conversionId": "currency-config-precision-removed", + "toMajor": 18 + }, + { + "surface": "permission.rowLevelSecurity[].tags", + "to": "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)", + "conversionId": "permission-rls-tags-removed", + "toMajor": 18 + }, + { + "surface": "action.aria / object.actions[].aria", + "to": "action key 'aria' removed (ADR-0049 enforce-or-remove — no action surface ever applied it; every renderer takes the accessible name from the action's required 'label', and the placing node's own 'aria' block names the region)", + "conversionId": "action-aria-removed", + "toMajor": 18 + }, + { + "surface": "analyticsCubes[].measures..name / analyticsCubes[].dimensions..name", + "to": "cube member key 'name' removed from measures and dimensions (ADR-0049 enforce-or-remove — nothing read it: every consumer resolves a member by its record KEY, published and queried as `.`. The record key is the member's name; to rename a member, rename its key)", + "conversionId": "cube-member-inner-name-removed", + "toMajor": 18 + }, + { + "surface": "flow.nodes[].config.mode (decision)", + "to": "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)", + "conversionId": "flow-decision-mode-inclusive-explicit", + "toMajor": 18 + }, + { + "surface": "view.list.tabs / view.listViews.*.tabs — the list view's own tab definitions", + "to": "list-view key 'tabs' removed (ADR-0049 enforce-or-remove — parsed and stored, drawn by nothing: no renderer ever mounted a tab bar for it, and the tab strip above an object's records is the saved-view switcher, which renders one tab per `listViews` entry; move each tab you want to a named list view)", + "conversionId": "view-list-tabs-removed", + "toMajor": 18 + }, + { + "surface": "analyticsCubes[].refreshKey", + "to": "cube key 'refreshKey' removed, with its 'every' and 'sql' (ADR-0049 enforce-or-remove — nothing read it: no analytics result is cached, so a declared refresh cadence refreshed nothing. Delete the key; a refresh cadence is declared again when a result cache exists)", + "conversionId": "cube-refresh-key-removed", + "toMajor": 18 + }, + { + "surface": "object.fields.*.defaultValue / action.params[].defaultValue / page.component.element:button.action.params[].defaultValue (type time)", + "to": "a `time` literal default's `Z` or zero-offset suffix is dropped, which names the same wall clock; a default with a non-zero offset is left as stored and reported as a TODO, because a `time` value carries no zone (ADR-0053 D-C1) and only its author knows which wall clock it meant", + "conversionId": "time-default-utc-suffix-dropped", + "toMajor": 18 + }, + { + "surface": "page.component.page:header.breadcrumb", + "to": "page:header prop 'breadcrumb' removed, whether 'true' or 'false' (no renderer ever drew a trail for it: objectui drew an empty slot and nothing filled it, and the app shell's header draws the navigation trail)", + "conversionId": "page-header-breadcrumb-removed", + "toMajor": 18 + }, + { + "surface": "connector.syncConfig / connector.fieldMappings", + "to": "connector keys 'syncConfig' and 'fieldMappings' removed (ADR-0049 — no engine ever ran a connector-attached sync or moved a value through a connector field mapping, so the `latest_wins` and `soft_delete` defaults resolved and deleted nothing. The DataSyncConfig, SyncStrategy, ConnectorConflictResolution and ConnectorFieldMapping shapes went with them. A sync is defined on its target instead: a `mapping` whose `connectorSource` names the connector it pulls from, with a `job` for the cadence)", + "conversionId": "connector-sync-keys-removed", + "toMajor": 18 + }, + { + "surface": "view.form.subforms[].columns[].field / view.formViews..subforms[].columns[].field", + "to": "form-view subform grid column entries respelled 'field' → 'name', the grid's column identity (the carrier accepted any value until it took the inline grid column contract; a relationship field's inlineColumns get the same respelling from field-column-lists-canonicalized)", + "conversionId": "form-view-subform-columns-canonicalized", + "toMajor": 18 + }, + { + "surface": "page.component.action:button.endpoint / page.component.action:icon.endpoint", + "to": "action:button / action:icon component prop 'endpoint' → 'target' on an `api` action — the rename `ActionSchema` already prescribes; the console's `api` handler reads `target` only. An `endpoint` on a block with no `actionType` or another one is left as stored and reported as a TODO", + "conversionId": "action-block-endpoint-to-target", + "toMajor": 18 + }, + { + "surface": "view.form.sections[].fields[].publicPicker", + "to": "form field 'publicPicker' removed (ADR-0087 D2 — the anonymous public-form record-search picker is retired: an anonymous public form no longer takes lookup, master_detail or user fields, and the anonymous lookup route is gone. Use a select field with static options, or put the form behind sign-in)", + "conversionId": "form-field-public-picker-removed", + "toMajor": 18 + }, + { + "surface": "dataset.measures[].field (aggregate count, empty string)", + "to": "a `count` dataset measure's empty `field` is removed: a count with no `field` counts rows, which is what the empty string compiled to, and a measure's `field` is now a column reference that refuses an empty string", + "conversionId": "dataset-count-measure-empty-field-removed", + "toMajor": 18 + }, + { + "surface": "agent.structuredOutput.format / agent.structuredOutput.fallbackFormat / agent.structuredOutput.transformPipeline", + "to": "agent structured-output formats 'regex' / 'grammar' / 'xml' and the transform step 'coerce_types' removed: the AI runtime refused each before the first turn, because structured output is checked only as JSON and no coercion engine exists. A block whose format was retired is deleted, a retired fallback format is deleted, and the coerce step is dropped from the pipeline", + "conversionId": "agent-structured-output-refused-members-removed", + "toMajor": 18 + }, + { + "surface": "translation.dashboards.widgets.subCaption", + "to": "translation widget key 'subCaption' removed: the metric sub-caption it overlaid onto the widget's 'options.description' is retired at both ends — the dashboard schema never declared that key and no authored widget wrote it, so the overlay was its only writer. A widget keeps one authored description, 'widget.description', translated by the widget node's 'description' key", + "conversionId": "translation-widget-sub-caption-removed", + "toMajor": 18 + }, + { + "surface": "agent.memory.longTerm.store", + "to": "agent memory key 'longTerm.store' removed: the memory store is platform infrastructure, not agent metadata — the AI runtime keeps long-term memory notes in its own database store and refused the 'vector' default and 'redis' before the first turn. The key is deleted; every other memory key stays", + "conversionId": "agent-memory-long-term-store-removed", + "toMajor": 18 + }, + { + "surface": "agent.lifecycle", + "to": "agent key 'lifecycle' removed: the conversation state machine was parsed and never read — no runtime moved an agent through a declared state. The key is deleted; a conversation phase is a skill with triggerConditions, orchestration is a Flow, record transitions are a state_machine validation rule", + "conversionId": "agent-lifecycle-removed", + "toMajor": 18 + }, + { + "surface": "page.component.object-grid.resizableColumns", + "to": "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)", + "conversionId": "object-grid-resizable-columns-removed", + "toMajor": 18 + }, + { + "surface": "page.requires", + "to": "page key 'requires' removed from react, full and slotted pages (a page with no kind is full) — the plugin-namespace list is derived from the source at save only on html / jsx pages; on the other kinds nothing derived or enforced it, and the Studio page editor drops it", + "conversionId": "page-requires-non-compiled-kind-removed", + "toMajor": 18 + }, + { + "surface": "page.component.element:text.variant", + "to": "element:text 'variant' spellings 'heading' → 'h2' and 'subheading' → 'h3' (the vocabulary converged on the nine values ui:text publishes; each old spelling already rendered that heading element, so the outline is unchanged and the heading takes that level's style)", + "conversionId": "element-text-variant-heading-levels", + "toMajor": 18 + }, + { + "surface": "page.component.object-master-detail-form.details[].sortField", + "to": "object-master-detail-form detail entry prop 'sortField' removed (the console reads no authored value: the line grid stamps the field it derives from the child object, so the key was accepted and dropped; delete the key — the child object's own position field keeps the line order)", + "conversionId": "object-master-detail-form-detail-sort-field-removed", + "toMajor": 18 + }, + { + "surface": "object.indexes[].unique / objectExtensions[].indexes[].unique", + "to": "declared-index bare `unique: true` → `unique: 'global'` (ADR-0120 D2 — the scope is stated, never positional; `'global'` is exactly the index bare `true` built, so the physical index is byte-identical; field-level `unique: true` is not converted)", + "conversionId": "declared-index-unique-scope", + "toMajor": 18 + }, + { + "surface": "manifest.permissions", + "to": "manifest 'permissions' as a flat list of permission strings removed (ADR-0049 — no loader ever read the list, so dropping it changes no grant; the structured { services, hooks, network, fs } block is the only form, and a permission string has no mechanical mapping onto it)", + "conversionId": "manifest-permissions-string-list-removed", + "toMajor": 18 + } + ], + "migrated": [ + { + "surface": "action.aria / object.actions[].aria — the ARIA block on an action", + "replacement": "The action's required `label`, which every action renderer uses as the accessible name (the visible button or menu-item text, and the `aria-label` of an icon-only action). To name the region that places the actions, the `aria` block of the placing node — `page.components[].aria` or the list view `aria`.", + "migrationId": "action-aria-retired", + "toMajor": 18, + "rationale": "The D2 conversion `action-aria-removed` deletes `aria` from every stack action and every object-nested action, and the delete is lossless: no surface that renders an action ever read the block, so the ARIA attributes it declared never reached the DOM. The residue is accessibility work the author did that no user benefited from. An author who wrote `aria.ariaLabel` believed screen-reader users heard that name; they heard the `label`. The strip deletes the text along with the key, and only the author can say whether it should become the `label` — which sighted users read too — or whether it described the toolbar or list the action sits in, and belongs in that node's `aria` block instead." + }, + { + "surface": "page.component.action:button.endpoint / page.component.action:icon.endpoint — the endpoint an `api` action button calls, on the two page blocks that run an action", + "replacement": "`target` — the one key the action runner dispatches an executor on, and the key `ActionSchema` already renames `endpoint` to.", + "migrationId": "action-block-endpoint-spelling-retired", + "toMajor": 18, + "rationale": "The D2 conversion `action-block-endpoint-to-target` renames `endpoint` to `target` in author sources and on every stored-row rehydration, for a block whose `actionType` is `api` — the one meaning the key declared, and the rename is lossless there. Three things are left. A block that carries `endpoint` with no `actionType` was called through the action runner's legacy API fallback, which a `target` with no type does not reach, so the author has to add `actionType: 'api'` as well. A block with another `actionType` never read `endpoint`, so only the author can say whether its value should become the `target` or be deleted. And a block carrying both spellings with different values is left for the author to keep one. Each is left as stored and reported as a TODO. Code is out of reach: a custom action handler that read `endpoint` off the action it was handed reads nothing once the block carries `target`." + }, + { + "surface": "`action.execution` — the bulk dispatch contract an action’s body is written for", + "replacement": "Declare `execution: 'perRecord' | 'aggregate'` on every action a list view wires into the selection bar, DERIVED from the wiring that action already has: a view naming it in `bulkActions: ['']` (the bare-string form) dispatches it once per selected row with that row's `recordId` ⇒ `execution: 'perRecord'`; a `bulkActionDefs` entry naming it with `execution: 'aggregate'` dispatches it once for the whole selection with every id in `params._selectedIds` ⇒ `execution: 'aggregate'`. The derivation is exact wherever an action is wired ONE way, because the wiring is what the body has been receiving all along — declaring it changes no behaviour, it writes down the behaviour. ⛔ There is no default: an action no view bulk-wires, and an action whose body genuinely serves both contracts (it reads `recordId` AND `_selectedIds` and copes with either), stays UNDECLARED rather than being given a value.", + "migrationId": "action-bulk-dispatch-contract-undeclared", + "toMajor": 18, + "rationale": "Not losslessly convertible, because the fact being written down does not live on the item being rewritten. The declaration belongs to the ACTION and the evidence for it belongs to the VIEWS — potentially several, in other files or other packages — so no per-item transform has both halves in hand, and `objectstack migrate meta` rewrites stored metadata by key. The residue is genuinely a judgement: an action wired BOTH ways has no correct value, because one call and N calls have different side effects and the platform will not silently unify them (the 2026-09-12 ruling that made an action declare its dispatch contract refused exactly that option). Such an action is TWO actions — split the body along the line the two wirings already draw and declare each half — or, if the body was deliberately written to serve both, it stays undeclared and the two wirings stand. The census that is this migration’s input was taken 2026-09-13 over objectstack@a9c64779046 (shipped app metadata, test fixtures excluded: 13 distinct bulk-wired actions — 11 unambiguously per-record, 1 unambiguously aggregate, 1 wired both ways) and hotcrm@c716a2ccb3d31574a1a238a590f3e331ddae0200 (3 distinct bulk-wired actions — 2 per-record, 1 aggregate, 0 wired both ways). So the both-ways residue is real but rare, which is why it is a structured TODO and not a blocking rewrite." + }, + { + "surface": "Action handler body — `ctx.engine.find(object, filter)` (`ActionEngineFacade.find`, `@objectstack/spec/ui`)", + "replacement": "`ctx.engine.find(object, { where: filter })` — the engine's own query envelope (`EngineQueryOptions`), the same options bag `IDataEngine.find` takes. The filter moves under `where` verbatim: `find('task', { status: 'open' })` → `find('task', { where: { status: 'open' } })`. An unfiltered `find(object, {})` is unchanged, and the rest of the envelope — `fields`, `orderBy`, `limit`, `offset`, `expand` — becomes reachable from a handler for the first time. A caller-supplied `context` is ignored: the facade is trusted and stamps its own elevated one.", + "migrationId": "action-engine-facade-find-query-envelope", + "toMajor": 18, + "rationale": "The rewrite itself is lossless and mechanical, but it is not automatable here: an action handler is authored TypeScript, and the chain rewrites stored metadata by key, so no `os migrate meta` step can reach a call expression inside a function body. The change is a WITHDRAWAL of the parameter shape an earlier typing fix chose (the filter alone), ruled by the director seat on 2026-09-12, with the maintainer's agreement, on the long-term axis 「one platform, one query shape」. The facade had been given a shape different from the engine's — the `where` half alone — which made the most natural spelling the wrong one: an author who passed the engine's envelope got `{ where: { where: … } }`, matching no row and resolving to `[]` with no error, while an unfiltered `{}` kept working under either belief so a dead handler looked partially alive. The alternative — refusing `where` at the top level with an intersection — was rejected because it asserts a vocabulary fact the spec declares nowhere, reserving the field name `where` across every customer's data model to buy one parameter's compile-time check." + }, + { + "surface": "stored `address` and `location` field VALUES (`AddressSchema` / `AddressValueSchema`, `LocationValueSchema` — ADR-0104 D1), and the two authoring doors that parse the same contract: a `location` / `address` field's literal `defaultValue` and an action param of those types — undeclared keys", + "replacement": "the declared key the rejection names. An address value accepts exactly `street`, `city`, `state`, `postalCode`, `country`, `countryCode`, `formatted`; a location value exactly `lat`, `lng`, `altitude`, `accuracy`. Every rejection carries the surface, the offending key and a rename (`postal_code` / `zipCode` / `zip` / `postcode` → `postalCode`, `latitude` → `lat`, `longitude` → `lng`). A key that names no declared member is removed at the producer — never tolerated at a consumer: an alias for an off-spec key in a consumer stays forbidden (contract-first — fix the metadata, not the runtime)", + "migrationId": "address-location-value-unknown-keys-refused", + "toMajor": 18, + "rationale": "Maintainer ruling 2026-09-01, option A: both value classes refuse undeclared keys. Both value classes were all-optional STRIPPING `z.object`s, so a value with a completely wrong key set parsed green and the wrong keys vanished from the parse output: the showcase seed wrote `postal_code`, the platform accepted it, dropped it, and rendered an empty ZIP box (found while counting stored address values for objectui's survey of which structured values its field validator checks; an earlier report had named the same stripping on the address widget's round-trip, whose ZIP input bound `zipCode` against a stored `postalCode`), while a stored-value scan over the class could only ever report a clean count it had no way to earn. Closing the two shapes restores declared = enforced and pulls \"loose\" back to the one deliberate exception (`FileValueSchema`, untouched). Where the refusal BITES is the ADR-0104 write path's own evidence-gated posture, deliberately unchanged: a record write carrying an undeclared key is refused only on a deployment that has attested `adr-0104-value-shapes` (or opted in with `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1`); everywhere else it stays warn-first and is reported to the admitted-violation sink, and `os migrate value-shapes` now COUNTS such keys, so a deployment holding them cannot attest until they are cleaned. No read path parses these shapes; a stored value reads back as written." + }, + { + "surface": "the SHIPPED platform admin permission sets `admin_full_access`, `organization_admin` and the derived `organization_admin_no_bypass` — their `objects[\"*\"].allowExport = true` wildcard grant (REMOVED; the rest of the wildcard is unchanged)", + "replacement": "an explicit `allowExport: true` on the object entries of an APP-authored permission set held by the principals meant to keep exporting. Nothing replaces the grant in the platform sets themselves", + "migrationId": "admin-export-wildcard-removed", + "toMajor": 18, + "rationale": "A capability NARROWING of a published set, and — like `export-axis-opt-in`, whose 17.0 story this completes — one no gate can announce: the metadata is unchanged and still parses, the shipped sets are re-seeded on upgrade, and the only observable is that an export which returned 200 now returns 403 `EXPORT_NOT_PERMITTED`. `export-axis-opt-in` told upgraders that \"package-shipped sets are re-seeded on upgrade, so the built-ins are handled — `admin_full_access` and `organization_admin` now carry the grant explicitly\"; from this major they deliberately do NOT, so a deployment that read that sentence and left its admins to the built-ins must now act. What the wildcard did, measured on 17.0.0 GA across 40 export probes: an org owner exported three objects on which NO app permission set granted export, 200 with full rows, and the app had no way to refuse — editing a code-package set answers `403 [not_overridable]`, and the org admin holds no app-authored set in which to write the per-object `false` that would have won. So an application could declare an object exportable by nobody, ship, and be silently wrong on an exfiltration boundary — declared ≠ enforced, on the axis where a silent gap costs the most. This is the 2026-08-07 ruling on the member baseline applied to export: that change removed `member_default`'s CRUD wildcard because a wildcard in a set every principal resolves is not a default but a floor nobody can get under; the export wildcard survived by omission rather than by decision, one tier up. It cannot be mechanically converted, in either direction: re-granting `allowExport` wherever an admin holds a set would restore today's behaviour and defeat the entire point, and leaving it withheld may revoke export an operator legitimately wants. WHICH principals may take a bulk copy is the segregation-of-duties judgement the axis exists to make explicit, and it belongs to the operator. Note the boundary this does NOT move: the export gate itself is unchanged and was never the defect (controls C1–C3 of the same run show it enforcing exactly), specific-over-wildcard precedence is unchanged, `allowExport` on a `\"*\"` entry remains a supported authoring shape in an app's OWN sets, and READ is untouched — an admin still sees every record they saw before. ADR-0087; maintainer ruling 2026-08-15, which removed `allowExport` from the wildcard entry of both shipped admin sets." + }, + { + "surface": "security.permission.adminScope.businessUnit (AdminScopeSchema, ADR-0090 D12) authored or stored BLANK: the empty string, or a value that is nothing but whitespace (spaces, a tab, a newline). The key is the scope's only required one and names the root business unit of the delegated subtree; a blank value satisfied the requirement while naming no unit", + "replacement": "the sys_business_unit.name (machine name) of the business unit at the root of the subtree the delegate administers, written out: `businessUnit: 'north_america'`. If the permission set should not delegate administration at all, remove `adminScope` from it. ⛔ There is no replacement that can be DERIVED from what was written: a blank names no unit, so the root the author meant is not recoverable, and the platform must not pick one.", + "migrationId": "admin-scope-business-unit-blank-refused", + "toMajor": 18, + "rationale": "Maintainer ruling A, 2026-09-23: an empty or whitespace-only `businessUnit` is refused at parse, and stored scopes are not rewritten. `AdminScopeSchema` declared `businessUnit` as a bare string with no minimum, so `{ businessUnit: '' }` and `{ businessUnit: ' ' }` parsed green — measured against the published spec 17.4.0 and re-measured on `main` before the change. This narrows a published face: every other key of the scope is scoped TO this one, and ADR-0090 D12 declares the scope's WHERE as a business-unit subtree, which a blank does not name. The delegated-admin gate resolves the anchor by exact name, so a blank anchor resolves to an empty subtree and approves nothing on the subtree axes — no escalation was measured; the defect is a declaration that does not enforce what it declares, satisfied most readily by an author (an AI author above all) that knew the key was required and did not yet know the unit. The refusal is a NON-TRANSFORMING refinement at the key's own path, deliberately not a trim: the metadata save path persists the submitted body verbatim rather than the parsed value, so a trimming schema would validate one string and store another that the gate's exact lookup cannot resolve. A real name therefore parses byte-identical. Scope is blankness only: a real name with surrounding whitespace is not judged by this entry. ⚠️ STORED ROWS ARE NOT REWRITTEN and there is no D2 conversion (no lossless rewrite exists — the root cannot be inferred, and dropping the scope would silently change who is a delegate). The read path does not re-validate stored rows, so no stored permission set becomes unreadable. A stored blank-anchored scope is refused on its NEXT WRITE instead: a Setup or data-door edit of that permission set answers 422 INVALID_METADATA naming adminScope.businessUnit, and the boot reconciliation backfill of a legacy record with no metadata definition reports it through its existing durability ERROR (ADR-0094 D4), whose own prescription is to make the record body spec-valid; restoring a trashed blank-anchored set brings the record back and reports the missing definition at ERROR the same way. ⛔ No path skips the row. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). ADR-0049 / ADR-0087 / ADR-0090." + }, + { + "surface": "kernel.advancedPluginLifecycle (the authorable config surface of `plugin-lifecycle-advanced.zod.ts` — 3 defs, 9 exported names: `AdvancedPluginLifecycleConfigSchema` / `AdvancedPluginLifecycleConfig` / `AdvancedPluginLifecycleConfigParsed`, `GracefulDegradationSchema` / `GracefulDegradation` / `GracefulDegradationParsed`, `PluginUpdateStrategySchema` / `PluginUpdateStrategy` / `PluginUpdateStrategyParsed`)", + "replacement": "(removed — there is no declarative replacement, because nothing ever read the declaration. The supported lifecycle surface is the HOST-DRIVEN library in `@objectstack/core`: construct `PluginHealthMonitor` and pass a `PluginHealthCheck` per plugin, construct `HotReloadManager` and pass a `HotReloadConfig` — the `content/docs/protocol/kernel/lifecycle.mdx` examples — rewritten to show the plugin exposing a method and the host registering it, never a declarative field — are the supported usage, and those input vocabularies (`PluginHealthStatus` / `PluginHealthCheck` / `PluginHealthReport`, `HotReloadConfig` with its embedded `DistributedStateConfig`, `PluginStateSnapshot`) SURVIVE in the same module as library parameter types. Degradation and update-strategy vocabularies return only via the ENFORCE route of ADR-0049 through a new ADR — the executor first, the vocabulary second)", + "migrationId": "advanced-plugin-lifecycle-config-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-08-25, route 2: retire the config container and keep the classes as a host-driven library. The container aggregated six config groups — `health`, `hotReload`, `degradation`, `updates`, `resources`, `observability` — and NO group had a runtime reader, re-measured per group at the retirement's base commit (8cdd696) with positive controls: the kernel never constructs `PluginHealthMonitor` or `HotReloadManager` (only their own unit tests and `core/examples/phase2-integration.ts` do, passing config DIRECTLY to the classes, never through this container); `degradation` / `updates` / `resources` / `observability` keys have no implementation body at all (controls: `checkMethod` resolves to `core/src/health-monitor.ts` and `debounceDelay` to `core/src/hot-reload.ts`, proving the scan sees real readers; the bare-name collisions — plugin-ordering's `optionalDependencies`, auth-manager's private `degradedFeatures`, plugin-security-advanced's `resourceLimits.maxCpu` read by `sandbox-runtime.ts` — are different surfaces, verified structurally). No manifest, stack collection or metadata-type binding ever embedded the container, so no authored document could carry it: an author declaring `health: {...}` or `rollback: { automatic: true }` got a clean parse and NOTHING — the shape of the plugin sandboxing / integrity / approval config that was never wired to anything, at container scale, sharpened by production-safety vocabulary (auto-restart, zero-downtime rolling updates, automatic rollback) an AI author (ADR-0033) reads as proof the capability exists. With no carrier key and no authored document there is nothing to tombstone and no seam for a D2 conversion: route 3, the shape of the dynamic plugin-loading family's removal and of the retired `ApiKeySchema` — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration." + }, + { + "surface": "agent.lifecycle — the agent conversation state machine left the shape; with it the XState StateMachineSchema family left @objectstack/spec/automation (StateMachineSchema, StateNodeSchema, TransitionSchema, ActionRefSchema, GuardRefSchema and their types), and StateNodeConfig left the root and /ai entries", + "replacement": "no key: delete `lifecycle` from every agent. Put what the machine meant where the platform enforces it — a phase of a conversation is a skill with its own `instructions` and `tools`, selected by its `triggerConditions` and attached through the agent's `skills`; a multi-step process is a Flow; a record's status transitions are a `state_machine` validation rule on the object (a flat table of each state's allowed next states). Code that imported the state machine exports declares the shape it needs itself, or drops it", + "migrationId": "agent-lifecycle-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove: `agent.lifecycle` was parsed and never read. No runtime — not this repository, not the cloud AI runtime that executes agents — moved an agent through a declared state or refused an undeclared transition, so an authored machine changed nothing an agent did. Enforcing it would have meant a statechart interpreter beside Flow, the two-engine shape ADR-0020 rejected, and what it reached for is already served: conversation phases by skills (ADR-0064), orchestration by Flow (ADR-0019), record transitions by the `state_machine` validation rule (ADR-0020). Authoring now refuses the key with that prescription, and TypeScript rejects it. The D2 conversion `agent-lifecycle-removed` deletes it from existing sources and stored agent rows, losslessly. `StateMachineSchema` had kept its file only for this door (ADR-0020 implementation note 1), so the family left with it — which of the three destinations each deleted machine meant is the author's judgement, not a mechanical rewrite" + }, + { + "surface": "agent.memory — longTerm.store left the shape (the memory store is the platform's); longTerm.maxEntries and reflectionInterval are required when longTerm.enabled is true, and reflectionInterval is refused without an enabled longTerm; longTerm.enabled is unchanged", + "replacement": "no storage key: delete `longTerm.store`, whatever it held — where long-term memory notes are kept is the platform's choice. An agent whose `longTerm.enabled` is true declares `longTerm.maxEntries` (how many distilled notes are kept for each user; the newest are recalled before the first round and older ones evicted) and `memory.reflectionInterval` (how many delivered interactions pass between the reflections that write a note). An agent without enabled long-term memory declares no `reflectionInterval`", + "migrationId": "agent-memory-store-retired-and-limits-required", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove: the `agent.memory` contract states exactly what the runtime honours. The cloud AI runtime, the one runtime that executes agents, enforces long-term memory from `enabled`, `maxEntries` and `reflectionInterval`: it recalls the newest `maxEntries` notes before the first round, writes one note every `reflectionInterval` delivered interactions, and evicts notes beyond `maxEntries`. It keeps the notes in its own database store, and before an agent's first turn it refused the `vector` store (the old default, so what an omitted `store` parsed to), `redis`, an enabled `longTerm` missing either number, and a `reflectionInterval` without an enabled `longTerm`. Authoring now refuses the same declarations, each with a prescription. The D2 conversion `agent-memory-long-term-store-removed` deletes `store` from existing sources and stored rows, losslessly: no value of it ever chose a backend. No default is declared for either number, because none has a measured basis — so an agent with long-term memory enabled and either number missing no longer parses, and only its author can choose the numbers it needs" + }, + { + "surface": "agent.structuredOutput — the values 'regex', 'grammar' and 'xml' left StructuredOutputFormat (at format and fallbackFormat) and 'coerce_types' left TransformPipelineStep (at transformPipeline); 'json_object', 'json_schema', 'trim', 'parse_json' and 'validate' are unchanged", + "replacement": "a JSON contract: `format: json_schema` with a JSON Schema in `schema` when the answer must have a shape, or `format: json_object` when any JSON value will do — or no `structuredOutput` block at all when the agent needs no output contract. A fallback format names one of the two JSON formats or is left out. In place of `coerce_types`, declare the exact types in `schema`, so the answer is validated as the model wrote it", + "migrationId": "agent-structured-output-refused-members-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove. The cloud AI runtime, the one runtime that executes agents, enforces `structuredOutput` on every final answer and refuses an agent that declares any of these four members before its first turn: the spec never had a key to carry the pattern or grammar a `regex` or `grammar` answer would be checked against, a final answer is checked only as JSON, and no coercion engine exists. Hosted model APIs constrain a final answer by JSON Schema only; regex and grammar constraints live in inference engines and in tool-input formats, not on an agent's answer. The D2 conversion `agent-structured-output-refused-members-removed` makes each stored or existing source parse: it DELETES a block whose `format` was retired, deletes a retired `fallbackFormat`, and drops `coerce_types` from the pipeline. The deletion of a block is the edit that needs judgement: it removes an output contract the runtime never kept, and only the author can say whether the agent should now carry a `json_schema` contract instead — the conversion cannot write the schema the author meant" + }, + { + "surface": "ConversationAnalytics.duration, the emitted session length whose name carried no unit (ai/conversation.zod.ts)", + "replacement": "durationSeconds — rename the key; the value is unchanged", + "migrationId": "ai-conversation-analytics-duration-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B (2026-09-02, extended on 2026-09-05 to runtime-emitted durations): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. It stands alone because it is the only offender in ai/ and the only one on its file. What makes the bare name worth a registry row rather than a quiet edit is the company it kept: every other number on ConversationAnalytics is a COUNT — totalMessages, totalTokens, peakTokenUsage, pruningEvents, tokensSavedByPruning — so the one field that carried a unit was the one field that did not say so, sitting in a block of twelve unitless integers. The two instants beside it, firstMessageAt and lastMessageAt, already spelled themselves; the measurement between them did not. Tombstoned with retiredKey(); the shape is not strict, so a bare deletion would strip in silence and an emitter writing the old spelling would lose the value with no error anywhere. Why a semantic entry and not a D2 conversion: conversation analytics are computed at runtime and handed to a consumer, never authored by hand and never stored as a sys_metadata row, so the conversion chain has no seam that would ever see one — the same disposition every runtime-emitted measurement in this stack has taken. ADR-0087." + }, + { + "surface": "action.ai.outputSchema (stack actions and object-nested actions) and agent.structuredOutput.schema — a JSON Schema in which an object subschema with no type carries a type-scoped keyword", + "replacement": "the same schema with a `\"type\"` declared on every subschema that carries a type-scoped keyword: `\"object\"` beside `properties`, `required`, `additionalProperties`, `patternProperties`, `propertyNames`, `minProperties` or `maxProperties`; `\"array\"` beside `items`, `prefixItems`, `contains`, `minItems`, `maxItems` or `uniqueItems`; `\"string\"` beside `minLength`, `maxLength`, `pattern` or `format`; `\"number\"` or `\"integer\"` beside `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum` or `multipleOf`. A subschema meant to accept several types declares them as an array (`\"type\": [\"string\", \"null\"]`).", + "migrationId": "ai-json-schema-untyped-subschema-refused", + "toMajor": 18, + "rationale": "Both slots are compiled by the cloud AI runtime — `action.ai.outputSchema` before the action runs, to validate its result, and `agent.structuredOutput.schema` as the agent's structured-output contract — and both readers call one guard whose schema reader does not check a type-scoped keyword on a subschema that declares no `type`. That guard refuses the whole schema before anything runs. The spec declared both slots as open records, so such a schema passed `defineStack`, `objectstack validate` and the metadata save door, and the author learned of it only when the action or agent was invoked. Both slots are now one declaration that mirrors the guard exactly — the same 22 type-scoped keywords (`properties`, `required`, `additionalProperties`, `patternProperties`, `propertyNames`, `minProperties`, `maxProperties`, `items`, `prefixItems`, `contains`, `minItems`, `maxItems`, `uniqueItems`, `minLength`, `maxLength`, `pattern`, `format`, `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`), present with any value on an object node whose `type` is absent; the same descent into every value of `properties`, `patternProperties`, `$defs`, `definitions` and `dependentSchemas` and into the single subschema or each array entry of `items`, `additionalProperties`, `contains`, `propertyNames`, `not`, `if`, `then`, `else`, `unevaluatedProperties`, `unevaluatedItems`, `anyOf`, `oneOf`, `allOf` and `prefixItems`, under typed and untyped parents alike, without following `$ref` — and refuses each offending subschema at its own path with the `type` to declare named. Boolean subschemas, `{}`, a node with any `type` value, and an untyped node carrying only keywords outside the list (`enum`, `const`, `$ref`, `anyOf`, `title`, …) are accepted, as the runtime accepts them. Measured on the built package: the per-type schema the metadata save door validates with refuses an action, an object-nested action or an agent carrying such a schema at the subschema path, and `defineStack` throws with the same path; the same schema with `type` declared is accepted at both. Read from source and not run: a row already stored still loads, because the database loader replays the conversion chain and parses nothing, and its next save is refused until the `type` is declared. No conversion is registered: every refused schema was already refused by the runtime, so nothing that worked stops working; and supplying a `type` is a judgment about what the author meant, not a lossless rewrite, because declaring one also narrows what the schema accepts. Population measured at the change, on origin/main 135daaa06b: zero untyped subschemas in the three authorings of either slot across the package fixtures (one action `ai.outputSchema`, two `structuredOutput.schema`), and zero authorings of either slot in the examples, the documentation and the published skills; the one `outputSchema` the examples carry is a connector action's, a different key. Deployed metadata NOT MEASURED." + }, + { + "surface": "analytics cube definitions (`defineCube` / `defineStack({ analyticsCubes })`: the cube, each metric, each dimension, each join) and the `/analytics/query` body's nested `timeDimensions[]` items — undeclared keys", + "replacement": "the declared key the rejection names. Every rejection carries the surface, the offending key and a rename suggestion (`title` → `label` on a metric/dimension, `label` → `title` on the cube, `granuarity` → `granularity`, `orderBy` → `order`; `filters` on a query gets the `where` prescription). A key that names no supported capability is simply removed", + "migrationId": "analytics-authorable-unknown-keys-refused", + "toMajor": 18, + "rationale": "The unknown-key strictness campaign (the sweep that ended silent stripping of undeclared keys as the default, one schema family at a time), its data/ batch. These shapes parsed `.strip` — an undeclared key on an authored cube was silently dropped, so a join authored with a typo'd `relationship` registered with the `many_to_one` default (a different join than the author declared) and a metric's misspelled key vanished under a successful parse. The subtle half: `/analytics/query`'s top level has been strict since the degraded shim's envelope dialect was retired (one URL, one request body), but top-level strictness does not recurse — `timeDimensions: [{ dimension, granuarity: 'day' }]` rode through the strict wrapper with the typo stripped, bucketing the whole range as one group under an ordinary 200. Undeclared keys on all eight sites are now refused at parse time with a prescriptive message. (Two of the eight were themselves removed later in this major, because nothing ever read them: the nested metric `filters[]` item, by `metric-filters-removed`, and the cube's `refreshKey` block, by `cube-refresh-key-removed`.)" + }, + { + "surface": "data.Cube.public — an analytics cube that declares public: false, and every cube in an artifact built by os compile before this release (the compiler writes the parsed stack, so it carries a materialized public: false on each cube that omitted the key)", + "replacement": "nothing, to keep a cube queryable: cubes are visible by default. Delete an authored `public: false` that only restated the old default, and write it only on a cube that must stay out of the analytics API. Recompile every `os compile` artifact built before this release", + "migrationId": "analytics-cube-public-default-visible-enforced", + "toMajor": 18, + "rationale": "A DECLARED-DEFAULT CORRECTION plus the enforcement that makes the key real. The analytics cube schema declared `public` with a default of `false` under an access-control comment, and nothing read it: `/analytics/meta` listed every cube and every query door answered it. The analytics service now reads it. A cube declared `public: false` is left out of `/analytics/meta`, and `/analytics/query` and `/analytics/sql` refuse it with 404 `CUBE_NOT_FOUND` — the same refusal, byte for byte, that an unknown cube name gets, so the refusal does not confirm a hidden cube exists. Enforcing the old default as declared would have hidden every cube that omits the key, so the default moves to `true` in the same change, and a cube that omits the key stays visible exactly as it was. Two holdings change behaviour on upgrade. An authored `public: false` — including one copied from the example app, which carried it — now hides the cube and refuses its queries. And an artifact built by `os compile` before this release carries a materialized `public: false` on every cube that omitted the key, because the compiler writes the parsed stack with its defaults applied; a host that registers cubes from such an artifact hides all of them until the artifact is recompiled. The key is visibility, not row security: records stay governed by object permissions and row-level security on every door, and the metadata door keeps serving cube definitions." + }, + { + "surface": "data.Cube.dimensions.granularities — an analytics cube time dimension whose granularities list holds exactly one interval, whether an author wrote it that way or the protocol 18 conversion cube-sub-day-granularities-removed reduced a longer list to it", + "replacement": "nothing, when that one interval is the bucket the dimension should be grouped at by default. When it is not, list every interval the dimension serves (two or more state no default) or omit the key. A dashboard or report whose query the engine aggregate path cannot evaluate (a custom-SQL measure, or a member of a cube with `joins` that resolves through one) either stops grouping by such a dimension or groups by one that declares no single interval", + "migrationId": "analytics-cube-single-granularity-default-enforced", + "toMajor": 18, + "rationale": "An inert key made real. A cube time dimension's `granularities` was read only for a cube the dataset compiler minted, where a one-interval list is the dataset's default bucket. A cube authored with `defineCube()` or `defineStack({ analyticsCubes })` never reached that reader, so grouping by its time dimension grouped raw timestamps, one group per distinct instant, whatever the list said. The analytics service now reads every cube by the compiled-dataset rule: on `/analytics/query` and on the `/analytics/sql` dry run, a time dimension the query groups by without stating a granularity is bucketed at the one interval its list declares. A granularity the query states still wins, one the list does not name is not refused, and a list of two or more states no default. Two holdings change on upgrade. A query grouping by such a dimension answers one row per bucket where it answered one row per timestamp. And a bucketed query leaves the raw-SQL path, which declines every bucketed query, for the engine aggregate path, which answers 400 `INVALID_FIELD` for every member it cannot evaluate — the same refusal, byte for byte, that the same query already got with that granularity stated by hand. Those members are: a custom-SQL measure (a `number`, `string` or `boolean` measure whose `sql` is an expression); and, on a cube whose members resolve through its `joins`, a measure or a `where` field over a joined object, a `timeDimensions` entry over a joined object (bucketed or a window, so grouping by a one-interval time dimension over a joined object is refused too), a dimension that traverses more than one relationship, and an `avg` or `count_distinct` measure beside any dimension over a joined object. The raw-SQL path serves every one of these, so each such query grouped by such a dimension goes from answered to refused. On a host whose `queryCapabilities` offers raw SQL with no engine aggregate bridge (a hand override: the analytics plugin wires both), no strategy remains for a bucketed query, so every newly bucketed query, a plain count included, goes from answered to \"No strategy can handle query\". The protocol-18 conversion `cube-sub-day-granularities-removed` strips the retired sub-day intervals from every authored and stored cube, so a dimension that offered one sub-day interval and one coarser interval now holds a one-interval list: a default bucket its author never wrote." + }, + { + "surface": "the ARRAY arm of timeDimensions[].dateRange on an analytics query — AnalyticsQuerySchema / the POST /analytics/query and /analytics/sql bodies, a dataset selection's timeDimensions, and any AnalyticsQuery a host passes to AnalyticsService.query in-process — authored with anything other than EXACTLY two string bounds: a one-element window such as [\"2026-01-01\"], the empty array [], and three or more bounds such as [\"2026-01-01\", \"2026-01-31\", \"2026-02-28\"]", + "replacement": "exactly two string bounds — `[start, end]`. A ONE-ELEMENT window is that day written as BOTH bounds: `['2026-01-01']` becomes `['2026-01-01', '2026-01-01']`, the shape the shipped migration table for the closed preset vocabulary already prescribes for a single day, and the shape all four analytics faces have selected that one day with since the fix that made them read the array arm one way. ⛔ The EMPTY array and THREE-OR-MORE bounds have NO replacement that can be derived from what was written: an empty array names no window at all, and a 3+ array names no pair — decide the window the widget was meant to show and write its two bounds, or drop the dateRange entirely (the field is optional, and absent means the query is not time-bounded). A relative window is a preset name from the closed vocabulary (`'last_7_days'`) or a date-macro pair (`['{7_days_ago}', '{today}']`).", + "migrationId": "analytics-date-range-array-two-bounds-required", + "toMajor": 18, + "rationale": "Maintainer ruling A of 2026-09-12, re-affirmed 2026-09-13, which tightened the array arm to exactly two string bounds: the arm was a bare `z.array(z.string())` with NO length constraint, while the refusal sentence in the same source file said verbatim that \"an explicit window is the two-element array [start, end]\" and the shipped migration table for the closed preset vocabulary told an author to write a single day as `['2026-01-20', '2026-01-20']`. So only the TYPE was weaker than the prose beside it, and a measurement of one authored document on each face found what that bought: one authored `['2026-01-01']` meant a point window on ObjectQLStrategy, NO time clause at all on NativeSQLStrategy (the whole of history), an unbounded-above window in the draft-preview evaluator, and a shifted point window in DatasetExecutor.runCompare — the same document, four backends, four different numbers, no error on any of them. The fix that followed made all four faces refuse it with the ADR-0112 envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`, which left the contract door LOOSER than every reader behind it; this narrowing closes that gap at the door. ⚠️ No D2 conversion and no stored-metadata rewrite, deliberately: rewriting `['2026-01-01']` to the same day twice at load would be the platform deciding, silently, that the author meant one day rather than a window whose end they forgot — and for the empty array and 3+ bounds there is nothing to decide FROM. The blast radius is the WIDGET, not the page: a stored dashboard carrying a now-refused range loses that widget with the accurate refusal shown, and the dashboard still loads. Since that fix every such stored range already failed at QUERY time with the same code and status, so this adds no new class of breakage — it moves the refusal to authoring time and states it accurately. ADR-0049 / ADR-0087 / ADR-0112." + }, + { + "surface": "the row window of an analytics query — limit and offset on AnalyticsQuerySchema, the POST /analytics/query and /analytics/sql bodies, and a dataset selection (POST /analytics/dataset/query) — authored as a negative number (limit: -1, offset: -1), a fraction (limit: 1.5), or an integer above Number.MAX_SAFE_INTEGER", + "replacement": "a non-negative integer, or no key at all: delete `limit` to return every row (a `limit: -1` written to mean \"no limit\" is exactly that), write `limit: 0` only for no rows, and delete `offset` (or write `offset: 0`) to skip nothing; a fraction becomes the integer page size that was meant. An `offset` with no `limit` stays valid and returns every row after the offset, on SQLite and PostgreSQL alike", + "migrationId": "analytics-query-window-non-negative-integer", + "toMajor": 18, + "rationale": "Both members were a bare `z.number()`, and every value outside the non-negative integers answered differently per driver and per face. Measured at POST /api/v1/analytics/query on SQLite and PostgreSQL 16, through the real dispatcher route: `limit: -1` returned every row on the native SQLite face, a 500 on native PostgreSQL and all but the last row on the ObjectQL face; `limit: 1.5` answered a 500, two rows and one row; `offset: -1` a 500 on both native drivers and every row on the ObjectQL face. No value had one answer, so the contract refuses them instead of any engine guessing (contract first, ADR-0049): the two members are `z.number().int().nonnegative()` on `AnalyticsQuerySchema`, the dataset selection reads the same two declarations off its shape, and the runtime doors answer the ADR-0112 envelope `400 VALIDATION_FAILED` naming `limit` or `offset` before any engine runs. ⚠️ No D2 conversion and no stored-metadata rewrite: the window is a QUERY-time request field, not a `sys_metadata` shape, and the refused values meant different windows on different backends, so coercing one would be the platform guessing which the author meant. The one stored producer that lowers into a selection, a dashboard widget's `limit`, is already declared a positive integer. Measured in this repository at the change: no example, fixture, document or published skill authors a negative or fractional analytics window. ADR-0049 / ADR-0112." + }, + { + "surface": "analyticsCubes[].measures..sql, analyticsCubes[].dimensions..sql and datasets[].measures[].field (data.MetricSchema.sql / data.DimensionSchema.sql / ui.DatasetMeasureSchema.field) authored as the row wildcard * where no count consumes it — a cube measure whose type is anything but count, any cube dimension, and a dataset measure whose aggregate is anything but count or that declares none (a derived measure)", + "replacement": "what the member meant. A row count: `type: 'count'` on a cube measure or `aggregate: 'count'` on a dataset measure, keeping `'*'` (a dataset count may also omit `field`). An aggregate of values: the column it aggregates — a field of the object (`amount`) or a relationship path ending in one (`account.amount`). A cube dimension: the column it groups by; to count rows, declare a `count` measure instead. A `derived` measure: delete the `field` key, which nothing read — a derived measure combines other measures by name", + "migrationId": "analytics-row-wildcard-outside-count-refused", + "toMajor": 18, + "rationale": "`'*'` is the row wildcard a `count` aggregates (`COUNT(*)`): it reads no field value, so no other aggregate has a column to read over it, and a dimension has no aggregate at all. The contract nevertheless admitted it in a cube member's `sql` on any measure and on a dimension, and in a dataset measure's `field` under any aggregate, and the analytics strategies passed it to the database as written. Measured at POST /api/v1/analytics/dataset/query over a real SQLite driver, on the native-SQL and the ObjectQL strategy alike: a dataset measure aggregating `'*'` under `sum`, `avg`, `min`, `max` or `count_distinct` answered 500 DATABASE_ERROR — a server fault for an authoring mistake the contract had admitted. A dataset measure compiles to the cube measure it names verbatim, so the same reading covers an authored cube measure; a dimension over `'*'` (GROUP BY *) was measured the same way when the dataset dimension was narrowed. Such a member never produced an answer, so no working document changes meaning: the failure moves from the query to the authoring parse, which names the slot and the aggregate and prescribes a `count` or a column. There is no D2 conversion: rewriting to `count` would change the figure the author asked for, and only the author knows which column a sum over `'*'` was meant to read. A STORED document is not rewritten: a metadata read still serves it as stored, with the refusal on its read diagnostics, and a re-save through the metadata write door is refused at the slot. The dataset query door parses every dataset it is handed, inline or saved, so a stored dataset carrying such a measure is refused 400 VALIDATION_FAILED on EVERY query — including a query that selects only its other measures, which used to answer: it fails closed until the member is fixed. An authored cube reaches the analytics runtime through the stack definition, whose parse refuses it when the stack is built. In-repo census before the change: no example, platform object, doc, skill or fixture authored one, and neither did objectui at the pinned commit; deployed metadata was NOT measured. ADR-0021 / ADR-0049 / ADR-0087" + }, + { + "surface": "the bare-STRING arm of timeDimensions[].dateRange on an analytics query — AnalyticsQuerySchema / the POST /analytics/query and /analytics/sql bodies, a dataset selection's timeDimensions, and any AnalyticsQuery a host passes to AnalyticsService.query in-process — authored as anything other than one of the thirteen declared date-range preset names (today, yesterday, this_week, last_week, this_month, last_month, this_quarter, last_quarter, this_year, last_year, last_7_days, last_30_days, last_90_days): the display spelling \"Last 7 days\" the schema comment used to show, the driver-memory dialect \"last N days\" / \"last 3 months\", or a bare ISO date such as \"2026-01-20\" (the SQL strategies' single-day dialect)", + "replacement": "a preset name from the closed vocabulary — `'last_7_days'` for \"Last 7 days\" / \"last 7 days\", `'last_30_days'`, `'this_month'`, and so on (`DATE_RANGE_PRESETS` in `@objectstack/spec/data` is the list; the rejection prints it) — or, for an explicit window, the two-element array the array arm always accepted: `['2026-01-20', '2026-01-20']` for the single day a bare ISO string used to mean on SQL, `['2026-01-01', '2026-01-31']`, or `['{7_days_ago}', '{today}']` in date-macro tokens", + "migrationId": "analytics-time-dimension-date-range-vocabulary-closed", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-09-06 on the analytics date-range string (option A — contract first): the protocol is the baseline, so the vocabulary is declared once in the schema and the drivers align to it, in a driver change of their own, instead of each guessing. The arm was a bare `z.string()` whose only documented example, `\"Last 7 days\"`, no driver could parse: driver-memory recognised exactly `today` and a case-sensitive `last N ` and fell every other string through to a `[range, range]` pseudo-window that — measured through mingo on 2026-09-05 — matched EVERY `Date`-typed row, 2099 included, because a `Date` compares above a `String` under BSON cross-type ordering; the SQL strategies read the same string as a single ISO day. A dashboard asking for one week silently got all of history on one backend and one day on the other, with no error on either. The string arm is now `z.enum(DATE_RANGE_PRESETS)` — derived from `data/date-range-presets.ts`, the vocabulary's single source of truth since the dashboard date filter's three copies of the list were folded into it, so the two cannot drift — and any other string is refused at parse time with one prescriptive issue at the field's own path; the runtime door answers the ADR-0112 envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` (`api/error-code-ledger.zod.ts`). ⚠️ No D2 conversion and no stored-metadata rewrite: this value is a QUERY-time request field, not a `sys_metadata` shape, and the two retired dialects meant different windows on different backends, so coercing one would be the platform guessing which the author meant. Measured in this repository at the ruling: three authored `'Last 7 days'`, all in spec tests, and no published dashboard authors the string arm at all (the shipped console lowers presets to the array arm). ADR-0049 / ADR-0112." + }, + { + "surface": "api.AssembledInstalledPackageSchema / api.InstalledPackageAtEitherStageSchema / api.ListInstalledPackagesResponseSchema / api.GetInstalledPackageResponseSchema / api.PackageApiContracts, with the types AssembledInstalledPackage, InstalledPackageAtEitherStage, ListInstalledPackagesResponse, GetInstalledPackageResponse and their Parsed twins — imported from @objectstack/spec/api", + "replacement": "the same names, unchanged, imported from `@objectstack/spec/api-assembled` — change the import path and nothing else. Every schema parses and refuses exactly what it did, the route map has the same four entries, and the JSON Schema ids are unchanged (`json-schema/api/AssembledInstalledPackage.json` and its three siblings are still published under `api/`). Every OTHER Package API declaration — the request schemas of both read doors, the install / uninstall / upgrade / rollback shapes, `PackageApiErrorCode` — stays on `@objectstack/spec/api`.", + "migrationId": "api-assembled-entry-split", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-09-17, option B (narrow the entry, rather than add a bundle-weight rule to the browser-reachability ledger or accept the weight as it stood): split the API entry so its browser-facing half no longer carries the assembled-package declarations. Those five embed the ASSEMBLED package body, which reaches the whole metadata vocabulary and, behind it, the datasource declaration and the driver-config validators; declared inside `@objectstack/spec/api`, that tree was part of every bundle of the entry, and a browser module importing two string constants from it paid for all of it. Measured on the splitting PR: that module (objectui `@object-ui/core` column-sortability) bundles to 166,529 bytes gzipped instead of 311,124. The split moves an import path, which is TypeScript source rather than metadata — nothing authors, stores or parses it — so there is no source a D2 conversion could rewrite, and the move is recorded here." + }, + { + "surface": "apis[].cacheTtl — the response-cache lifetime of a declared API endpoint", + "replacement": "`cacheTtlSeconds` — the same lifetime, in seconds, with the unit in the key name. It still applies to GET endpoints only.", + "migrationId": "api-endpoint-cache-ttl-unit-in-key", + "toMajor": 18, + "rationale": "The D2 conversion `api-endpoint-cache-ttl-to-cache-ttl-seconds` renames `cacheTtl` to `cacheTtlSeconds` in the `apis` collection and on stored endpoint rows, keeping the value, and the rename is lossless: the key always meant seconds. The judgment is whether the author knew that. The unit lived only in the description, on the same endpoint surface where `rateLimit.windowMs` spells its unit in milliseconds, so a value written in milliseconds — `cacheTtl: 60000` meant as one minute — cached responses for almost seventeen hours, and the rename carries 60000 over unchanged. A cache that lives a thousand times longer than intended serves stale data long after the underlying records change, with no error anywhere. Only the author can say which unit each value was written in." + }, + { + "surface": "EnhancedApiError.retryAfter (api/errors.zod.ts) — the ADR-0112 error envelope on the wire", + "replacement": "retryAfterSeconds — rename the key; the value (seconds) is unchanged", + "migrationId": "api-error-retry-after-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B of 2026-09-02 on duration-shaped keys: the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. BREAKING ON THE WIRE, and ruled in deliberately: its 2026-09-05 population ruling puts the ~16 runtime-emitted measurements in scope because they are read by humans and agents even if nobody authors them, and names ApiError.retryAfter explicitly, with its own BREAKING note. The ambiguity here is sharper than the usual bare duration. A consumer meets TWO retry-after values on the same 429 response: this envelope field, which has always been delta-seconds, and the HTTP Retry-After header, which per RFC 9110 section 10.2.3 may carry EITHER delta-seconds OR an HTTP-date. Spelled identically, they read as one value in two places; spelled retryAfterSeconds, the envelope states its own unit and the header keeps its own rules. THE HTTP HEADER IS A SEPARATE, UNCHANGED SURFACE — its name is fixed outside this repo and nothing in this rename touches it. Do not \"fix\" the header to match, and do not read a green grep for `retry-after` in transport code as leftover work. A SEMANTIC entry rather than a D2 conversion because an error envelope is emitted, never stored: it is not a stack collection member and never a sys_metadata row, so the conversion chain has no seam that would see one. ADR-0087, ADR-0112." + }, + { + "surface": "two api-layer runtime configuration durations whose name carried no unit: DataLoaderConfig.cacheTtl (api/contract.zod.ts) and RouteDefinition.timeout (api/router.zod.ts)", + "replacement": "cacheTtlSeconds (seconds) and timeoutMs (milliseconds) — rename each key; both values are unchanged", + "migrationId": "api-runtime-config-durations-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B of 2026-09-02 on duration-shaped keys: the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. Two keys on two shapes, in one entry because they share a disposition and an audience: both are api-layer runtime configuration a host or plugin builds in code, and neither is part of a published metadata document. DataLoaderConfig.cacheTtl named seconds only in its describe on a batching config whose other numbers are counts (maxBatchSize, maxConcurrency); RouteDefinition.timeout said \"Execution timeout in ms\" in prose and nothing else. Both are retiredKey() tombstones — neither shape is strict, so a bare deletion would strip the old key in silence and an unknown-key error could not carry the rename. Why a semantic entry and not a D2 conversion: a DataLoaderConfig is a per-request batch-loader construction argument and a RouteDefinition is a router registration built by a plugin at start — neither is a stack collection member and neither is ever stored, so the chain has no seam that would run on them (the kernel/Manifest:loading precedent). Worth knowing while grepping: packages/runtime declares its OWN local RouteDefinition interface for the ai:routes hook payload — a different type with no duration key at all, untouched by this rename. ADR-0087." + }, + { + "surface": "approvals position address role: — the approverId filter of the approvals request list, the actorId of every decision, and a stored pending_approvers slot", + "replacement": "`position:`, the one spelling of a position address; a flow approver authored as `{ type: 'role', value: }` becomes `{ type: 'position', value: }`", + "migrationId": "approval-position-address-role-retired", + "toMajor": 18, + "rationale": "The fourth face of the ADR-0090 D3 `role` retirement, beside `actor-user-roles-to-positions` and `action-session-roles-to-positions`, and like them a runtime face with no spec schema. The approvals service read `role:` as a second spelling of `position:` wherever it compares a slot with the caller (the \"My Pending\" filter, the participant gate, `viewer.can_act`, and the slot test of every decision), because 15.x-era slots and the stock console's identity list carried it. ADR-0090 D3 retires the word with no alias window, so once the pinned console sent `position:` the arm came out in one edit (maintainer ruling, 2026-10-04). `position:` is now the only position address: a `role:` ask matches only a slot stored under that exact spelling, and a `role:` actor is refused with 403 `FORBIDDEN`. The same ruling closed the one WRITER of the spelling. The deprecated `role` approver TYPE already resolved as `org_membership_level` (the org-membership tier: owner, admin, member), but when that lookup found no one the fallback slot kept the AUTHORED spelling, `role:`, and a holder of a same-named position decided it through the arm, so the runtime silently honoured a membership-tier declaration as a position. The fallback now writes the canonical `org_membership_level:`, and no path writes a `role:` slot. Two classes of pending request are therefore decided only by the privileged override, or by a reassign to a real approver: a request a 15.x-era release stored as `role:`, and a new request from a flow that still authors `{ type: 'role', value: }` and whose tier lookup finds no one. No stored slot is rewritten: the ruling refused a one-time rewrite as the permanent migration debt ADR-0090's first forcing fact names. Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds. FIRST, no metadata key moves: the address is runtime DATA (a request slot, a query parameter, a decision body's actor), never a `sys_metadata` row, so there is no source for a declarative transform to rewrite. SECOND, the one authored shape that leads here, `{ type: 'role', value: }`, is ambiguous by construction: the deprecated alias means the membership TIER, and whether its author meant a position instead is a judgment only that author can make, so a mechanical rewrite to either type would guess. The `ApproverType` `role` alias itself is a separate retirement and is unchanged here. ADR-0090 D3, ADR-0087." + }, + { + "surface": "artifact `packages[].manifest.plugins` and `packages[].manifest.devPlugins` — the two keys inside an ASSEMBLED package body (`AssembledPackageBodySchema`, ADR-0130 D4)", + "replacement": "Declare `plugins` / `devPlugins` at the stack TOP LEVEL only — the artifact envelope, where `os serve` / `os migrate` / `os dev` read them and where `composeStacks` still concatenates them (`concat` is unchanged for in-memory composition). Delete both keys from every `packages[i].manifest` body: a multi-package artifact that carried them is rebuilt from source (`os build` / `composeStacks(…, { manifest: 'preserve' })` no longer folds them into a body), and a hand-written `packages[]` entry drops them.", + "migrationId": "assembled-package-body-plugins-envelope", + "toMajor": 18, + "rationale": "A classification error, not a new special case (maintainer ruling A, 2026-09-04: both keys are artifact envelope keys, top level only, never inside `packages[]` — decided while one artifact was being taught to carry several co-owning packages). `plugins` and `devPlugins` were the only members of the assembled-body key set whose values are runtime ASSEMBLY instructions rather than serialisable metadata: `plugins` holds what a host hands to `kernel.use()` — live plugin instances, manifests or package names — and `devPlugins` is the `os dev` load list. Inside an artifact a package body is inert JSON, so a plugin written under `packages[i].manifest` could never be constructed by any loader; every reader (`serve.ts`, `schema-migration-plugins.ts`) reads the top level, and the \"resolve `packages[]` when the top level is absent\" repair every other reader took would have turned a silent skip into a boot that registers garbage. Options B (readers resolve JSON descriptions into live plugins) and C (the emitter special-cases the two keys) were refused. After the ruling, \"an artifact carries metadata, a host assembles plugins\" is one sentence every reader inherits. Not losslessly convertible: hoisting a body-level plugin to the envelope changes who loads it, and a live instance has no JSON form to move." + }, + { + "surface": "system.AuthConfig.audience", + "replacement": "explicit `auth: { audience: { posture: 'open' | 'email_domain', selfRegistrationPermissionSet: '' } }` (deployments that intend open self-registration only)", + "migrationId": "audience-posture-default-invite-only", + "toMajor": 18, + "rationale": "The default audience posture flipped when one declared posture replaced the emergent self-registration default: an UNDECLARED `audience` now means `invite_only` — email/password self-registration (and social-provider JIT sign-up) is refused with 403 SELF_REGISTRATION_CLOSED unless the address holds a pending invitation. Previously the emergent default was open self-registration with no email verification. Whether a deployment truly means to admit strangers (public portal) or was open only by accident is a security judgment no transform can make — and a posture that opens self-registration must also DECLARE the permission set a self-registrant receives and accepts forced email verification, neither of which can be invented mechanically." + }, + { + "surface": "GET /api/v1/automation — the flow-list route of the automation door, together with its request and response schemas ListFlowsRequestSchema and ListFlowsResponseSchema (and their ListFlowsRequest, ListFlowsRequestParsed, ListFlowsResponse and ListFlowsResponseParsed types), FlowSummarySchema and its FlowSummary type, the listFlows entry of AutomationApiContracts, and the automation.list method of @objectstack/client. Every other automation route is unchanged, including POST /api/v1/automation (create a flow) at the same path", + "replacement": "GET /api/v1/meta/flow — flows are metadata (ADR-0106), and this is the governed read of them; from the SDK it is `client.meta.getItems` with the type `flow`. It answers the full flow definitions rather than bare names, so a caller that only needs the names maps each item to its `name`. The runtime enablement and trigger binding of every flow — the one piece of engine state a definition does not carry — is `GET /api/v1/automation/_status` (`client.automation.getRuntimeStatus`), which is unchanged", + "migrationId": "automation-flow-list-route-retired", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-09-25 on the list doors found declaring `limit` / `cursor` and never reading them (this route is door ④; verbatim 「退役,统一走 /meta/flow」, given when asked why the flow list does not use the standard API), under ADR-0049 enforce-or-remove. The route's contract described a capability nobody built: ListFlowsRequestSchema declared `status`, `type`, `limit` (default 50) and `cursor`, and the handler read none of them — it asked the automation service for its flow names with no arguments at all. ListFlowsResponseSchema declared a page of FlowSummary rows with `total`, `nextCursor` and `hasMore`, and the handler answered a bare array of names beside a literal `hasMore: false`. So a caller filtering by status received every flow, a caller paging with a cursor re-read the only page forever, and a caller reading FlowSummary fields read undefined — each with a 200 and no error. Measured before removal, on the main branch of this repository and cloud and on objectui at both its pinned commit and main: zero callers of the route or of the SDK method outside their own tests, while both real flow lists in the product — the Console flow-runs page and the Setup packaged-automation page — already read GET /api/v1/meta/flow. Implementing the declared contract instead would have built a second, weaker metadata list beside the governed one; retiring it leaves one read. There is no alias and no transition window: GET simply stops being mounted there. There is no D2 conversion and no tombstone, because the shape is HTTP-only — nobody authors a ListFlowsRequest and nothing persists one — so the three schemas are whole-def removals in RETIRED_DEFS_BY_MAJOR and this entry carries the record. ADR-0049 / ADR-0087 / ADR-0106." + }, + { + "surface": "api.listRuns cursor — the pagination query parameter of GET /api/v1/automation/:name/runs declared by ListRunsRequestSchema, its slot on IAutomationService.listRuns, and its option on all three @objectstack/client run-list surfaces (automation.runs.list, automation.listRuns, environment().automation.listRuns). The limit parameter of the same door is NOT part of this retirement and is unchanged, default(20) included", + "replacement": "a wider `limit` — this door does read it, bounded to 1..100, and it is spent as the run store's history window. There is no replacement for `cursor` itself, deliberately: nothing ever minted one, so no caller holds a value to carry over, and the response `nextCursor` it would have paired with has never been emitted. Read the response `hasMore` to learn whether the window was short — it is now computed from the engine rather than the constant `false` it used to be, so for the first time it answers the question a caller reaching for a cursor was actually asking", + "migrationId": "automation-runs-cursor-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove (maintainer ruling 2026-09-21 on the list doors found declaring `limit` / `cursor` and never reading them — this door is door ①, and the ruling took letter C of three for it; letter A — build a cursor protocol for a 100-row window — and letter B — retire the key and leave the `hasMore` lie standing — were both considered and refused). `cursor` was declared on the request, VALIDATED at the boundary, forwarded into a `cursor?: string` slot on the service contract, and read by no implementation: the engine never looked at the option, and no emit site has ever written the response half `nextCursor`, so a caller looping until the cursor ran out re-read the first and only window forever with no error. ⭐ The `limit` half of this door was NOT retired, and the distinction is the ruling, not an oversight. The sibling `/packages` door retired its `limit` with its `cursor` (the 2026-09-13 ruling aligning that door's declaration with its reads: pagination is no part of a small bounded list) because nothing read it; the parent ruling explicitly does not transfer here. On this door `limit` is read end to end — the boundary enforces the declared 1..100 range off the schema itself, the service takes it as an option, and the engine spends it as `RunStore.listHistory`'s window — and the Console's flow-runs page sends it today. Retiring it would have been a regression, and its `.default(20)` stays with it. The same card computes `hasMore`, which is the half a bare retirement would have left lying. `GET /api/v1/automation/:name/runs` shipped a literal `hasMore: false` beside a list the engine had already truncated with `.slice(0, limit)`, so a caller asking for one row of a thousand was handed one row and told that was all of them. The engine now reports truncation to the door through a new optional contract member, `IAutomationService.listRunsPage`, which returns `{ runs, hasMore }`: it over-reads its history source by exactly one row and compares the merged, filtered, ordered set to the caller's window. The over-read is what makes the answer sound — `runs.length === limit` cannot tell a flow with exactly `limit` runs from one with ten thousand, and `RunStore.listHistory`'s signature is deliberately unchanged because over-reading is expressible in the `limit` it already takes. There IS a tombstone: the request schema is non-strict, so a bare deletion would have made Zod SILENTLY STRIP whatever a generated client kept sending — a clean parse and a parameter that never takes effect, which is this card's own defect re-created one layer down (ADR-0104). `cursor` is therefore a `retiredKey()`, typed `never` for tsc and raising the prescription at any parse, and is registered in RETIRED_KEYS_BY_MAJOR[18]. There is NO D2 conversion: a conversion rewrites an authored source or a stored `sys_metadata` row, and this shape is HTTP-only — nobody authors a `ListRunsRequest` and nothing persists one. The `os migrate meta` house sentence is therefore correctly absent from the prescription. There is no `acceptRetiredDefaultResidue` stage either: `cursor` carried no default, so it materialized into no artifact and there is no residue to accept. The SDK half is part of the retirement rather than a follow-up: `@objectstack/client` declared `cursor` and appended it on all three run-list surfaces, so retiring the key in the schema alone would have left the one generated client this repo ships typing it `string` and sending it into a route that silently drops it — the ADR-0104 shape the tombstone exists to prevent, re-created one layer down. The same call was made when the notifications `cursor` was retired: the client dropped the option and recorded the removal in its docblock. ADR-0049 / ADR-0087." + }, + { + "surface": "`fields..unique` on a `type: 'autonumber'` field when the author OMITS the key — the contract default moves from `false` (no index) to `'organization'` (one holder per organization; the NULL-safe tenant-composite unique index `(COALESCE(organization_id, '__global__'), )` on an organization-scoped object, a plain unique index where the object has no organization key)", + "replacement": "keep the omission to take the default — an auto-number is a business identifier and is unique per organization from now on with zero application-side declaration; write `unique: false` EXPLICITLY on the one autonumber field that is a display-only sequence and is never used to identify the record. Every other field type keeps `unique: false` as its default, and every authored spelling (`true` / `'organization'` / `'global'` / `false`) parses exactly as before", + "migrationId": "autonumber-default-unique-organization", + "toMajor": 18, + "rationale": "Not losslessly convertible because the change is data-dependent, not textual: a table that already holds duplicate auto-numbers (a counter that re-issued a burned number, or the seed/API tenancy split running two counters for one object) cannot take the index the default now declares. The SQL driver refuses to silently degrade — it logs at `error` naming the index, the columns and the remedy, the same boot's drift pass names the conflicting key groups with row counts, and `os migrate plan` reports the blocked `create_index` with the same groups (ADR-0120 D4) — but which of the duplicate rows keeps the number is a business decision no migration entry can make. Maintainer ruling 2026-08-31, on a downstream CRM's measurement that eight of its nine auto-numbered business identifiers could be issued twice: an auto-number that may repeat is not an identifier, so unique is the platform default and opting out is the declaration, not the other way round." + }, + { + "surface": "the six branded identifier schemas of `@objectstack/spec/shared` (`shared/branded-types.zod.ts`, removed whole): `ObjectNameSchema`, `FieldNameSchema`, `ViewNameSchema`, `AppNameSchema`, `FlowNameSchema`, `RoleNameSchema`, and their type exports (`ObjectName`/`ObjectNameParsed` through `RoleName`/`RoleNameParsed`).", + "replacement": "(removed — no replacement brand layer. Parse an identifier through the schema of the surface that stores it: object and field names through `ObjectSchema`/`FieldSchema` (inline snake_case regex), flow names through `FlowSchema`, app names through `AppSchema` (`SnakeCaseIdentifierSchema`), position/role names through `PositionSchema`. A caller that wants a standalone identifier check uses `SnakeCaseIdentifierSchema` or `SystemIdentifierSchema` from `@objectstack/spec/shared` directly — both stay published.)", + "migrationId": "branded-identifier-schemas-retired", + "toMajor": 18, + "rationale": "Maintainer ruling 2026-09-01 (director decision batch C, verbatim 「同意」: retire) — ADR-0049 enforce-or-remove. The brands promised compile-time safety (\"you cannot pass an ObjectName where a FieldName is expected\") that no consumer could obtain: no schema in either repository ever composed a brand, so nothing produced or accepted a branded value, while the surfaces the brands were named for are validated by inline regexes or bare `SnakeCaseIdentifierSchema` three files away. Binding was weighed and not adopted: zero consumers exist, binding would silently change five surfaces' accept sets (the inline regexes admit a leading underscore the brand base does not), and a future real need for centralized identifier grammar re-opens freely against actual pull." + }, + { + "surface": "the data write doors — a by-id update or delete of a row the caller cannot read, on every object and for every principal", + "replacement": "read 404 `RECORD_NOT_FOUND` on a by-id update or delete as \"no row you can see has this id\" — the read door's meaning — and keep a 403 for a row the caller can read but may not write", + "migrationId": "by-id-write-unreadable-row-not-found", + "toMajor": 18, + "rationale": "A WRITE-DOOR ANSWER, made one with the read door's. A by-id update or delete of a row the caller cannot read used to answer a 403 — `PERMISSION_DENIED` where a write-class row filter binds the caller, otherwise a later gate's own 403, such as `FORBIDDEN` from record sharing or a parent-derived gate's code on attachments and comments — while an id that names no row answered 404, so the write door told a hidden row apart from a missing one. The by-id write pre-image check now asks every principal whether it can read the row it addressed, through a by-id read in its own context that every data middleware's visibility applies to, and answers a row that read does not return with the read door's not-found: the same code, status and body a nonexistent id gets. It also refuses a by-id write a principal no row filter binds could previously land on a row hidden from it, such as an attachment's uploader or a comment's author whose parent record they can no longer read. A caller who can read the row but may not write it keeps its 403. Writes the platform issues under the caller's context — the engine's cascade delete, a hook's write, the referential clear of a lookup — keep their previous answer, and writes not routed by id are unchanged." + }, + { + "surface": "CacheWarmup.strategy — the value 'scheduled' left the warmup-strategy enum (packages/spec/src/system/cache.zod.ts), and the enum describe stopped promising \"scheduled (cron)\". The key itself, DistributedCacheConfig.warmup.strategy, is unchanged and still authorable", + "replacement": "'eager' to warm at startup or 'lazy' to warm on first access — the two strategies the vocabulary ever described without pointing outside itself. There is no replacement for the cadence: a warmup on a schedule is a job. Declare a `job` with schedule.expression (system/job.zod.ts) whose handler does the warming — that is the one cron slot this platform evaluates, and it is the slot deliberately kept when the seven cron-typed positions nothing read were deleted", + "migrationId": "cache-warmup-scheduled-strategy-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove, closing the residue the retirement of the seven cron-typed positions nothing reads left inside the schema it had just edited. That retirement deleted CacheWarmup.schedule — the cron key this enum member selected — and declined the member itself on the reading that it is \"a value, not a position this ruling names\". That is a statement about the ruling's SCOPE, not a finding that the value was sound: after the deletion the member declared a warmup cadence with no key left to configure it, no engine that has ever run one, and a .describe() still promising \"(cron)\" — ADR-0049 declared-not-enforced in the form Prime Directive 10 names outright, a capability advertised that the runtime does not deliver. Re-measured on main at 690f083f83 with a lit control rather than inherited from the card: CacheWarmupSchema has zero runtime consumers outside its declaring file (six files reference it — the generated reference page import, the declaration-map and export-origins catalogues, the ADR-0058 D7 ledger comment and two spec test files — while the control, ConnectorSchema, resolves to 46 files), and no cache-warmup engine exists anywhere on the platform. Bookkeeping follows the hot-reload-inert-state-strategies-retired and crypto.hash precedents: an enum-VALUE narrowing puts nothing in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) and leaves the four surface ratchets byte-identical (no def changed, and they key on positions and names, never on a def's value set), so the prescription hangs on the enum's own error map dispatched by issue.input — telling the author of a TYPO that their value \"was removed\" would misinform. It is a SEMANTIC entry rather than a D2 conversion because there is no source to rewrite: CacheWarmup is bound to no metadata type and embedded in no stack collection, so no authored document and no stored row has ever carried this value, and os migrate meta has nothing to list. Route 3 of the retirement playbook, the shape of the dynamic plugin-loading family's removal and the advanced plugin-lifecycle config's retirement: this entry IS the declaration. ADR-0049, ADR-0087." + }, + { + "surface": "object.fields..required on a `master_detail` reference under `sharingModel: 'controlled_by_parent'` — authored via `ObjectSchema.create()`", + "replacement": "`required: true` on the master reference (or nothing at all — the builder now forces `required: true` when the key is omitted). An explicit `required: false` on that shape is refused at `ObjectSchema.create()` with a located error carrying this same prescription. Metadata at rest is untouched: raw `.parse()`/`.safeParse()` still accept the old shape, the security gate's derived enforcement stays, and the lint rule `relationship/master-detail-required` stays `warning` until its own v18 promotion (Direction 1 of the 2026-08-16 maintainer ruling whose Direction 2 this is)", + "migrationId": "cbp-master-detail-required-forced", + "toMajor": 18, + "rationale": "A `controlled_by_parent` detail derives ALL of its record access from the master that its `master_detail` reference names (ADR-0055). With the reference not `required`, an insert may omit the master FK: the row lands with a null FK that the derived read filter `masterFK IN (accessible master ids)` can never match — unreadable by everyone — and every later by-id write answers `422 MISSING_REQUIRED_FIELD`. The finding behind the ruling measured that only the security gate closed this shape while the declaration surface still accepted it. The maintainer ruling (2026-08-16, Direction 2) makes the unsafe shape impossible to NEWLY declare at the builder; whether to keep `required: false` was never a real choice (the value contradicts the sharing model), so the flip is forced rather than convertible — and an explicitly authored `false` is refused rather than silently rewritten (ADR-0032 \"no silent failure\")." + }, + { + "surface": "object.fields.MASTER.required / .readonly / .system, where MASTER is a `master_detail` reference on an object declaring `sharingModel: 'controlled_by_parent'` — as judged by `os lint` under `relationship/master-detail-required`", + "replacement": "`required: true` on every `master_detail` reference of a `controlled_by_parent` object, with neither `readonly: true` nor `system: true` on it: declare the master reference as an ordinary required field. `os lint` now reports each of the three unsafe shapes there — `required` absent or `false`; `required: true` + `readonly: true`; `required: true` + `system: true` — at `error` under `relationship/master-detail-required`, so `os lint` exits non-zero and the metadata-generation rubric marks the stack invalid. On every other object the rule is unchanged: a `warning` for a `master_detail` without `required: true`, and no finding for the two flagged shapes.", + "migrationId": "cbp-master-detail-required-lint-error", + "toMajor": 18, + "rationale": "A `controlled_by_parent` detail derives ALL of its record access from the master that its `master_detail` reference names (ADR-0055). Record validation never checks a field that is not `required`, and skips `readonly` and `system` fields before its required check is reached, so on these three shapes nothing but the security gate refuses an insert that omits the master FK. A record that lands without it anyway is readable by nobody — the derived read filter `masterFK IN (accessible master ids)` never matches null — and every later by-id write is refused. Before this step the lint predicate was `required !== true` at `warning` on every object: the two flagged shapes drew no finding at any severity, and the third drew a warning that an author or a generator could ignore. The maintainer ruling of 2026-08-16 (Direction 1) scheduled the promotion for the v18 boundary as a deliberate narrowing of the authoring contract. Its builder half (the `cbp-master-detail-required-forced` entry) forces `required: true` at `ObjectSchema.create` but never inspects `readonly` or `system`, so two of the three shapes still pass the builder and meet their first authoring-time refusal here, and the third still reaches it from any object not authored through the builder. Runtime tolerance is unchanged on purpose: the security gate keeps refusing these inserts, and keeps resolving the master for metadata already at rest." + }, + { + "surface": "security.PermissionSet rowLevelSecurity[].using and .check, and sharingRules[].condition — a CEL predicate comparing a field with != or == against a list, either a list literal or a current_user membership set the runtime resolves to an array (org_user_ids, positions, accessible_org_ids, or a key staged into rlsMembership), and the negation of such a comparison. On driver-mongodb, also a query filter carrying $ne with an array comparand, at any depth under $and / $or / $not", + "replacement": "the list operator the comparison was standing in for. \"One of these values\" is in: record.status in [\"open\", \"pending\"], or record.reviewer_id in current_user.org_user_ids. \"None of these values\" is the negated in: !(record.status in [\"closed\", \"archived\"]). In a query filter, $in and $nin. Scalar != and ==, null, in, and field-to-field comparisons lower exactly as before", + "migrationId": "cel-predicate-list-comparand-refused", + "toMajor": 18, + "rationale": "The @objectstack/formula pushdown compiler lowered such a comparison to a $ne carrying the array, to a bare-array equality, or to a $not around one. A row-level using clause is composed into the query after the engine's comparand-shape check, and driver-mongodb passed the shape to the server: measured through mingo, the named proxy for MongoDB query semantics, $ne against an array and the $nor that a negated equality becomes selected every row storing a scalar, so the read returned the rows the policy was written to hide. A check written != against a membership set admitted and stored every write, on driver-sql as on driver-mongodb. The compiler now refuses the comparison with reason unsupported, so the RLS compiler drops the policy and fails closed when no other policy applies: reads under it return no rows and check writes are refused 403. A declared sharing rule with such a condition is skipped at bootstrap and never seeded. The authoring lint reports a list literal as rls-predicate-unenforceable; a membership set holds its value only per request, so that form is refused at request time. driver-mongodb refuses $ne with an array comparand with INVALID_FILTER / 400, as driver-sql and driver-memory already do. Metadata AT REST is not rewritten and this entry adds no D2 conversion: the platform cannot tell which list operator a list comparison was standing in for, and a policy rewritten on the author's behalf would change which rows it admits, which is the policy author's decision. ADR-0058 D4 / ADR-0087." + }, + { + "surface": "security.PermissionSet rowLevelSecurity[].using and .check, and sharingRules[].condition — a CEL predicate whose comparison is handed something other than one value: an ordering operator (>, >=, <, <=) against a list literal or a current_user membership set; an in list with a member that is itself a list; a comparison with no field at all against a membership set (current_user.org_user_ids != \"x\"); an ordering operator against the current_user root or a key that resolves to an object; and a field compared with another field (==, !=, or an ordering operator) where either column holds a list or an object on the record, as a json column or a multiple lookup does. In a filter passed to matchesFilterCondition, also $gt / $gte / $lt / $lte with an array, and $in / $nin with an array member", + "replacement": "the comparison the predicate was standing in for. \"One of these values\" is in: record.status in [\"open\", \"pending\"], or record.reviewer_id in current_user.org_user_ids; \"none of these values\" is !(record.status in [\"closed\", \"archived\"]), with the list flat. An ordering takes one bound: record.status > \"m\", and a range is two comparisons joined by &&. A comparison against the caller names one key: record.reviewer_id > current_user.id. A field compared with a json or multiple field has no pushdown form: compare with a single-valued column, or move the condition into a validation rule or hook. One-value comparisons, flat in lists, and field-to-field comparisons between single-valued columns lower and evaluate exactly as before", + "migrationId": "cel-predicate-one-value-comparand-refused", + "toMajor": 18, + "rationale": "Ruling A of 2026-09-24 refused a list under != and in the equality slot, holding both to the declared comparand — a literal or a `{ $field }` reference; stage 2d closes the same fault one position over, measured through the real plugin-security on driver-sql and driver-memory. !(record.status in [[\"closed\", \"archived\"]]) lowered to a negated $in whose only member was a list, which the strictly comparing write-check evaluator matched on no record, so the negation admitted and stored every write, and driver-memory returned every row on a read. record.status > [\"m\"] compared the list as the string \"m\". current_user.org_user_ids != \"x\" and current_user.org_user_ids > \"a\" folded to \"no restriction\": every write admitted and every row read. record.reviewer_id > current_user compared the whole caller object as a string. record.status != record.tags, with tags a json or multiple field, matched every post-image, so the check admitted and stored every write. The CEL compiler now refuses the first four with reason unsupported, so the RLS compiler drops the policy and fails closed when no other policy applies (reads return no rows, check writes are refused 403, the analytics read scope is the deny scope) and a declared sharing rule is skipped at bootstrap; the authoring lint reports what the source shows (a list literal, the current_user root). The compiler cannot see a column's type, so the last is refused by the write-check evaluator on the record whose compared column holds a list or an object: INVALID_FILTER / 400, nothing stored. Metadata AT REST is not rewritten and this entry adds no D2 conversion: the platform cannot tell which comparison a predicate was standing in for, and rewriting it on the author's behalf would change which writes and rows it admits, which is the policy author's decision. ADR-0058 D4 / ADR-0087 / ADR-0112." + }, + { + "surface": "security.PermissionSet rowLevelSecurity[].using and .check, and sharingRules[].condition — a CEL predicate comparing with != or == against the bare current_user root, the variable with no key named after it, whether the other side is a field or a literal, and the negation of such a comparison. For a caller of the published compiler that binds its own variables, also a variable that resolves to an object", + "replacement": "the key of current_user the comparison means: record.owner_id == current_user.id, or current_user.organization_id, or current_user.email. A membership test is in: record.owner_id in current_user.org_user_ids. Scalar keys, membership sets under in, literals, null and field-to-field comparisons lower exactly as before", + "migrationId": "cel-predicate-variable-root-comparand-refused", + "toMajor": 18, + "rationale": "The @objectstack/formula pushdown compiler resolved the bare root to the whole caller context object, every kernel-resolved key at once with the membership arrays included, and lowered the comparison to a $ne carrying that object, to a bare-object equality, or to a $not around one; a constant comparison such as current_user != \"guest\" folded to no restriction. A strict compare never equals an object, so through the real SecurityPlugin on driver-sql a check written != against the root, or its negated ==, admitted and stored every insert and by-id update it was written to refuse, a USING-only such policy admitted every insert on the write pass, and explain reported the read as narrowed with the caller membership sets echoed in its readFilter. ADR-0058 D2 declares the operand opposite a field as a literal, a current_user scalar or a pre-resolved current_user set, and the published $eq / $ne contract declares a literal or a { $field } reference; the root is none of them. The compiler now refuses it with reason unsupported in both of its modes, so the authoring lint reports it (rls-predicate-unenforceable on either clause, sharing-rule-unlowerable-condition on a sharing condition), and the RLS compiler drops the policy and fails closed when no other policy applies: reads under it return no rows, check writes are refused 403, and explain answers denies. A declared sharing rule with such a condition is skipped at bootstrap as it already was, now with reason unsupported instead of unresolved-variable. Metadata AT REST is not rewritten and this entry adds no D2 conversion: the platform cannot tell which key the author meant, and a policy rewritten on the author's behalf would change which rows it admits. ADR-0058 D2 / ADR-0087." + }, + { + "surface": "change-management duration keys: `ChangeImpact.downtime.durationMinutes`, `RollbackPlan.steps[].estimatedMinutes`, `ChangeRequest.implementation.steps[].estimatedMinutes`", + "replacement": "nothing to re-declare — delete the keys. No change-management engine exists on the platform: nothing schedules a maintenance window, executes or times an implementation or rollback step, or compares an estimate with what happened, so there is no live mechanism to declare a duration to", + "migrationId": "change-management-duration-keys-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on the unread deadline keys (ruled A: retire per family). Three minute-shaped keys, at three nested sites, sat in the exported change-management schemas and in the generated reference docs — an author could write `estimatedMinutes: 15` on a rollback step and reasonably expect it to feed a schedule — and read by NOTHING: the schemas are exported from `@objectstack/spec/system`, mounted by no stack key, registered as no metadata type, absent from the 2026-06 liveness ledgers, and the reader census over every package outside `packages/spec` (tests and changelogs excluded) and over objectui at the pinned sha returned zero hits for every key. All three sites are NESTED (`downtime.durationMinutes`, `steps[].estimatedMinutes` twice), so the authorable-surface ratchet — which walks top-level def properties — never listed them; their `RETIRED_KEYS_BY_MAJOR[18]` entries carry the nested spelling for the spec-changes / upgrade-guide projection. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections; none of these schemas is either, so a conversion would be a transform with no seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent)." + }, + { + "surface": "the change-management family, retired whole: the six defs system/ChangeImpact, system/ChangePriority, system/ChangeRequest, system/ChangeStatus, system/ChangeType and system/RollbackPlan, and every name system/change-management.zod.ts exported from @objectstack/spec/system (the six *Schema consts, their z.input aliases and the ChangeRequestParsed alias)", + "replacement": "nothing to re-declare — no change-management engine exists on the platform, so there is no working configuration to migrate to. Nothing routed a change request for approval, walked its implementation steps, honoured a rollback plan or gated on `securityImpact.requiresSecurityApproval` / `approval.required`; a change record the organisation keeps is ordinary object data, declared as an object with its own fields, and an approval that must actually gate something is a flow (ADR-0018) with an approval node. Metadata change tracking on the platform is `sys_metadata` history and the package model (ADR-0126), unrelated to this vocabulary. If ITIL change management becomes a product capability it re-declares fresh, through the enforce route of ADR-0049 — the engine first, the vocabulary second", + "migrationId": "change-management-family-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 on the families' remaining keys and defs (ruled A: retire the three compliance-shaped families whole via RETIRED_DEFS_BY_MAJOR, the integration/ErrorMappingConfig precedent; not roadmapped). Six defs and roughly fifty declared keys sat on the exported surface and in the generated reference docs, and were read by NOTHING: the schemas were exported from `@objectstack/spec/system`, mounted by no `stack.zod.ts` key, registered as no metadata type, absent from the 2026-06 liveness ledgers, and the reader census over every package outside `packages/spec` (tests and changelogs excluded), over `examples/**` and `skills/**`, and over objectui at the pinned sha returned zero hits for every exported name, with a lit control. `ChangeRequest.approval.required` and `ChangeRequest.securityImpact.requiresSecurityApproval` read as gates the platform enforced, and neither ever did — the worst form of the declared-but-unenforced shape, on a security-adjacent surface. Tagging the family `[EXPERIMENTAL — not enforced]` was the fallback the ruling did not take (a human-only signal). The duration-key tombstones of the 2026-09-02 per-family ruling (three nested sites, `RETIRED_KEYS_BY_MAJOR[18]`, D3 `change-management-duration-keys-retired`) leave with their defs' source; their registry entries stay as history. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections; none of these schemas is either, so a conversion would be a transform with no seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent), and with no carrier key there is no shape on which a tombstone could sit." + }, + { + "surface": "dashboard.widgets[].chartConfig.aria / report.chart.aria / report.blocks[].chart.aria — the ARIA block on a chart config", + "replacement": "The sibling `description`, which the chart renderer lowers onto the chart graphic as its accessible name (`role=\"img\"` with an aria-label). One accessibility vocabulary per chart node.", + "migrationId": "chart-config-aria-retired", + "toMajor": 18, + "rationale": "The D2 conversion `chart-config-aria-removed` deletes `aria` from every dashboard widget chart config, report chart and report block chart, and the delete is lossless: no chart renderer on either face ever applied the block, so the ARIA attributes it declared never reached the DOM. The residue is accessibility work the author did that no user benefited from. An author who wrote `aria.label` for a chart believed screen-reader users heard that name; they heard the `description` if one was set, and nothing specific if not. The strip deletes the label text along with the key, and only the author can say whether that text should become the chart's `description` — a field that other readers of the chart may also show — or whether the existing description already says it." + }, + { + "surface": "kernel.cliCommandContribution (the orphan exported schema of `cli-extension.zod.ts` — 1 def, 2 exported names: `CLICommandContributionSchema` / `CLICommandContribution`)", + "replacement": "(removed — there is no declarative replacement, because no declarative surface ever carried it. CLI commands are registered through oclif's native plugin discovery: the plugin package declares an `oclif` section in its own `package.json` — `OclifPluginConfigSchema` in the same module describes that live surface and SURVIVES, as does the module docblock's Commander.js migration record, which the `manifest.contributes.commands` tombstone cites)", + "migrationId": "cli-command-contribution-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove, applied to the exported orphan-value-schema class: an exported schema with no consumer reads as a capability, the lesson of the plugin sandboxing / integrity / approval config that was never wired to anything. The schema described a \"CLI Command Contribution declaration in the manifest\" and claimed retention \"for describing command metadata in plugin manifests\" — but after the retirement of the plugin manifest's nine dead `contributes` members tombstoned `manifest.contributes.commands` (protocol 18), no manifest surface could legally carry these entries: the export advertised a shape whose only declared carrier rejects it. The manifest never referenced this schema even before the tombstone — its inline `commands` item schema was an independent duplicate. Zero consumers outside spec's own test and generated artifacts, measured at the retirement's base commit (146f448a5) with positive controls in objectstack, objectui (pinned sha) and cloud. With no carrier key and no authored document there is nothing to tombstone and no seam for a D2 conversion: route 3, the shape of the advanced plugin-lifecycle config's retirement and of the retired `ApiKeySchema` — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration." + }, + { + "surface": "client.analytics.query / client.analytics.meta / client.analytics.explain / client.automation.trigger — the resolved value of four published `@objectstack/client` methods: the runtime dispatcher's `{ success, data }` envelope before, its `data` member after", + "replacement": "the payload — `r.data.X` → `r.X` on all four. `client.analytics.query`, called with a query, now resolves to `AnalyticsResult`: `r.data.rows` → `r.rows`. `client.analytics.meta`, with or without a cube name, resolves to `AnalyticsMetadataResponse['data']`, the bare cube list: `r.data[0].name` → `r[0].name`. `client.analytics.explain` resolves to `AnalyticsSqlResponse['data']`, `{ sql, params }`: `r.data.sql` → `r.sql`. `client.automation.trigger`, given a trigger name and a payload, resolves to `AutomationResult`: `r.data.status` → `r.status` and `r.data.runId` → `r.runId` — the same value `client.automation.execute` already answered for the same handler. Same call, same wire body, one SDK calling convention", + "migrationId": "client-envelope-convergence-analytics-automation", + "toMajor": 18, + "rationale": "`ObjectStackClient` had two response readers. `unwrapResponse` strips the runtime dispatcher's `{ success, data }` envelope and hands back `data`; every other dispatcher-served method already used it, and these four alone ended `return res.json()`, so their callers alone had to read `.data`. All four now end `return this.unwrapResponse(res)` and their return declarations are the payload types. THE WIRE IS BYTE-IDENTICAL: every route answers exactly the body it answered before, no Zod schema moves, no `packages/spec` declaration moves, no authorable key and no stored representation is involved — the landing diff touches no `packages/spec` path at all — so a raw-HTTP caller is unaffected and `objectstack migrate meta` has nothing to rewrite. This is registered rather than exempted because the change is NOT wholly compiler-delivered, and the gap is exact rather than theoretical. For the three analytics methods it is: every old read is `error TS2339: Property 'data' does not exist on type …`, so tsc names each site. `client.automation.trigger` is the exception — `AutomationResult` itself declares `success: boolean` and `error?: string` (`AutomationResult` in `packages/spec/src/contracts/automation-service.ts`, byte-identical at the merge base and at this landing), so `r.success` and `r.error` COMPILE ON BOTH SIDES while their meaning moves: before, `r.success` was the envelope's flag — always `true` on a resolved call — and `r.error` was never set on a 2xx; now they are the run's own, and a refusal the door does not classify as 400 / 409 / 422 is answered 200 carrying `success: false` with `error` set. A consumer branching on either reads a DIFFERENT QUESTION at the same spelling, with no diagnostic anywhere. And there is no authored source for the conversion chain to rewrite: this is a published TypeScript surface whose enforced channel is tsc at the call site, and for an untyped JS caller there is no constrained channel at all — `.data` simply reads `undefined` — which is why the ledger entry is the only notification that reaches them. That is the same argument the two sibling entries on this package make (`client-delete-result-success`, `client-meta-reset-result-reset`), and this one is the stronger case of the three: those corrected declarations that were UNINHABITED, revealing a defect rather than breaking working code, whereas this moves reads that work today. ⛔ Do not write `r.rows ?? r.data.rows`: there is one producer shape, and a consumer accepting two spellings is what contract-first exists to prevent. The failure path is unchanged and deliberately so — `ObjectStackClient.fetch` rejects on every non-2xx BEFORE either reader runs, carrying the ADR-0112 error envelope, and `unwrapResponse` itself never throws. ADR-0087 D3." + }, + { + "surface": "client.meta.deleteItem(...).deleted / .type / .name (the return of `client.meta.deleteItem()` and the environment-scoped `client.environment(id).meta.deleteItem()`)", + "replacement": "`reset` — `r.deleted` → `r.reset`. Same call, same wire body, declared shape. Both twins now declare `DeleteMetaItemResponse` (`@objectstack/spec/api`); `type` and `name` have no replacement because the reset door never echoed them — the caller already holds both, it passed them in", + "migrationId": "client-meta-reset-result-reset", + "toMajor": 18, + "rationale": "Both `deleteItem` declarations on `@objectstack/client` declared `Promise<{ type: string; name: string; deleted: boolean }>` while `DeleteMetaItemResponseSchema` declares `{ success, reset?, message? }`. The declaration was not merely imprecise, it was UNINHABITED: `DELETE /meta/:type/:name` ends in `res.json(result)` with `deleteMetaItem`'s return, and not one of that method's four return branches carries `type`, `name` or `deleted`. Both surfaces are pure `unwrapResponse` / `_unwrap` passthroughs — and the reset body carries no `data` key, so nothing is stripped — which makes the declaration a CLAIM about the wire, never a rewrite of it, and the claim was false in the one direction that matters: the compiler endorsed a spelling no server has ever sent. `if (r.deleted)` compiled and read `undefined` on EVERY reset, including the ones that really removed an overlay row; `if (r.reset)` was rejected by the compiler and correct on the wire. So this REVEALS a defect rather than breaking working code — every reader of the old key was already reading `undefined`, on every deployment and not just some. The truthful flag also carries the distinction the phantom one could not express at all: `reset: true` means an overlay row was deleted, `reset: false` means none existed and the item was already at its artifact default. Registered as a semantic entry rather than a mechanical conversion for the reason the rewrite does not capture: a call site that branched on `r.deleted` has been taking the FALSE branch unconditionally since it was written, and whatever that branch did — or skipped — is what has to be re-read. There is no authored source for the chain to rewrite either; this is a published TypeScript surface whose enforced channel is tsc at the call site, and for an untyped JS caller there is no constrained channel at all, which is why this entry is the only notification that reaches them. ⛔ Do not write `r.reset ?? r.deleted`: there is one producer shape, and a consumer accepting two spellings is what contract-first exists to prevent. No deprecated `deleted?: boolean` transition key ships, for the same reason — a transition period is for keys that WORKED, and this one never did. The identical correction one door over is `client-delete-result-success`; the wire is deliberately untouched here, per the 2026-08-29 ruling that reality is the contract. ADR-0087." + }, + { + "surface": "client.oauth.applications.delete(clientId) — both halves of what a caller of this published `@objectstack/client` method observes: the DECLARED return, `Promise` before and `Promise` after, and the SETTLE BEHAVIOUR, which rejected with `SyntaxError: Unexpected end of JSON input` on every successful delete before and resolves after", + "replacement": "no value — `void`. There is nothing to move a read TO, because the promise never resolved for a caller to read anything off it. The migration is on the settle path instead: `try { await client.oauth.applications.delete(id); } catch { /* it probably worked */ }` → drop the workaround, the `catch` was executing on EVERY successful delete and now executes only on a real failure. A read off the resolved value — `(await client.oauth.applications.delete(id)).deleted` — was unreachable code that has never executed and now stops compiling (TS2339). Same call, same request, same wire body", + "migrationId": "client-oauth-applications-delete-void", + "toMajor": 18, + "rationale": "The route answers HTTP 200 with a ZERO-BYTE body: `POST {auth}/oauth2/delete-client` returns nothing from its handler, the vendor declares the endpoint `void`, and the response carries `content-type: application/json` with NO `content-length` header at all. The method ended `return res.json()`, so it rejected `SyntaxError: Unexpected end of JSON input` on every successful delete — after the row had already been removed server-side. There was no success path a caller could observe, and the obvious recovery made it worse: the retry failed DIFFERENTLY, with the route's 404 `not_found`, because the client was already gone. The method now reads the body as text, returns on the empty case, and still parses (and still throws on) a non-empty one — so the ONLY behaviour that moved is the zero-byte case, which is the defect itself. THE WIRE IS BYTE-IDENTICAL: same route, same request body, same status codes, same error bodies — which on this route are better-auth's FLAT `{ error, error_description }`, NOT ObjectStack's nested ADR-0112 envelope; no Zod schema and no `packages/spec` declaration moves, no authorable key and no stored representation is involved, so a raw-HTTP caller is unaffected and `objectstack migrate meta` has nothing to rewrite. This is registered rather than exempted because the change is NOT compiler-delivered where it matters, and the gap is exact rather than theoretical. The change has two halves and only one of them has a diagnostic. (1) The declared return moves from a ledgered `any` to `void`, so a typed caller that read a property off the resolved value now gets `error TS2339` — but that read was UNREACHABLE, since the promise never resolved, so the compiler names only code that has never run. (2) The half that DID run on every call — a `try`/`catch` wrapped around the delete — compiles identically before and after, with no diagnostic anywhere, while its `catch` block stops executing. So for the only behaviour that was ever observable, `tsc` names ZERO sites; and for an untyped JS caller there is no constrained channel at all. That is why the ledger entry is the only notification that reaches an upgrader — the same argument the three sibling entries on this package make (`client-delete-result-success`, `client-meta-reset-result-reset`, `client-envelope-convergence-analytics-automation`). ⚠️ Note the DIRECTION, which is the inverse of the usual break: this does not stop working code from working, it makes a method that could never succeed succeed. The hazard is therefore inverted too — code written to survive a permanent failure is now inert, and any alerting or error budget fed by this method's rejections goes quiet. ⛔ Do not keep the old behaviour behind a flag or a wrapper that re-throws: there is one producer shape, and the rejection was never a contract, it was a parse of an empty string. ⛔ Do not synthesise `{ deleted: true }` either: the 200 carries zero bytes and therefore zero information, and \"it was already gone\" is distinguished on the ERROR channel — a client that is not there answers 404 `{ error: 'not_found' }`, which `ObjectStackClient.fetch` raises as a throw before the body reader runs — so a synthesised success value would be a shape the wire never sends and strictly less informative than the 404 the caller already receives. ADR-0087 D3." + }, + { + "surface": "`@objectstack/spec/cloud` — the whole published subpath (`packages/spec/src/cloud/`, 11 modules, 94 JSON-Schema defs): the cloud control plane's own contracts (`environment.zod`, `environment-package.zod`, `tenant.zod`, `developer-portal.zod`, `marketplace-admin.zod`, `app-store.zod` — 62 defs) and the package & marketplace format (`package.zod`, `package-version.zod`, `marketplace.zod`, `package-l10n`, `template-manifest.zod` — 30 defs)", + "replacement": "Two answers, by owner. (1) The package & marketplace FORMAT moved unchanged to `@objectstack/spec/marketplace` (`packages/spec/src/marketplace/`): rewrite the import path — `import { PackageSchema } from '@objectstack/spec/cloud'` becomes `from '@objectstack/spec/marketplace'` — and nothing else; every def, key and JSON Schema is byte-identical under its new `$id` category (`RENAMED_DEFS`, 32 entries). `EnvironmentType(Schema)` — the 7-member taxonomy the discovery fold table is total over — is re-declared in `@objectstack/spec/api` (`api/discovery.zod.ts`); the environment-artifact envelope was only ever a re-export and is imported from `@objectstack/spec/system`. (2) The cloud control plane's contracts have NO open-source replacement: `environment.zod` and `tenant.zod` are re-declared in the cloud repo beside their producer, and `developer-portal.zod`, `marketplace-admin.zod`, `app-store.zod`, `environment-package.zod` are deleted outright — zero consumers in any repo (maintainer ruling 2026-09-07, option A: cloud does not host the four consumer-less files, the move deletes them). Recoverable from git history at `d5d8d50db` if a declaration is ever wanted again; that is a new card in the cloud repo, not a re-import.", + "migrationId": "cloud-subpath-retired", + "toMajor": 18, + "rationale": "Maintainer direction (2026-09-06, verbatim, untranslated): 「我一直觉得 cloud 的协议应该放在云端,没必要开源」; ruled option B \"cut by owner\" on 2026-09-07: the control-plane half leaves the open-source spec, the package & marketplace format half stays. The control-plane schemas' producer and every consumer live in the closed cloud repo — the open-source tree read exactly one type from them (`EnvironmentType`, for the discovery fold table). Leaving them published made the obvious-looking binding of `client.environments.*` to a camelCase `Environment` row compile and read `undefined` at runtime against a snake_case wire (the client SDK's cloud methods carried no return annotation and were typed from `any`, and `@objectstack/spec/cloud` declared camelCase rows for a control plane that speaks snake_case); with the declarations gone the mis-binding is structurally impossible rather than warned about in a docblock. No alias and no deprecation window, per the standing 2026-08-27 ruling 「项目在创业阶段,用户也很少,短期不考虑渐进。」. Not losslessly convertible: an import path is TypeScript source, not a metadata document `objectstack migrate meta` can rewrite." + }, + { + "surface": "kernel.cluster.driver (ClusterDriverSchema, kernel/cluster.zod.ts) - the `postgres` and `nats` enum values", + "replacement": "the drivers that actually ship - `memory` (single-process default), `redis` (@objectstack/service-cluster-redis, the production recommendation), or `custom` + registerClusterDriver(name, factory) for a self-provided transport. A config naming `postgres` or `nats` never worked: pick `redis`, or register the transport yourself under `custom`", + "migrationId": "cluster-driver-dangling-values-removed", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-08-24 on the cluster driver line-up (option B adopted): single-node is the ObjectOS EE boundary, multi-node is Cloud differentiation, and a DB-first postgres cluster driver is not built absent concrete customer pull. The ruling's principle rider decides this entry: a schema-valid value must not be an unconditional runtime throw. Both removed values were dangling by the same measurement - the only non-test registerClusterDriver() caller is service-cluster-redis, so `driver: 'postgres'` or `driver: 'nats'` passed schema validation and then reached defineCluster()'s unconditional `Cluster driver \"\" is not registered` throw. It is a SEMANTIC entry rather than a mechanical conversion because the right replacement is a deployment decision (which transport actually backs this cluster), not a rename a codemod could apply; nothing at rest breaks, because a stored config naming either value never survived boot in the first place. The ruling records its own reversal condition: a value returns to the enum only in the release that ships an implementation behind it. No authorable KEY was retired (the `useExistingPool` field stays, reworded), so nothing lands in RETIRED_KEYS_BY_MAJOR." + }, + { + "surface": "The connectorConfig block of every type: 'connector_action' flow node — the BLOCK, and its connectorId and actionId once it is written. The block was optional on the node and both ids were any string inside it, so a node with no block, or with connectorId or actionId empty or only whitespace, parsed. That is the state of a node authored without its configuration, and of a new connector node from the Studio flow designer, which seeds both ids empty. At any depth, including an ADR-0031 region body. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate, a flow saved from the Studio flow designer (a connector node added and saved before it is configured), and a flow row already sitting in sys_metadata. Also reached: connectorId, actionId or input written under the node's config instead of the block, where the load-time conversion cannot complete the pair and leaves them there", + "replacement": "Declare what the node dispatches, on the node: `connectorConfig: { connectorId: 'slack', actionId: 'chat.postMessage', input: { channel: 'C0WINS000', text: 'Done' } }` — `connectorId` the registered connector's `name`, `actionId` one of the action keys that connector declares, `input` optional. Keys written under the node's `config` move into the block. A node you cannot configure yet is deleted until you can: there is no placeholder connector, and a block with empty ids names nothing to dispatch to", + "migrationId": "connector-action-config-required", + "toMajor": 18, + "rationale": "The block is the node's whole contract: the connector_action executor reads nothing else and refuses the node when `connectorId` or `actionId` is empty. The build doors checked only the block's shape once it was written, so `FlowSchema.parse`, `AutomationEngine.registerFlow` and `objectstack validate` all admitted a node with no block, or with an empty id, and every run that reached the node then failed at the executor's guard — a guard refusal, never routed to a `fault` edge, and no rerun could succeed because the config is metadata. The flow parse now refuses what that read refuses, in the walk that reaches every region body, so all three doors answer alike. A whitespace-only id is refused with the empty one: a connector `name` is a snake_case identifier, so whitespace names nothing a dispatch can reach. ⚠️ No D2 conversion: the platform cannot know the connector or the action the author left out, and no value it could write would dispatch anything. ⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0031." + }, + { + "surface": "connector.errorMapping — the rules / defaultCategory / unmappedBehavior / logUnmapped block and its per-rule keys, on a connector and on a stack connectors[] entry", + "replacement": "(removed — no connector engine maps an external error through authored rules.) Retry behaviour is `retryConfig`, which the outbound fetch applies. No connector-level channel shows an end user a message: an error users must read is surfaced by whatever handles the connector call's failure.", + "migrationId": "connector-error-mapping-retired", + "toMajor": 18, + "rationale": "The D2 conversion `connector-error-mapping-removed` deletes the whole block from every connector, stack entry and stored connector row, with one notice per connector, and the delete is lossless: no provider, dispatcher or materializer ever mapped an external error through the rules, so the eleven nested keys configured nothing. The judgment is about what the rules were written to achieve. A rule marking an upstream code `retryable` never changed a retry — if that retry matters, it belongs in `retryConfig`. A rule with a `userMessage` never showed that message to anyone, although the spelling matches the live API-error channel and read as a user-facing refusal; if users need that text, whatever handles the failed call has to surface it. `unmappedBehavior` and `logUnmapped` suppressed or logged nothing. Which of these intents still matters is known only to the connector's author." + }, + { + "surface": "ConnectorProviderContext.connectionTimeoutMs, the declared connect deadline handed to every ConnectorProviderFactory (integration/connector-provider.ts)", + "replacement": "requestTimeoutMs for the deadline the platform keeps; for a connect-only bound, the provider's own providerConfig, where the provider owns the vocabulary", + "migrationId": "connector-provider-context-connection-timeout-ms-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove, maintainer ruling 2026-09-22 letter A: retire connector.connectionTimeoutMs. The spec key is tombstoned and its authored sources are rewritten by the D2 conversion connector-connection-timeout-ms-removed; this entry carries the half a conversion cannot reach. The key was placed on this context by the round that made the connector resilience policy live, explicitly as a CARRY — handed over so that a custom provider on a transport able to separate the phases could honour it. Measured before removal, none did, and the carry itself was the last thing keeping the key alive in argument: the built-in rest and openapi factories read ctx.connectionTimeoutMs only to deposit it back onto the def that GET /connectors echoes, and connectorFetchOptions — the one mapping from authored policy onto the platform's outbound fetch — was never handed it. Being handed a value is not honouring it, so the carry is the same parsed-unmarked-unenforced state on one more surface, and it leaves with the key rather than outliving it as an orphan a factory could still read. Why a semantic entry and not a D2 conversion: a provider factory is CODE. There is no authored source and no sys_metadata row holding a read of ctx.connectionTimeoutMs, so the chain has no seam to rewrite — the removal reaches a factory author as a tsc error and as this entry, never as a mechanical edit. The declaration cannot be made honest by implementing it either: a WHATWG fetch exposes one AbortSignal over the whole operation and never the connect phase, so bounding time-to-response with this key would kill a slow-but-connected upstream the author meant to allow with a large requestTimeoutMs. ADR-0087, ADR-0097." + }, + { + "surface": "connector.health (healthCheck / circuitBreaker), connector.status and connector.webhooks — on a connector and on a stack connectors[] entry", + "replacement": "(removed — nothing replaces the probe, the breaker or an authored status.) Participation is `enabled` (and `provider` on a declarative instance); whether a registered connector can be dispatched is the computed `state` (`ready` / `degraded`) on `GET /api/v1/automation/connectors`; a webhook that is actually delivered is declared in the top-level `webhooks:` collection; probes and circuit breaking belong in the connector provider or an upstream gateway.", + "migrationId": "connector-resilience-keys-retired", + "toMajor": 18, + "rationale": "The D2 conversion `connector-resilience-keys-removed` deletes `health`, `status` and `webhooks` from every connector, stack entry and stored connector row, one notice per key, and the delete is lossless: no loop ever polled a connector endpoint, counted failures or tripped a breaker, no code read an authored status, and a webhook nested in a connector was never registered, materialized or delivered. Three judgements remain. First, a probe or breaker the author believed was protecting a flaky upstream never was — if that protection matters, it has to be built where calls are made (the connector provider) or in front of the upstream (a gateway). Second, `status` values like `active` or `error` gated nothing; an author who used `status` to switch a connector off needs `enabled: false` on the declarative entry instead. Third, the nested webhooks are STRIPPED, not moved: redeclaring one in the top-level `webhooks:` collection STARTS deliveries that never happened before, so which of them should exist is the author's call — and their `events` (`sync.completed`, `auth.expired` and the rest) and `signatureAlgorithm` have no counterpart there. The chain: in this same protocol step, `connector-health-and-trigger-durations-unit-in-key` no longer renames `health.circuitBreaker.monitoringWindow` to `monitoringWindowMs` — the whole block that key lived in is removed, so an author holding either spelling ends with no key at all. That conversion's other half, `triggers[].interval` to `intervalSeconds`, was absorbed the same way by the removal of the whole `triggers` array (`connector-triggers-removed`), so the rename itself is no longer in the step." + }, + { + "surface": "connector.syncConfig (strategy / direction / realtimeSync / timestampField / conflictResolution / batchSize / deleteMode / filters) and connector.fieldMappings[] (source / target / defaultValue / dataType / required / syncMode), on a connector and on a stack connectors[] entry", + "replacement": "A sync is defined on its TARGET: a `mapping` (`targetObject`, `fieldMapping`, `mode`, `upsertKey`) whose `connectorSource` names the `rest` or `openapi` connector instance it pulls from (`connector`), the action that reads the records (`action`, with a fixed `input` and a `recordsPath`) and, for a timestamp-incremental pull, a `watermark` (`field` on the record, `param` on the request); a `job` sets the cadence. The pull executor reads the binding when a `job` drives it — a `job` whose `pull: { mapping }` names the mapping, on the job's schedule; the binding alone moves no rows.", + "migrationId": "connector-sync-keys-retired", + "toMajor": 18, + "rationale": "The D2 conversion `connector-sync-keys-removed` deletes `syncConfig` and `fieldMappings` from every connector, stack entry and stored connector row, one notice per key, and the delete is lossless: no engine ever ran a connector-attached sync or moved a value through a connector field mapping, so nothing the upgrade removes was ever happening. Three judgements remain. First, any part of the deployment designed around a connector sync running has never been running, so the author decides which syncs should now exist as target-side mappings; the conversion STRIPS the keys and never writes a `mapping`, because a mapping that is pulled STARTS writes into a table that never received them — its target object, match key and cadence are the author's. Second, the retired block named a direction, a conflict policy and a delete policy that no runtime applied — every delete is a hard delete, and `latest_wins` resolved nothing — and the pull that replaces it is one-way (external to local) and writes only through the mapping's `mode` and `upsertKey`: an author who relied on `export`, `bidirectional`, `soft_delete` or a conflict policy decides what to do without them. Third, a connector field map moved values nowhere; carrying its `source` → `target` pairs into `mapping.fieldMapping` makes them real for the first time, including any `defaultValue`, which the import mapping spells as a `constant` transform, and `required`, which the target field declares." + }, + { + "surface": "connector.triggers — the ConnectorTrigger array (key / label / description / type / intervalSeconds, and the interval spelling it was renamed from), on a connector and on a stack connectors[] entry", + "replacement": "(removed — nothing replaces a connector trigger.) Start the work from a flow that calls the connector's action in a `connector_action` node: an external event starts an `api` flow that the event's sender calls, and a scheduled pull is a `schedule` flow.", + "migrationId": "connector-triggers-retired", + "toMajor": 18, + "rationale": "The D2 conversion `connector-triggers-removed` deletes `triggers` from every connector, stack entry and stored connector row, one notice per connector, and the delete is lossless: the automation engine registered a connector's actions only, no polling loop read an interval, no receiver was driven by a `webhook` trigger, and no provider derived one — so a declared trigger never started a flow, before or after the upgrade. Three judgements remain. First, any part of the deployment designed around a connector trigger firing has never been running, so the author decides which of those triggers should now exist as flows: a `polling` trigger becomes a `schedule` flow whose `connector_action` node calls the connector's read action, and a `webhook` trigger becomes an `api` flow that the external sender calls. The conversion STRIPS the array and never writes a flow, because a flow that runs STARTS work that never happened before — its cadence, its action and what it does with the result are the author's. Second, a polling cadence is in SECONDS: the key was renamed from `interval` to `intervalSeconds` earlier in this same protocol step because the bare `interval` means milliseconds elsewhere in this spec, so a trigger written `interval: 60000` for one minute asked for once every sixteen hours or so — carry the intended cadence, not the stored number, into the schedule. Third, turning a `webhook` trigger into an `api` flow opens an inbound endpoint that never existed before (the trigger declared no receiver and no verification), and the platform refuses an `api` flow with no per-flow secret and verifies a signature on every call — so whether the external sender can sign its calls decides whether that flow can receive them directly. The chain: in this same protocol step, `connector-health-and-trigger-durations-unit-in-key` no longer renames `triggers[].interval` — the whole array that key lived in is removed, so an author holding either spelling ends with no key at all." + }, + { + "surface": "analyticsCubes[].joins..sql / analyticsCubes[].joins..relationship — the authored ON clause and the declared cardinality on a cube join", + "replacement": "analyticsCubes[].joins..name alone. The ON clause is DERIVED from the declared relationship between the two cubes' objects, as a foreign-key equality: NativeSQLStrategy emits the LEFT JOIN and its ON from the dotted member path, and ObjectQLStrategy lowers the same alias to a relationship traversal with no ON clause at all. The record KEY is the foreign-key FIELD on the base object, never a second spelling of the object the join reaches.", + "migrationId": "cube-join-sql-and-relationship-retired", + "toMajor": 18, + "rationale": "The KEYS convert mechanically and do: the paired D2 conversion `cube-join-sql-and-relationship-removed` deletes both from every join, which is lossless because neither ever had an effect to lose, and names the cube in each notice. What does NOT convert is the INTENT. `sql` was REQUIRED and documented as the ON clause, and no reader ever consulted it: an authored condition was REPLACED by the synthesised foreign-key equality and the aggregate came back under a 200, joined on something the author had not asked for. `relationship` carried a `.default('many_to_one')` that nothing dispatched on, so `one_to_many` parsed, changed no SQL, and kept the many-to-one arithmetic. Deleting the keys restores honesty but does not give an author who wanted a non-FK join the thing they wanted, and it does not re-check the numbers the replaced join already produced. That is why this entry is a TODO addressed to them rather than a claim that the strip finished the job. A custom join condition is a capability card with its injection / allow-list boundary decided first, which the ruling deferred deliberately." + }, + { + "surface": "analyticsCubes[].measures..name / analyticsCubes[].dimensions..name — the inner name a cube member used to require", + "replacement": "The record key. `measures` and `dimensions` are records, and the key a member is declared under IS its name: the analytics API publishes it as `.` and a query names it that way. To rename a member, rename its key.", + "migrationId": "cube-member-inner-name-retired", + "toMajor": 18, + "rationale": "The D2 conversion `cube-member-inner-name-removed` deletes the inner `name` from every metric and dimension of every cube, and the delete is lossless in behaviour: every consumer — discovery, both query strategies, the in-memory driver — resolves a member by its record key, so the inner value was never read. Where it EQUALED its key there is nothing left to decide. Where it DISAGREED, the key was already the name every query, dashboard and report used, and the inner value was a spelling nothing read; the conversion notice prints both. Only the author can say whether the disagreeing spelling was the one they meant — in which case the member must be re-keyed, and every consumer that names `.` changes with it — or a stale copy to drop." + }, + { + "surface": "analyticsCubes[].measures..sql and analyticsCubes[].dimensions..sql (data.MetricSchema.sql / data.DimensionSchema.sql) authored as a SQL expression — a CASE expression, an aggregate or a ratio of aggregates, a quoted or $-prefixed spelling, or any other value that is not a column reference", + "replacement": "a column reference: a field of the cube's object (`amount`), a relationship path ending in one (`account.amount`), or `'*'` for a count. A derived value moves to an ADR-0021 dataset over the same object: a conditional count or sum is a dataset measure with its own structured `filter` (`{ name: 'done_count', aggregate: 'count', filter: { status: 'done' } }`), and a ratio, sum, difference or product of measures is `derived: { op, of: [...] }` over measures named in the same dataset (`{ name: 'done_rate', derived: { op: 'ratio', of: ['done_count', 'task_count'] }, format: '0.0%' }`). A dimension that bucketed a column with a CASE expression has no expression form in either layer: group by the column itself, or keep the bucket as a field of the object and name that field", + "migrationId": "cube-member-sql-expression-retired", + "toMajor": 18, + "rationale": "Maintainer ruling D (2026-09-30), from the analytics field-level read gate: a member whose `sql` is an expression names no single field, so no platform check can judge which fields it reads, and the analytics strategies never agreed on it — the raw-SQL path emitted it verbatim and the ObjectQL path refused it. ADR-0021 already set the direction for the author surface (\"zero raw SQL / zero raw expressions\"); it governed the dataset layer and left the cube members it compiles to open, which is the gap this closes. The dataset form is the declared home of a derived value because every field it reads is named: a measure filter names its fields, and a derived measure references other measures by name only. There is no D2 conversion: the rewrite moves a member to a different metadata type and cannot be derived from the expression text in general, so only the author can say which dataset measures express what the expression meant. A ratio also changes SCALE on the way: a `derived` ratio is a 0–1 fraction, while an expression that multiplied by 100 returned percentage points — pair the ratio with a `%` numeral pattern (the server marks a ratio column's percent scale as a fraction) and re-check any consumer that read the old number raw. ADR-0021 / ADR-0049 / ADR-0087" + }, + { + "surface": "analyticsCubes[].measures..type (data.AggregationMetricType) authored as number, string or boolean — the custom-SQL-expression metric types", + "replacement": "the aggregate the measure means: `sum`, `avg`, `min` or `max` over the column, `count` (over `'*'` for a row count, or over a column for its non-null values), or `count_distinct`. A value computed per row becomes a field of the object (a stored or formula field) that the measure aggregates; a ratio or other value derived from measures is `derived: { op, of: [...] }` on an ADR-0021 dataset", + "migrationId": "cube-metric-expression-types-retired", + "toMajor": 18, + "rationale": "The three types existed to mark a measure whose `sql` was the whole computation — a ratio, a CASE, a window function — and named only what it returned. Since `cube-member-sql-expression-retired` a member's `sql` is a column reference, so the types had nothing left to declare: measured before this retirement, the raw-SQL strategy emitted the referenced column unaggregated (a bare column in a grouped statement, by SQL's own rules an error on PostgreSQL and an arbitrary row's value on SQLite) and the ObjectQL strategy refused the measure. There is no D2 conversion: the column alone does not say which aggregate the author wanted — a `number` over `amount` may have meant its sum, its average or its largest value — so only the author can choose, and a measure whose old expression computed something per row needs that value stored on the object before any aggregate can read it. Nothing is rewritten or dropped at rest: a stored or built cube that still carries one of the three is refused, with the prescription, at the boot and write doors, and a cube that reaches the analytics service without meeting the parse is refused at query time with the same text. ADR-0049 / ADR-0087" + }, + { + "surface": "analyticsCubes[].measures..filters — the per-metric raw-SQL filter list", + "replacement": "One of the two filters that ARE applied: a `where` condition at query time, or an ADR-0021 dataset measure with a structured `filter`. (A third channel — folding the condition into the metric's own `sql` expression — left with `cube-member-sql-expression-retired`: a member's `sql` is a column reference.)", + "migrationId": "cube-metric-filters-retired", + "toMajor": 18, + "rationale": "The D2 conversion `metric-filters-removed` deletes `filters` from every cube metric, and the delete is lossless in the narrow sense: neither SQL strategy ever read the key, so a metric authored with `filters: [{ sql: \"stage = 'closed_won'\" }]` already returned the UNFILTERED aggregate under the author's metric name, and still does. That is exactly why the strip does not finish the job. The author wrote a condition because they wanted a filtered number; every dashboard, report and export reading that metric has been showing a larger one. Only the author can say which of the two live mechanisms expresses the condition they meant — a query-time `where` changes every query, a dataset measure moves the metric to the governed layer — and whether numbers already published from the unfiltered metric need to be revisited." + }, + { + "surface": "analyticsCubes[].refreshKey (every, sql) — a cube's declared refresh cadence and data-change probe", + "replacement": "Nothing: delete the key. No analytics result is cached, so every query against a cube is computed when it is asked. A refresh cadence is declared again when a result cache exists.", + "migrationId": "cube-refresh-key-retired", + "toMajor": 18, + "rationale": "The D2 conversion `cube-refresh-key-removed` deletes `refreshKey` from every cube, and the delete is lossless: nothing read `every` or `sql`, and no analytics result was ever cached for them to refresh, so no query answers differently. What the conversion cannot check is whether anything the author built assumed that cube results were cached or refreshed on a schedule. They never were." + }, + { + "surface": "object.fields.*.currencyConfig.precision — the decimal-places key of a currency field's configuration, and its never-accepted `decimals` / `scale` spellings", + "replacement": "(removed — nothing replaces it.) A currency amount's decimal places are its currency's ISO 4217 minor unit (2 for USD, 0 for JPY, 3 for KWD), derived from the currency itself and declared nowhere. Delete the key. Do not move the number to the field-level `precision`: that key is the amount's total digit count, not its decimal places, and it is unchanged.", + "migrationId": "currency-config-precision-retired", + "toMajor": 18, + "rationale": "The D2 conversion `currency-config-precision-removed` deletes the key from every field's `currencyConfig` on objects and object extensions — in author sources, in stored object rows and in built artifacts, which can carry a `2` the old schema wrote into parse output without anyone authoring it — and the delete is lossless: no renderer or runtime ever read the key. Every display face derives the width from the currency. Two judgments remain, and neither is a rewrite. First, a width that never applied: the old contradiction check judged an authored value only on a `fixed` field whose code has a known ISO 4217 minor unit, so on a `dynamic` field, and on a `fixed` field whose code has none (a crypto or custom code), an author could declare a width other than the one the field displays — and read amounts as if it applied. Whether the displayed width is acceptable for that field is the author's call. Second, code the chain cannot reach: a plugin, integration or export of your own that read `currencyConfig.precision` from served object metadata now finds no key, and must derive the width from the field's currency the way the platform's renderers always did." + }, + { + "surface": "dashboard `header.actions[]` entries with `actionType: 'modal'` — an `actionUrl` naming a defined action, a bare object name, or the `_` prefix form (`create_`/`new_`/`add_`/`edit_`/`update_` + object). The keys themselves are unchanged and still parse; what changed is what the string RESOLVES to", + "replacement": "a declared page name (`stack.pages`, by `name`) — a modal target names a PAGE, only. To open an object's create/edit form from a dashboard header, use `actionType: 'form'` with an `.` form-view target (`actionType` accepts the full action-type enum, so that shape reaches this surface too)", + "migrationId": "dashboard-header-modal-target-page-only", + "toMajor": 18, + "rationale": "Maintainer ruling A on modal targets (2026-08-09): a `type: 'modal'` string target names a PAGE, only — the spec TSDoc, the published docs and `defineStack`'s cross-reference walk already agreed, and the renderer's page-then-object leniency (self-labelled Back-compat) was retired rather than codified. One objectui change deleted the object fallback in the shared `useActionModal`; a second deleted `DashboardView`'s own second copy of the prefix convention (which had no page resolution at all), after enumerating both repos' corpora and finding zero producers of the prefix form. The `os validate` lint rule (`validateDashboardActionRefs`) then still pointed the other way: it accepted the retired shapes — blessing buttons that dispatch to a named refusal at runtime — and ERRORED on a page-named target, the one shape the runtime serves. The rule now resolves a modal target against declared pages, only. The ruling explicitly declined the middle shape (keep the prefix, reject bare object names): `create_opportunity` names the page `create_opportunity`, or it names nothing." + }, + { + "surface": "dashboard.refreshInterval — the auto-refresh cadence of a dashboard", + "replacement": "`refreshIntervalSeconds` — the same cadence, in seconds, with the unit in the key name. The old rename hints (`refresh`, `autoRefresh`, `pollInterval`) now point at it.", + "migrationId": "dashboard-refresh-interval-unit-in-key", + "toMajor": 18, + "rationale": "The D2 conversion `dashboard-refresh-interval-to-refresh-interval-seconds` renames `refreshInterval` to `refreshIntervalSeconds` in the `dashboards` collection and on stored dashboard rows, keeping the value, and the rename is lossless: the key always meant seconds. Two judgments remain. First, the unit: nothing in the old name said seconds, and three other spellings authors reached for named no unit either, so a value written in milliseconds — `refreshInterval: 30000` meant as thirty seconds — asked for a refresh about every eight hours, and the rename keeps 30000. Second, the reader: the dashboard renderer ships in the separately released console, and when this rename landed it still read the old key, so a console that has not yet moved sees no cadence and starts no timer. A dashboard that stops refreshing after the upgrade is that lag, not a wrong value — which only a look at the running console can tell apart." + }, + { + "surface": "`dashboard.widgets[].chartConfig.type` / `.xAxis` / `.yAxis` / `.series` — the four keys that said which chart family to draw, which series exist and which column each one reads on a DATASET-BOUND widget (REMOVED)", + "replacement": "the widget’s own `type` and its ADR-0021 dataset selection. `chartConfig.type` becomes the widget’s `type` (the chart family has always been the widget’s — the dashboard renderer maps the widget type to the chart family and never read the chart config’s). `chartConfig.xAxis.field` becomes an entry in the widget’s `dimensions`: the dataset dimension the category axis plots. Each `chartConfig.yAxis[].field` becomes an entry in the widget’s `values`: the dataset measure that axis plots, one entry per mark, and a second axis is a second measure rather than a second axis declaration. Each `chartConfig.series[].name` is the same measure name, so a series list that matched `values` needs nothing and one that did not was already being ignored. What has NO replacement, and is the reason this is a TODO rather than a rewrite: the PRESENTATION those objects carried alongside the binding — `ChartAxis.title` / `format` / `min` / `max` / `stepSize` / `showGridLines` / `position` / `logarithmic`, and `ChartSeries.label` / `color` / `type` / `yAxis` / `stack` / `dashArray` / `opacity`. The dataset’s own dimension and measure declarations are what label and format a dataset-bound chart now; `colors` on the same chart config remains the palette channel, and a per-series mark type (the combo chart a widget could author through `series[].type`) has no authoring channel on this face at all.", + "migrationId": "dashboard-widget-chart-config-structure-refused", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-09-12 on the dataset-bound chart config, taking options C+D together: the protocol states the ownership split AND refuses the structural keys by name, because stating it without refusing them leaves the declared-but-inert shape ADR-0049 exists to end, and refusing them without stating it leaves an author with no reason. The defect being closed is not cosmetic: an authored `yAxis[].field` was a LIVE MEMBERSHIP CHANNEL — the renderer synthesised a series from the authored axes when the chart declared none — so one authored axis could silently re-point a dataset-bound series at a different column while the chart still drew, which reads as a true statement about the data. ⛔ Not mechanically convertible: the D2 conversion can delete the keys from a stored widget, but moving what they MEANT into the dataset selection needs facts the item does not carry — whether the dataset declares a dimension by that name, whether the measure is in the dataset at all, and whether the author wanted the axis they wrote or the one the selection derives. An authored field naming a column outside the selection is exactly the case where a walker guessing would produce a different chart rather than a refused one. The keys are NOT retired from the chart config itself: `ReportChartSchema` keeps its own `xAxis`/`yAxis` (narrowed to its bound dataset’s dimension and measure names), and the react `` tier keeps all four, because an inline-data chart has no dataset to derive structure from and the author’s axes are the only ones there are." + }, + { + "surface": "dashboard widget measure arity WITHOUT a dimension — `dashboard.widgets[].values` (`DashboardWidgetSchema.values`) on a widget whose `dimensions` is absent or empty and whose `type` is one of the seven chart types that declare no rendering for several measures: `pie` / `donut` / `funnel` / `scatter` / `radar` / `treemap` / `sankey`", + "replacement": "Pick a visual that renders several measures, or split the widget. With no dimension, `type: 'table'` renders a row of measures and a bar-family type (`bar` / `column` / `horizontal-bar`) renders one bar per measure; both keep the unbounded `values` they have always had. The full set of types that render several measures on a dimensionless widget is the exported constant `DASHBOARD_WIDGET_MULTI_MEASURE_TYPES` — at this release `table`, `pivot`, `bar`, `column`, `horizontal-bar`, `line`, `area` and `combo` — and the refusal prints it from that constant. Or keep the type and give each measure its OWN widget: a new `id`, the same `dataset`, that one measure in `values`, and its own `layout` if the dashboard pins grid positions. ⛔ The migration does not do this for you and no conversion could: whether a dimensionless two-measure pie meant a table, a bar chart or two pies is an authoring choice, and N widgets need N ids and N boxes on a 12-column grid, which is a LAYOUT decision about a dashboard the registry has never seen.", + "migrationId": "dashboard-widget-dimensionless-multi-measure-refused", + "toMajor": 18, + "rationale": "Maintainer ruling D, on objectui's finding that a widget silently drops every measure after the first, applying the maintainer's standing rule 「协议不正确的应该先修改协议」 — the protocol is fixed where it admits measures a widget type cannot render. Its first application bounded the metric FAMILY to one measure (`dashboard-widget-metric-family-multi-measure-refused`); this entry applies the same principle to the chart types. Measured in objectui by the dev who delivered the multi-measure renderings for `table` / `pivot` and the bar, line, area and combo families: the other seven `ChartTypeSchema` members, given no dimension and two or more measures, render `values[0]` and drop the rest — the dataset query selects and computes every measure, and all but the first are thrown away. Every door accepted the document, because `values` is `z.array(z.string()).min(1)` with no upper bound outside the metric family. That is the declared≠delivered shape ADR-0049 exists to end. The census before the change found zero authored dimensionless multi-measure widgets of any type in the platform's examples or in objectui's example apps, so this ships at once with no deprecation window: there is no window in which a queried-and-discarded measure does anything. Relaxing later is free and needs no second migration — if one of the seven gains a declared multi-measure rendering (a radar of measures, a funnel of measure stages) it joins the constant, while leaving the key unbounded costs an author a widget that silently drops what they declared. ⛔ This change does not invent those renderings." + }, + { + "surface": "dashboard widget measure arity — `dashboard.widgets[].values` (`DashboardWidgetSchema.values`) on a widget whose `type` is one of the metric FAMILY (`metric` / `kpi` / `gauge` / `solid-gauge` / `bullet`), INCLUDING a widget that declares no `type` at all and so resolves to the `metric` default", + "replacement": "ONE measure per tile. Keep the measure the tile is actually for — in practice `values[0]`, which is the only one that has ever rendered — and give each of the others its OWN widget: a new `id`, the same `dataset`, that one measure in `values`, and its own `layout` if the dashboard pins grid positions. ⛔ The migration does not do this for you and no conversion could: N tiles need N ids and N boxes on a 12-column grid, which is a LAYOUT decision about a dashboard the registry has never seen. If several numbers in ONE widget is what was meant, that is a different visual and the arity rule is not in its way: `type: 'table'` renders a row of measures, and the chart families (`bar` / `line` / `area` / `combo`) render one mark per measure — all of them keep the unbounded `values` they have always had.", + "migrationId": "dashboard-widget-metric-family-multi-measure-refused", + "toMajor": 18, + "rationale": "Maintainer ruling D of 2026-09-12, on objectui's finding that a metric tile silently drops every measure after the first, applying the maintainer's standing rule 「协议不正确的应该先修改协议。」 — judge the protocol wrong rather than invent display semantics for `values[1..]`. Measured in objectui's first report of the defect: `values` was `z.array(z.string()).min(1)` with NO upper bound on every widget type, so a `metric` tile could declare three measures; the dataset query selected and computed all three, and the tile rendered `values[0]`. The other two were queried and dropped on the floor — the declared≠delivered shape ADR-0049 exists to end, kept alive by a runtime warning rather than closed. An objectui fix (merged) added the declared sub-caption, and objectui's interim half made the tile SAY that the extra measures are not rendered: that makes the tile honest about dropping them, it does not make the document legal. A metric tile answers ONE number — that is what the family means on every mainstream dashboard product, and `ChartTypeSchema` groups these five under \"Performance (single value)\" in its own words. Several numbers is a DIFFERENT visual, not a variant of this one, so the repair is an accept-set narrowing and not a renderer feature. ⛔ NOT the other arm (the wish, from a downstream application's manager dashboard, for several numbers on one tile): under this ruling that is a request for a different widget type, and it stays reachable through `table` / the chart families, which this narrowing does not touch. Ships at once, no deprecation window: there is no window in which a queried-and-discarded measure does anything. Widening later (a real gauge renderer that draws a target band, say) costs an author nothing and needs no second migration — a narrowing that is later relaxed is free, while leaving the key unbounded costs them a tile that silently drops what they declared." + }, + { + "surface": "dashboard widget measure arity WITH a dimension on a single-series chart type — `dashboard.widgets[].values` (`DashboardWidgetSchema.values`) on a widget whose `dimensions` declares one or more dimensions and whose `type` is `pie`, `donut`, `funnel`, `treemap` or `sankey`; and the check export `checkDashboardWidgetDimensionlessMeasureArity` (from `@objectstack/spec/ui`), renamed `checkDashboardWidgetChartMeasureArity`", + "replacement": "Keep ONE measure on the widget, or pick a visual that renders several. With a dimension, `type: 'table'` renders a column per measure and a bar-family type (`bar` / `column` / `horizontal-bar`) renders one bar per measure in each category; both keep the unbounded `values` they have always had. Or keep the type and give each measure its OWN widget: a new `id`, the same `dataset` and `dimensions`, that one measure in `values`, and its own `layout` if the dashboard pins grid positions. ⛔ The migration does not do this for you and no conversion could: whether a two-measure pie by stage meant a table, a grouped bar chart or two pies is an authoring choice, and N widgets need N ids and N boxes on a 12-column grid, which is a LAYOUT decision about a dashboard the registry has never seen. A mirror that chained the old check export by name imports `checkDashboardWidgetChartMeasureArity` instead: same signature, same attachment point, and it refuses everything the old name refused.", + "migrationId": "dashboard-widget-single-series-multi-measure-refused", + "toMajor": 18, + "rationale": "Triage's ruling on objectui's finding that a dimensioned pie draws only its first measure: the spec refuses, the renderer does not invent. It extends `dashboard-widget-dimensionless-multi-measure-refused`, which applied maintainer ruling D (「协议不正确的应该先修改协议」) to a widget with NO dimension, to the dimensioned arm for the five types that draw one series whatever the dimension. Measured in objectui's shared chart renderer: the `pie` / `donut`, `funnel`, `treemap` and `sankey` arms each bind the first series and read no other, so `{ type: 'pie', dimensions: ['stage'], values: ['revenue', 'cost'] }` drew one slice per stage for `revenue` and no trace of `cost` — the dataset query selects and computes every measure, and all but the first are thrown away. Every door accepted the document, because the dimensionless rule stepped aside for any widget that declared a dimension. That is the declared≠delivered shape ADR-0049 exists to end. A pie of several measures has zero measured pull, so no rendering is invented for it. The census before the change found zero authored dimensioned multi-measure widgets of the five types in the platform's examples, its first-party dashboards or objectui's example apps, so this ships at once with no deprecation window. Relaxing later is free and needs no second migration — a type whose renderer gains a declared rendering for several measures with a dimension leaves the single-series set — while leaving the shape accepted costs an author a widget that silently drops what they declared. The check export is renamed in the same change because its old name said a widget with a dimension was outside it, which stopped being true; no first-party consumer chained it under that name." + }, + { + "surface": "dashboard widget stage order — `dashboard.widgets[].options.stageOrder` (`DashboardWidgetOptionsSchema.stageOrder`) on a widget whose `type` is anything other than `funnel`, INCLUDING a widget that declares no `type` at all and so resolves to the `metric` default", + "replacement": "either `type: 'funnel'` on the widget that meant to declare a stage order, or — for every other widget type — DELETE `stageOrder` and order the widget with `options.sortBy` + `options.sortOrder`, which lower into the dataset query as `order: { : 'asc' | 'desc' }` instead of re-sorting what it returned. There is no third spelling: no other widget type has ever read the key, so nothing is lost by removing it that was not already absent from what rendered. The refusal lands at `options.stageOrder` and names the type the widget carries, the one type that reads the key, and the two keys to reach for instead.", + "migrationId": "dashboard-widget-stage-order-non-funnel-refused", + "toMajor": 18, + "rationale": "The first finding of the report that `options.stageOrder` is honoured by the funnel branch only, ADR-0049 enforce-or-remove, and the enforce arm of a defect whose whole content was SILENCE. `options` is the open renderer-extras bag, so `stageOrder` was an ungated member of it: a `horizontal-bar` (or `line`, `pie`, `table`, `metric`) widget carrying an authored lifecycle order PARSED, booted, and forwarded the array to the renderer, which never consulted it. Measured at this repo's `.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694`: the forwarded `categoryOrder` prop has exactly one read in the charts plugin (`buildCategoryRank(categoryOrder)`, `AdvancedChartImpl.tsx:1514`) and it sits inside the `chartType === 'funnel'` guard opened at line 1473; the prop's other two occurrences in that file are its declaration and its destructure. The producer side has no gate either — `DatasetWidget.tsx:1468` builds the explicit order for ANY widget and forwards it whenever non-empty. So the authored order was accepted by the metadata layer, carried all the way to the chart, and dropped there, with nothing anywhere to say so: the widget rendered in whatever order the analytics query returned and looked deliberate. The reporter measured exactly that in a live app — a `horizontal-bar` carrying a seven-stage contract lifecycle rendered alphabetically by display label. The four SIBLING members of the same bag are not in this narrowing and were measured not to share the defect: `dateGranularity`, `sortBy`, `sortOrder` and `limit` are read unconditionally at the top of `DatasetWidget` (lines 443-455, outside every type branch) and lower into the `DatasetSelection` the server compiles, so they act on every widget type. `stageOrder` was the only member whose effect was confined to one branch. ⛔ NOT the other arm of the card (\"or ordered marks honour it\"): teaching `bar` / `line` / `area` to sort by a category order is a renderer change in the objectui repo, and widening the set of types that read the key can be done later WITHOUT a second migration — a narrowing that is later relaxed costs an author nothing, while leaving the key accepted-and-inert costs them a chart that silently lies. Ships at once, no deprecation window: there is no window in which an inert key does anything." + }, + { + "surface": "FileValue.duration, the media length on the expanded file/image/avatar/video/audio read shape, whose name carried no unit (data/field-value.zod.ts)", + "replacement": "durationSeconds — rename the key; the value is unchanged, and a fractional second is still legal", + "migrationId": "data-file-value-duration-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling A of 2026-09-17 on the last two duration keys no closed duration type could express: rename the key and record an ADR-0087 conversion-layer entry, with no new closed type and no narrowing of anything already stored. This key declared its unit in NO channel at all — no `.describe()`, no JSDoc, no unit token in the name — so the published reference page printed a bare number and the authoring site printed nothing. What makes the bare name worth a registry row is the company it kept: the only other number on FileValue is `size`, a BYTE count, so the one member that measured time was indistinguishable from a count at the very site an author (very often a model, ADR-0033) writes it. `durationSeconds` rather than a mechanical `durationSec` or `lengthSeconds`: this spec already spells a length of time `durationSeconds` in SIX places, and they are ENUMERATED rather than counted because a bare number in shipped prose cannot be re-checked against the tree — ai/conversation.zod.ts ConversationAnalytics.durationSeconds; and on system/metrics.zod.ts MetricAggregationConfig.window.durationSeconds, ServiceLevelIndicator.window.durationSeconds, ServiceLevelObjective.period.durationSeconds, ServiceLevelObjective.errorBudget.burnRateWindows[].durationSeconds and MetricsConfig.retention.durationSeconds. The media length is therefore the SEVENTH spelling of one vocabulary, not the first of a second one. The retired-key tombstone entry data/FileValue:duration carries the same six keys in the same order, so the two surfaces that state one fact cannot drift apart. The value type is deliberately UNCHANGED at `z.number().optional()`: a fractional second is the ordinary shape of a media length, so the closed `DurationSeconds` type (`.int().nonnegative()`, published beside `EpochMs` as a closed duration type) was considered and REFUSED by the ruling, and so was an `.int()` floor. That refusal is the load-bearing half — this row is one of the six genuine durations the closed types' unit set was derived from, and it is the one that takes a NAME instead of a TYPE. Tombstoned with retiredKey(); FileValueSchema is the one deliberate z.looseObject in this file, so a bare deletion would wave the old spelling through as an unrecognised extra key and the prescription would never be spoken. Why a semantic entry and not a D2 conversion: FileValueSchema is the ADR-0104 D3 wave-2 EXPANDED READ form, derived at read time from a sys_file id — the STORED form is FileReferenceIdValueSchema, an opaque string — so a file value is never authored as this shape and never persisted as a sys_metadata row, and the conversion chain has no seam that would ever see one. ADR-0104, ADR-0087." + }, + { + "surface": "NoSQLQueryOptions.timeout, the per-query driver deadline whose name carried no unit (data/driver-nosql.zod.ts)", + "replacement": "timeoutMs — rename the key; the value is unchanged", + "migrationId": "data-nosql-query-options-timeout-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. It stands alone because it is the only offender on its file. The neighbour is what makes it a real hazard rather than a naming preference: batchSize sits directly beside it, a plain row COUNT with the same z.number().int().positive() shape and the same order of magnitude, so two adjacent bare integers meant milliseconds and documents respectively with nothing at the call site to separate them. Tombstoned with retiredKey(); the shape is not strict, so a bare deletion would strip in silence and the query would run with no deadline at all while its author believed one was set — the failure a driver timeout exists to prevent. Why a semantic entry and not a D2 conversion: these options are a per-call driver argument, reached only through AggregationPipeline.options, which no stack.zod.ts collection declares and no sys_metadata row stores, so the chain has no seam. ADR-0087." + }, + { + "surface": "ui.Dataset filter and ui.DatasetMeasure filter — an ARRAY as an EQUALITY comparand on a field INSIDE a nested-relation condition, now refused when the dataset is PARSED: the implicit form { account: { region: [...] } } and the explicit form { account: { region: { $eq: [...] } } }, the empty array included, at any relation depth and under $and / $or / $not. Every other schema that carries a FilterCondition keeps the shared schema's reach", + "replacement": "the operator the list was standing in for, on the same field inside the same relation, exactly as in filter-equality-array-comparand-refused-at-save. \"One of these values\" is $in: { account: { region: { $in: [\"a\", \"b\"] } } } (authoring spelling \"in\"). \"The stored multi-value field holds this value\" is $contains with ONE member (authoring spelling \"contains\"), and an $or of those for any-of. A filter that meant a single value writes that value: { account: { region: \"a\" } }. The list operators keep their arrays, empty lists included; every scalar, null above all, is untouched; and $ne is NOT judged by this entry", + "migrationId": "dataset-filter-nested-relation-equality-array-refused-at-save", + "toMajor": 18, + "rationale": "Measured on origin/main 9e7824a445 before the change: DatasetSchema parsed a dataset whose filter was { account: { region: [\"a\"] } }, and one whose measure filter was { account: { region: { $eq: [\"a\"] } } }, GREEN — while the analytics where door, which charts both carriers on every path (the native-SQL and ObjectQL strategies and the draft preview), flattens the relation to the dotted member account.region and hands the list to the shared comparand-shape face, which refuses it with INVALID_FILTER / 400. So such a dataset saved clean and every chart built on it failed, for a different person, later. The shared FilterConditionSchema does not descend a field spec with no $ key, because the engine reads one as a deep-equality comparand; ruling A of 2026-09-24, which made the schema door refuse what the compile face refuses, drew the line there and it stays there. Triage on 2026-09-25 routed the fix to the two analytics carriers instead, rather than stop the analytics door descending, which would change what a nested list means: they refine their filter with the analytics door's own walk ($and / $or arrays and $not descended, other $ keys skipped, a plain object with no $ key descended as a nested relation at any depth) and refuse, inside a nested relation only, exactly what that door refuses there, in the face's words from the one builder both doors import. The one difference is that the door appends the location (at where.account.region) and the carrier does not, because its issue carries the location as its path (filter.account.region, measures.0.filter.account.region.$eq). A list outside a nested relation is the shared schema's refusal and is reported once. No filter changes meaning: the refusal moves from chart time to save. Metadata AT REST is not rewritten and this entry adds no D2 conversion, for the reason the runtime entry gives: an array on equality has no single honest value. The read path does not re-validate stored rows, so a stored dataset keeps loading; what changes is that re-saving it through the metadata protocol (422 INVALID_METADATA), defineStack or os validate is refused at the filter's path. Such a filter has failed every chart since the analytics door began refusing it, so the refusal is a repair and not a loss. In-repo census at 9e7824a445: no dataset or measure filter in examples, platform objects, docs or skills carries the shape; deployed datasets were NOT measured. ADR-0021 / ADR-0087." + }, + { + "surface": "dataset measure `aggregate` × `field` pairs (`DatasetMeasureSchema`, the rows inside `Dataset.measures[]`) over a TEMPORAL field — `date`, `datetime`, `time` — whose aggregate that declared `FieldType` cannot carry: `avg` and `sum` over any of the three. ⚠️ This entry is ONE OF TWO on this leg, and its scope sentence is kept as written: it covered the temporal class and nothing else when it was registered. The non-temporal `sum` / `avg` rows followed in a later change, which registered NO entry of its own — it declared `not-required (already-registered dataset-measure-aggregate-field-type-refused)` against THIS id — so its widening rides this entry's prescription rather than a separate one. The `count_distinct` row's JSON-stored types (`json`, `composite`, `repeater`, `record`, `location`, `address`, `vector`, `multiselect`, `checkboxes`, `tags`) left the table in a third change, which likewise registered no entry and rides this one: no two backends compare those values for equality alike, so `count` is the aggregate that stays. The `min` / `max` rows over every class the table refuses are the second entry, `dataset-measure-selecting-aggregate-field-type-refused`. ⇒ Read BOTH when migrating; there is no third", + "replacement": "an aggregate the field's type accepts, per `AGGREGATE_FIELD_TYPE_COMPATIBILITY` (`@objectstack/spec/data`): `min` / `max` for a temporal field — both return a real instant of the field's own type — or `count` / `count_distinct`, which read no arithmetic off the value. A DURATION is not recoverable from an aggregate over instants: store it as a number (a computed \"days open\" field) and aggregate that. A `derived` measure whose `of` names a refused measure is fixed by fixing that measure, not the `derived` one", + "migrationId": "dataset-measure-aggregate-field-type-refused", + "toMajor": 18, + "rationale": "Nothing between the author and the driver correlated a measure's aggregate with its field type, so `avg` over a `Field.datetime` compiled to `AVG(col)` and reached the backend — where the ANSWER was decided by the dialect rather than by the data. Measured on both halves: SQLite coerces the column's canonical UTC text to a number by reading its leading digits, so `avg(submitted_at)` over 2026-05 and 2025-01 returns `2025.5` — the average YEAR, no error, no log; PostgreSQL 16 answers `function avg(timestamp with time zone) does not exist` (SQLSTATE 42883). ⚠️ The two halves are not evidenced alike: the SQLite half is PINNED by a live `sql.js` suite in `__tests__/aggregate-datetime-measure-refusal.test.ts`, while the Postgres half was MEASURED IN-SESSION on PostgreSQL 16.13 and is not pinned by any test — the live PG conformance job carries no cell for it. Nothing depends on it: the refusal is decided from declared metadata before a driver is reached. ⭐ The silent half is the dangerous one, and it is the DEV default: `derived: { op: 'difference', of: [avg_a, avg_b] }` over two such averages rendered `-0.85` on a tile labelled \"average cycle time delta\" — indistinguishable from a correct answer, which is the shape Prime Directive #12 exists to remove. Which pairs are accepted is therefore a contract, declared once in `@objectstack/spec` under the director's ruling of 2026-09-06 (\"both legs, table in spec\") and executed by the consumer legs; the compile-time leg (`dataset-compiler`, `service-analytics`) refuses the pair with `DATASET_INVALID` / 400 before any query is built, using the declared type the host already supplies through `AnalyticsServiceConfig.sourceFieldMeta`. ⚠️ A `date` / `datetime` used as a DIMENSION — grouping, bucketing, date-range filtering — is untouched: this is about aggregation only." + }, + { + "surface": "dataset measure `aggregate` × `field` pairs (`DatasetMeasureSchema`, the rows inside `Dataset.measures[]`) pairing `min` or `max` with a field whose declared `FieldType` that aggregate cannot carry — every type outside the numeric, temporal and boolean classes. Named in full so an author can grep their own metadata: the string family (`text`, `textarea`, `email`, `url`, `phone`, `password`, `secret`, `markdown`, `html`, `richtext`, `code`, `color`, `signature`, `qrcode`), the option types (`select`, `radio`), the references (`lookup`, `master_detail`, `tree`, `user`), `autonumber`, the multi-option types (`multiselect`, `checkboxes`, `tags`), the file family (`image`, `file`, `avatar`, `video`, `audio`), the structured-JSON types (`json`, `composite`, `repeater`, `record`, `location`, `address`, `vector`) and `formula` — 37 field types × 2 aggregates = 74 pairs", + "replacement": "an aggregate the field's type accepts, per `AGGREGATE_FIELD_TYPE_COMPATIBILITY` (`@objectstack/spec/data`), or a different way of asking the question. ⚠️ There is no lossless rewrite, which is why this is a semantic TODO and not a D2 conversion: nothing can compute \"the smallest text value\" in a way every backend agrees on, so no transform can preserve the answer. The three routes an author actually has, per intent: ① the measure was COUNTING in disguise (\"how many distinct owners\") ⇒ `count`, which accepts every type because it reads no value, or `count_distinct`, which accepts every type but the JSON-stored ones (the structured-JSON types and `multiselect` / `checkboxes` / `tags`, whose values no two backends compare for equality alike); ② the measure wanted a FIRST or LAST RECORD (\"the earliest-titled task\") ⇒ that is a SORT on a list or report, which orders once in a declared direction, not an aggregate that asks each backend for its own smallest value; ③ the measure wanted a QUANTITY that happens to be stored as text or JSON ⇒ store it as a numeric or temporal field (a computed column) and aggregate that. A `derived` measure whose `of` names a refused measure is fixed by fixing that measure, not the `derived` one", + "migrationId": "dataset-measure-selecting-aggregate-field-type-refused", + "toMajor": 18, + "rationale": "Director ruling B of 2026-09-13: the compile door enforces the table for every aggregate. The table refused these 74 pairs from the day it was declared and NOTHING executed the refusal: the compile leg (`dataset-compiler`, `service-analytics`) carried an explicit scope condition — `if (!DERIVING_AGGREGATES.has(aggregate)) return;` — so `min` / `max` were never judged whatever the field type, and `service-analytics`' `measureResultType` went further and typed `min` / `max` over the string classes as a supported `'string'` result and over a `formula` field from its declared `returnType`. Four declarations, three answers, one pair — the worst shape of declared≠enforced, because nobody could tell which sentence was the contract. ⭐ The divergence is real and it is the ORDER rather than the arithmetic: string order is collation-dependent, so two backends answer two different \"smallest\" values for one metadata document, and `min(jsonb)` does not exist on PostgreSQL at all — the same shape Prime Directive #12 exists to remove. The ruling settled all three sub-questions together rather than per field class, because one shared fixture drove members of both halves: the string classes stay REFUSED as the director's 2026-09-06 ruling put them (「`min`/`max` numeric plus `date`/`datetime`; everything else refused」) and the table is NOT amended; the non-string classes are refused AND enforced; and `formula` is refused on the table's own storage ground — it is VIRTUAL in SQL storage, no column is emitted, so no aggregate can be lowered to it whatever `returnType` says. ⚠️ The \"ruled C — the table is to be AMENDED to accept the string rows\" note the tree carried in two test files had no ruling behind it: the card it cited is closed as a duplicate with zero rulings on it, and the earlier recorded ruling on this table says the opposite. Business pull was measured and is zero — the shipped `min` / `max` cases were in-tree fixtures pinning a result TYPE, not customer datasets reading one. ⚠️ Confidence gap, recorded rather than hidden: customer datasets in the `cloud` repository were not readable when this was decided." + }, + { + "surface": "datasets[].dimensions[].field and datasets[].measures[].field (ui.DatasetDimensionSchema.field / ui.DatasetMeasureSchema.field) authored as anything but a column reference — a SQL expression (an arithmetic, an aggregate, a CASE, a subquery, a function call), a quoted or $-prefixed spelling, a padded or empty string, a broken path, or * on a dimension", + "replacement": "a column reference: a field of the dataset's object (`amount`), or a relationship path ending in one (`account.amount`) whose relationships are declared in `include`; on a measure also `'*'` for a count, and a count may omit `field` altogether (never `field: ''`). A derived value takes its ADR-0021 form: a conditional count or sum is a measure with its own structured `filter` (`{ name: 'done_count', aggregate: 'count', filter: { status: 'done' } }`), and a ratio, sum, difference or product of measures is `derived: { op, of: [...] }` over measures named in the same dataset (`{ name: 'done_rate', derived: { op: 'ratio', of: ['done_count', 'task_count'] }, format: '0.0%' }`). A dimension that bucketed a column with an expression has no expression form: group by the column itself, or keep the bucket as a field of the object and name that field", + "migrationId": "dataset-member-field-expression-refused", + "toMajor": 18, + "rationale": "The dataset layer was declared to take no raw SQL (ADR-0021 \"zero raw SQL / zero raw expressions\"), and its `field` was documented as a field or a relationship path, but the slot was a bare string and parsed anything — declared, never enforced (ADR-0049). The runtime had already closed the other end for an expression: the analytics dataset door refuses one with a 403 refusal, inline or saved, because an expression names no single field and no platform check can judge which fields it reads. So an expression could be saved and never answered. That door never judged an empty `field` — it skips one. The cube members a dataset compiles to were narrowed to the same accept set earlier (`cube-member-sql-expression-retired`); the dataset compiler copies `field` into the member's `sql` verbatim, so the two slots now share one declaration. A dimension additionally refuses `'*'`: grouping by every column is no axis, and both analytics strategies answered such a dimension with a 500 database fault. An empty string is refused on both: a dimension groups by nothing, and a count spells \"no field\" by omitting the key. One empty string had a working row and has a lossless repair: a `count` measure with `field: ''` (the shape a blank Field box in Studio's dataset inspector stores) compiled to the row count on SQLite's native-SQL path, and without the key it compiles to `COUNT(*)` — the D2 conversion `dataset-count-measure-empty-field-removed` drops it from stored rows and sources. Everything else has no mechanical rewrite into a column: an expression becomes a measure filter, a derived measure or a field of the object, and a ratio changes scale on the way (a `derived` ratio is a 0–1 fraction, so an expression that multiplied by 100 returned percentage points). ADR-0021 / ADR-0049 / ADR-0087" + }, + { + "surface": "datasource.config.options.auth.password (mongodb) — a login credential written into the MongoClient options passthrough", + "replacement": "remove the `auth` block from `options` (its other keys — `replicaSet`, `tls`, timeouts — stay legal) and bind the secret: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` secrets-store reference, with the username kept in the URL (`mongodb://user@host/db`)", + "migrationId": "datasource-config-mongo-options-credential-refused", + "toMajor": 18, + "rationale": "The FOURTH spelling of the same inline secret: earlier publish refusals closed the top-level `password` key, the URL userinfo password and the credential-bearing URL query parameters — and the `options` passthrough stayed open one syntax over. `options: { auth: { username, password } }` parsed green, persisted the password cleartext into `sys_metadata` (served back by the ordinary data API), and genuinely authenticated: mongodb@7.5.0 transforms the block into `MongoCredentials` (measured), so the workaround was live, not inert. A non-empty string `auth.password` is now refused at publish with the binder prescription; `auth.username` alone stays writable (the asymmetry the URL grammar keeps between its two userinfo halves — a username is not credential material), as do all non-credential passthrough options. The bound secret wins over a passthrough `auth` block at connect (measured when the bound secret was made to reach the mongo client on its URL branch), so the replacement changes which store holds the secret, never which credential connects. There is no mechanical rewrite, for the same reason as the sibling entries `datasource-config-inline-credential-refused`, `datasource-config-url-userinfo-refused` and `datasource-config-url-query-credential-refused`: moving the value requires ENCRYPTING it into a `sys_secret` row through a running secret binder, which a source-file transform cannot do — and auto-dropping only the nested password would leave an `auth` block the client refuses at construction (measured: `credentials must be an object with 'username' and 'password' properties`). Runtime-environment DSNs (`OS_DATABASE_URL` and friends) never pass through the publish door and are unaffected by construction. The read path now also redacts the stored passthrough secrets (`options.auth.password`, `options.proxyPassword`, TLS key material, `AWS_SESSION_TOKEN`) instead of serving them back in cleartext." + }, + { + "surface": "datasource.config.options.**: any credential-SPELLED key (`password`, `authToken`, or a former alias — `passwd`/`pwd`/`token`/`jwt`/`auth_token`/`authtoken`) holding a non-empty string at any object depth of the mongodb options passthrough", + "replacement": "remove the nested key (no measured client behaviour reads any such position other than `auth.password`, which has its own refusal); if a real secret must reach the connection, bind it — the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`) or a direct `external.credentialsRef` reference", + "migrationId": "datasource-config-options-nested-credential-spelling-refused", + "toMajor": 18, + "rationale": "The nested-position closure of the `datasource-config-mongo-options-credential-refused` family: that entry refused the one MEASURED login position (`options.auth.password`) and left every other nested spelling of the same secret accepted — `options.auth.token`, `options.pool.password`, any credential-spelled key one object level down parsed green, persisted cleartext into `sys_metadata` (served back by the ordinary data API), and was served on the datasource read doors with `redactedConfigKeys: []` because the read-side nested judgment was a hand-enumerated path table. Publish now refuses a non-empty string under any credential SPELLING at any object depth of the passthrough — the same one spelling list the top level refuses and the read path redacts, so a nested position is treated identically to the top-level key it mirrors. Arrays are off the walk (row-shaped data is not config). The read path now also redacts these spellings at every depth, for every driver, and carries them forward on an untouched Save. There is no mechanical rewrite, for the same reason as the sibling credential entries: moving a value into `sys_secret` requires a running secret binder, which a source transform cannot do — and unlike `auth.password`, a nested spelling at an unmeasured position buys nothing at connect, so the usual outcome is deletion, which only the author can confirm." + }, + { + "surface": "datasource.config.url (postgres) — connection URLs the `pg` client cannot parse (libpq's multi-host `h1:5432,h2:5433` form, a non-numeric port, a scheme-less non-URL, a malformed percent-escape), plus the filesystem-reading query parameters `?sslcert=` / `?sslkey=` / `?sslrootcert=`", + "replacement": "a single-host URL `pg` itself parses — `postgresql://[user@][host][:port][/dbname][?params]` (unix-socket forms stay accepted: a leading-`/` path, `socket:`, or a percent-encoded socket host). For a multi-host cluster, point the URL at one node or at a proxy/pooler in front of the cluster — `pg` does not implement libpq's multi-host DSN, so no spelling of it can connect. For certificate material, use the datasource-level `ssl` block (`ssl: { ca: …, cert: …, key: … }` next to `driver`) instead of file-path query parameters", + "migrationId": "datasource-config-postgres-url-unparseable-refused", + "toMajor": 18, + "rationale": "`PostgresConfigSchema.url`'s own describe text documents the postgres URL grammar, but until protocol 18 the value was only string-scanned for credentials (the URL userinfo password and credential query parameters) and `${…}` placeholders — deliberately so at the SHARED helper, whose refusal to parse is load-bearing for mongo's multi-host/`+srv` forms (`new URL()` rejects the multi-host form outright, and the mongo arm hands the authored URL to its client untouched). For postgres that leniency was no check at all: `pg@8.22.0` does not implement libpq's multi-host DSN — both `pg-connection-string`'s `parse` and `pg`'s `ConnectionParameters` throw `TypeError [ERR_INVALID_URL]` on `postgresql://app@h1:5432,h2:5433/app` (measured) — so an operator could publish exactly that URL, see it saved, and discover only at connect time that it can never open a connection, via a bare `Invalid URL` whose `input` field `pg` redacts. The refusal now asks the same grammar one door up: `parse` from `pg-connection-string` (the parser `pg` itself uses) runs at publish, per-driver, and what it throws on is refused with the value's path named. Two adjacent shapes are refused as structurally unusable rather than parse-refused, both measured: a scheme-less value \"parses\" only by resolving against the parser's placeholder base (`postgres://base`), i.e. `pg` would connect to the literal host `base` with the authored text as the database name; and `?sslcert=`/`?sslkey=`/`?sslrootcert=` make `parse` itself call `fs.readFileSync`, so the verdict would depend on the validating host's filesystem — certificate material already has its declared home in the datasource-level `ssl` block. There is no mechanical rewrite: a URL `pg` cannot parse does not carry enough structure to say which single host the author meant (a multi-host DSN names several on purpose), so the choice of target is the author's. Runtime-environment DSNs (`OS_DATABASE_URL` and friends) never pass through the publish door and are unaffected by construction." + }, + { + "surface": "datasource.config.url / datasource.config.syncUrl (turso) and datasource.config.url (postgres) — credential-bearing URL query parameters (`?authToken=` on turso, `?password=` on postgres)", + "replacement": "the same URL with the credential query parameter removed (non-credential parameters such as `?tls=` / `?sslmode=` stay legal), plus the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` secrets-store reference", + "migrationId": "datasource-config-url-query-credential-refused", + "toMajor": 18, + "rationale": "The inline-credential closure refused the credential KEYS and the next refusal the URL userinfo spelling; the query string was the third spelling of the identical secret, one syntax over. `libsql://x.turso.io?authToken=eyJ…` landed in `sys_metadata` cleartext exactly as `config.authToken` did — and at connect `@libsql/core` assigns the URL token OVER the binder-injected one (measured), so the workaround also silently defeated the bound secret; `pg-connection-string` likewise honours `?password=` over userinfo (measured). Only parameters a measured client actually reads are refused: mysql and mongo ignore `?password=` (measured), so their URLs are unaffected. Runtime-environment DSNs (`OS_DATABASE_URL`, `OS_DATABASE_AUTH_TOKEN` and friends) never pass through the publish door and are unaffected by construction. There is no mechanical rewrite, for the same reason as the sibling entries `datasource-config-inline-credential-refused` and `datasource-config-url-userinfo-refused`: moving the value requires ENCRYPTING it into a `sys_secret` row through a running secret binder and stripping the cleartext, which a source-file transform cannot do — auto-stripping the parameter alone would silently drop a live credential instead. Do not substitute a `${…}` placeholder into the URL: placeholders in authored metadata are resolved by nothing and reach the database client verbatim (measured when the inline-credential refusal was built)." + }, + { + "surface": "datasource (mongodb) — `external.credentialsRef` bound while `config` authors no `url` and names no `username`", + "replacement": "decide what the datasource is meant to do, then make the two halves agree: add `username` to `config` so the bound secret is interpolated beside it into the composed connection URI at connect — or, for a datasource genuinely meant to connect unauthenticated, remove the `external.credentialsRef` binding (and unbind the orphaned `sys_secret` row via the Setup → Datasources form). Authoring a `config.url` that names a user is a third valid shape, judged by the prescription of the sibling URL-branch entry, `datasource-credentialsref-mongo-url-no-user-refused`.", + "migrationId": "datasource-credentialsref-mongo-composed-no-username-refused", + "toMajor": 18, + "rationale": "The pair cannot work as written, and until protocol 18 it was accepted in silence at every door it passed. With no `config.url` the driver factory COMPOSES the connection URI from the discrete fields, and the bound secret has exactly one route into it — the userinfo written beside a username (`buildMongoUrl`: `const auth = user ? … : ''`). A falsy `username` closes that route, and the branch has no second one: `buildMongoAuth` returns early when there is no `url`, because the composed branch injects THROUGH the URI it builds rather than beside it. So `credentialsRef` bound with no `url` and no `username` composed `mongodb://host:port/db`, connected ANONYMOUSLY, and told the operator nothing. Nothing can be fabricated to rescue it: a MongoDB handshake cannot authenticate from a password alone — the same measured asymmetry behind the sibling URL-branch refusal. Both branches had always agreed on this input, so this inherits that ruling rather than re-opening it, and lands at the same authoring/publish door — the one place both halves are visible at once — as the \"absence must be loud\" half of the family of driver-factory arms, closed one driver at a time, that each dropped something declared without a word: the optional-driver arms that answered a missing package with no remedy, the turso arm that never read its bound secret, and the mysql and mongo DSN branches that discarded one. Deliberately NOT refused, each measured: a discrete `username` that is present and non-empty (the secret is live there — the composed branch has always interpolated it), an empty-string `credentialsRef` (not a binding — the connect path resolves under a truthy check), a non-string `username` (the driver config gate already reports the type error), and every other driver arm (the postgres equivalent is judged on its own client's measurement, never inherited — `pg` receives the bound password regardless of the DSN naming a user). An EMPTY-STRING `username` IS refused, unlike the sibling entry's present-but-empty userinfo carve-out: there MongoClient itself throws (`URI contained empty userinfo section`) so the shape is already loud, while here `username: ''` composes the same userinfo-free URI and connects — silently. There is no mechanical rewrite because the valid fixes are CONTRADICTORY intents — authenticate (name the user) versus anonymous (drop the binding) — and choosing between them requires knowing what the datasource is for." + }, + { + "surface": "datasource (mongodb) — `external.credentialsRef` bound while `config.url` names no user in its userinfo", + "replacement": "decide what the datasource is meant to do, then make the two halves agree: add the username to the URL's userinfo (`mongodb://user@host/db`) so the bound secret is injected at connect — or, for a datasource genuinely meant to connect unauthenticated, remove the `external.credentialsRef` binding (and unbind the orphaned `sys_secret` row via the Setup → Datasources form)", + "migrationId": "datasource-credentialsref-mongo-url-no-user-refused", + "toMajor": 18, + "rationale": "The pair cannot work as written, and until protocol 18 it was accepted in silence at every door it passed. MongoClient credentials need a username as well as a password, and with `url` present the discrete `username` field is superseded — the only place the username can come from is the URL's own userinfo. So the connect-time injection of the bound secret on the URL branch is conditional on the URL naming a user: `mongodb://app@host/db` + bound secret authenticates, while `mongodb://host/db` + bound secret connects ANONYMOUSLY with the secret unused and the operator told nothing. Injecting anyway was measured worse (mongodb@7.5.0): fabricating an empty username turns a connection that works anonymously today into a guaranteed handshake failure, and refusing at connect would contradict `MongoConfigSchema.url`'s published contract (\"bind the secret … and it is injected at connect time\") while planting a per-branch asymmetry inside the driver factory — the defect class closed when each DSN branch was made to inject the bound secret its composed branch already used. The refusal therefore lands at the authoring/publish door, the one place both halves are visible at once, as the \"absence must be loud\" half of the family of driver-factory arms, closed one driver at a time, that each dropped something declared without a word: the optional-driver arms that answered a missing package with no remedy, the turso arm that never read its bound secret, and the mysql and mongo DSN branches that discarded one. Deliberately NOT refused, each measured: the present-but-empty userinfo forms (`mongodb://@h/db`, `mongodb://:p@h/db` — MongoClient itself throws `MongoParseError: URI contained empty userinfo section`), an empty-string `credentialsRef` (not a binding — the connect path resolves under a truthy check), the composed branch (no `url`, where the discrete `username` is live), and every other driver arm (the postgres equivalent is judged on its own client's measurement, never inherited — `pg` injects on a user-less DSN by its own measured mechanism). There is no mechanical rewrite because the two valid fixes are CONTRADICTORY intents — authenticate (add the username) versus anonymous (drop the binding) — and choosing between them requires knowing what the datasource is for." + }, + { + "surface": "`indexes[].unique: true` on a declared index (`objects[]` and `objectExtensions[]`) — the bare boolean, the one `unique` spelling whose scope was positional", + "replacement": "a stated scope: `unique: 'global'` (one holder across the whole installation — exactly the index bare `true` built, which is what the chain writes) or `unique: 'organization'` (one holder per organization — the driver prepends the NULL-safe organization key part `COALESCE(organization_id, '__global__')` to `fields` at registration). `unique: false` / omitted is unchanged, and field-level `unique: true` is unchanged and stays valid (it means per organization there)", + "migrationId": "declared-index-bare-unique-true-retired", + "toMajor": 18, + "rationale": "The mechanical rewrite keeps every index exactly as it was built — `'global'` IS the verbatim column list bare `true` materialized, so nothing on disk changes. What the chain cannot know is what the author MEANT. On a declared index bare `true` read like \"unique per organization\" to anyone who knew the field-level meaning, and silently built an installation-wide constraint instead: an index meant per organization has been refusing a second organization's value all along, and its refusal told that organization somebody else holds it. Each respelled index is therefore a decision the owner makes once: keep `'global'` for a genuinely installation-wide key (a hostname, an external provider id, an engine dedup key), or move it to `'organization'` so each organization may hold the value once — a change to the physical index that `os migrate plan` shows before anything is applied." + }, + { + "surface": "DeviceRequestResponse.interval (api/auth-endpoints.zod.ts) — the polling cadence in the device-flow response body", + "replacement": "intervalSeconds — rename the key; the value (seconds, default 2) is unchanged", + "migrationId": "device-request-response-interval-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B (2026-09-02, extended on 2026-09-05 to runtime-emitted durations): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. This key was ATTRIBUTED to RFC 8628 by the campaign card and reached this card only after the attribution failed verification, so the evidence is recorded here rather than left in a PR body. Ruling B exempts a key that mirrors a name fixed outside this repo, declared on the schema as .meta({ externalVocabulary }) — and DeviceRequestResponseSchema does not mirror RFC 8628 as a SET: `code` is not `device_code`, `verificationUrl` is not `verification_uri`, and `expiresAt` is not `expires_in` — a different name AND a different type, an ISO-8601 instant where the RFC carries a relative lifetime. A schema that has already renamed every RFC field it carries into house style cannot claim the standard fixes the one name it left bare. So it is a rename, and deliberately NOT a marker: a wrongly marked key is exempted permanently and silently, while a wrongly renamed one is visible. A SEMANTIC entry rather than a D2 conversion because the shape is RUNTIME-EMITTED — the body of POST /api/v1/auth/device/request, never a stack collection member and never a sys_metadata row, so the conversion chain has no seam that would see one. ADR-0087." + }, + { + "surface": "the document family, retired whole: the four defs data/DocumentTemplate, data/Document, data/ESignatureConfig and data/DocumentVersion, and every name data/document.zod.ts exported from @objectstack/spec/data (DocumentTemplateSchema, DocumentSchema, ESignatureConfigSchema, DocumentVersionSchema, their z.input aliases and their Parsed aliases)", + "replacement": "a printable document is a PAGE that declares `print` — no separate template type. Author the document (an invoice, a delivery order, a letter, a report) as an ordinary `page` with `kind: 'full'`, its blocks in `regions` drawn from the printable block subset (`record:details`, `record:highlights`, `record:line_items`, `element:text`, `element:image` and the rest of PRINTABLE_PAGE_COMPONENT_TYPES), and a `print` block for the paper, margins, running header and footer, page numbers and page-break hints. A docx-with-placeholders template, a stored document with versions, and an e-signature workflow have no replacement, because nothing on the platform ever merged, stored or sent any of them; a document record the organisation keeps is ordinary object data, and its files are `sys_file` attachments", + "migrationId": "document-schemas-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove, by the ruling of record on the PDF / print document card (letter B′, 2026-10-08): \"A document is a page with a print declaration; no new template type\", and \"The zero-reader DocumentTemplateSchema, DocumentSchema and ESignatureConfigSchema retire in v18 under ADR-0049 with ADR-0087 entries, so that 'template' means one thing.\" Four defs sat on the exported surface and in the generated reference docs — a docx template with typed placeholders, a document with versioning, access control and an e-signature block, and the signer workflow — and were read by NOTHING: they were exported from `@objectstack/spec/data`, mounted by no `stack.zod.ts` key, registered as no metadata type and absent from every liveness ledger, and the reader census over every package, app and example outside `packages/spec` (generated reference docs, release notes and changelogs aside), over objectui at its pin and its main, and over hotcrm returned zero hits for every exported name, against lit controls. Keeping them would have given an author two meanings of \"template\" — the dead docx one and the print page — and an AI that imports DocumentTemplateSchema a schema no runtime reads. DocumentVersionSchema had one carrier, DocumentSchema.versioning, and leaves with it. The ESignatureConfig deadline-key tombstones (RETIRED_KEYS_BY_MAJOR[18], D3 `esignature-config-deadline-keys-retired`) leave with their def's source; their registry entries stay as history. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections; none of these schemas is either, so a conversion would be a transform with no seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent), and with no carrier key there is no shape on which a tombstone could sit. `cloud` and real customer code are UNMEASURED." + }, + { + "surface": "`DriverOptions.timeout` (data/driver.zod.ts) — the per-call options argument of every `IDataDriver` method", + "replacement": "`DriverOptions.timeoutMs` (milliseconds) — rename the key; the value is unchanged", + "migrationId": "driver-options-timeout-to-timeout-ms", + "toMajor": 18, + "rationale": "Maintainer ruling 2026-09-02 on duration units (ruled B — no grandfathered baseline): the unit of a duration-shaped `z.number()` key lives in the key NAME, never only in the description. `timeout` said \"Timeout in ms\" in prose and nothing else. Tombstoned with retiredKey (`DriverOptionsSchema` is not strict, so a bare deletion would strip the old key in silence) and registered as `data/DriverOptions:timeout`. Why a semantic entry and not a D2 conversion: a `DriverOptions` object is built at a call site and handed to a driver method — it is not a stack collection member and is never stored, so the chain has no seam that runs on it. Measured on ca46f8f12: no in-repo driver reads the key (the engine's own per-call budget is a separate `timeoutMs` on its options), so callers move their spelling with no behaviour change." + }, + { + "surface": "IDataDriver find, findOne, count, aggregate, update, delete, bulkUpdate, bulkDelete, updateMany, deleteMany, create and bulkCreate on TursoDriver's remote (libSQL) face, called with a tenant context", + "replacement": "a tenant-scoped call on the remote face reaches the rows the local face reaches for the same options: the caller's organization, rows with no organization, and under the group posture the caller's membership set. A by-id `update` outside that scope answers `null`, a by-id `delete` answers `false`, and a predicate write counts only the rows in scope. `create` stamps the caller's organization on a row that names none. To reach rows of every organization, call without `tenantId`, as on the local face", + "migrationId": "driver-remote-doors-tenant-scoped", + "toMajor": 18, + "rationale": "The engine hands every driver the caller's organization as `DriverOptions.tenantId`, and the group posture's membership set as `tenantIds` (ADR-0131 D8, ADR-0105 D2). TursoDriver's local face applies them through `SqlDriver.applyTenantScope` on every read and on every update and delete predicate, and stamps the organization on insert. Its remote face compiles its own statements, and its doors received no driver options: their statements carried the caller's filter and nothing else, and a remote `create` wrote no organization. Where the engine's Layer 0 wall composes a predicate above the driver, that wall held other organizations' rows back. Where it composes none (the posture in which Layer 0 is inert, or an elevated caller that carries its organization), the driver scope is the only fence, and on the remote face there was none. The remote doors now compile the local face's own predicate, by asking the same chokepoint, and AND it onto each statement, so the two faces answer the same rows by construction. The remote `create` stamps the organization as the local `create` does. `distinct` still refuses a tenant-scoped call on the remote face. The call signatures are unchanged, so nothing reaches the compiler. Code that relied on a tenant-scoped remote call reaching another organization's rows now gets the miss answer each door already declares, and a remote `create` that relied on landing a row with no organization now finds it under the caller's. ADR-0131 D8 / ADR-0087." + }, + { + "surface": "SqlDriver protected methods calendarDayExclusiveUpperBound, calendarDayUpperBoundRewrite and calendarDayBetweenRewrite (inherited by SqliteWasmDriver and TursoDriver)", + "replacement": "lower the filter before the driver compiles it — `lowerFilterCondition(where, { isDatetimeColumn })` from `@objectstack/spec/data` — instead of calling or overriding a driver method; leave `isDatetimeColumn` out and the whole-day rule applies to every column", + "migrationId": "driver-sql-calendar-day-methods-removed", + "toMajor": 18, + "rationale": "The exported `SqlDriver` class of `@objectstack/driver-sql` declared three `protected` methods that were its own copy of the whole-day rule of ADR-0053 D-D1: on a `datetime` column, a bare-day inclusive upper bound (`$lte '2026-01-05'`, or the maximum of a `$between`) compiled as `$lt` the next day, and the last supported day (`9999-12-31`) compiled as no upper bound. `calendarDayExclusiveUpperBound` computed that bound, `calendarDayUpperBoundRewrite` rewrote a `$lte` with it, and `calendarDayBetweenRewrite` rewrote a `$between` with it. The shared filter lowering in `@objectstack/spec/data` (`lowerFilterCondition`) now applies the rule once, at the engine's `where` seam and at the RLS compile seam, before any driver sees the filter, so the driver's copy was deleted, and the three methods with it (ADR-0053 D-D1 items 5 and 9, as amended). Two consequences reach a subclass, and only one of them reaches the compiler. A subclass that CALLS one of the three, or declares one with `override`, stops compiling: TS2339 and TS4113, measured with tsc 6.0.3 against the published declaration. A subclass that re-declares one WITHOUT `override` compiles cleanly, with `noImplicitOverride` off and also with it on, because the base class no longer has a member to override. That declaration is never called: the driver calls none of the three any more, so the override goes silently dead and the rule it carried stops applying. An untyped JS subclass gets a `TypeError` at a call and the same silent death for an override. A driver subclass is CODE, never stack metadata, so there is no authored source for the chain to rewrite and no schema tombstone. For the silent half, this entry is the only notice there is: the same disposition as `driver-sql-distinct-bare-filter-typed` and `runtime-httpserver-wrapper-retired`. In this repo the one caller was `TursoDriver`'s remote face, changed in the same PR. A read through the engine or the RLS compile seam answers as before, because the seam lowers first; a filter handed to the driver directly is now compared as written. ADR-0053 / ADR-0087." + }, + { + "surface": "a `where` naming a column the table does not have, on `driver-sql` (and its `TursoDriver` / `SqliteWasmDriver` subclasses) — `find()` / `findOne()` answered `[]` and `count()` threw the dialect's own error; both now refuse with `INVALID_FILTER` / 400. The `aggregate()` door of `TursoDriver`'s remote face answered `[]` for a missing column or a missing table, and now refuses as the local face does: `INVALID_FILTER` / 400 for a `where` column the table lacks, `INVALID_FIELD` / 400 for a `groupBy` or aggregation column the table lacks, and `DATABASE_ERROR` / 500 for an object whose table is absent", + "replacement": "name a column the object actually has, or run schema sync so a recently declared field exists as a column before filtering, grouping or aggregating on it, and so the object's table exists. A caller that legitimately wants \"no rows unless this matches\" gets that from a predicate over a real column; there is no spelling of an unresolvable column that means \"match nothing\", which is exactly what the old empty list was mistaken for", + "migrationId": "driver-sql-unresolvable-where-column-refused", + "toMajor": 18, + "rationale": "One predicate had two answers. `SqlDriver.findRows()` carries the unknown-column recovery ladder (an unsortable query loses its ORDER BY, not its rows), whose rungs are all built from `buildBase()` — and `buildBase()` always re-applies `query.where`. So the ladder can drop a projection and can drop an ORDER BY, but it can never drop the clause that failed when the unresolvable column is in the WHERE: both rungs raise the same error and the method fell to `return []`. `SqlDriver.count()` runs a separate statement with no ladder at all, so the identical predicate threw. Measured on better-sqlite3, one seeded row: `where { 'title.x': 'y' }` gave `find()` 0 rows and NO error, while `count()` threw `code: 'SQLITE_ERROR'`, `status: undefined`, message `select count(*) as \\`count\\` from \\`task\\` where \\`title\\`.\\`x\\` = 'y' - no such column: title.x`.\n\nA list view calls both halves, so one query produced an empty page from the rows half and a 500-shaped failure from the total half — and a caller reading only the rows got a silent empty page saying \"no records exist\" for what was really \"your predicate never ran\". That is the single most AI-legible failure to get wrong: an agent reads \"no matching records\" and writes its next query on that belief. The thrown half was no better — the dialect's own `code`, no `status` (an unclassified 5xx at the REST boundary rather than a caller mistake), and the statement's bound literals inlined in the message, the same predicate-text disclosure shape the driver's field-reference filter refusals had already been made to stop echoing (the full diagnostic goes to the server log, never the response).\n\nRuled by the maintainer on 2026-08-15: refuse BOTH halves with `INVALID_FILTER` / 400, naming the column. The envelope is not minted here — it is what every sibling refusal on this path already answers, required on both SQL drivers by `cross-field-conformance-cases.ts` and pinned by `sql-driver-boolean-identity.test.ts` and `sql-driver-cross-field-conformance.test.ts` — so what closes is a declared-vs-enforced gap, not a new posture. Recover-both was excluded by the ruling's own argument: dropping a WHERE returns rows the caller explicitly excluded, and the ladder's own premise — rows matter more than their order — is an argument about how rows are PRESENTED, which does not transfer to a predicate. The ladder KEEPS both of its recoveries — only the WHERE-failure terminal became a refusal.\n\nReach, stated rather than assumed: the refusal fires on the wordings the ladder has always recognised — SQLite (`no such column: x`) and Postgres (`column \"x\" does not exist`). MySQL spells it `Unknown column 'x' in 'where clause'`, which neither arm matches, so on MySQL this condition still travels out as the raw dialect error; widening that predicate would also hand MySQL the ladder's recoveries it has never had, which is an accept-set change in the opposite direction and is filed separately.\n\nAddendum 2026-08-16. The paragraph above is kept as the state at registration; this amends it. MySQL joined the one shared predicate, so the reach is now all three dialects this driver speaks, and a MySQL reader must NOT conclude the migration does not apply — it applies exactly as it does on SQLite and Postgres. Because that predicate serves both consumers at once, the accept set moved in BOTH directions on MySQL in one line, and both halves were ruled together (option A, maintainer, 2026-08-16; a split predicate — the envelope while withholding the recoveries — was considered and refused). (1) THE ENVELOPE: an unresolvable WHERE column now refuses with the same `INVALID_FILTER` / 400 naming the column, instead of travelling out as the raw `ER_BAD_FIELD_ERROR` with the statement's bound literals inlined — that disclosure shape closed on the last dialect that still had it. (2) THE RECOVERIES: MySQL also gained the ladder's projection and ORDER-BY recoveries it had never had, so an unresolvable column in a projection or an ORDER BY now returns recovered rows where it used to throw. The two halves arrive together because `ER_BAD_FIELD_ERROR` spells every clause position with one sentence — `Unknown column 'x' in 'where clause'` / `'field list'` / `'order clause'` — so all three ride one arm of the predicate; that is pinned as the ruled direction by the widened predicate sweep in `sql-driver-unresolvable-where-column-refusal.test.ts`. The widening can never drop a predicate: every ladder rung is rebuilt from `buildBase()`, which unconditionally re-applies `query.where`. Unchanged by the ruling: a DOTTED filter key is still classified per dialect (Postgres raises undefined_table, which neither arm matches), the axis owned by the dotted-filter verdict, which refuses a dotted key whose head is a relation, a formula or a plain column at the protocol and engine doors. The entry id, surface and prescription are unchanged — this is a text amendment, not a new migration.\n\nThis is a CODE-path API, not stored metadata, so — like `engine-dotted-projection-refused` and `engine-find-formula-filter-refused` — there is no `sys_metadata` row for the D2 chain to rewrite and this entry is the notification channel. No mechanical rewrite exists: the platform cannot know which real column a mistyped filter key meant, and guessing one would answer with rows the caller never asked for. ADR-0112." + }, + { + "surface": "an `upsert` with no `conflictKeys` — or naming the primary key — on a MySQL table that carries a non-primary UNIQUE key, in `driver-sql` (and its `TursoDriver` / `SqliteWasmDriver` subclasses). It merged onto whichever UNIQUE key the row collided with, silently rewriting a DIFFERENT row; when that happens the write is now rolled back and the call refuses with `VALIDATION_ERROR` / 400", + "replacement": "name the business key you meant to merge on (`conflictKeys`), so the intent is checkable and the pre-flight can answer for it; or drop/rename the extra UNIQUE key so the primary key is the only thing a row can collide on; or run the object on SQLite / PostgreSQL, which compile `ON CONFLICT (...)` and honour the named arbiter. There is no spelling of \"merge onto whatever key happens to collide\" that was ever correct — the old behaviour rewrote a row the caller never identified", + "migrationId": "driver-sql-upsert-cross-row-identity-merge-refused", + "toMajor": 18, + "rationale": "MySQL's only merge statement is `ON DUPLICATE KEY UPDATE`, which carries NO conflict target: knex drops the named keys before the statement leaves the process, so the merge lands on whichever UNIQUE index the row collides with first. Two earlier pre-flight refusals closed the half where no unique index backed a caller-named target and the half where a rival unique key could absorb a caller-named one. This entry closes the residue those two left by construction: the `conflictKeys`-less call and the `['id']` call, which compile byte-identically and which no pre-flight can judge, because neither names anything.\n\nMeasured on live MySQL 8.0.46 through the same knex + `mysql2` path `upsert` takes, `email` and `tax_id` both `unique: true`, NO `conflictKeys` at all: seeding `{email:'d@b.com', tax_id:'T-9', title:'first'}` inserted id `iVvD35rMk4BIayYc`, and `{email:'e@b.com', tax_id:'T-9', title:'second'}` then RESOLVED with no error — one row, the SEEDED one, its `email` rewritten `d@b.com` -> `e@b.com`. The id the caller was handed back was in no row at all. The identical pair on SQLite raises `UNIQUE constraint failed: ….tax_id` and leaves the seeded row untouched.\n\nRuled by the maintainer on 2026-08-15, as a contract principle rather than a MySQL detail: *an `upsert` must never modify a row whose identity the caller did not supply and whose conflict key it did not name.* Enforcement was delegated to the drivers lane with blanket refusal excluded by name — refusing every `conflictKeys`-less upsert on any table with a business unique key would refuse the platform's own lifecycle archiver. Measured before choosing: on this path the merge target is always the primary key, so EVERY non-primary UNIQUE key is a rival and \"narrowed to tables carrying a rival key\" and \"every table with a business unique key\" are the same set — the narrowing that made a pre-flight refusal proportionate for a caller-named target does not exist here.\n\nSo the enforcement is a post-hoc identity check instead, and it is exact rather than heuristic: `id` is insert-only on the merge path (made so once a merge on a non-primary conflict key was measured rewriting the existing row's primary key), so a row merged on the primary key always still carries the id the call supplied, and a row merged on any other key never does. Absence of that row after the statement is therefore a biconditional for \"this landed on a row the caller never identified\", which is why the refusal has no false positives. It runs inside a transaction with the statement — \"never modify\" is not satisfied by noticing afterwards — and only on MySQL tables that carry a rival UNIQUE key, so a table whose only key is its primary key keeps its single autocommitted round trip unchanged.\n\nThis is a CODE-path API, not stored metadata, so — like `driver-sql-unresolvable-where-column-refused` — there is no `sys_metadata` row for the D2 chain to rewrite and this entry is the notification channel. No mechanical rewrite exists: the platform cannot know which business key an unnamed merge meant, and guessing one would merge onto a row the caller never named, which is the defect. ADR-0112." + }, + { + "surface": "`@objectstack/driver-turso`'s published `TursoConfigSchema` — the Spec / Studio mirror of the turso connection config a host may render configuration UI from — keys `localPath` and `wasm`", + "replacement": "delete both keys. The embedded replica's local file is named by `url` (`file:./replica.db`) with `syncUrl` pointing at the remote primary, which is what the driver has always read; nothing selects a WASM build of libSQL, and a runtime that cannot load native bindings uses the remote arm (`libsql://` / `https://`), which needs none", + "migrationId": "driver-turso-config-local-path-wasm-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove, ruled per key by the maintainer on 2026-09-06, once all three of this package's unread config keys had been measured: both keys were declared on the package schema with a describe promising behaviour (\"Local file path for embedded replica\", \"Use WASM build for edge/browser environments\") and were read by no code — the driver names the replica file via `url`, and no mechanism picks a WASM build. Forwarding `localPath` would have created a second way to say what `url` says; forwarding `wasm` would have meant building a WASM selection that does not exist. Why a semantic entry and not a D2 conversion: `@objectstack/spec`'s own turso contract (`data/TursoConfig`, strict) never declared either key, so no stack source or stored datasource row that passed the spec door can carry them, and a value that never did anything has no lossless rewrite — the key is deleted by hand. Both stay declared on the package schema as `z.never()` tombstones (the shape is a plain z.object, so a bare deletion would strip in silence) carrying this prescription. The third key the same measurement found, `TursoDriverConfig.timeout`, was forwarded rather than removed and needs no entry. ADR-0049, ADR-0087." + }, + { + "surface": "IDataDriver upsert on SqlDriver, SqliteWasmDriver and TursoDriver (both faces): a tenant-scoped call whose conflict lands on a row of another organization, and the tenant column on the merge leg", + "replacement": "a tenant-scoped upsert merges only into a row of the organization it writes under; a conflict anywhere else answers `UNIQUE_VIOLATION` / 409 and writes nothing, so handle it as the colliding insert it is from the caller's organization. To move a row between organizations, call the driver's `update` door on that row: an upsert keeps the stored row's organization on merge", + "migrationId": "driver-upsert-cross-organization-conflict-refused", + "toMajor": 18, + "rationale": "`upsert` resolves its conflict against the whole table, and the primary key and a `unique: 'global'` column are installation-wide (ADR-0120 D1). So the row a tenant-scoped call (`options.tenantId` on an object with a tenant column) collided with could belong to an organization the caller cannot read. The merge leg wrote every payload column except the insert-only ones onto that row, and the tenant column was not insert-only: the other organization's columns were overwritten and the row was re-parented to the caller's organization, with no error. That was the one driver door the tenant predicate did not reach (ADR-0131 D8). The merge leg is now fenced to rows whose stored tenant column equals the written one, for any conflict target, the primary key included. A conflict anywhere else, including a row with no organization, is refused with `UNIQUE_VIOLATION` / 409, the registered code a colliding insert gets, and the refusal names no organization. The fence is a predicate inside the merge statement on SQLite, PostgreSQL and the remote libSQL face. MySQL's merge statement takes no predicate, so there the statement and a read of the landed row run in one transaction (a savepoint inside a caller's transaction) and the read's failure rolls the write back. The tenant column also joined `insertOnlyUpsertColumns`, so an upsert with no tenant context keeps the organization of the row it merges into. Two things can break, and neither reaches the compiler, since the call signature is unchanged. Code that let a tenant-scoped upsert land on another organization's row now gets a refusal where it got a silent merge. Code that relied on a payload's tenant value to move a row on merge now finds the row where it was. ADR-0131 D8 / ADR-0087." + }, + { + "surface": "Page-component `dataSource.filter` (`ElementDataSourceSchema`, the binding every data-bound element carries) and the `filter` prop of the four `object-*` blocks in `ComponentPropsMap` — `object-grid`, `object-metric`, `object-kanban`, `object-calendar` (the FORM: the MongoDB-style `FilterConditionSchema` record at the binding, and the accept-anything `z.unknown()` at the four block doors, vs the `ViewFilterRule` array)", + "replacement": "`z.array(ViewFilterRuleSchema)` at all five doors — the rule array `[{ field, operator, value }, ...]` every other `filter` door in the map already carries (`record:related_list`, its Add-affordance picker, `element:number`, `element:record_picker`). A record-form filter `{ status: 'active' }` becomes `[{ field: 'status', operator: 'equals', value: 'active' }]`; an operator object `{ status: { $ne: 'done' } }` becomes `[{ field: 'status', operator: 'not_equals', value: 'done' }]`; several keys become several rules (they AND). An ObjectQL AST tuple array `[['owner_id', '=', '{current_user_id}']]` — which the `z.unknown()` block doors also took — becomes `[{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }]`; the value placeholders and date macros are unchanged. Legacy operator shorthands (`eq`, `ne`, `gt`, `notIn`, …) are accepted and normalized on parse. The dashboard widget `filter` (`dashboard.zod.ts`) is a different family, judged on its own, and is not moved by this entry; `object-grid.defaultFilters` is a different key and is not named by the ruling this entry records.", + "migrationId": "element-data-source-and-object-block-filter-rule-array", + "toMajor": 18, + "rationale": "One filter orthography platform-wide (the maintainer's 2026-08-25 ruling, option B: a filter door takes the `ViewFilterRule` array rather than keeping a record-shaped exception every author and AI would have to remember) reached two more locations the ComponentPropsMap census could not see (ruled 2026-09-06, option A: converge the binding and the four block doors family-wide, under one entry, rather than record an exception). The binding-level `dataSource.filter` alone still said `FilterConditionSchema`: it refused the array the consumer's own pins author at that key, and `element:record_picker` carried two orthographies at two keys (`properties.filter` the rule array, `dataSource.filter` the record) resolved through one `??` in the renderer — the shape in which a dropped or misread filter returns the wrong rows without an error. The four `object-*` doors said `z.unknown()`: a read-point record derived from the renderers on 2026-08-13, when the `object-*` blocks first got props schemas in the map, twelve days before the ruling, not an exception to it — so an author following the showcase wrote the record and an author following the manifest wrote an array, and each got a silent success receipt while the html tier already declared `array` for the grid and the metric. The record's `$and` / `$or` / `$not` keys were misread by every gate block anyway (the console's filter converter had no branch for them), so the exception would have preserved a capability the consumer does not honour. Sequenced measurement-first, as the family had to be: at the objectui pin `a472b07` the `object-metric` aggregate path posted an array `where` that `POST /analytics/query` refused with 400 on every array form, so the converge was parked behind a bump of that pin; at the pin this repo builds against (`53ded82b`) the adapter lowers an authored array through `translateFilterArray` and the spec's own `parseFilterAST` sink before the wire, `ObjectGrid.tsx` lowers a rule array through `toFilterNode`, `ObjectKanban.tsx` / `ObjectCalendar.tsx` hand it verbatim to `$filter` where `convertQueryParams` lowers it, and the binding's composition seam AND-combines it with the named view's rules through `mergeFilterNodes`. The ruled migration check ran with the change: the in-repo sweep found four spec test fixtures at the binding (`page.test.ts`, all record form), five showcase authors at the block doors (`my-work.page.ts`, `index.ts`: four records on `object-metric`, one AST tuple array on `object-grid`) and three lint fixtures — every one rewritten to the rule array in the same change, and zero outside those files; this entry carries the prescription for authors outside the repo. Metadata AT REST: the mappable part of the table above is a D2 conversion, `page-component-filter-record-to-rule-array` (ruled 2026-09-12, option B: convert what maps losslessly and name what does not, rather than leave every stored row to its next save or flatten combinators), so `os migrate meta --stored` (the pass over a deployment's `sys_metadata` rows) rewrites a stored page whose `filter` is a flat record, an operator object whose operators the rule vocabulary spells, several such keys, or a single-level AST tuple array, and every stored-row read replays the same rewrite until it does. It is retired from the load path: an author writing the record form is still refused at the `filter` door. ⚠️ A filter carrying `$and` / `$or` / `$not` is left exactly as stored — the rule array only ANDs, and flattening a combinator changes which rows the page selects — and so is any filter with a part that has no lossless rule spelling: a `null` value (where a block queries an object the renderer skips that key, so it constrains nothing, and where its rows are inline it selects the rows whose value is null — no one rule keeps both, so the TODO names the `is_null` rule for the rows with no value and leaves which rows to select to the author), an operator such as `$null` / `$exists` or an AST `like`, an array or object comparand in equality position, or an AST `and` / `or` group. None of this depends on where a block's rows come from: a filter on a component whose rows are inline (`data: { provider: 'value' }` or `staticData`) — the binding's included — is rewritten or left exactly as it would be on a block that queries an object, because the `object-map`, `object-tree`, `object-calendar` and `object-gantt` blocks of the objectui version this release pins match a rule array against those rows and select the rows the stored form selected. A row left as stored keeps loading unchanged (`applyConversionsToStoredItem` replays the chain without validating, by its own contract), and its `filter` door refuses the form: at `dataSource.filter` on the page's next save; at a block's `properties.filter` — like `properties.defaultFilters`, a key of the open `properties` bag — only as the component-props gate's advisory finding (os validate, os build, os lint), since a re-save through the metadata API is not refused there. For a combinator record that refusal names the combinator and says why no rule spells it. `os migrate meta --stored` lists each such filter as a TODO under its row, naming the block and what blocks the rewrite (a value that is not a record or AST form at all — a bare string or number one of the former `z.unknown()` doors took — is neither converted nor reported, and a row carrying nothing else reads as already on protocol); a row whose only finding is such a TODO is reported `skipped`, and the run's exit code does not change for it." + }, + { + "surface": "page.component.element:filter / page.component.element:form — the bare component node itself, left standing by the `element-filter-removed` and `element-form-removed` conversions after they strip its properties", + "replacement": "Delete the component node. `element:filter` → a list surface owns its own filtering: use a view's `userFilters` quick-filter bar or the list toolbar's filter builder. `element:form` → the object-bound `object-form` block, which is rendered, designer-publishable and carries the same intent (`objectName`, `fields`, `mode`, `submitText`). Nothing is placed where the node was unless the page needs it — which region keeps its layout is the judgment this step delegates", + "migrationId": "element-filter-and-form-node-refused", + "toMajor": 18, + "rationale": "Both elements were retired whole at element grain (ADR-0049 enforce-or-remove): no renderer for either ever shipped in objectui, framework or cloud, so every authorable key was a capability claim nothing kept. The conversions are mechanical where they can be — they strip all twelve keys losslessly — and stop at the node, because removing an authored page node changes the LAYOUT of a page the author composed, and a conversion cannot know whether the region should close up, hold a replacement, or keep its slot. That residue is no longer inert: both names are members of `RETIRED_PAGE_COMPONENT_TYPES`, so `PageComponentSchema.type` refuses them by name, and a stack that replays the chain and stops there is schema-INVALID. Mechanical where it can be, delegated where it cannot — this entry is the delegation, in writing" + }, + { + "surface": "page.component.element:text_input.targetVariable / page.component.element:record_picker.targetVariable — the declarative binding hint on the two input elements", + "replacement": "Declare the binding on the page variable instead: a `variables[]` entry whose `source` is the input component `id`. That reverse lookup is the one binding the renderer has ever honoured; the variable name is the author's choice, and `targetVariable` named it from the wrong end.", + "migrationId": "element-input-target-variable-retired", + "toMajor": 18, + "rationale": "The D2 conversion `element-input-target-variable-removed` deletes `targetVariable` from every text-input and record-picker component, and the delete is lossless: no renderer, hook or runtime ever read the key, so an input authored with it and without a matching `variables[].source` wrote nothing, with a success receipt and no diagnostic. What the delete cannot do is restore the intent. An author who wrote `targetVariable: 'contact_email'` meant that input to feed that variable, and after the strip the page is exactly as unbound as it always was — now without even the hint that says so. Whether the variable exists, whether its `source` already names this component, and whether anything downstream (a flow input, a filter, a visibility predicate) reads it are facts about the author's page that no conversion can see, so the binding is delegated rather than invented." + }, + { + "surface": "`element:number` component props — `filter` (the FORM: the MongoDB-style `FilterConditionSchema` record vs the `ViewFilterRule` array)", + "replacement": "`z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` every other `filter` input in `ComponentPropsMap` already declares (`record:related_list` and its Add-affordance picker). A record-form filter `{ status: 'won' }` becomes `[{ field: 'status', operator: 'equals', value: 'won' }]`; an operator object `{ amount: { $gt: 100 } }` becomes `[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys become several rules (they AND). Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are accepted and normalized on parse", + "migrationId": "element-number-filter-rule-array", + "toMajor": 18, + "rationale": "One filter orthography platform-wide (the maintainer's 2026-08-25 ruling, option B: align the element to the `ViewFilterRule` array rather than keep it the record-shaped exception). `ComponentPropsMap['element:number'].filter` was the one `filter` input in the map declared as the MongoDB-style record (`FilterConditionSchema`) while its siblings declared the `ViewFilterRule` array, so the filter a list view stores and renders was refused by the KPI element beside it, and the objectui parity gate had to carry a reasoned exemption to look away. The convergence was sequenced consumer-first (ruling recorded 2026-08-25, Option A): the console adapter was changed so `ObjectStackAdapter.aggregate()` runs the same `translateFilterArray` its `find()` path runs, and the objectui pin carrying it was re-measured before this entry moved — but that measurement named the wrong hop, and the runtime route's refusal of the array corrects it here. `translateFilterArray` yields AST tuples, which are still a `FilterArray` — input-only sugar — so the real path is: authored array → `translateFilterArray` → lowered by `parseFilterAST` (`@objectstack/spec/data`, the single sink the `FilterArray` docblock names, since the maintainer's 2026-08-04 ruling C declared the array input-only sugar with one lowering seam) in the adapter, BEFORE the wire → a `FilterCondition` on the body. The hop that decides it is the runtime route `POST /analytics/query`, which parses `where` with `AnalyticsQueryRequestSchema` — a `FilterCondition` and nothing else — so an un-lowered array is refused there before any service code runs. `lowerAnalyticsWhere` (`service-analytics`), where that earlier measurement stopped, is the IN-PROCESS door (added when an array `where` was found silently dropped on the analytics path) for callers reaching `analyticsService.query` directly, not the wire's; it too still refuses a RAW rule-object array by design. The adapter-side lowering lands in the console's own repository. The ruled migration check ran with the change: the sweep of first-party corpora (examples/, skills/, create-objectstack, content/docs/, packages/apps/, spec fixtures) found ONE `element:number` author writing a record-form `filter` — a spec test fixture, rewritten to the array form in the same change — and zero outside the spec package; this entry carries the prescription for authors outside the repo." + }, + { + "surface": "`element:record_picker` component props — `filter` (the FORM: the MongoDB-style `FilterConditionSchema` record vs the `ViewFilterRule` array)", + "replacement": "`z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` the map's array-declared `filter` doors already carry (`record:related_list`, its nested Add-affordance picker, `element:number`; the four `object-*` blocks declare `filter` as `z.unknown()`, a gap measured on its own). A record-form filter `{ status: 'active' }` becomes `[{ field: 'status', operator: 'equals', value: 'active' }]`; an operator object `{ amount: { $gt: 100 } }` becomes `[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys become several rules (they AND). Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are accepted and normalized on parse. The binding-level `dataSource.filter` on the same node is a different key (`ElementDataSourceSchema`) and is not moved by this entry", + "migrationId": "element-record-picker-filter-rule-array", + "toMajor": 18, + "rationale": "One filter orthography platform-wide (the maintainer's 2026-08-25 ruling, option B: every `filter` door takes the `ViewFilterRule` array rather than keeping record-shaped exceptions). `ComponentPropsMap['element:record_picker'].filter` was the LAST `filter` input in the map still declared as the MongoDB-style record (`FilterConditionSchema`) after `element:number` converged: the three array-declared doors (`record:related_list`, its nested Add-affordance picker, `element:number`) carried the `ViewFilterRule` array and the four `object-*` doors declare `z.unknown()`, so the filter a list view stores and renders was refused by the picker beside them, and a lone holdout is the state where the next author copies the wrong form. Sequenced measurement-first, as that convergence had to be (the 2026-08-25 Option-A ordering ruling: measure the consumer's read path before the contract moves): at the objectui pin `00d3f09c` the renderer hands `filter` to `query.$filter` and calls `adapter.find()` (`components/src/renderers/basic/record-picker.tsx`); `ObjectStackAdapter.convertQueryParams` lowers an ARRAY `$filter` through `translateFilterArray` into filter AST tuples (`data-objectstack/src/index.ts`), the same door every list view's stored rule array already takes, and the engine lowers the tuples before the driver (`engine-filter-array-lowering.test.ts`); nothing on that path parses `properties` against the installed spec. The pin and objectui `main` (`f7cf7e8`) are byte-identical on every read-path file. The ruled migration check ran with the change: the sweep of first-party corpora (examples/, skills/, content/docs/, docs/, packages/**, .changeset/) found ONE `element:record_picker` author writing a record-form `filter` — a spec test fixture, rewritten to the array form in the same change — and zero outside the spec package; this entry carries the prescription for authors outside the repo." + }, + { + "surface": "page components of type element:text — properties.variant authored as heading or subheading (ElementTextPropsSchema.variant)", + "replacement": "one of the nine values `ui:text` publishes: `h1`-`h6`, `body`, `caption` or `overline`. 'heading' → 'h2' and 'subheading' → 'h3' (the heading element each one always rendered), or the level the page outline means", + "migrationId": "element-text-variant-heading-subheading-retired", + "toMajor": 18, + "rationale": "The ruling converged `element:text` on the vocabulary `ui:text` already publishes, because a heading is a document level, not a text style: `heading` and `subheading` named a style and left the renderer to pick a level. It landed in two releases so authors outside this repository could move first — 17.5.0 added the nine and refused nothing, and 17.6.0 was a full release in which both vocabularies parsed. The D2 conversion `element-text-variant-heading-levels` makes the ruled edit: `heading` → `h2`, `subheading` → `h3`. That keeps the heading element (the renderer drew `heading` as an h2 element and `subheading` as an h3 element), so the document outline a screen reader walks is unchanged, but not the size: `heading` drew in the `h3` style and `subheading` in a medium-weight small heading style, and `h2` / `h3` draw their own, larger styles. Whether the page wanted that level is the author's call — a heading placed for its size rather than its place in the outline may want a deeper level. Nothing is dropped at rest: a stored page replays the rewrite at rehydration; a page component's `properties` is not parsed on the save path, and the component-props gate reports an old spelling as an advisory `component-props-invalid` finding, carrying the prescription, on `os validate`, `os build` and `os lint`. ADR-0087" + }, + { + "surface": "a `where` / filter whose KEY is a dotted path with a relation, virtual-`formula` or plain-scalar head (`{\"project_id.name\": …}`, `{\"is_open.x\": …}`, `{\"title.x\": …}`) — at BOTH doors: the REST ingress (`assertFilterFieldsExist`, covering everything that reaches `findData`) and the engine seam itself (`engine.find` / `findOne` / `count` / `aggregate` / `update` / `delete`), which saved reports, flows and dashboard widgets reach directly", + "replacement": "denormalise the value onto the queried object (a stored field, written when the source changes) and filter that — the same remedy, in the same words, the SORT axis prescribes when it refuses the dotted spelling. To read a related column, `$expand` is unchanged; to CONDITION on one, the stored denormalised field is the supported shape. A dotted path into a structured/JSON field (`{\"address.city\": …}`) is NOT refused and keeps its current per-driver behaviour", + "migrationId": "engine-dotted-filter-refused", + "toMajor": 18, + "rationale": "FILTER was the last of the four query axes with no verdict for a dotted name: SORT refuses it, PROJECTION refuses it at both doors, and the FILTER gates judged a key on its HEAD SEGMENT only — so `where {\"project_id.name\": \"Apollo\"}` cleared the unknown-name check (which refuses a key naming no field of the object) because `project_id` is a real field, reached a driver that cannot serve the path, and answered 200 with zero rows. The formula verdict deliberately skipped dotted keys, so the axis answered one unserviceable intent two ways by spelling: `{is_open: true}` was refused while `{\"is_open.x\": true}` rode through.\n\nMeasured across all THREE drivers before ruling: relation-head, formula-head, system-column-head and plain-scalar-head dotted filters return ZERO rows on `driver-memory`, `driver-sql` AND `driver-mongodb`, each under an ordinary 200 indistinguishable from an empty table. There is no working capability for this refusal to remove: an ObjectStack lookup stores the related record's SCALAR id (SQL: a string column plus FK; Mongo: a single-key index on a scalar), while Mongo's dotted paths traverse EMBEDDED DOCUMENTS — so the spelling is well-formed Mongo that matches nothing. On driver-sql, knex reads the dot as a table qualifier and emits a column no dialect can resolve; `find()` falls into the unknown-column recovery ladder and returns `[]` silently (the same measurement caught the list and count halves answering that query two different ways, a divergence with its own entry, `driver-sql-unresolvable-where-column-refused`, that now refuses it on both).\n\nBoth doors now refuse the three measured-dead head classes with `400 INVALID_FIELD`, naming the whole offending key exactly as the caller wrote it and carrying the remedy sentence — no new mechanism, no new error class, per the maintainer's ruling. Both judge the head by the SAME `@objectstack/spec/data` classification (`classifyDottedFilterHead`), the one-source move the formula verdict made with `isVirtualSearchField`, so the doors cannot drift into answering one spelling two ways. Precedence mirrors the sort axis, verdict for verdict: `unknown` > `dotted` > unmaterializable.\n\nDELIBERATELY UNJUDGED, per the same ruling: a dotted path whose head is a structured/JSON field (`address.city`) — the one spelling the drivers genuinely disagree on (live on memory and mongodb, 2 rows in the measurement; silently empty on sql). Refusing it for symmetry would delete a working capability on two of three backends; declaring JSON-path filtering a capability (`supports`) waits for a real consumer. Array-valued heads (`multiple: true`, tag types) and file heads are unjudged for the same measured reason. The nested-relation OBJECT form `{ owner: { region: \"NA\" } }` is untouched: the refusal targets the dotted-STRING spelling alone.\n\nThis is a CODE-path API, not stored metadata, so — like `engine-find-formula-filter-refused` and `engine-dotted-projection-refused` one step down — there is no `sys_metadata` row for the D2 chain to rewrite and this ledger entry is the notification channel. No mechanical rewrite exists: the platform cannot invent the stored column the remedy prescribes, and it must not join or post-filter instead — the drivers have already applied `limit`/`offset`, so any post-hoc predicate would filter an arbitrary page.\n\nAUTHOR-REACHABLE SURFACES: a saved report's `query.filter` (`sys_saved_report`) is forwarded VERBATIM into `engine.find` by `plugin-reports`, bypassing the ingress; flow node `config.filter` and dashboard widget filters are author-written the same way. A dotted filter path is exactly what an AI author writes by analogy with `$expand`, projection spellings and SQL joins — and it used to answer an empty list indistinguishable from \"no matching records\", often with the related value reading correctly in the very same response. It now fails loudly, with the remedy in the message. Registered on the same inherited ruling as its siblings — the SORT-axis engine refusal was registered in this ledger although no stored row needs rewriting, re-affirmed for the FILTER axis on 2026-08-13. ADR-0112." + }, + { + "surface": "four epoch-instant keys whose name carried no unit: WebSocketEvent.timestamp, SimplePresenceState.lastSeen, KernelContext.startTime (inherited by TenantRuntimeContext) and HealthStatus.timestamp", + "replacement": "the same instants named for what they mark and typed with the new shared EpochMs schema (shared/epoch.zod.ts): occurredAt, lastSeenAt, startedAt and checkedAt. The VALUE is unchanged in every case — still milliseconds since the Unix epoch, still Date.now(). Only the key name and the declared schema move", + "migrationId": "epoch-instant-keys-renamed", + "toMajor": 18, + "rationale": "Maintainer ruling B (2026-09-05, on the population the 2026-09-02 rule reaches): a duration-shaped z.number() carries its unit in the key NAME, minus two structural classes declared ON THE SCHEMA rather than in a gate ledger. Epoch instants are the first class. They read to the rule exactly like an offending duration — a bare name plus a describe that says \"milliseconds\" — but renaming them the way the rule prescribes would resolve the wrong confusion: measured on this package own authorable surface, all 51 distinct keys ending in Ms are durations (timeoutMs, backoffMs, latencyMs, uptimeMs) and all 51 distinct keys ending in At are instants (createdAt, expiresAt, lastUsedAt). Spelling an instant with the Ms suffix would move it INTO the duration family. So the exemption is a declaration on the contract: the value becomes EpochMs, which states the epoch-millisecond unit once, and the key takes this package established At convention. Two of the six instants ruling B names (ServiceMetadata.registeredAt and ScopeInfo.createdAt) were already correctly named and only changed schema, so they are not retirements and appear in no table. A SEMANTIC entry rather than a D2 conversion because all four keys are RUNTIME-EMITTED — a WebSocket event and a presence payload are wire messages, a kernel context is constructed by host code at boot, a health report is emitted by the startup orchestrator — so none is ever stored as a sys_metadata row and the conversion chain has no seam that would see one. That is the same disposition kernel/KernelContext:previewMode already carries on one of these very defs, and ruling B prescribes it explicitly: an ADR-0087 conversion where the key is authorable, a semantic entry where it is runtime-emitted. ADR-0087." + }, + { + "surface": "e-signature deadline keys: `ESignatureConfig.expirationDays` / `reminderDays` (`document.eSignature.expirationDays` / `document.eSignature.reminderDays`)", + "replacement": "nothing to re-declare — delete the keys. No e-signature engine exists on the platform: no signature request is sent, expired or reminded by any layer, so there is no live mechanism to declare an expiry window or a reminder interval to. `ESignatureConfig` itself stays (`provider` / `enabled` / `signers`), unchanged", + "migrationId": "esignature-config-deadline-keys-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; the 2026-09-02 ruling on the unread deadline keys held this pair on one condition — \"no roadmap ⇒ they retire with the other three families\" — and the maintainer answered it on 2026-09-05 (no roadmapped e-signature consumer), so the ruling's own branch resolves to retirement. Two day-shaped keys sat on the published authorable surface (`authorable-surface/data.json`) and in the generated reference docs — an author could write `expirationDays: 30` and reasonably expect a signature request to lapse after thirty days — and were read by NOTHING: the reader census over every package outside `packages/spec` (tests and changelogs excluded), over `examples/**` and `skills/**`, and over objectui at the pinned sha returned zero hits for `expirationDays`, `reminderDays`, `eSignature` and the `ESignatureConfig` names, with a lit control inside `packages/spec`. Both carried defaults (30 days, 7 days) that were materialized into every parsed configuration without ever being consulted. `cloud` and real customer configurations are UNMEASURED. Why D3 semantic and not a D2 conversion: `DocumentSchema` is not a stack collection member and `document` is no metadata type, so the chain has no seam that would ever see one (the `kernel/MetadataPluginConfig:additionalTypes` precedent); the prescription reaches authors through the `retiredKey()` tombstones (`tsc` + the parse) and this entry." + }, + { + "surface": "every EVALUATED expression slot in the spec — the 34 declaring positions of the census of engine-evaluated slots outside the flow ledger that survive into this major, enumerated by identity and not by a name scan: the formula Field.expression; the predicate keys visibleWhen / visibleOn / readonlyWhen / requiredWhen / visibility / disabledWhen / visible / disabled / condition / when, on Field, SelectOption, InlineGridColumn, ScriptValidation, CrossFieldValidation, ConditionalValidation, Hook, ObjectFieldGroup, RowCrudActionOverride, CriteriaSharingRule, PluginPermission.filter, MultiVersionSupport routing, Action and ActionParam (including each param option), BaseNavItem, BulkActionDef, PageComponent, PageTabs items, RecordAlert, ListViewShape, FormFieldBase, FormSection and the settings-manifest Specifier and manifest visible — authored either as an expression envelope carrying only ast ({ dialect: 'cel', ast: … } with no source), or with a source that is blank after trimming, through the envelope key ({ dialect: 'cel', source: ' ' }) or the bare-string shorthand for it. ⚠️ The census this entry was written against counted 36, and the two that are deliberately absent here are the ServiceLevelIndicator successCriteria and TraceSamplingConfig composite condition expression arms. They are not lost: they were RETIRED OUTRIGHT in this same unpublished major by the observability-cel-predicates-retired entry of this step, under ADR-0049 enforce-or-remove, because nothing evaluated either. Both entries first ship together, so an upgrader never meets those two slots under THIS rule — the composite of the two changes is the retirement alone, and stating the narrowing for a slot that no longer accepts an expression at all would send the upgrader to author one. That absorption is the only reason the count here is not the census figure of 36. The published TypeScript interface RowCrudPredicates narrows with the two slots it mirrors. Reachable wherever metadata is authored or stored: defineStack sources, an exported stack passed to objectstack validate, a POST body on any of these metadata types, and a row already sitting in sys_metadata", + "replacement": "a non-blank `source`. ⭐ For an `ast`-only envelope the recovery is MECHANICAL and lossless for `cel`, which is the one dialect in this population that has an AST at all: `printCelAst(ast)` from `@objectstack/formula` (shipped with this narrowing, the inverse of `parseCelToAst`) prints the AST back to surface syntax, and the recovered string is the new `source` — keep the `ast` beside it if you want, an `ast` BESIDE a string `source` is untouched and stays admitted everywhere. ⚠️ Lossless is about MEANING, not bytes: the printer re-renders from the parse tree, so single-quoted literals come back double-quoted (`record.p == 'x'` → `record.p == \"x\"`) and parentheses the parser dropped do not come back. It answers `null` — never a guess — for an `ast` it cannot round-trip through the platform's own bounded parser; that `null` is the hand-migration case. For a BLANK `source` there is nothing to print from, so this entry delegates the judgment, and it is the same fork the flow-edge `condition` narrowing named: author the predicate the slot was meant to carry, or REMOVE the key entirely. ⚠️ Those two are not interchangeable and the choice is per slot, not per file. A refused predicate reached its evaluator and faulted, and what the fault DID differs by slot: on the fail-closed ones (`ObjectFieldGroup.visibleWhen`, `RowCrudActionOverride.visibleWhen`, `BulkActionDef.visible`, the two settings-manifest `visible` slots) it HID or EXCLUDED, so removing the key REVEALS what was hidden; on the fail-soft ones (the rest) it left the gate open, so removing the key preserves what was happening. Removing to clear the refusal is therefore safe on one half of the population and a silent disclosure on the other", + "migrationId": "evaluated-expression-slots-source-required", + "toMajor": 18, + "rationale": "Maintainer ruling 2026-09-12, option A: every engine-evaluated expression slot requires a non-blank `source`, with an ADR-0087 migration path, while the persistence contract stays wide. The rule first set for the flow-node ledger, and then carried to `FlowEdgeSchema.condition`, generalises to every other slot an engine evaluates. Each of those slots now composes `EvaluatedExpressionInputSchema` instead of `ExpressionInputSchema`, so an evaluated slot is held to what the engine can actually run. The engine reads `source` alone (`cel-engine.ts` `evaluate`: \"AST-only evaluation not yet supported; persist `source`\"), so both refused spellings landed in its fault arm on every release that carried them — and measured at the chokepoint, the engine never silently SUCCEEDS on either: it returns a `parse` fault, and what happened next was decided entirely by the slot's fail policy. Nothing between the author's keystroke and that fault said a word — the authoring lint `validateVisibilityPredicates` measured 0 findings on an `ast`-only envelope and 0 on a blank `source`, against two control legs that each measured 1. The refusal is one rule with one sentence, `EVALUATED_EXPRESSION_SOURCE_REQUIRED`. ⚠️ `ExpressionSchema` / `ExpressionInputSchema` are deliberately NOT narrowed and neither is their alias `PredicateInputSchema`: they are the PERSISTENCE contract (`source` OR `ast`) and stay wide by the same ruling's item 2. The narrowing is at the evaluated slots only. ⚠️ Why this is a D3 entry and not a D2 conversion, even though a printer now exists. The conversion layer lives in `packages/spec`, which is dependency-free by Prime Directive #2 and carries no engine — `packages/formula`'s own `normalize.ts` header states the same boundary from the other side (\"Spec layer cannot do step 2 because it must remain dependency-free; this package owns the engine import\"). A conversion that had to call the CEL printer could not live where conversions live, and a conversion that guessed without one would be the platform inventing a predicate. So the printer ships as a named, tested export the migration PRESCRIBES, and the judgment the printer cannot make — a blank `source`, an opaque `ast` that does not round-trip, any future dialect with no printer — stays here as the structured TODO, naming the object, field and slot. ⚠️ And for a row ALREADY STORED the consequence is wider than the key. `applyConversionsToStoredItem` replays the conversion chain on rehydration, but no conversion can supply a `source` that was never written, so a stored row carrying either spelling now fails its schema parse at the seam that loads it rather than parsing and faulting later. That is the intended direction — the refusal moves from run time, where it was invisible on the fail-soft slots and destructive on the fail-closed ones, to load time, where it names the row. ADR-0087, ADR-0058, ADR-0049." + }, + { + "surface": "`EventNameSchema` and its `EventName` type (`@objectstack/spec/shared`, `shared/identifiers.zod.ts`), and the dot-notation grammar it imposed on its only three binding fields: `EventTypeDefinitionSchema.name` and `EventSchema.name` (`kernel/events/core.zod.ts`) and `EventMessageSchema.eventName` (`api/websocket.zod.ts`).", + "replacement": "(removed — no replacement grammar layer. The three binding fields stay and widen to plain `z.string()`; the event vocabulary the platform actually checks is the closed literal enums `DataEventType` / `BulkDataEventType` (`@objectstack/spec/api`, `api/events.zod.ts`), which stand as the only event-name contract. A caller that imported `EventNameSchema` for standalone validation deletes the import; if it was validating platform event names, it parses through the enums instead.)", + "migrationId": "event-name-schema-retired", + "toMajor": 18, + "rationale": "Maintainer ruling 2026-09-01 (director decision batch C, verbatim 「同意」: retire) — ADR-0049 enforce-or-remove. The schema presented itself as the platform's event-name grammar while nothing that runs consumed its three binding schemas, and the closed enums that do the real checking never referenced it. The event surface is platform-defined, not author-extensible, so a grammar layer for a hypothetical extension surface is a trap, not a reserve: a generator satisfying `EventNameSchema` has satisfied nothing the platform will check, while one emitting outside the closed enums is refused by a rule the identifier file never mentioned." }, { - "surface": "dashboard.aria / dashboard.performance / dashboard.widgets[].performance", - "to": "dashboard keys 'aria'/'performance' and widget 'performance' removed (inert, removed under ADR-0049 enforce-or-remove: no renderer applied any of them)", - "conversionId": "dashboard-inert-keys-removed", - "toMajor": 17 + "surface": "`ExecutionStepLog.iteration` on a step whose `regionKind` is `parallel-branch` — the per-step records under `ExecutionLog.steps`, as the automation run endpoints return them — and the new optional `ExecutionStepLog.branch` key", + "replacement": "Read the parallel branch index from `branch`. `iteration` is now single-valued: the zero-based iteration of the enclosing `loop`, carried through any nesting, so a branch step of a `parallel` node that sits inside a loop body carries BOTH keys — `iteration` for the row and `branch` for the branch. A consumer that grouped or labelled steps by `iteration` under `regionKind: parallel-branch` moves that read to `branch`; a consumer reading `iteration` on `loop-body`, `try` or `catch` steps changes nothing.", + "migrationId": "execution-step-iteration-single-valued", + "toMajor": 18, + "rationale": "The key was declared as the zero-based loop iteration OR the parallel branch index of the enclosing region — one field, two meanings, told apart only by reading `regionKind` first. The engine tagged each step with its innermost region only, so for a `parallel` node inside a `loop` body every branch step recorded the branch index and no step of that branch recorded the loop iteration: a per-row failure inside a branch was attributable to a branch, never to the row the sweep was processing. The sibling try/catch rule had already settled the containment case — a try/catch region has no index of its own, so it carries the loop iteration — and deliberately left `parallel` open, because there the two indexes genuinely compete for one field. The maintainer ruling of 2026-09-03 took option A: `iteration` always means the enclosing loop iteration and the branch index moves to its own optional key, so a reader no longer has to branch on `regionKind` to know which number it holds, and getting that wrong no longer silently books a failure against the wrong row. Option B — keep the overload and add a second index whose presence depends on nesting shape — was not taken. This is not a mechanical conversion: a step record written before this change carries `iteration` under `parallel-branch` with the branch-index meaning, and only its producer knows whether the parallel node sat inside a loop. The measured corpus held zero `loop { parallel }` nestings and one consumer reading the key — a grouping key in the objectui flow-runs panel — so the migration is a consumer-side read move, not a data rewrite. The engine tagger that writes both keys follows this contract change as its own card; until it lands, `branch` is declared and unwritten, and `iteration` on a `parallel-branch` step written by an older engine still holds the branch index." }, { - "surface": "dashboard.widgets[].responsive", - "to": "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)", - "conversionId": "dashboard-widget-responsive-removed", - "toMajor": 17 + "surface": "the export-job API family, retired whole: the twelve defs api/ExportJobStatus, api/CreateExportJobRequest, api/CreateExportJobResponse, api/ExportJobProgress, api/ScheduledExport, api/GetExportJobDownloadRequest, api/GetExportJobDownloadResponse, api/ListExportJobsRequest, api/ExportJobSummary, api/ListExportJobsResponse, api/ScheduleExportRequest and api/ScheduleExportResponse with every name api/export.zod.ts exported for them from @objectstack/spec/api (the Schema consts, their z.input aliases and their Parsed aliases) and the ExportApiContracts route map; the IExportService contract with its six types (CreateExportJobInput, CreateExportJobResult, ExportJobDownload, ListExportJobsOptions, ExportJobListResult, ScheduleExportInput) from @objectstack/spec/contracts; and automation/ScheduleState (ScheduleStateSchema, ScheduleState, ScheduleStateParsed) from @objectstack/spec/automation", + "replacement": "nothing to re-declare for the job family — no route ever served it, so no caller holds a job id, a progress body or a download link to carry over. The export the platform DOES serve is the synchronous streaming door GET /api/v1/data/:object/export (@objectstack/rest, the SDK method data.export): it answers the file itself as CSV, JSON or XLSX. ExportFormat stays published (ExportImportTemplate still references it). A recurring export is a Job (system/job.zod.ts) whose handler you write, with its cadence on Job.schedule.expression — the one cron slot the platform evaluates. A scheduled flow declares its cadence on its start node (config.schedule), and its run history is ExecutionLog / FlowRunSummary; ScheduleState had no counterpart to point at because no scheduler ever kept one. The import-job family in the same module (ImportJob…, ListImportJobs…, ImportJobApiContracts) is served and is NOT part of this retirement", + "migrationId": "export-job-family-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling A of 2026-09-12 (retire the family, IExportService and ScheduleExportInput; ScheduleState retired with it unless a live consumer is measured), the landing route the maintainer ruled on 2026-09-24 (route A: objectui retires its own side of the unimplemented async-export path first, then this retirement), and a scope note the maintainer agreed on 2026-09-25 that folds in the declared limit / cursor of the export-job list — one of three sibling list doors found declaring them and never reading them. The family declared an asynchronous export API, create / progress / download / list / schedule / cancel under /api/v1/data/export and a POST on /api/v1/data/:object/export, that NOTHING served: @objectstack/rest mounts no /api/v1/data/export route and only the GET on /api/v1/data/:object/export, IExportService recorded no evidenced provider binding, and the reader census over objectstack outside packages/spec, over objectui at the pinned sha (which carries objectui's own retirement) and over cloud main returned zero code files naming any of the forty-three exported names, each beside a lit control. An AI reading the contract found a complete, well-typed export-job API and wrote calls that answer 404 — and once the retirement of the cron-typed positions nothing read had deleted theirs, ScheduledExport / ScheduleExportRequest kept a REQUIRED schedule block that could hold no schedule, so an author who filled in its timezone believed they had scheduled something. ScheduleState described the runtime state of a scheduled flow that no scheduler wrote or read. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and applyConversionsToStoredItem maps a metadata type onto one of its collections; none of these shapes is either — they are HTTP bodies, a route map, a service interface and an unpersisted runtime record — so a conversion would be a transform with no seam that ever runs, and with no carrier key there is no shape on which a tombstone could sit. Those earlier cron-position deletions on three of these defs registered nothing and stay unregistered; the defs themselves are now the RETIRED_DEFS_BY_MAJOR[18] entries." }, { - "surface": "dashboard.widgets[].actionUrl / dashboard.widgets[].actionType / dashboard.widgets[].actionIcon / dashboard.widgets[].aria", - "to": "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)", - "conversionId": "dashboard-widget-action-aria-removed", - "toMajor": 17 + "surface": "object.fields..scale on a field whose `type` is `currency` — any declared value, `scale: 0` included; the `Field.currency` helper passes it through unchanged. `scale` on `number`, `percent`, `rating`, `slider` and `formula` is untouched", + "replacement": "no `scale` on a currency field. DELETE the key — that is the whole migration: a currency amount's decimal places are its currency's, not a field setting. The currency's ISO 4217 minor unit decides how the amount displays, and the field's write allowance stays unconstrained — a currency write is accepted with the decimals it carries, as it always was on a currency field that declared no `scale`. ⛔ Nothing replaces the key: do not re-declare its value under any other key.", + "migrationId": "field-currency-scale-refused", + "toMajor": 18, + "rationale": "The maintainer's ruling of 2026-09-23 (option B) retires `scale` from the `currency` field type, and the ruling of 2026-09-24 (option 乙 — a currency's decimal places are the currency's, not a setting) words the remedy. On a currency field the key was three-faced: the metadata-admin field designer offered it as stored metadata, the amount's cell never read it (fraction digits come from the currency's ISO 4217 minor unit), and the record validator's `max_scale` branch still refused writes carrying more decimals — so an author who set it bought a narrower write contract and no visible change. `FieldSchema` now refuses the key on `currency` at parse, and the validator stops reading it for the type in the same release, so a stored declaration narrows nothing either. ⛔ No alias and no grace window, per the ruling. NOT mechanically converted, deliberately: a conversion that dropped the key would accept it on every load, which is the grace window the ruling refused; the refusal names the key and its one-line fix instead. Two behaviour changes ride along and are part of what an upgrade means: (1) a currency write with more decimals than a former `scale` is now ACCEPTED — the write allowance stays unconstrained, the contract every currency field without `scale` already had; (2) at the console pin measured when this was written, the grid summary footer and the dashboard metric widget read a currency column's `scale ?? 0`, and the ruling lands this change only after the console derives both faces from the currency, the way the cell does, and the pin has moved past that change. Population measured at the change, on origin/main 1f89ba0d70 by AST sweep: 15 `Field.currency` declarations in `examples/` (app-crm 4, app-showcase 11) and 13 documentation examples carried `scale`, every one of them `scale: 2`; all were deleted in the same change." }, { - "surface": "dashboard.widgets[].compareTo", - "to": "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)", - "conversionId": "dashboard-widget-compareto-converged", - "toMajor": 17 + "surface": "field.inlineColumns[] and field.relatedListColumns[] on lookup and master_detail fields — the two column lists that used to accept any object", + "replacement": "`inlineColumns` entries are strict, name-keyed columns — `{ name, label?, type?, … }`, where `{ name }` alone hydrates the rest from the child object field. `relatedListColumns` entries are child field-name strings.", + "migrationId": "field-inline-and-related-list-columns-closed", + "toMajor": 18, + "rationale": "The D2 conversion `field-column-lists-canonicalized` rewrites what it can resolve without guessing: an inline column spelled `{ field: 'x' }` becomes `{ name: 'x' }` with every other key kept, and a related-list column object folds to its identity string. Two things remain the author's. First, the conversion leaves alone, on purpose, an inline entry that carries BOTH `field` and `name` (rewriting a live key on the strength of a stale one would guess) and a related-list object with no resolvable identity (a conversion must not invent data) — those now fail the parse and only the author knows which column was meant. Second, the fold DROPS a related-list object's decoration keys — a label, a width — because no object spelling rendered reliably on that list; the author decides whether a label they wrote there belongs on the child field itself instead. Both lists used to accept any object, so a mis-keyed column published clean and drew blank cells: a column that was blank before this release was usually one of these, and the author should confirm it now names a real child field." }, { - "surface": "agent.knowledge", - "to": "agent key 'knowledge' removed (inert, removed under ADR-0049 enforce-or-remove: declaring sources/indexes never scoped retrieval; restrict at the knowledge-service level)", - "conversionId": "agent-knowledge-removed", - "toMajor": 17 + "surface": "object field `deleteBehavior: 'set_null'` authored on a `master_detail` field", + "replacement": "an explicit `deleteBehavior: 'restrict'` or `'cascade'` (or no declaration, which is the cascade default) — re-declared deliberately, because only the author knows which they meant. There is deliberately NO automatic conversion: `'set_null'` here asked for the child rows to be KEPT, and both mechanical rewrites betray that intent in a different direction — stripping the key silently ratifies the cascade the author did not ask for (the same collapse of intent that produced the defect), while `'restrict'` is the only rewrite that cannot lose data (the parent delete is refused while children exist — the closest honest reading of \"keep my children\") but turns a delete that silently succeeded into a loud refusal. If the children genuinely must survive the parent, the field wants to be a `lookup`, not a `master_detail`", + "migrationId": "field-master-detail-set-null-refused", + "toMajor": 18, + "rationale": "`FieldSchema` accepted `deleteBehavior: 'set_null'` on a `master_detail` while the engine's `cascadeDeleteRelations` resolves every value except `restrict` on that type to `cascade` — so the declaration asked for the children to be kept and the engine DELETED them, silently, at the moment the parent went away: data loss relative to the declared intent, the ADR-0049 declared-but-unenforced shape on a delete path. Honoring the value is ruled out (maintainer, 2026-08-19): a detail row whose master reference is nulled becomes an unreachable orphan, which is precisely what the orphan-detail work exists to prevent. The schema now refuses the authored combination at parse time (declared = enforced), and the engine logs loudly if a raw registration or a pre-tightening stored row still carries it to the coercion site. A BARE `master_detail` is untouched: the default still materializes as `'set_null'` in parse output (byte-identical to before) and still resolves to cascade." }, { - "surface": "skill.triggerPhrases", - "to": "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)", - "conversionId": "skill-trigger-phrases-removed", - "toMajor": 17 + "surface": "object field `maxLength` declarations — `maxLength: 0`, negative or non-integer values on any type, and the key with any value on field types outside `BOUNDED_STRING_FIELD_TYPES` (`boolean`, `lookup`, `autonumber`, `formula`, `select`, `json`, `secret`, …)", + "replacement": "a positive-integer `maxLength` (>= 1) on a bounded-string field type — `text`, `textarea`, `email`, `url`, `phone`, `password`, `markdown`, `html`, `richtext`, `code`, plus `signature`/`qrcode`, which joined once the write seam enforced a declared bound on them (the set is `BOUNDED_STRING_FIELD_TYPES`; the narrowing itself landed on the ten-member set of its day) — or no declaration at all. Deleting the key is mechanical and behaviour-preserving for a MISPLACED declaration: the write-time validator only ever applied `max_length` inside its bounded-string branch, so the key was inert by construction on every other type. A MALFORMED value on a bounded-string type is the judgment case — the validator's raw `>` comparison did consume it (`maxLength: 0` accepted only the empty string, a negative value refused every write, `maxLength: 12.5` behaved as \"at most 12\"), and the SQL schema-drift planner consumed `maxLength: 0` as `varchar(0)` DDL until it was taught to stop reading a malformed bound as authoritative — so only the author knows the bound they MEANT: re-declare it as a positive integer, or delete it deliberately accepting the unbounding", + "migrationId": "field-max-length-malformed-or-misplaced-refused", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-08-24, tightening both halves — the value's shape and the types the key applies to (enforcement shipped on the 17.x line — accept-set narrowings ride minors, and this entry tells `migrate meta` users at the major boundary; registration was deferred to a follow-up because the registry file was serialized behind an in-flight change when the enforcement landed). Shape: a character length is a positive integer, so the key tightened from `z.number()` to `z.number().int().min(1)` — `maxLength: 0` measurably sent schema-drift planning `varchar(0)` DDL no server accepts, at severity error/destructive, before that consumer was taught to stop reading a malformed bound as authoritative (the house pattern the `precision`/`scale` integer refusal set). Applicability: the key sat on the BASE field schema — authorable on `boolean` / `lookup` / `autonumber`, types where nothing bounded is stored — while the write-time validator (objectql `record-validator.ts`) only ever enforced it on its ten bounded-string types, the one list of the three that had a measured reader; that list is promoted to the protocol as `BOUNDED_STRING_FIELD_TYPES`, the schema refuses the key outside it (ADR-0078 declared=enforced), and both authoring forms (`field.form.ts`, previously 3 types; `object.form.ts`, previously 9) show the key for exactly that set." }, { - "surface": "stack.api.requireAuth", - "to": "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", - "conversionId": "stack-api-require-auth-removed", - "toMajor": 17 + "surface": "object field `minLength` declarations — `minLength: 0`, negative or non-integer values on any type, and the key with any value on field types outside `BOUNDED_STRING_FIELD_TYPES` (`boolean`, `lookup`, `autonumber`, `formula`, `select`, `json`, `secret`, …)", + "replacement": "a positive-integer `minLength` (>= 1) on a bounded-string field type — `text`, `textarea`, `email`, `url`, `phone`, `password`, `markdown`, `html`, `richtext`, `code`, `signature`, `qrcode` (the twelve-member `BOUNDED_STRING_FIELD_TYPES` set; `signature`/`qrcode` joined once the write seam enforced a declared bound on them) — or no declaration at all (\"no minimum\" is expressed by OMITTING the key, never by `minLength: 0`). Deleting the key is mechanical and behaviour-preserving for a MISPLACED declaration (the write-time validator only ever applied `min_length` inside its bounded-string branch, so the key was inert by construction elsewhere) and for `minLength: 0` / negative values anywhere (a string length is never below zero, so the check could not fire). A FRACTIONAL value on a bounded-string type is the judgment case: the validator's raw `<` comparison did consume it (`minLength: 2.5` behaved as \"at least 3\"), so only the author knows the integer they MEANT — re-declare it deliberately if the constraint was wanted", + "migrationId": "field-min-length-malformed-or-misplaced-refused", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-08-25 (option B, the lower bound at 1): `minLength` carried the exact defect pair the 2026-08-24 ruling closed for `maxLength` (`field-max-length-malformed-or-misplaced-refused`), and converges on the same template. Shape: the key was `z.number()`, so `minLength: -5` and `minLength: 2.5` parsed cleanly while describing no character length; it is now `z.number().int().min(1)`. The lower bound is 1 by ruling: `minLength: 0` is refused loudly — a vacuous always-true declaration is exactly the noise an AI metadata author mass-produces, and the refusal surfaces it at authoring time. Applicability: the key sat on the BASE field schema — authorable on `boolean` / `lookup` / `autonumber`, types where nothing bounded is stored — while the write-time validator (objectql `record-validator.ts`) only ever enforced it on the bounded-string set; the schema now refuses it outside `BOUNDED_STRING_FIELD_TYPES` (ADR-0078 declared=enforced), and both authoring forms (`field.form.ts`, previously 3 types; `object.form.ts`, previously 9) show the key for exactly that set." + }, + { + "surface": "object.fields..multiple — an authored `multiple: true` on a field whose `type` is outside MULTI_CAPABLE_TYPES (`select` / `radio` / `lookup` / `user` / `file` / `image`) union MULTI_OPTION_TYPES (`multiselect` / `checkboxes` / `tags`) — e.g. `master_detail`, `tree`, `text`, `boolean`, `datetime`, `avatar`", + "replacement": "a multi-capable type that actually holds several values: `multiselect` / `checkboxes` / `tags` for several option codes, a `lookup` with `multiple: true` for several related records (the replacement for a multi-valued `master_detail` / `tree`), `file` / `image` with `multiple: true` for several attachments — or, where the field really does hold one value, dropping the `multiple` key. `MULTI_CAPABLE_TYPES` and `isMultiValueField` are unchanged, so every field that was ALREADY multi-valued by that predicate keeps its declaration, its storage and its read path verbatim.", + "migrationId": "field-multiple-non-capable-type-refused", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-09-13, option 1′ (the earlier rule refusing an authored `radio` with `multiple: true`, generalised): two definitions of \"multi-valued\" disagreed. `FieldSchema` accepted `multiple: true` on ANY type; driver-sql's `isJsonField` read it raw (`|| !!field.multiple`) and built a JSON ARRAY column; `isMultiValueField` — the spec predicate consumers shape queries from — answered \"not multi-value\" for the same field. A related list therefore composed `=` against a JSON array column and the driver answered the user a 400 (the console's related list pinned the divergence on the consumer side when it began shaping that filter from the spec predicate, and its follow-up recorded the driver half as owed and not filed). There is NO lossless conversion: the column was physically built as a JSON array, so the stored value is an array while the replacement type may want one scalar, several ids, or several option codes — which of those the author meant is a business judgment the chain cannot make. Hence a structured TODO rather than an auto-rewrite (ADR-0087 D3 \"never silence\", ADR-0032 \"no silent failure\"). Population measured at ruling time: 0 in-tree and 0 in HotCRM (shallow clone c716a2c) — every `multiple: true` there is on `lookup` / `select`; re-measured on origin/main 689d606f by AST sweep, still 0. WIDER THAN THE JSON-COLUMN DECISION ALONE: every site in driver-sql that asked `field.multiple` \"is this value multi-valued\" now asks `isMultiValueField` — the DDL writer, the read-side deserializer, the varchar-width mirror, the cross-field comparison class, the four scalar read-coercion registries on both of their fills, the two MySQL temporal-widening candidate sets, and the schema differ. So a stored field in the retired shape also LEAVES the JSON read path and ENTERS the scalar one: its column is no longer deserialized as JSON, the declared-type text-operator gate applies to it, and a `$contains` against it answers the declared no-match instead of a membership test." + }, + { + "surface": "the field-level predicates objects[].fields[].requiredWhen and objects[].fields[].readonlyWhen, and a select option's objects[].fields[].options[].visibleWhen, whose CEL reads THROUGH a reference field (a lookup, master_detail, user or tree field): record.account.tier where account is such a field; likewise previous.account.tier, and parent.account.tier on a master-detail line item whose master declares account. Refused wherever objects are validated as authored: objectstack validate, build and lint over defineStack({ objects }) sources and exported stacks", + "replacement": "the check as a `validations[]` rule of `type: 'script'` — the one predicate the server reads one hop through a reference (the related record is loaded before it runs) — whose `condition` states the FAILURE. For `requiredWhen: P` on field F: P and F empty, e.g. `record.account.tier == 'enterprise' && (record.po_number == null || record.po_number == '')`. For `readonlyWhen: P` on F: P and F changed, on updates only (`events: ['update']`), e.g. `record.account.tier == 'gold' && record.discount != previous.discount`. For an option gated by P: that option picked while P does not hold — the option is then offered to everyone and refused on save. Or read a column the object itself declares (denormalise the related value onto it). A read through `previous` or `parent` has no hydrated seam at all, a validation rule included: read a column the bound record declares instead", + "migrationId": "field-predicate-reference-traversal-refused", + "toMajor": 18, + "rationale": "Triage routed this on 2026-09-25 to remedy A: refuse the traversal at authoring, with a prescription. The field level is never hydrated: `rule-validator.ts` evaluates `requiredWhen` / `readonlyWhen` / an option's `visibleWhen` against the record alone, so a reference there holds the related record's bare id and every read through it faults, on every row. Measured on the engine before this change: a traversing `requiredWhen` refused every insert and every update that reached it, a traversing `readonlyWhen` refused every update that wrote its field (an insert is exempt), and an option gated through a reference was admitted whatever the related record said (option visibility is fail-open) — while `objectstack validate` passed a stack carrying all three, exit 0. ADR-0137 D2 made the runtime fail closed; the defect was that authoring did not say so first (NORTH-STAR priority rule 4). The same traversal inside a `validations[]` `script` rule is served, one hop deep, and stays accepted. ⚠️ No D2 conversion, and the reason is the judgment this entry delegates: moving a field predicate into a validation rule turns a condition into a FAILURE condition, moves an option from hidden to offered-then-refused, and the right `events` scope depends on what the author meant — none of it mechanical. Hydrating the field level instead is a capability of its own and is not done here. ADR-0087, ADR-0137." + }, + { + "surface": "field.reference_to — the legacy runtime spelling of a lookup or master_detail target, on object fields and object-extension fields", + "replacement": "`reference` — the one spelling the field schema has ever accepted, and the one the wire serves.", + "migrationId": "field-reference-to-spelling-retired", + "toMajor": 18, + "rationale": "The D2 conversion `field-reference-to-alias` renames `reference_to` to `reference` in author sources and on every stored-row rehydration, so the wire only ever carries `reference`; for a row with only the legacy spelling the rename is lossless. Two things are left. First, a row carrying BOTH spellings with DIFFERENT targets is left untouched, on purpose: the loader will not pick a target for the author, so that field keeps failing its parse until someone decides which object it points at. Second, code is out of reach: a plugin, script, custom renderer or external client that read `reference_to` off served field metadata worked only because a stored row happened to carry the legacy spelling, and it now reads nothing — the frontend fallback that tolerated the spelling is scheduled to go, after which a missed reader degrades a lookup to a picker with no target. The camelCase `referenceTo` is a different surface (resolved action params) and is not part of this family." + }, + { + "surface": "object field `scale` / `precision` declarations (`Field.number` and friends) — non-integer or negative values (`scale: 2.5`, `precision: -1`)", + "replacement": "a non-negative integer digit count, or no declaration at all. The mechanical conversion (`field-malformed-scale-precision-removed`) deletes a malformed value — behaviour-preserving, because the write-time `scale` enforcement deliberately skipped malformed declarations, so they enforced nothing — but only the author knows the count they MEANT (`scale: 2.5` was probably `2` or `3`): re-declare it deliberately if the constraint was wanted", + "migrationId": "field-scale-precision-integer-refused", + "toMajor": 18, + "rationale": "Both keys are digit COUNTS (\"Total digits\" / \"Decimal places\"), and `z.number()` admitted values with no defined meaning as a count. That looseness became load-bearing when `scale` was made enforced at write time (an over-scale write refused, never rounded): the runtime branch deliberately guards on `Number.isInteger(def.scale) && def.scale >= 0` — inventing floor/round semantics in a consumer would be PD #12 guessing — so a typo'd declaration (`scale: 2.5`) silently got no enforcement at all: exactly the declared-but-inert shape that hides AI-authored metadata errors. The schema now refuses non-integer and negative values for both keys at parse time (`z.number().int().min(0)`, ADR-0078 declared=enforced). `CurrencyConfigSchema.precision` (under `currencyConfig`) was a different surface with its own bounds and alias table — retired in this same protocol major by `currency-config-precision-removed`, not enforced here." + }, + { + "surface": "either endpoint of a $between range, authored BLANK — the empty string, or an absent (undefined) bound — in any filter this platform stores or executes. The carriers split in two, because they are DETECTED differently and only one half answers on save. (a) REFUSED AT SAVE — the enforced FieldOperatorsSchema / RangeOperatorSchema copy itself, reached by a caller that validates a filter against it directly, and the NormalizedFilter AST. (b) NOT JUDGED AT SAVE — every stored metadata carrier, and that is BOTH authoring dialects, not only the loose one. A view, page or component filter RULE (ViewFilterRuleSchema) admits it: the rule value accepts a string and the operator-shape check judges ARITY alone, so a two-element range with a blank element is a well-formed rule. A dashboard widget filter, a dashboard options-source filter, a dataset filter, a dataset measure filter, a report runtimeFilter, a rollup summaryOperations filter and a relatedListFilter are typed FilterConditionSchema, a loose record intersected with the $and / $or / $not shape and carrying one refinement. ⚠️ That refinement DOES judge an operator map, so the slot is not unjudged: a bare date-range PRESET name in an ordering position — a $gt / $gte / $lt / $lte value, or a $between endpoint — is refused at that position's own path, and it is refused through an otherwise GREEN document; the sibling entry filter-preset-ordering-comparand-refused is that rule, on this same carrier set. What these carriers never judge is a $between endpoint for BLANKNESS or for ARITY. Measured: a dashboard widget whose filter reads close_date $between 2026-01-01 and an empty string parses GREEN, as do a one-element, a three-element and an empty $between, while the same widget carrying a preset endpoint is refused at filter.close_date.$between.0. So for THIS shape every one of those documents parses GREEN and the endpoint is refused only when the filter is EXECUTED, at the engine comparand-shape door. ARITY is not what changed: a blank bound is a well-formed TWO-element range one of whose elements means nothing", + "replacement": "two endpoints that are present and non-empty — the bound the author meant, written out. If only ONE side is genuinely bounded, that is not a range at all: drop `$between` and write the side you have as a scalar comparison, `{\"$gte\": min}` for a lower bound and `{\"$lte\": max}` for an upper one, which every backend already answers. ⛔ There is no replacement that can be DERIVED from what was written: the bound the author did not type is not recoverable from the one they did, and picking either reading (drop the operator, or treat the blank side as unbounded) would be the platform inventing a filter. `null` bounds are a different entry: they were already refused by the 2026-08-31 ruling, whose message prescribes the null predicate because a `null` author was reaching for absence, not for a bound", + "migrationId": "filter-between-blank-endpoint-refused", + "toMajor": 18, + "rationale": "Maintainer ruling A of 2026-09-17: a blank `$between` endpoint is refused at the authoring door, and the refusal names the blank side. `FieldOperatorsSchema.safeParse({ $between: [1, ''] })` answered `success: true` — measured on the card against the installed spec 17.4.0 and re-measured on `origin/main` before the change. This is a NEW RULE narrowing a published face, ⛔ not a pull-back to a declared one: the endpoint contract shared by both bounds says verbatim that \"Each endpoint is a number, a Date, or a string\", and the empty string is a string, so the acceptance was conformant. What made it wrong is the other half of the same contract — \"Closed interval [min, max]\" — which no backend can honour against a blank: driver-sql binds it into `whereBetween`, the JS matchers compare it as a value, and the range stops bounding on that side while still reading as a complete range. The reference matcher had already been taught to survive the null-bound form of exactly this (a bounded range answered EVERY valued row, because both of the arm's comparisons are false against a missing bound); the door that admitted it was never addressed. The only producer ever measured is a UI builder padding a HALF-TYPED pair with `''` so that a length-based completeness check passes it — nobody WANTS a blank bound, which is why it is refused rather than given a published meaning (option B was declined: a semantics nobody asked for, to be honoured per driver). The refusal names the blank SIDE (MIN / MAX plus the index) because with a padded pair both bounds are present and the author is the one person who cannot see which is empty. Scope is the empty string and `undefined` and nothing wider: whitespace-only endpoints are deliberately NOT judged, since narrowing a published face further than the ruling is the seat call this card's whole history refuses to make. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). ⚠️ No D2 conversion and no stored-metadata rewrite, and the load path was MEASURED rather than assumed: `applyConversionsToStoredItem` — the one primitive every stored-row rehydration seam calls — never throws and never validates, and replays only the positively-recognised lossless transforms in the conversion registry; measured on `origin/main`, a stored view carrying `{ close_date: { $between: ['2026-01-01', ''] } }` comes back as the SAME object reference. So the load path today neither drops a refused operator nor refuses the row, and no conversion in the registry drops a filter OPERATOR (the three filter-adjacent entries are key strips and a key rename). That is also the precedent the two nearest narrowings of this same surface set — `filter-preset-ordering-comparand-refused` and `analytics-date-range-array-two-bounds-required` — both of which decline a D2 conversion on the ground that rewriting would be the platform guessing which bound was meant. Dropping the operator would be worse than guessing: it deletes a constraint the author wrote and WIDENS the result set silently, the failure mode `$nin` carries in the same file. The read path does not re-validate stored rows, so no stored view becomes unreadable — and, because every stored carrier is typed loosely or judged by arity alone (see `surface`), re-saving one is not refused either. What changes is the enforced operator schema itself, which answers at the endpoint's own path with the blank side named, and the engine comparand-shape door, which refuses an executed filter carrying one. The objectui half — the builder stops padding a half-typed pair, so the console never meets this refusal mid-typing — is a change to the console's own filter builder and lands on its own schedule, either side of this one. ADR-0049 / ADR-0078 / ADR-0087." + }, + { + "surface": "either endpoint of a $between range, authored as a { $field } column reference, in any filter this platform stores or executes. The carriers split in two, because they are DETECTED differently and only one half answers on save. (a) REFUSED AT SAVE — a view, page or component filter RULE (ViewFilterRuleSchema, whose value is shaped by the operator) and the NormalizedFilter AST the query faces validate against, plus the enforced FieldOperatorsSchema copy itself. (b) NOT JUDGED AT SAVE — a dashboard widget filter, a dataset filter, a report runtimeFilter, a rollup filter and a relatedListFilter: every one of those slots is typed FilterConditionSchema, a loose record intersected with the $and / $or / $not shape and carrying one refinement. ⚠️ That refinement DOES judge an operator map, so the slot is not unjudged: a bare date-range PRESET name in an ordering position — a $gt / $gte / $lt / $lte value, or a $between endpoint — is refused at that position's own path, and it is refused through an otherwise GREEN document; the sibling entry filter-preset-ordering-comparand-refused is that rule, on this same carrier set. What these carriers never judge is a $between endpoint for a COLUMN REFERENCE or for ARITY. Measured: a dashboard widget whose filter reads close_date $between { $field: \"contract.start\" } and 2026-12-31 parses GREEN, as do a one-element, a three-element and an empty $between, while the same widget carrying a preset endpoint is refused at filter.close_date.$between.0. So for THIS shape every one of those documents parses GREEN and the endpoint is refused only when the filter is EXECUTED, at the runtime lowering door this change closes. ARITY is not what changed: a reference endpoint is a well-formed TWO-element range one of whose elements no backend resolves", + "replacement": "a literal bound — the value the range was meant to stop at, written out. If the range was genuinely meant to be COLUMN-TO-COLUMN, that is not a $between at all: write the two bounds separately as scalar comparisons, {\"$gte\": {\"$field\": \"a\"}} for the lower bound and {\"$lte\": {\"$field\": \"b\"}} for the upper one, which is the position the column-to-column comparison compiles on every face. ⛔ There is no replacement that can be DERIVED from what was written: the literal a reference stood for is not recoverable, and dropping the operator would delete a constraint the author wrote and WIDEN the result set silently. A reference remains legal, unchanged, as the WHOLE comparand of $eq / $ne / $gt / $gte / $lt / $lte", + "migrationId": "filter-between-field-reference-endpoint-refused", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-08-11 on column-reference range endpoints, ADR-0049 enforce-or-remove: REMOVE. Both $between endpoint unions carried FieldReferenceSchema and no backend ever resolved one in a list position — matches-filter.ts leaves the list unresolved and orders against the raw reference OBJECT, so the range silently matches nothing, and both SQL faces refuse the position with INVALID_FILTER / 400. The published endpoint contract has stated the rule verbatim since that day: \"A { $field } reference is NOT an endpoint shape\" (RANGE_ENDPOINT_DESCRIPTION, packages/spec/src/data/filter.zod.ts). ⚠️ That ruling shipped at the AUTHORING SCHEMA door alone, and no ledger entry was written for it — measured before this change: no semantic entry, no retired key, no spec-changes row and no upgrade-guide line named the shape. That was not an omission, and this entry SUPERSEDES a recorded answer rather than filling a silence: the 2026-08-11 changeset carried the disposition not-required (no-migration-prescription), reviewed and accepted with that ruling and shipped in the published CHANGELOG. What changed is the fact that disposition rested on. It was claimed for a removal whose reach was believed to be the authoring schema alone; the runtime half now ships with a migration prescription of its own (below), and a body carrying a prescription is exactly what that category refuses. So the transition is registered here, covering BOTH doors, and the earlier not-required reading is retired by this record. The runtime lowering door disagreed with the declaration for the whole of that window: parseFilterAST({ f: { $between: [{ $field: \"a\" }, \"M\"] } }) returned the filter unchanged, same object reference, measured on origin/main immediately before the change and re-measured after. One published sentence, two truth values, decided by which door a caller came through — and the door that passed it is the one an embedder reaches by handing a lowered filter straight to a driver. This entry therefore registers the transition for BOTH doors, not only the second, which is why it is filed as an entry of its own rather than as an already-registered rider. ⚠️ No D2 conversion and no stored-metadata rewrite, and the load path was MEASURED rather than assumed: applyConversionsToStoredItem — the one primitive every stored-row rehydration seam calls — never throws and never validates, and replays only the positively-recognised lossless transforms in the conversion registry; measured on this branch, a stored view carrying { close_date: { $between: [{ $field: \"contract.start\" }, \"2026-12-31\" ] } } comes back as the SAME object reference. Rewriting is not available in principle here, not merely declined: the literal the author meant is not recoverable from a reference, and the column-to-column reading has a different OPERATOR SHAPE (two scalar bounds), so producing it would be the platform rewriting one filter into another. That is the same ground the two nearest narrowings of this surface set stand on — filter-between-blank-endpoint-refused and filter-preset-ordering-comparand-refused. The read path does not re-validate stored rows, so no stored view becomes unreadable; what changes is that RE-SAVING one is refused, at the endpoint's own path, with the side named. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). ADR-0049 / ADR-0087 / ADR-0112." + }, + { + "surface": "data.FilterCondition — a comparand the comparand-type face refuses, now refused when the document is PARSED: a plain object where a single value belongs (an $eq, $ne, ordering, text or flag comparand such as { a: 1 }, including a { $field } whose name is not a string), a Map, a class instance, a function, a Symbol, undefined, or a bigint beyond plus or minus 2^53, whether it is the comparand itself, an implicit-equality comparand or an $in / $nin / $between list member. On every schema that carries a FilterCondition, at the reach the save door already had (the field entries of a condition and of every $and / $or / $not member); and, on a dataset filter, a dataset measure filter and now a dashboard widget filter, a report runtimeFilter and a joined report block runtimeFilter, INSIDE a nested-relation condition as well, for these shapes and for every shape the earlier entry names — so ui.DashboardWidget.filter, ui.Report.runtimeFilter and ui.JoinedReportBlock.runtimeFilter gain the nested-relation reach of the two dataset carriers", + "replacement": "a value of one of the six accepted comparand types — a string, number, bigint within plus or minus 2^53, boolean, null or Date — or a { $field: \"column\" } reference where a column is meant. A value set belongs in $in; an absent value is the null predicate ($eq null / $ne null) or an omitted key, never undefined; a bigint beyond 2^53 is compared as a string or within range. Inside a nested relation on a widget filter or a report runtimeFilter, write the same spelling the top-level refusal prescribes. A Date, a { $field } reference, a {placeholder} string resolved at request time (such as {current_user_id} or {today}) and a bigint within 2^53 are untouched, and the save door keeps a bigint as written", + "migrationId": "filter-comparand-types-and-widget-nested-slots-refused-at-save", + "toMajor": 18, + "rationale": "The save door narrows to exactly what the query faces already refuse (the second stage of closing the family of comparand shapes the save door accepted and the query faces refused). The comparand-type face (normalizeFilterComparandTypes, the accepted set the maintainer ruled on 2026-08-12: string, number, bigint, boolean, null and Date) refuses these values on every query: parseFilterAST, the engine seam, the analytics where door and the read-scope compiler all run it. Measured on origin/main 17bd3187 before the change: FilterConditionSchema, a dataset filter, a dataset measure filter, a dashboard widget filter, a report runtimeFilter and a joined report block runtimeFilter each parsed GREEN for { stage: { $eq: { a: 1 } } }, { stage: { $in: [{ a: 1 }] } } and a Map comparand, top level and nested, while the type face and the analytics where door refused each with INVALID_FILTER / 400. And a dashboard widget filter, a report runtimeFilter and a joined report block runtimeFilter parsed GREEN for { acct: { stage: { $in: [\"won\", null] } } } and for a list in a nested equality slot, which the analytics where door refuses when they are charted, because only the two dataset carriers had the nested-relation walk. The save door now asks the type face itself, read-only, after the shape face, so it refuses exactly what the face refuses and passes what it passes; one slot raises one refusal, in the query doors' order (shape, then type, then the flag rule), in the face's own words less its location clause. At the top level of a filter and in its $and / $or / $not members, a second issue on a slot a face already refused (the schema door's own $icontains and date-preset arms) is no longer raised; inside a nested relation on an analytics carrier those two arms still judge the slot beside the faces, so a nested $icontains with a refused comparand, or a nested one-bound $between of a preset name, can carry two issues. Neither moves a verdict. The widget filter and both report runtimeFilters declare the same analytics-carrier filter as the dataset carriers, so their nested-relation slots are judged by the same walk: every stored filter the analytics where door charts now refuses on save what that door refuses on chart. Metadata AT REST is not rewritten and this entry adds no D2 conversion: none of these values has a single honest meaning as a comparand, which is why each was refused. The read path does not re-validate stored rows, so a stored document keeps loading; re-saving it through the metadata protocol (422 INVALID_METADATA), defineStack or os validate is refused at the filter's path. A JSON document can carry only the plain-object cells; the others arrive only from TypeScript authoring. Such a filter has failed every query since the type face's ruling, so the refusal is a repair and not a loss. ADR-0049 / ADR-0087 / ADR-0112." + }, + { + "surface": "data.FilterCondition — an ARRAY as an EQUALITY comparand, at the runtime filter doors (the shared comparand-shape face that parseFilterAST and the engine lowering seam both run): the implicit form { field: [...] } — which the FilterArray sugar [\"field\", \"equals\", [...]] lowers to, and likewise \"=\", \"==\" and \"eq\" — and the explicit form { field: { $eq: [...] } }, at any depth under $and / $or / $not, the empty array included", + "replacement": "the operator the list was standing in for. \"One of these values\" is $in: { field: { $in: [\"a\", \"b\"] } } (authoring spelling \"in\"). \"The stored multi-value field holds this value\" is $contains with ONE member: { field: { $contains: \"a\" } } (authoring spelling \"contains\"), and an $or of those for any-of. A filter that meant a single value writes that value: { field: \"a\" }. The list operators ($in / $nin / $between) keep their arrays, empty lists included; every scalar equality comparand, null above all (the has-no-value predicate), is untouched; and $ne is NOT judged by this entry", + "migrationId": "filter-equality-array-comparand-refused", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-09-23 (option 乙 — rather than declaring an array equality the SQL-family backends would have to invent, or documenting a divergence that stays silent on one backend): an array in the implicit-equality slot is refused at the shared face, for every driver at once — no alias, no grace window. The comparand-shape face declared that moving a rule to it 「closes that door for every driver at once」, and before this change it judged only the list-operator slot; the equality slot passed both shared doors and each backend answered it alone. Measured on the lowered node { tags: [\"a\"] } at this release, beside a scalar and an $in control. driver-sql on SQLite REFUSED it with INVALID_FILTER / 400 at the top level, and nested under $and / $or / $not answered 500 DATABASE_ERROR instead (driver-turso and driver-sqlite-wasm are built on driver-sql and were not run separately). driver-memory REFUSED it with INVALID_FILTER / 400 at every depth. The formula matcher returned no row, including a row storing exactly [\"a\"]. driver-mongodb ANSWERED it: its translateFilter emits the array unchanged, and MongoDB equality on an array operand selects a stored array equal to [\"a\"] or holding [\"a\"] as an element — mingo 7.2.4, the named proxy, over [\"a\"], \"a\", [\"a\",\"b\"], [\"b\",\"a\"], [[\"a\"],\"x\"], [[\"a\"]], \"b\" and [] selected [\"a\"], [[\"a\"],\"x\"] and [[\"a\"]]. The service-analytics filter normalizer read the FilterArray form as MEMBERSHIP: [\"stage\", \"=\", [\"won\", \"lost\"]] charted as stage IN (won, lost), and its OBJECT form read the same list four ways: { stage: [...] } as IN, $eq with a list as its first member alone, $eq with an empty list as no predicate, and an empty implicit list as the FALSE constant. A live mongod, MySQL, PostgreSQL and a live Turso server were NOT measured. So one stored filter was a 400 on most backends and a silent, differently-shaped row set on one. The shared face now refuses it with INVALID_FILTER / 400 before any driver runs, naming the field, the path and both remedies. Which doors refuse it at this release, and with what: the shared face, inside parseFilterAST and at the engine lowering seam, with INVALID_FILTER / 400; the analytics where door in BOTH spellings, the FilterArray form through parseFilterAST and the OBJECT form because that door hands each equality-slot list to the shared face before it builds a node, with the same INVALID_FILTER / 400 and the same sentence (that door alone, among the runtime doors, also refuses a list inside a nested-relation condition, which it flattens to a dotted member); and, on SAVE, the schema door (FilterConditionSchema and the $eq operator slot), with the same sentence as a parse issue at the filter's own path, which is the sibling entry filter-equality-array-comparand-refused-at-save, plus the two carriers that analytics door charts (a dataset filter and a measure filter) inside a nested relation too, which is dataset-filter-nested-relation-equality-array-refused-at-save. The ruling records the hosted product as running on the SQL family, where the top-level shape was already a 400, so the population that can observe a change is self-hosted driver-mongodb, plus any filter nested under a combinator on the SQL family (a 500 becomes a 400). $ne carrying an array measured the same split and is deliberately left to its own ruling. Metadata AT REST is not rewritten and this entry adds no D2 conversion: an array on equality has no single honest value, and choosing between $in and $contains is the author's call, not the platform's. ADR-0049 / ADR-0087 / ADR-0112." + }, + { + "surface": "data.FilterCondition and the $eq slot of data.FieldOperators — an ARRAY as an EQUALITY comparand, now refused when the document is PARSED: the implicit form { field: [...] } and the explicit form { field: { $eq: [...] } }, the empty array included, on every schema that carries a FilterCondition — a dataset filter and a dataset measure filter, a dashboard widget filter and an options-source filter, a report and joined-report-block runtimeFilter, a field relatedListFilter and a rollup summaryOperations filter, a solution-blueprint summary filter, an analytics query where, a dataset selection runtimeFilter, a query where and having, the data-engine aggregate call's having, an aggregation filter and a query-filter where — plus FieldOperatorsSchema.$eq, its documentation copy EqualityOperatorSchema.$eq, and the NormalizedFilter AST that validates against it", + "replacement": "the operator the list was standing in for, exactly as in filter-equality-array-comparand-refused. \"One of these values\" is $in: { field: { $in: [\"a\", \"b\"] } } (authoring spelling \"in\"). \"The stored multi-value field holds this value\" is $contains with ONE member: { field: { $contains: \"a\" } } (authoring spelling \"contains\"), and an $or of those for any-of. A filter that meant a single value writes that value: { field: \"a\" }. The list operators ($in / $nin / $between) keep their arrays, empty lists included; every scalar equality comparand, null above all, a Date and a { $field } reference are untouched; and $ne is NOT judged by this entry", + "migrationId": "filter-equality-array-comparand-refused-at-save", + "toMajor": 18, + "rationale": "Ruled on 2026-09-24 (option A), applying the standing refusal of an array in the equality slot to the schema door: FilterConditionSchema (implicit equality) and FieldOperatorsSchema.$eq refuse an array comparand at parse, with the SAME remedy text the shared compile face emits — one constant, two doors; a stored filter carrying the shape is refused loudly on its next save, and never silently dropped, because a dropped filter shows MORE rows than intended. Measured on origin/main a0920b42dc before the change: a dataset whose filter was { stage: [\"won\", \"lost\"] }, and one whose measure filter was { stage: { $eq: [\"won\", \"lost\"] } }, both parsed GREEN, as did FilterConditionSchema and FieldOperatorsSchema on the bare shapes, while the shared comparand-shape face refused both with INVALID_FILTER / 400. So such a document published clean and then failed every query that used it, for a different person, later. The schema door now prints the face's own sentence, from one builder both doors import; the only difference is that the face appends the location (at where.stage) and the schema door does not, because its issue carries the location as its path (filter.stage, measures.0.filter.stage.$eq). The reach is the face's and no wider: the field entries of a condition and of every $and / $or / $not member, but NOT a field spec with no $ key (a nested-relation or deep-equality condition), which the face never descends either. ⚠️ Three positions therefore still refuse only at execution. (1) A list inside a nested-relation condition, { account: { region: [\"a\"] } }: the analytics where door flattens that to the dotted member account.region and refuses it when the filter is charted; a dataset filter and a measure filter refuse it on save as well, which is the sibling entry dataset-filter-nested-relation-equality-array-refused-at-save. (2) The where option of the data-engine calls (find, count, update, delete, aggregate, vector find): its type is a union whose first arm is an open record, so it parses and the face refuses it when the call runs. (3) $ne carrying a list, which no ruling has decided. Two request doors parse these carriers and now answer the shape before the analytics compiler does: the REST dataset selection (its runtimeFilter) and the analytics query body (its where) refuse with VALIDATION_FAILED / 400 and this sentence at the field, one step ahead of the compiler's INVALID_FILTER / 400. Metadata AT REST is not rewritten and this entry adds no D2 conversion, for the reason the runtime entry gives: an array on equality has no single honest value. The read path does not re-validate stored rows, so a stored document keeps loading; what changes is that re-saving it through the metadata protocol (422 INVALID_METADATA), defineStack or os validate is refused at the filter's path. Such a filter has failed every query since the runtime entry, and on the SQL family before it at the top level, so the refusal is a repair and not a loss. ADR-0049 / ADR-0087 / ADR-0112." + }, + { + "surface": "the case-insensitive contains comparand, in BOTH authoring vocabularies — the $ dialect key $icontains inside FilterConditionSchema (query where clauses, read-scope rules, dashboard and analytics filters) and the infix spelling icontains on ViewFilterRuleSchema (view, tab, page and block filters) — where the comparand is the EMPTY STRING or is not a string at all", + "replacement": "a NON-EMPTY STRING, or no condition at all. A comparand that was empty is a predicate that constrains nothing, so the repair is to DROP the condition rather than to write something in it. A comparand that was a number, boolean or null is written as the string it was meant to match: value 42 becomes value \"42\" only if a substring match on the two characters is really what was meant, and if it is not, the operator was the wrong one. On a view rule an OMITTED value is untouched — absence is not a comparand and this rule says nothing about it", + "migrationId": "filter-icontains-comparand-refused-at-parse", + "toMajor": 18, + "rationale": "The protocol half of the maintainer's 2026-09-20 ruling (option C-prime) on the console's filter converter, whose first rule reads, verbatim and untranslated: 「the differences are the protocol's to close」. The platform already DECLARED both refusals, as data, in this package: FILTER_TEXT_CASES carries a REJECTION row for an empty comparand and one for a non-string comparand, each with code INVALID_FILTER and each requiring the refusal to name the operator. All five driver packages run both rows in their own suites, and the drivers re-run for this change (driver-sql on SQLite, driver-memory, driver-mongodb's translateFilter) each refuse both comparands with INVALID_FILTER / 400; the formula matcher does not refuse them, it answers false for every row. Nothing applied them at PARSE on either vocabulary, so the protocol declared the refusal and then admitted the document that would hit it — the declared-not-enforced shape ADR-0049 exists to close. The narrowing is DERIVED from the table, not transcribed beside it: both doors call the published predicate isRefusedTextComparand and the published reason text textComparandRefusalReason, the pair published in this package beside FILTER_TEXT_CASES for exactly this reason, so a row added to the table reaches both doors without an edit at either. $contains, $startsWith, $endsWith, $like and $ilike keep the answer they give today, because widening by analogy is the table's decision and not a door's. The two vocabularies differ on one point and it is a fact about them rather than an extra rule: a view rule's value key is OPTIONAL, so an absent comparand is left unjudged there; the $ dialect has no absent, so an explicit undefined in a comparand slot is the refused non-string shape — the same reading the comparand-type door already takes of that cell. Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion. An empty comparand has no lossless replacement (dropping a condition changes which rows a view returns, which is the author's decision) and a non-string one has no honest coercion (the platform refuses to answer a query nobody wrote). The read path does not re-validate stored rows, so a stored filter keeps loading; what changes is that RE-SAVING it is refused, with the reason text three shipped consumer faces already show at query time. ADR-0049 / ADR-0087 / ADR-0112." + }, + { + "surface": "data.FilterCondition — the lowering of the view operators is_empty, isempty, is_not_empty and isnotempty (on a ViewFilterRule, a sharing rule and any filter array), and a $empty object written as a record field value", + "replacement": "Nothing to rewrite for a rule on a declared field: is_empty now lowers to { field: { $empty: true } } and is_not_empty to { field: { $empty: false } }, answered by the field's declared type — a text-like field is empty when it is null or the empty string, a multi-value field when it is null or the empty list, every other type only when it is null. Where no face holds the column's declared type the rule is refused: on the built-in id, write is_null / is_not_null; on a federated object whose driver does not implement external-object registration (driver-memory, driver-mongodb), bind it on a driver that implements federation (the boot error names the object); on an AnalyticsService built without sourceFieldMeta, pass sourceFieldMeta or write is_null / is_not_null; on a multi-value column over a SQL dialect driver-sql does not model, write is_null / is_not_null. A record write that carries a $empty object as a field value writes the value itself instead; a filter belongs in where", + "migrationId": "filter-is-empty-lowers-to-empty-operator", + "toMajor": 18, + "rationale": "One ruling set what 「is empty」 means once, per field type; a second spelled it as the $empty operator, which each compile face expands from the field's declaration. It was staged out of FILTER_OPERATORS until every face answered it, then added in the same change that flipped the lowering, after measuring that no face drops it. Two consequences reach stored metadata. A stored 「is empty」 on a text or multi-value field finds more rows: the ones holding the empty string or the empty list, which the $null lowering missed. And the rule is refused where the face that answers it holds no declaration for the column — the four compositions the replacement names — where the $null lowering compiled IS NULL. The same change made the write door refuse a $empty object as a field value, because that door refuses every filter operator the protocol enforces as a value: before, a text-like field stored it. Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: the stored spelling is unchanged, and its new meaning is the ruled one. ADR-0087 / ADR-0112." + }, + { + "surface": "data.FilterCondition and the $ne slot of data.FieldOperators — an ARRAY as the comparand of $ne. At the runtime filter doors (the shared comparand-shape face that parseFilterAST and the engine lowering seam both run): { field: { $ne: [...] } }, which the FilterArray sugar [\"field\", \"ne\", [...]] lowers to, and likewise \"!=\", \"<>\", \"neq\", \"not_equals\" and \"notequals\", at any depth under $and / $or / $not, the empty array included. At parse: FieldOperatorsSchema.$ne, its documentation copy EqualityOperatorSchema.$ne, and the NormalizedFilter AST that validates against it", + "replacement": "the declared list-negation operator. \"None of these values\" is $nin: { field: { $nin: [\"a\", \"b\"] } } (authoring spellings \"nin\", \"not_in\", \"notin\"). A filter that meant a single value writes that value: { field: { $ne: \"a\" } }. $ne: null (the has-a-value predicate), every scalar, a Date and a { $field } reference are untouched, and the list operators ($in / $nin / $between) keep their arrays, empty lists included", + "migrationId": "filter-ne-array-comparand-refused", + "toMajor": 18, + "rationale": "Ruled on 2026-09-24 by the director seat, on the standing contract text (option A): the shared comparand-shape face refuses an array under $ne for every driver, and FieldOperatorsSchema.$ne refuses it at parse, with one remedy text naming the declared list-negation operator by its spec spelling — no alias, no window. The governing text is $ne's own published describe: the comparand is a literal, or a { $field } reference to another column of the same table. An array is neither, so the refusal pulls the doors back to what $ne already declared. Measured on the card before any stage landed, on the lowered { tags: { $ne: [\"a\"] } }: driver-sql and driver-memory REFUSED it with 400; driver-mongodb ANSWERED it as MongoDB reads $ne against an array operand, not equal to that array and not holding it as an element, which is every scalar row (mingo, the named proxy; a live mongod was NOT measured); and the formula evaluator matched EVERY row, which on the row-level write check admitted every write a != policy against a list was written to refuse. Those two answering faces were closed first, each at its own face, under rls-predicate-array-comparand-refused and cel-predicate-list-comparand-refused. Measured on origin/main 9e7824a4, after both and before this change: the shared face passed the shape at every depth (so did its FilterArray lowering, and the engine's delegating wrapper), and FieldOperatorsSchema, EqualityOperatorSchema and the NormalizedFilter AST all parsed it GREEN. Now the face refuses it with INVALID_FILTER / 400 before any driver runs, and the operator slot refuses it on parse, with one sentence from one builder: the face names the field and appends the location (at where.tags.$ne); the slot cannot see either, and its issue carries the location as its path. On the SQL family and driver-memory the verdict does not move (400 before, 400 after); the text and the moment move, to the face, before any driver. ⚠️ Not moved by this entry: FilterConditionSchema, the schema every stored filter carrier parses through (dataset, dashboard widget, report, rollup and the rest), does not parse a field's operator map through FieldOperatorsSchema and its own walk does not judge $ne, so such a carrier still SAVES a $ne list and the face refuses it at query time; the ruling names the face and the operator slot, not that walk. Metadata AT REST is not rewritten and this entry adds no D2 conversion: a list under $ne has no single honest value, and whether it meant none of these values or one value is the author's call. ADR-0049 / ADR-0087 / ADR-0112." + }, + { + "surface": "a dashboard date-range preset name (last_7_days / last_30_days / last_90_days, today, yesterday, this_week, last_week, this_month, last_month, this_quarter, last_quarter, this_year, last_year) authored as a bare ORDERING comparand in a filter — a $gt / $gte / $lt / $lte value or a $between endpoint, a greater_than / less_than / before / after / between view filter rule value, or an ordering [field, op, value] filter triple. WHICH DOOR refuses it at publish is decided by the carrier's declared type and by its key. The carriers measured fall in groups, and the groups are a list of what was measured, not a closed partition: the grep in the acceptance criteria is the catch-all. (1) A slot typed FilterConditionSchema — a dashboard widget filter, a dashboard global-filter options-source filter (optionsFrom.filter), a dataset filter, a dataset measure filter, a report runtimeFilter (on the report or on a joined-report block), a rollup summaryOperations.filter and a relatedListFilter — is refused at PARSE, at the comparand's own path, and the @objectstack/lint filter-preset-comparand rule reports it as well. (2) A filter under a key the lint walks, whose declared type carries no preset check, parses GREEN, and the lint rule is the only door that refuses it: a ViewFilterRuleSchema rule array (a view's filter, a page element's dataSource.filter, a page component's filter prop), and a Mongo-shape filter record typed as a loose record rather than FilterConditionSchema (a flow CRUD node's config.filter). The lint is likewise what refuses a preset in an ordering filter triple wherever its walk meets one", + "replacement": "the date-macro window the preset already means — { $gte: \"{30_days_ago}\" } for last_30_days, { $between: [\"{week_start}\", \"{week_end}\"] } for this_week, and so on (the rejection names the exact window per preset; DATE_RANGE_PRESET_MACRO_WINDOWS in @objectstack/spec/data is the table) — or an ISO date such as 2026-01-15. The preset names themselves stay fully legal where a layer resolves them to a window: the dashboard date-filter positions (dateRange.defaultRange, a date global filter defaultValue) and an analytics query's timeDimensions[].dateRange. A filter comparand is not one of those positions", + "migrationId": "filter-preset-ordering-comparand-refused", + "toMajor": 18, + "rationale": "The authoring half (option C) of the maintainer's 2026-08-15 ruling on uninterpretable temporal comparands, ruled alongside the engine door (option B) that refuses them at query time. The preset vocabulary is declared in the dashboard schema and lowered to {date-macro} bounds by the shipped console before any query is sent — so the names were declared in one layer and unrecognised in the next, with no error at the boundary. Authored as a bare comparand (a saved report, an integration, an MCP client, an AI-authored query), the name reached the driver as written and compared false against every row: HTTP 200, count 0, indistinguishable from \"there is no data\" (measured on the defect report: $gte \"last_30_days\" returned 0 of 51 seeded rows where the macro spelling returned the 38 in-window). The engine now refuses the bare name on a declared temporal field at query time (INVALID_FILTER / 400); this entry records the AUTHORING-time half, and that half is two doors with different reach, not one: the FilterConditionSchema parse refuses the shape on the slots typed that way, and the @objectstack/lint filter-preset-comparand rule refuses it on every filter its walk reaches, which makes it the only door for a walked filter whose declared type carries no preset check. Both answer at publish, where the author — an AI author in particular — can still act on the message; the surface's groups say which measured carrier sits under which door. Ordering positions only at the schema door, deliberately: it judges no equality or membership, because a select/picklist column legitimately stores values that collide with preset names and a schema has no field type in hand. The lint rule, which reads the stack's object metadata, additionally refuses a preset in an equality or membership position, in a filter its walk reaches, on a field it can resolve to a declared date or datetime (where the filter binds to no object, or the field resolves to nothing, that arm cannot fire), and on a temporal field the engine door already refuses those with the field type in hand. ⚠️ Metadata AT REST is deliberately not rewritten and there is no D2 conversion: this shape was never written by any first-party producer (every preset in this repo and the example apps sits in a dashboard date-filter position — measured) and never executed usefully (it returned a silent zero before the engine door and a 400 after). Coercing it at load would be the platform guessing which bound the author meant. The read path does not re-validate stored rows, so no stored dashboard becomes unreadable; what changes is that RE-SAVING one is refused with the window named. ADR-0049 / ADR-0078 / ADR-0112." + }, + { + "surface": "data.FilterCondition — every comparand slot the query faces refuse, now refused when the document is PARSED: a $null or $exists flag that is not a boolean (a string such as \"false\", null, a number); a null $gt / $gte / $lt / $lte comparand; an $in or $nin comparand that is not a list, or a list holding null; a $between comparand that is not a two-element list, or whose endpoint is null, blank or a { $field } reference; and an array under $ne. On every schema that carries a FilterCondition: a dataset filter and a dataset measure filter, a dashboard widget filter and an options-source filter, a report and joined-report-block runtimeFilter, a field relatedListFilter and a rollup summaryOperations filter, a solution-blueprint summary filter, an analytics query where, a dataset selection runtimeFilter, a query where and having, the data-engine aggregate call's having, an aggregation filter and a query-filter where; and, on a dataset filter and a dataset measure filter only, the same slots INSIDE a nested-relation condition", + "replacement": "the spelling the refusal prescribes, which is the one the query faces already prescribe. A flag is the boolean itself: $null true is \"has no value\", $null false is \"has a value\", and $exists is the inverse. Absence is the null predicate, never null in an ordering or list position: $eq null is \"has no value\", $ne null is \"has a value\", and \"one of these values OR has no value\" is an $or of an $in and a $null true. A single value for $in is a one-member list, or plain equality. A range is two bounds in a two-element list; a range bounded on one side is a $gte or a $lte; a column-to-column range is a $gte and a $lte whose comparands are { $field } references. \"None of these values\" is $nin, never $ne with a list. The null predicate itself, a { $field } reference as a whole comparand, an empty $in or $nin list and a whitespace endpoint are untouched", + "migrationId": "filter-query-face-comparands-refused-at-save", + "toMajor": 18, + "rationale": "The save door narrows to exactly what the query faces already refuse (the family of comparand shapes the save door accepted and the query faces refused; the $ne member is route A, the same reach and the same one sentence as the equality slot of filter-equality-array-comparand-refused-at-save). The shared comparand-shape face refuses on every query a null ordering comparand (ruled 2026-09-01), a non-list $in / $nin and a malformed $between range, a null list member or endpoint (ruled 2026-08-31), a blank endpoint (ruled 2026-09-20), a { $field } endpoint (ruled 2026-08-11) and an array under $ne (ruled 2026-09-24); every query face refuses a non-boolean $null / $exists flag, because the backends read one in opposite directions. Measured on origin/main af32cf9a before the change: a dataset filter, a dataset measure filter, a dashboard widget filter and a report runtimeFilter each parsed GREEN for one instance of every shape the surface names, while the face refused each one with INVALID_FILTER / 400 and the analytics where door refused every one of them, the flags included. So such a document published clean and then failed every chart built on it. The save door now asks the face itself about each slot, so it refuses exactly what the face refuses and passes what the face passes; the words are the face's, or the sentence the enforced operator slot already prints for the same comparand, never the face's location clause, which the issue's path carries instead. The reach is the face's and no wider: the field entries of a condition and of every $and / $or / $not member, and NOT a field spec with no $ key (a nested-relation condition), which neither the face nor the drivers' flag checks descend. The analytics where door DOES descend one (it flattens the relation to dotted members and judges each), so the two dataset carriers, whose own nested-relation walk already refused an equality list there (dataset-filter-nested-relation-equality-array-refused-at-save), now ask the same judge about every slot inside a relation. ⚠️ So one position still refuses only at execution: a refused shape INSIDE a nested-relation condition on a dashboard widget filter or a report runtimeFilter, which reach the analytics where door too but carry the shared schema's reach only. Metadata AT REST is not rewritten and this entry adds no D2 conversion: none of these shapes has a single honest meaning (that is why each was refused), and a conversion would have to pick one. The read path does not re-validate stored rows, so a stored document keeps loading; re-saving it through the metadata protocol (422 INVALID_METADATA), defineStack or os validate is refused at the filter's path. Such a filter has failed every query since the runtime refusal of its shape, so the refusal is a repair and not a loss. ADR-0049 / ADR-0087 / ADR-0112." + }, + { + "surface": "a STORED filter body the engine executes, where a text operator names a field whose declared type can never store a string. Measured carriers: `sys_saved_report.query_json.filter` (executed verbatim as `engine.find(object, { where: q.filter })`, and reached again by every `sys_report_schedule` row through its `report_id`), `FieldSchema.summaryOperations[].filter` (ANDed with the parent-FK match and handed to `engine.aggregate`), `ListView.filter` and tab filters (`ViewFilterRuleSchema`, whose `contains` / `not_contains` / `icontains` / `starts_with` / `ends_with` spellings lower to the same operators through `AST_OPERATOR_MAP`), and the `FilterConditionSchema` carriers on dashboards (widget `filter`, `GlobalFilter`), datasets and reports (`runtimeFilter`), plus `FieldSchema.relatedListFilter`. NOT this surface: an RLS / sharing / tenant predicate, which the platform composes onto the AST AFTER this door and which the door therefore never judges.", + "replacement": "compare the field with an operator its declared type can answer — `$eq` / `$ne` / `$in`, or a range (`$gte` / `$lt`) for a temporal or numeric field — or aim the text operator at a text-valued field instead. A dotted path into a structured-JSON field (`address.city`) stays legal and is deliberately unjudged. NO rewrite is mechanical: the author's intent is not recoverable from the stored condition — `{ amount: { $contains: '5' } }` may have meant `$eq: 5`, a range, or a filter on a different column altogether — so the loader must not choose one.", + "migrationId": "filter-text-operator-declared-type-refused", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-09-05 (option C-deny: refuse now, over the type sets the contract already declares, minting no new vocabulary), landed at the engine seam. A text operator (`$contains` / `$notContains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike`) over a field whose DECLARED type can never store a string — `NUMERIC_VALUE_TYPES` ∪ `BOOLEAN_VALUE_TYPES` ∪ `CALENDAR_DATE_TYPES` ∪ `INSTANT_TYPES` ∪ `CLOCK_TIME_TYPES` ∪ `STRUCTURED_JSON_TYPES` — is refused at the engine's field-aware door with `INVALID_FILTER` 400 instead of reaching a driver. It is a RUNTIME narrowing over an AUTHORED surface, which is why it is registered here rather than disposed of as needing no prescription: NO schema changed, so a stored filter carrying the refused shape still parses and still loads — `FilterConditionSchema` constrains no field type, and `ViewFilterRuleSchema` takes `field: z.string()` with `contains` in its operator enum — and the first sign of it is a 400 on the read that executes it. Before the door those reads answered `[]` (or every row for `$notContains`, or a SQLite coercion accident) with no diagnostic, which is the silent cell the ruling closed. `objectstack migrate meta` cannot repair the stored bodies for the reason `replacement` records, so this is a structured TODO rather than a graduated conversion." + }, + { + "surface": "an approval flow node whose config the approval node contract (ApprovalNodeConfigSchema) refuses — a key it does not declare (escalation.bogusKey, a top-level key such as steps or onApprove, an alias such as escalation.timeout), a value it refuses (escalation.timeoutHours below 1, an unknown behavior or escalation.action, an empty approvers list, a fallbackApprovers list under any policy but fallback), or a key it requires left out (approvers; escalation.timeoutHours inside an escalation block). Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved from the Studio flow designer, and a flow row already sitting in sys_metadata", + "replacement": "the shape the approval contract declares, written on the node's `config`: `approvers` with at least one approver, and inside an `escalation` block a `timeoutHours` of at least 1 (wall-clock hours; `timeoutHours: 1` is the shortest SLA the contract accepts). An undeclared key is renamed to the key the refusal's did-you-mean names (`timeout` → `timeoutHours`, `mode` → `behavior`, `quorum` → `minApprovals`) or deleted; a process-level key (`steps`, `entryCriteria`, `onApprove`, `onReject`, `rejectionBehavior`) moves onto the flow graph as the refusal's guidance says. To turn an SLA off, delete the whole `escalation` block — an `escalation: { enabled: false }` with no `timeoutHours` is refused like any block missing it", + "migrationId": "flow-approval-node-config-contract-refused", + "toMajor": 18, + "rationale": "An approval node's executor (`plugin-approvals`) parses `node.config` against `ApprovalNodeConfigSchema` before it does anything else and fails the node on ANY issue. Registration already refused an undeclared key, against the descriptor's published `configSchema`, but a refused value (`timeoutHours: 0.5`) registered and then failed every run that reached the node — the config is metadata, and no rerun could succeed. The build doors asked about neither: `FlowSchema.parse` judged only the builtin node types' executor contracts, and only for a key left out, so `objectstack validate` and `objectstack compile` exited 0 on an `escalation.bogusKey` or a `timeoutHours: 0.5` and compile copied it into the artifact. The contract is the spec's own, so the build can judge it with no plugin loaded: the approval node joins a declared contract map beside the builtin executor contracts, read by the one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share (`flowNodeConfigRefusals`), and is judged WHOLE — every issue the contract raises is refused, because the executor refuses on every one. An undeclared key or a refused value is `node-config-refused-by-contract`, anchored at the key, in the contract's own sentence (its did-you-mean included); a key left out keeps `node-config-key-missing` or `node-config-key-required-by-rule`. The builtin arm is unchanged and stays presence-only. A plugin node type whose contract the spec does not declare stays outside the build doors, as before. ⚠️ No D2 conversion: the platform cannot know the approvers, the key or the value the author meant, and no value it could write would keep what the flow did. ⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0019." + }, + { + "surface": "a get_record, create_record, update_record, delete_record, notify, http, screen, map, loop or parallel flow node whose config carries a key its executor contract does not declare — a typo (titl), a key the walk at registration already named (fieldValues on a write node, bulk on update_record, visibleIf on a screen field), a key copied from another node type (outputVariable on an http node, flowName on a loop), or a key nothing reads (bogusKey) — at the config itself, or on a screen field or one of its options, a body-less legacy loop included. Never a key inside a free-form map (a filter, fields, headers, defaults, input, payload or templateData key is author data), never a key on a region object (a loop body, a parallel branch) or on its nodes and edges (the region check at registration owns those), and never a try_catch key, which try-catch-and-retry-policy-undeclared-keys-refused covers once the retry policy closed. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved from the Studio flow designer, and a flow row already sitting in sys_metadata", + "replacement": "the key the contract declares, or no key: rename a typo to the declared key it meant (the refusal carries the contract's did-you-mean for a near miss), follow the contract's own prescription for a known slip (`fieldValues` → `fields`, `bulk` / `all` / `multiple` → `multi: true`, `options: { multi }` → a top-level `multi`, a screen field's `visibleIf` → `visibleWhen`, a loop's `itemVariable` → `iteratorVariable`), and delete a key nothing reads (an `http` node's `outputVariable` among them: the http executor binds no output variable)", + "migrationId": "flow-builtin-node-config-undeclared-keys-refused", + "toMajor": 18, + "rationale": "Each of these executors (`service-automation` `builtin/crud-nodes.ts`, `notify-node.ts`, `http-nodes.ts`, `screen-nodes.ts`, `map-node.ts`, `loop-node.ts`, `parallel-node.ts`) parses the node's `config` against a strict contract before it acts. Until now the build doors' executor-contract arm held key membership back on these types, on the premise that registration judges it: `registerFlow`'s undeclared-key walk (`validateNodeConfigKeys`) refuses such a key against the node type descriptor's `configSchema`. So a `notify` node carrying `bogusKey` passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile` (which copied it into the artifact), and then registration refused the whole flow: at boot it was skipped with a warn, and a flow saved from Studio was stored and then silently not registered. The one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first), `objectstack validate` and the metadata save door share (`flowNodeConfigRefusals`) now refuses such a key on these types as `node-config-refused-by-contract`, anchored at the key, one refusal per key, in the contract's own words and closed with the rename-or-remove remedy, and the descriptor walk stands aside for every type that judge covers (`builtinNodeConfigKeysJudged`), so each type has one judge. Measured before the move: on each of these types the descriptor's declared key sets, at every position the walk descends to, equal the keys the contract accepts there, so registration refuses exactly what it refused before. ⚠️ `try_catch` is the one builtin not moved here: its contract's `retry` was the shared `RetryPolicySchema`, which stripped an unknown key, while its descriptor closes `retry` to five keys. It moves in `try-catch-and-retry-policy-undeclared-keys-refused`, once that schema closed. ⚠️ A body-less legacy `loop` is not parsed at run time, and it is judged here on key membership alone, which is what registration refused there already. ⚠️ A spelling an ADR-0087 D2 conversion still rewrites at load (`object` and `filters` on a CRUD node, `to` / `subject` / `body` / `url` on a `notify`, `flow` on a `map`) is converted before the judge at every door that converts first; met by a direct `FlowSchema.parse` or `defineFlow()` it is refused like any other undeclared key. ⚠️ No D2 conversion: the platform cannot know what an undeclared key was meant to be. ⚠️ Where such a node already sits the whole flow is refused, as registration already refused it: from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load; a save from Studio answers 422 naming the key. ADR-0087, ADR-0031." + }, + { + "surface": "a builtin flow node (get_record, create_record, update_record, delete_record, notify, http, screen, script, subflow, map, loop, parallel, try_catch) whose config carries a value its executor contract refuses — a value of the wrong type (create_record outputVariable 42, a screen field min written as the string 1, get_record limit as a string, update_record multi as a string), a value outside the declared set or range (notify severity loud, screen mode view, loop maxIterations 0, try_catch retry.maxRetries above 10), an empty script function or subflow flowName, or a rule finding on present keys (a notify template beside an inline title). Never a value carrying a token in braces, an undeclared or retired key, a region slot, or an http signingSecret. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved from the Studio flow designer, and a flow row already sitting in sys_metadata", + "replacement": "the value the contract declares, written at the key the refusal names: a string where it wants a string (`outputVariable: 'taskId'`), a number where it wants a number (`min: 1`, `limit: 10`, `maxIterations: 5`, `timeoutMs: 5000`), a boolean where it wants a boolean (`multi: true`, `durable: true`), one of the declared values (`severity: 'warning'`, `mode: 'edit'`), or a value inside the declared range. Outside `http`, a number or boolean slot takes a LITERAL only: those executors parse the config as authored, so a `{token}` template there (`limit: '{page.size}'`, `maxIterations: '{cap}'`) passes the build doors and still fails every run. Only `http` interpolates its config before it parses, so only an `http` slot may also take a sole-token template that resolves to the declared type (`timeoutMs: '{timeout}'`, `durable: '{durable}'`). For a rule finding, follow the rule's own sentence (keep `template` or the inline `title` / `message`, not both)", + "migrationId": "flow-builtin-node-config-values-refused", + "toMajor": 18, + "rationale": "Every builtin executor (`service-automation` `builtin/`) parses its node's `config` against the contract `getBuiltinNodeConfigContracts()` names before it acts, and refuses the node on any finding. The build doors judged only the keys that contract requires, left out, so a present value it refuses — `create_record` `outputVariable: 42`, a screen field `min: '1'` (the shape the Studio designer used to store for a field's Min / Max) — passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile`, registered, and then failed every run that reached the node: the config is metadata, and no rerun could succeed. The one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share (`flowNodeConfigRefusals`) now refuses such a value as `node-config-refused-by-contract`, anchored at the key, in the contract's own words — the code the approval contract already uses. It judges only what the build can know the run will parse, and holds one more class back by ruling: a value carrying a `{token}` is never refused at the build doors for its pre-interpolation type — which is no promise it runs, since every builtin but `http` parses its config as authored and so still refuses a token in a number or boolean slot at its first run; `http` parses after interpolating its whole config, so only token-free values are judged there and never `signingSecret`, which the credential channel may supply; a `loop` with no `body` is not parsed by its executor and is judged for nothing; the region slots of `loop`, `parallel` and `try_catch` are judged as graphs of their own and by `validateControlFlow`. An undeclared or retired key, a `predicate` ledger slot (a screen field `visibleWhen`) and a `value` ledger slot (a CRUD `fields` value) keep the judges they had. ⚠️ No D2 conversion: the platform cannot know the value the author meant. ⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0031." + }, + { + "surface": "a decision node branch — an element of config.conditions[] — written without its expression key, or with expression: null, at any depth including an ADR-0031 region body. That includes a branch whose predicate sits under another key (condition is the edge spelling). Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate, a flow saved from the Studio flow designer with a branch row whose expression cell is empty, and a flow row already sitting in sys_metadata", + "replacement": "the predicate the branch was meant to test, as non-blank bare CEL text under `expression` (`{ label: 'high', expression: 'record.amount > 10000' }`); a predicate written under `condition` moves to `expression`. To keep the branch and its label but never take it, write `expression: 'false'` — that is a CHANGE of behaviour, not a preserved one: a run that reached the branch used to fail there (`condition evaluation error`), and now routes on to the next branch or the declared fallback. ⚠️ Not by dropping a decision's only branch: with no `conditions` the node routes by its out-edges alone, so the out-edge that branch labelled is no longer held back", + "migrationId": "flow-decision-branch-expression-absent-refused", + "toMajor": 18, + "rationale": "`DecisionConditionSchema` declares a branch `{ label, expression }` with `expression` a required `z.string()`, but nothing parses a decision node's open config against it, and the expression-ledger resolver skipped an absent value as \"not authored\" — so a branch with no predicate passed `FlowSchema.parse`, `AutomationEngine.registerFlow` and `objectstack validate`, and the decision executor then handed `evaluateCondition` an envelope with no `source`, which it refuses: the build accepted what the run refused. The ledger now marks the slot `required` (reconciled against that schema's own `required` list), the resolver emits the absent value there, and all three doors refuse it through `predicateSlotRefusal`, leading with `PREDICATE_SLOT_STRING_REFUSAL` — the walk, function and sentence that already refuse the blank string. ⚠️ No D2 conversion: the platform cannot know the rule the author left out, and `'false'` would change what the flow does rather than keep it. ⚠️ Where such a branch already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0032." + }, + { + "surface": "flow.nodes[].config.mode (decision) — an OMITTED mode on a decision that branches on its out-edges and carries two or more conditioned ones", + "replacement": "nothing, where the out-edge conditions partition (exactly one can hold for any record): an omitted `mode` now means exclusive, the first true edge in declaration order wins, and the run is what it always was. `mode: 'inclusive'` where the flow RELIES on more than one branch running for one record — the value the D2 conversion `flow-decision-mode-inclusive-explicit` writes onto every such decision so nothing changes silently. Where the conditions overlap by accident (a `!=` guard beside a later `==` branch), neither: narrow them into a partition, or mark the fallback `isDefault: true`, and delete the written key.", + "migrationId": "flow-decision-edge-branching-first-match", + "toMajor": 18, + "rationale": "A DEFAULT FLIP of a shipped node type, ruled rather than patched: the schema, the docs and the engine's own comment all called an edge-branched decision an exclusive gateway while the traversal took EVERY out-edge whose condition held, one after another, and reported nothing — a CRM application's lead-conversion flow rendered a refusal screen AND ran the conversion in one execution. The traversal now matches the declaration (BPMN exclusive gateway, Salesforce Flow Decision, n8n Switch default), and the every-true-edge behaviour is the BPMN inclusive gateway an author must write down. The KEY converts mechanically and does: `flow-decision-mode-inclusive-explicit` writes `mode: 'inclusive'` wherever two or more conditioned out-edges leave a decision that declares no `conditions` list, so the migrated source runs exactly as before. What does NOT convert is the INTENT: the count cannot tell a partition (where the key is redundant) from a reliance on multi-branch runs (where it is load-bearing) from an accidental overlap (where the old behaviour was the bug), so the mechanical edit list the chain replay prints is where that judgment is made, node by node. And the conversion replays ONLY there: it is a default flip, so the authoring funnel never rewrites a source written against the new contract, and the automation engine's flow rehydration seam and the artifact-ingestion door both refuse it by id (a code-shipped flow, a REST body, a Studio save and a scaffolded artifact all arrive undated). BREAKING for stored rows, by maintainer ruling: the promise that a flow keeps its behaviour is kept by authored sources and built artifacts only. A decision stored in `sys_metadata` with no `conditions` list, no `mode` and two or more conditioned out-edges takes the new meaning on upgrade — it evaluates first-match — and nothing rewrites the row: no stored-row migration, no cutoff, no read-path completion, because nothing about a stored row says it was saved before the flip. The one-line fix, for a stored node that meant every branch, is `mode: 'inclusive'`; `os migrate meta --stored` lists every such node, report only, so an operator can review the candidates before and after the upgrade." + }, + { + "surface": "a structural flow condition, BOTH slots — edges[].condition on FlowEdgeSchema, the branch predicate AutomationEngine.evaluateCondition runs at every traversal, and config.condition on a flow NODE, which is a decision node predicate and on a start node the trigger gate — authored either as an expression envelope carrying only ast ({ dialect: 'cel', ast: … } with no source), or with a source that is blank after trimming, through the envelope key ({ dialect: 'cel', source: ' ' }) or the bare-string shorthand for it (condition: ' '). The node slot joined this entry with the two later changes that rebound AutomationEngine.registerFlow and objectstack validate to the edge door's own rule rather than deriving a second one; it is the same decision reaching the second slot, which is why it is named here instead of in an entry of its own. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, an exported stack passed to objectstack validate, a POST /api/v1/automation body, and a flow row already sitting in sys_metadata", + "replacement": "a non-blank `source` — `{ dialect: 'cel', source: 'record.amount > 10' }`, or the bare string `'record.amount > 10'` — if the edge was meant to branch; or REMOVE the `condition` key entirely if it was meant to be unconditional. ⚠️ Those two are not interchangeable, and the choice is the judgment this entry delegates: a refused condition evaluated to a silent `false`, so the edge NEVER fired, while an absent `condition` is an unconditional edge that ALWAYS fires. Deleting the key to clear the refusal inverts the edge rather than preserving it. An `ast` BESIDE a string `source` is untouched and stays admitted everywhere", + "migrationId": "flow-edge-condition-evaluated-slot-source-required", + "toMajor": 18, + "rationale": "The evaluated-slot rule, carried to the edge condition — the line that first refused an `ast`-only envelope no engine can evaluate, and refused a non-string node predicate at registration instead of letting the evaluator answer it a silent `false`: `FlowEdgeSchema.condition` now composes `EvaluatedExpressionInputSchema` instead of `ExpressionInputSchema`, so an evaluated slot is held to what the engine can actually run. The engine reads `source` alone (`cel-engine.ts` `evaluate`: \"AST-only evaluation not yet supported; persist `source`\"), so both refused spellings landed in its empty-source arm and answered a SILENT `false` on every release that carried them — they parsed, registered, passed `objectstack validate`, and then produced a branch that quietly never fired (measured on 2026-09-05 by driving an `ast`-only envelope through `AutomationEngine.evaluateCondition` directly). The refusal is one rule with one sentence, `EVALUATED_EXPRESSION_SOURCE_REQUIRED`. ⚠️ No D2 conversion is possible, and this is exactly why the change needs a D3 entry rather than none. An `ast`-only envelope carries no `source` to derive one from — lowering an AST to surface syntax is the compiler direction the platform does not run — and dropping a blank `condition` would flip the edge from never-fires to ALWAYS-fires, which is the platform guessing which of two different flows the author meant. ⚠️ And the consequence for a flow ALREADY STORED is wider than the edge, which is the part no author-time prescription reaches. `applyConversionsToStoredItem` is deliberately not applied to `flow` (`spec/src/conversions/stored.ts`, and the same skip in `metadata/src/loaders/database-loader.ts` `rowToData`) because flow-node conversions need the automation engine's live executor registry; flows canonicalize at `registerFlow` instead, which parses through `canonicalizeStoredFlow` → `FlowSchema.parse`. Each of the three boot paths in `service-automation/src/plugin.ts` wraps that call in try/catch, logs one `warn` naming the flow, and CONTINUES — so a stored `sys_metadata` flow with such an edge is no longer registered at all: its trigger is never armed and the WHOLE flow stops running, not just the branch, announced only by that warn line. A repo-wide census at `ae19f5edb` (examples/, packages/, content/, skills/) found zero edge conditions of either spelling against a lit control, so there is nothing in THIS repository to rewrite — a repo reading, which is why the notification is registered here rather than skipped. ADR-0087, ADR-0032." + }, + { + "surface": "a flow edge whose source or target is not the id of a node in the graph that declares it — the flow's own nodes for a top-level edge, the region body's nodes for an edge inside a loop, parallel or try_catch region, so a top-level edge into a region node is one of them — and a later edge of the same graph with the same source, target, type, condition and branch label as an earlier one. Reachable wherever a flow is authored or stored: defineStack flows sources, defineFlow, an exported stack passed to objectstack validate, a flow saved from the Studio flow designer after a node was removed (its edges were left behind, and a node added later under the reused id picked them up), and a flow row already sitting in sys_metadata", + "replacement": "an edge whose `source` and `target` are node ids declared in the same graph as the edge: re-point the endpoint at the node it was meant to reach, or delete the edge. Deleting a dangling edge changes nothing a run did, with one exception: a conditioned edge into a missing node still counted as the branch taken when its condition held, so a default sibling was passed over and, on an exclusive `decision`, the later conditioned siblings were skipped — where a flow relied on that, point the edge at a node that ends the branch. For a repeated edge, delete the later copy: the target then runs once per traversal instead of once per copy — a CHANGE of behaviour wherever the copies ran it more than once, which is the defect being removed. An edge meant to take its own route needs its own `condition` or branch `label`", + "migrationId": "flow-edge-unresolved-or-repeated-refused", + "toMajor": 18, + "rationale": "The engine resolves an edge's endpoints in the graph that declares it — traversal looks the target up there, and a region runs against a view of its own nodes and edges — and runs a target once per out-edge it selects. `FlowSchema` held node ids and edge ids unique and checked neither that an edge names a node of its graph nor that it is not a copy of another, so a draft holding an edge into a node it no longer had, or one edge three times, passed `FlowSchema.parse`, `objectstack validate` and the metadata save door, published with `_diagnostics.valid: true`, and ran: the dangling edge carried the run nowhere, silently, and the repeated edge ran its target once per copy (one record update created three identical records). The parse now refuses both at every depth the region walk reaches: an endpoint at `edges.N.source` / `edges.N.target` (or the region path `nodes.N.config.body.edges.M.target`), naming the missing id and, when it is a node of another graph, that graph; a repeated edge at `edges.N`, naming the earlier copy. Repeated means the key the engine selects on — `source`, `target`, `type`, `condition` (its dialect and source) and branch `label` — so two nodes joined by edges with different conditions, a `fault` edge beside a default one, or `approve` and `reject` branches into one node stay legal. A region edge naming no node of its region was already refused at registration by the region analysis; the top-level half had no refusal anywhere. ⚠️ No D2 conversion: a dangling endpoint carries no intent a rewrite could recover, and dropping a repeated edge changes how many times its target runs. ⚠️ Where such an edge already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack` flows source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0031." + }, + { + "surface": "a flow node whose config leaves out a key its executor contract requires — objectName on get_record / create_record / update_record / delete_record, recipients on notify (and title when there is no template), url on http, function on script, flowName on subflow, collection and flowName on map, collection on a loop that has a body, branches on parallel, try on try_catch, and on screen each field name, each option value and label, and a lookup field reference — and a decision node whose conditions is not an array, holds a branch that is not an object, or holds a branch whose label is absent, null, blank or not a string; at any depth including an ADR-0031 region body. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate, a flow saved from the Studio flow designer (a node added and saved before it is configured; a decision branch row whose label cell is empty; a screen field row whose name cell is empty), and a flow row already sitting in sys_metadata", + "replacement": "the missing key, written on the node's `config` — the value the node was meant to act on (`objectName: 'account'`, `url: 'https://…'`, `collection: '{rows}'`, …). For a decision branch, the label of the out-edge the branch should take (`{ label: 'approved', expression: 'record.amount > 1000' }`, beside an out-edge labelled `approved`), `conditions` written as an array of such objects, and a bare predicate string moved under `expression`. To branch on the out-edges instead, delete `conditions` and put each predicate on its edge's `condition`. A legacy flat-graph `loop` (no `body`) needs no `collection` and is untouched", + "migrationId": "flow-node-config-required-keys-refused", + "toMajor": 18, + "rationale": "A flow node's `config` is an open record, so what its executor requires was checked by no build door: `FlowSchema.parse`, `AutomationEngine.registerFlow` and `objectstack validate` all admitted a node missing a key its executor contract requires, and the executor's own contract parse then refused the node on every run that reached it — the config is metadata, so no rerun could succeed. A decision branch with no label was worse: it never failed, the matched branch reported no label and traversal took EVERY out-edge, so the flow ran green down the wrong paths. All three doors now refuse these shapes through one judge, `flowNodeConfigRefusals`, which parses each builtin node's config against the very contract its executor parses against (`getBuiltinNodeConfigContracts()`, reconciled against the executors' own parse calls) and keeps only the keys left out — a present value of the wrong type and an undeclared key are judged where they were before — plus the decision branch shape its executor reads raw. A key a rule of the contract requires (a notify with no template needs a title; a lookup screen field needs its reference) is refused in the contract's own words. ⚠️ No D2 conversion: the platform cannot know the object, URL, collection, function or out-edge label the author left out, and no value it could write would keep what the flow did. ⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0031." + }, + { + "surface": "the two ledger predicate slots on a flow node — config.conditions[].expression on a decision node (a branch predicate) and config.fields[].visibleWhen on a screen node (a field visibility predicate) — authored as a string that is blank after trimming ('', ' ', a tab or a newline), at any depth including an ADR-0031 region body. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate, and a flow row already sitting in sys_metadata", + "replacement": "the predicate the branch or field was meant to test, as non-blank bare CEL text (`expression: 'record.amount > 10'`, `visibleWhen: 'amount > 0'`); or KEEP what the blank did. On a screen field, drop the `visibleWhen` key: an absent `visibleWhen` shows the field unconditionally, which is what a blank one already did at run time (the resume contract treated it as absent, and the renderer fell back to showing the field). On a decision branch, write `expression: 'false'`: the evaluator answered the blank `false`, so the branch keeps its label and is still never taken. ⚠️ Not by dropping a decision's only branch: with no `conditions` the node routes by its out-edges alone, so the out-edge that branch labelled is no longer held back. On a structural condition removal differs again: dropping a blank `condition` turns a never-firing edge into an always-firing one (`flow-edge-condition-evaluated-slot-source-required`)", + "migrationId": "flow-predicate-slot-blank-string-refused", + "toMajor": 18, + "rationale": "Maintainer ruling A of 2026-09-13: the two sibling predicate slots refuse a blank string at authoring. Both slots are declared bare CEL text (`z.string()`) and both admitted a blank string at every door: the expression ledger resolver skipped it as \"not authored\", and `AutomationEngine.evaluateCondition` answered it `false` — so a decision branch carrying it was never taken, with nothing said at any layer, and a screen field carrying it was shown with its predicate ignored. An earlier fix had pinned that admission as correct because the two sides agreed. The ruling is that self-consistency between parser and evaluator is not a defence when the author's intent is silently dropped — the third instance of one rule, after the structural `config.condition` and a blank evaluated `source`. The blank is now refused at `FlowSchema.parse`, at `AutomationEngine.registerFlow` (which parses first) and at `objectstack validate`, all three through `predicateSlotRefusal`, leading with `PREDICATE_SLOT_STRING_REFUSAL`. ⚠️ No D2 conversion, and the reason is the judgment this entry delegates: the blank is where an author meant to write a rule, and the platform cannot tell a predicate somebody forgot from one they meant to delete. Keeping what ran is mechanical; writing the predicate is what the author intended; only the author knows which. ⚠️ Where such a blank already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0032." + }, + { + "surface": "a script or subflow flow node whose config carries a key its executor contract does not declare — a typo (funtion), a key copied from another node type (a subflow timeoutMs written inside config, an approvers list on a script), or a key nothing reads (bogusKey). script declares function, inputs and outputVariable; subflow declares flowName, input and outputVariable. Never a retired script key (actionType, template, recipients, variables, script), which keeps its own path, and never a key on any other builtin node type, whose undeclared keys registration already judges against the node type descriptor. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved from the Studio flow designer, and a flow row already sitting in sys_metadata", + "replacement": "the key the contract declares, or no key: rename a typo to the declared key it meant (`function`, `inputs`, `outputVariable` on a `script`; `flowName`, `input`, `outputVariable` on a `subflow`), move a value the function or the child flow should receive into `inputs` (script) or `input` (subflow), move a `subflow` timeout to the node itself (`{ id, type: 'subflow', timeoutMs: 30000, config: { … } }`), and delete a key nothing reads. The refusal carries the contract's own sentence, with its did-you-mean for a near miss", + "migrationId": "flow-script-subflow-config-undeclared-keys-refused", + "toMajor": 18, + "rationale": "The `script` and `subflow` executors (`service-automation` `builtin/screen-nodes.ts`, `builtin/subflow-node.ts`) parse the node's `config` against a strict contract (`ScriptConfigSchema`, `SubflowConfigSchema`) before they act, and refuse the node on an undeclared key. No door before the run judged one: `registerFlow`'s undeclared-key check derives the declared set from the node type descriptor's `configSchema`, and these two descriptors publish none (the schemaless class, `SCHEMALESS_NODE_CONFIG_SCHEMAS`), while the build doors' executor-contract arm judged required keys and present values but held key membership back on the premise that registration judges it. So a `script` node carrying `bogusKey` passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile` (which copied the key into the artifact), registered, and then failed every run that reached the node: the config is metadata, and no rerun could succeed. The one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share (`flowNodeConfigRefusals`) now refuses such a key on these two types as `node-config-refused-by-contract`, anchored at the key, one refusal per key, in the contract's own words — the code the value half and the approval contract already use. Every other builtin keeps its undeclared keys where they were judged: at registration, against its descriptor, with that check's own prescriptions. `decision` is schemaless too, but its executor parses no contract, so an undeclared key there fails no run and stays unjudged. A retired `script` key keeps its tombstone path. ⚠️ A spelling the ADR-0087 D2 conversion `flow-node-script-config-aliases` or `flow-node-subflow-flow-alias` still rewrites at load (`functionName`, `input` on a `script`; `flow` on a `subflow`) is converted before the judge at every door that converts first; met by a direct `FlowSchema.parse` or `defineFlow()` it is refused like any other undeclared key, as the missing canonical key already was. ⚠️ No D2 conversion: the platform cannot know what an undeclared key was meant to be. ⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0031." + }, + { + "surface": "flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a single-brace template token", + "replacement": "a double-brace template hole, rendered by the formula template engine over the flow's variables: a variable path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, {{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an assignment node — arithmetic and functions as a CEL value envelope, the date macros and the run-user paths as the value-slot spelling that still reads them — and written as {{ variable }}", + "migrationId": "flow-text-slot-single-brace-refused", + "toMajor": 18, + "rationale": "ADR-0032 Decision 3 fixes one template delimiter, double braces, and deletes the single brace: it collides with CEL map literals, and an author who meets both dialects in one flow mixes them. The 17.x interpolator and the template engine render the same text for a path holding a string, a number, a boolean, null, an absent key or variable, an ISO date string, an object or an array, but not for every value — a Date rendered JSON-quoted under the interpolator and as its ISO text under the engine, and a screen title, screen description or end message that was one token holding an object, an array or a Date rendered String(value) — so no conversion is lossless (ADR-0087 D2) and none is applied. Arithmetic, function calls, the date macros and the run-user paths have no hole spelling: a hole is a path with a formatter, never logic. A flow carrying a single-brace token in a text slot is refused at registration, by objectstack validate and by the node contract; a stored flow carrying one is skipped at boot with a warn naming it." + }, + { + "surface": "the record and previous roots a record-change flow receives — a password or secret field, and an internal field, of the triggering record, on every object", + "replacement": "read a credential through a privileged binder — the flow credential channel for an http node's signing secret, or a privileged server-side read such as the engine's resolveSecretField — never off `record` or `previous`; on those roots a set credential-class field now reads as the mask `SECRET_MASK`, an unset one as null, and an `internal: true` field is absent", + "migrationId": "flow-trigger-record-credential-masked", + "toMajor": 18, + "rationale": "ADR-0100: a credential-class value leaves the engine only through a privileged dereference, and every generic channel serves the mask. The record-change trigger built a flow's record and previous from the engine's own write result, which keeps the stored row whole for privileged in-process callers, so a password field's plaintext, a secret field's stored handle and an internal field's value reached the flow — and from there its variables, a paused run's persisted state and that state's read doors. The trigger now projects both roots through the same helper every external write response uses: a credential-class field (secret, and password outside the exempt managedBy buckets) carries the mask, or null when unset, and an internal field is omitted. Every other field keeps its value, every other variable is untouched, and the engine's own write result, the stored row and the privileged read paths are unchanged." + }, + { + "surface": "flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token", + "replacement": "a CEL value envelope, { dialect: \"cel\", source: \"…\" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, list[0]; a variable whose name starts with $ is read through vars, vars[\"$error\"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", + "migrationId": "flow-value-slot-template-dialect-refused", + "toMajor": 18, + "rationale": "The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. Two spellings are kept with their old meaning, because CEL cannot write them yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has no string form for one) and the run-user paths beginning $User. (the flow CEL scope binds no user). A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it." + }, + { + "surface": "a create_record, update_record or delete_record flow node whose config.objectName is the string sys_metadata or sys_metadata_history, at any depth including an ADR-0031 region body", + "replacement": "Change metadata through the metadata API (`PUT /api/v1/meta/:type/:name`, the metadata protocol), where it is validated and its provenance is recorded. Delete the node, or point its `objectName` at the object the flow really means to write. Elevation (`runAs`, a system context) does not change this.", + "migrationId": "flow-write-node-stored-metadata-target-refused", + "toMajor": 18, + "rationale": "`FlowSchema` accepted a `create_record`, `update_record` or `delete_record` node whose `objectName` names `sys_metadata` or `sys_metadata_history`, the tables that hold stored metadata. The maintainer ruled (2026-10-03) that app-authored work may not write those tables: the metadata protocol is their only writer, where a change is validated and its provenance recorded, and a flow is app-authored automation. The runtime enforces that at the node, refusing the write before it resolves a filter, computes a field or calls the data engine, under every run identity; but every authoring door still accepted such a flow, and the author learned otherwise only at its first run. The parse now refuses it too, through the one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` and `objectstack validate` share (`flowNodeConfigRefusals`), with the runtime's prescription: `objectstack validate`, `defineStack`, compile, an artifact's parse, `registerFlow` and the metadata save door each name the node at `nodes.N.config.objectName`. The refused set is exactly the runtime's: one of those three write nodes, whose `objectName` is a string naming a stored-metadata table by exact name. A `get_record` node is outside it (a read is not a write), and so is a dynamic target, a `{token}` template or an expression envelope: the parse cannot read it as a name, and the run judges the name it hands the data engine. No authored flow writing either table was measured in this repository, its examples, its skills or its docs. There is no mechanical rewrite: retargeting the node or deleting it each changes what the author wrote, and the runtime already never ran it. Where such a node already sits, the whole flow is refused: registered from `sys_metadata` at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register." + }, + { + "surface": "view.form.sections[].fields[].publicPicker — the anonymous public-form record-search picker", + "replacement": "No record search on an anonymous public form. For a choice from a fixed list, a `select` field with static `options`. For a choice of an existing record, the same form behind sign-in, where the lookup field renders with the signed-in user's access.", + "migrationId": "form-field-public-picker-retired", + "toMajor": 18, + "rationale": "The D2 conversion `form-field-public-picker-removed` deletes `publicPicker` from every form field, and the delete is lossless in effect: the block's only reader was the anonymous lookup route, which is gone, and the public-form resolve route now leaves lookup, `master_detail` and `user` fields off the anonymous rendering whatever the row carries. What the strip cannot decide is the visitor's path. A public form that used the picker let an anonymous visitor search and pick a record; after the upgrade that field is simply absent from the form, so a submission arrives without the value. Whether the choice was really from a small fixed set (a `select` with static `options`), or needs a real record and therefore a signed-in user, is a product decision only the author can make." + }, + { + "surface": "view.form.sections[].fields[].options[].default — the per-option pre-selection on a form view's own option list", + "replacement": "The object field's own option list, where `default` is enforced: `default: true` on that field's options entry, or the field-level `defaultValue`.", + "migrationId": "form-view-option-default-retired", + "toMajor": 18, + "rationale": "The D2 conversion `form-view-option-default-removed` deletes `default` from every option of every form-view field it reaches, and the delete is lossless: nothing on the form path ever read it — the insert-path default falls back to the OBJECT definition's options, and no form renderer seeds a value from a form view's. So a form that marked an option as default never pre-selected it, and still does not. The judgment is in the replacement. The form-view key was scoped to ONE form; the object field's `default` applies on EVERY insert path — every form of that object, the API, imports. Moving the marker there makes the form do what its author wanted and also changes what records created elsewhere receive when the value is omitted. Only the author can say whether that wider default is correct, or whether the pre-selection should be dropped." + }, + { + "surface": "view.form.subforms[].columns[] and view.formViews..subforms[].columns[] — the form view's inline grid columns, which used to accept any value", + "replacement": "each entry is the strict, name-keyed inline grid column a relationship field's `inlineColumns` takes — `{ name, label?, type?, … }`, where `{ name }` alone hydrates the rest from the child object's field. Write `name` where a column said `field` (or `fieldName`, `key`); delete `scale` from a column declaring `type: 'currency'`; delete any key the column schema does not declare.", + "migrationId": "form-view-subform-columns-closed", + "toMajor": 18, + "rationale": "Both carriers feed the one console grid, which reads only the keys the column schema declares and keys a column by `name` alone. On the form view the columns were never judged, so a mis-keyed column published clean and drew a blank grid column, and a key the other carrier refuses — `scale` on a currency column, under the maintainer's ruling of 2026-09-23 (option B, `scale` retired from the currency type) and the remedy ruled on 2026-09-24 (option 乙 — a currency's ISO 4217 minor unit decides its display) — published green here. The carrier now references the column schema, so every rule it holds applies here too, with its own prescription. Only the `field` spelling is converted mechanically — by the conversion `form-view-subform-columns-canonicalized`, which rewrites stored rows and assembled artifacts and lists the edit under `os migrate meta`, while an author writing `field` meets the refusal. Which column an unknown key or a mixed `field`/`name` entry meant is the author's call — a conversion that dropped the key would accept on every load what the parse now refuses. Population measured at the change, on origin/main cb4c31dd52: zero authored `subforms` in the repository (the showcase derives its master-detail grids from the data model instead), against one authored `inlineColumns` block as the control. Deployed metadata NOT MEASURED." + }, + { + "surface": "hook.object naming sys_metadata or sys_metadata_history, as the string or as any member of the list, on a hook that carries a body", + "replacement": "Change metadata through the metadata API (`PUT /api/v1/meta/:type/:name`, the metadata protocol), where it is validated and its provenance is recorded. Delete the hook, or point its `object` at the tables the logic really concerns. Elevation (`runAs`, a system context) does not change this.", + "migrationId": "hook-body-stored-metadata-target-refused", + "toMajor": 18, + "rationale": "`HookSchema` accepted a hook whose `body` targets `sys_metadata` or `sys_metadata_history`, the tables that hold stored metadata. The maintainer ruled (2026-10-03) that an app-authored body may not touch those tables: for a body, the metadata protocol is their only writer, where a change is validated and its provenance recorded. The runtime enforces that where a body hook becomes a handler, refusing such a hook at registration so that it never runs, but every authoring door still accepted it: the metadata save door answered 200, and the author learned otherwise only from a server log. The parse now refuses it too, with the runtime's own prescription, so `objectstack validate`, `defineStack`, compile, an artifact's parse and the metadata save door (a 422) each name the target at `object`, or at the list member. The refused set is exactly the runtime's: a hook carrying a `body`, in any form, whose `object` names a stored-metadata table, as the string or as any member of the list, and one such member refuses the whole hook. A hook with no `body` (a code `handler`, which is how the platform writes its own hooks) and the wildcard `'*'` are outside it, as they are at registration: a wildcard names no stored-metadata table, so it binds, and the runtime never runs its body for those tables' events. No authored hook targeting either table was measured in this repository, its examples or hotcrm. There is no mechanical rewrite: retargeting the hook, dropping its body or deleting it each changes what the author wrote, and the runtime already never ran it. A stored hook row of this shape still loads, now with a `[metadata_spec_invalid]` warning and a `_diagnostics` badge, and is still never bound." + }, + { + "surface": "engine.registerHook('beforeFindOne' | 'afterFindOne' | 'beforeCount' | 'afterCount' | 'beforeAggregate' | 'afterAggregate', handler)", + "replacement": "for the findOne pair, register on 'beforeFind' / 'afterFind' — they already fire for `findOne`; for the count and aggregate pairs there is no hook seam at all, so move the logic to `engine.registerMiddleware(fn)` and read `ctx.operation === 'count' | 'aggregate'`, composing the predicate onto `ctx.ast.where`", + "migrationId": "hook-register-undispatched-lifecycle-event-refused", + "toMajor": 18, + "rationale": "`registerHook` took `event: string` and, for a name outside the dispatched set, warned and then REGISTERED the handler anyway. Six of those names are inside the engine's own lifecycle namespace — (`before`|`after`) x `OperationContext['operation']` minus the eight the engine dispatches — so an author writing one of them believes they are subscribing to an engine lifecycle event, and what they get back is an inert declaration: ADR-0078's prohibited fourth state (parsed, unmarked, silently inert) on an authorable seam.\n\nThe measured consequence is a data-visibility one, which is why this is not a cosmetic warning. A downstream consumer registered READ FILTERS on `beforeFindOne` and `beforeCount`, expecting them to scope single-record reads and list totals; they sat inert through every boot behind about forty warning lines. `findOne` was still filtered — `beforeFind` covers it — so the mistake gave no signal there. `count` was not: a `limit`ed list answered a `total` counting rows the caller could not see. `aggregate` was not either: a `groupBy` was not narrowed at all. A filter that was supposed to narrow visibility and silently did not run is a guardrail the author believes they armed.\n\nRefused at REGISTRATION rather than repaired on the dispatch side. Making `count()` and `aggregate()` dispatch hooks would widen what a hook may intercept — a different and much larger decision — and it would also be the wrong seam: read authorization and row filtering are the middleware chain's job, which is what `HookEvent` in `@objectstack/spec` already says and what `count()` already honours (its AST rides the operation context precisely so the security and sharing middlewares can scope it). The refusal names the per-seam repair in its own message, because \"this never fires\" alone cannot tell the two seams apart: one is a rename, the other is a different API.\n\nThe refusal is scoped to those six names, not to everything outside the dispatched set. `triggerHooks` is public, so a plugin dispatching its own event under a name outside the engine's vocabulary (`'myPlugin:flush'`) is a legitimate reading — that is why the change that collapsed the hook taxonomy to the eight dispatched events made this branch a warn — and it still warns and still registers. The population is DERIVED from the operation union rather than typed out, so a new engine verb widens it without an edit; a hand-written list of refused names would be this same defect one layer up.\n\nThis is a RUNTIME registration API, not stored metadata, so — like `hook-register-empty-object-target-refused` at the previous step — there is no `sys_metadata` row for the D2 chain to rewrite, and the ledger entry is the notification channel. The metadata door was never open on this axis: `HookSchema.events` is `z.array(HookEvent)`, and `HookEvent` enumerates exactly the eight dispatched names, so no authored or stored hook could ever carry one of the six. The exposure was entirely on the code door. ADR-0078." + }, + { + "surface": "hook.timeout — the per-invocation time limit of a data hook", + "replacement": "`timeoutMs` — the same limit, in milliseconds, with the unit in the key name.", + "migrationId": "hook-timeout-unit-in-key", + "toMajor": 18, + "rationale": "The D2 conversion `hook-timeout-to-timeout-ms` renames `timeout` to `timeoutMs` in author sources and on stored hook rows, keeping the value, and the rename is lossless: the key always meant milliseconds, and the conversion leaves an already-canonical `timeoutMs` alone and refuses a pair that disagrees. The judgment is the one the rename exists for. The unit used to live only in the key's description, beside body-level keys that spelled theirs, so an author who wrote a seconds value — `timeout: 30` meaning thirty seconds — got a limit of thirty milliseconds and no error, and the rename carries that 30 over unchanged. Only the author knows which unit they meant, so each value needs reading once. A pair left unconverted because the two spellings disagree needs the author to choose, and code that builds or reads a hook definition in TypeScript is outside the chain's reach." + }, + { + "surface": "`HotReloadConfig.stateStrategy` values 'disk' and 'distributed', plus the `HotReloadConfig.distributedConfig` key and the `DistributedStateConfig` def it carried (3 exported names: `DistributedStateConfigSchema` / `DistributedStateConfig` / `DistributedStateConfigParsed`)", + "replacement": "'memory' for in-process state preservation across a reload, or 'none' to disable it — the two values `PluginStateManager` actually implements. There is no in-tree replacement for durable or distributed plugin state: persist it in the host, which owns the process lifetime these strategies pretended to outlive. Real disk or distributed persistence returns only via the ENFORCE route of ADR-0049 — the implementation first, the declaration with it.", + "migrationId": "hot-reload-inert-state-strategies-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove, applied one level INSIDE the library the maintainer's 2026-08-25 ruling on the advanced plugin-lifecycle config kept. That ruling retired the authorable lifecycle-config container and deliberately kept `HotReloadConfigSchema` as a host-driven library parameter type; this card measured the kept vocabulary's own remainder and found the same defect in it. Measured at cdbd9204b6 with a firing positive control (`stateStrategy` resolves to real readers in `core/src/hot-reload.ts`, so the scan sees readers): the 'disk' and 'distributed' arms of `PluginStateManager.saveState` both wrote to the SAME in-memory Map as 'memory' — the in-source comments said 'memory fallback' — and announced the substitution at DEBUG level only, so a host that asked for durable or cluster-replicated state got process-local memory and no error: state that does not survive the restart it was configured to survive. `distributedConfig` had ZERO readers anywhere (every reference inside `packages/spec` itself plus the generated reference page; nothing in objectui), so an author could name a Redis endpoint, a TTL and a replication factor and nothing ever opened a connection — the shape of the plugin sandboxing / integrity / approval config that was never wired to anything (an exported schema no runtime reads is read as a capability), sharpened by cluster-persistence vocabulary an AI author (ADR-0033) reads as proof the capability exists. The key left with the enum value its own doc comment named it \"required\" for, and `DistributedStateConfig` was its orphan value schema. Two routes in one card because the surface has two shapes: an enum-VALUE narrowing is invisible to the four ratchets (the def still emits), so its prescription hangs on the enum's own `error` map dispatched by `issue.input` (the `crypto.hash` / `managedBy: 'system'` precedent); the whole-def removal MUST move them, and that movement is its own evidence. No D2 conversion and no tombstone: `HotReloadConfig` is not an authorable surface — no metadata-type binding, stack collection or manifest embed ever carried it, and nothing in the tree parses `HotReloadConfigSchema` outside its own unit test — so there is no authored document to rewrite and no one who could receive a parse-time prescription. Route 3, the shape of the dynamic plugin-loading family's removal and of that lifecycle-config ruling: this entry IS the declaration." + }, + { + "surface": "`HotReloadConfig.watchPatterns`, and the `HotReloadManager.startWatching` placeholder that read it", + "replacement": "Run your own watcher and call `HotReloadManager.scheduleReload(pluginName, reloadFn)` when a file changes — that is the debounced integration point this class actually implements, and it is unchanged. Declare your globs wherever your watcher reads them; there is no in-tree replacement for the key, because file watching is the HOST's job in this host-driven library. The platform already depends on `chokidar` in `@objectstack/metadata`, `@objectstack/metadata-fs` and `@objectstack/cli` — never in `@objectstack/core` — so a host has a working model to copy.", + "migrationId": "hot-reload-watch-placeholder-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove, applied one symbol over from the inert 'disk' / 'distributed' state strategies retired in the same file, and on the same per-key test. `HotReloadManager.startWatching` contained NO watcher: its whole body was a guard plus `logger.info('File watching started', { patterns })` above an in-source note saying real watching \"would require chokidar or similar / This is a placeholder for the integration point\". `watchHandles` was only ever read, deleted, iterated and cleared and NEVER set, so `stopWatching`'s cleanup branch and the teardown loop over its keys were structurally UNREACHABLE rather than merely untaken (measured with a firing positive control: `reloadTimers.set` resolves a real writer in the same file and the same scan; `watchHandles.set` resolves nothing anywhere). So `watchPatterns` had no reader that ACTED on it — its only two uses were log lines — and an author could declare a glob while no file change could ever trigger a reload. This is the shape of the plugin sandboxing config that was never wired to anything, with the volume turned up: the inert state-strategy fallback at least announced itself at DEBUG, whereas this said \"File watching started\" at INFO — positive confirmation of a capability that did not exist, which an operator, or an AI author (ADR-0033), reads as proof and stops looking. Neither of the other two ADR-0049 states was available: ENFORCE would build for a caller that does not exist (no runtime composes `HotReloadManager` — only its own unit test and `core/examples/phase2-integration.ts` construct it, the same fact that decided the state-strategy retirement's route), and EXPERIMENTAL requires a roadmap, where a scan of every planning doc returned ZERO mentions of hot-reload file watching against 145 control hits in the same files. Route 3 again: `HotReloadConfig` is not an authorable surface — no metadata-type binding, stack collection or manifest embed ever carried it, and nothing in the tree parses `HotReloadConfigSchema` outside its own unit test — so there is no authored document to rewrite and nobody who could receive a parse-time prescription, so there is no D2 conversion either — it would be a transform with no seam that ever runs. The key is TOMBSTONED rather than deleted, and the BUILD is what decided that: the plain deletion was tried first and `gen:schema` gate (a) refused it, because `HotReloadConfigSchema` is not `.strict()` and a bare deletion would be a silent strip (the failure measured when a field key pruned from a non-strict schema still parsed successfully and simply vanished, ADR-0104) — the very defect being retired, one layer down. The state-strategy retirement could take route 3 because what left there was a whole DEF; a key leaving a SURVIVING def has no such exit. This entry IS the declaration." + }, + { + "surface": "identity.apiKey (the whole of `ApiKeySchema` in identity/identity.zod.ts — 1 def, 3 exported names: `ApiKeySchema`, `ApiKey`, `ApiKeyParsed`)", + "replacement": "(removed — there is no replacement schema, because the deleted one never described the real table. The single declaration of `sys_api_key` is the ObjectSchema in `@objectstack/platform-objects` (`identity/sys-api-key.object.ts`): columns `name, prefix, user_id, active_organization_id, scopes, expires_at, last_used_at, revoked, key, id, created_at, updated_at`, snake_case, `revoked` as the kill switch — not `enabled`. Rows are minted by `POST /api/v1/keys` (`runtime/src/domains/keys.ts`) and verified by `core/src/security/api-key.ts`, keyed by the `osk_` prefix. Per-key rate limiting returns only via the ENFORCE route of ADR-0049 through a new ADR — the executor first, the vocabulary second)", + "migrationId": "identity-api-key-schema-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-08-15, disposition B: delete the schema. `ApiKeySchema` documented better-auth's `apiKey` PLUGIN schema — a plugin this platform does not load (`plugin-auth/src/managed-extension-fields.ts` states the table is hand-rolled ObjectStack): `start` and `lastRefetchAt` name columns that do not exist; `enabled` inverts the real `revoked` column's polarity; `rateLimitEnabled` / `rateLimitTimeWindow` / `rateLimitMax` / `remaining` advertise a per-key rate-limit capability nothing implements (the sharpest PD #10 instance — a reader can reasonably conclude API keys support rate limiting); `permissions` and `metadata` have no columns; `organizationId` is camelCase fiction next to the real snake_case `active_organization_id`. Zero consumers measured (08-14, re-verified at the retirement's base commit): only its own unit test, the export snapshots, the generated reference page and a prose mention in `cloud/developer-portal.zod.ts` (corrected in the same PR — the marketplace-key plan it gestured at is ruled NOT live). One table had two declarations and the published one was fiction; the generated reference page rendered it faithfully, which is how the defect surfaced as a docs card. With no carrier key and no authored document there is nothing to tombstone and no seam for a D2 conversion: route 3, the shape of the earlier removals of the dynamic plugin-loading family, the `ui/` interaction configs, the widget / i18n shapes, five declared-but-inert surfaces and two credential-bearing schemas no `sys_metadata` door reached — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration." + }, + { + "surface": "incident-response deadline keys: `IncidentResponsePhase.targetHours`, `IncidentNotificationRule.withinMinutes` / `regulatorDeadlineHours`, `IncidentNotificationMatrix.escalationTimeoutMinutes`, `IncidentResponsePolicy.triageDeadlineHours` / `retentionDays`", + "replacement": "nothing to re-declare — delete the keys. No incident-response engine exists on the platform: nothing tracks a phase against a clock, sends or times an incident notification, notifies a regulator, walks the escalation chain on a timer or sweeps incident records on a schedule, so there is no live mechanism to declare a deadline to. Retention of stored records is the object-level `lifecycle` block (ADR-0057), declared on the object that stores the records and enforced by the LifecycleService — not a number on this policy document", + "migrationId": "incident-response-deadline-keys-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on the unread deadline keys (ruled A: retire per family). Six hour/minute/day-shaped keys sat on the published authorable surface and in the generated reference docs — an author could write `triageDeadlineHours: 4` and reasonably expect the platform to escalate after four hours — and read by NOTHING: the schemas are exported from `@objectstack/spec/system`, mounted by no stack key, registered as no metadata type, absent from the 2026-06 liveness ledgers, and the reader census over every package outside `packages/spec` (tests and changelogs excluded) and over objectui at the pinned sha returned zero hits for every key. Three of the six carried defaults (30 minutes, 1 hour, 2555 days) that were materialized into every parsed document without ever being consulted. A compliance-shaped deadline that fails silently is the worst form of the declared-but-unenforced shape ADR-0049 names; tagging it `[EXPERIMENTAL — not enforced]` was the fallback the ruling did not take. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections; none of these schemas is either, so a conversion would be a transform with no seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent)." + }, + { + "surface": "the incident-response family, retired whole: the eight defs system/Incident, system/IncidentCategory, system/IncidentNotificationMatrix, system/IncidentNotificationRule, system/IncidentResponsePhase, system/IncidentResponsePolicy, system/IncidentSeverity and system/IncidentStatus, and every name system/incident-response.zod.ts exported from @objectstack/spec/system (the eight *Schema consts, their z.input aliases and the three *Parsed aliases)", + "replacement": "nothing to re-declare — no incident-response engine exists on the platform, so there is no working configuration to migrate to. Nothing classified, tracked, escalated or notified an incident and nothing notified a regulator; a compliance record the organisation keeps is ordinary object data, declared as an object with its own fields and enforced by the object engine (validation, permissions, the object-level `lifecycle` block under ADR-0057). If incident response becomes a product capability it re-declares fresh, through the enforce route of ADR-0049 — the engine first, the vocabulary second", + "migrationId": "incident-response-family-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 on the families' remaining keys and defs (ruled A: retire the three compliance-shaped families whole via RETIRED_DEFS_BY_MAJOR, the integration/ErrorMappingConfig precedent; not roadmapped). Eight defs and roughly forty declared keys sat on the exported surface and in the generated reference docs, and were read by NOTHING: the schemas were exported from `@objectstack/spec/system`, mounted by no `stack.zod.ts` key, registered as no metadata type, absent from the 2026-06 liveness ledgers, and the reader census over every package outside `packages/spec` (tests and changelogs excluded), over `examples/**` and `skills/**`, and over objectui at the pinned sha returned zero hits for every exported name, with a lit control. Several keys were boolean capability claims of exactly the shape ADR-0049 names — `IncidentNotificationRule.notifyRegulators`, `IncidentResponsePolicy.requirePostIncidentReview` — so an author (very often an AI, ADR-0033) could write `notifyRegulators: true`, parse clean, and hold a compliance promise the platform never kept, with no error and no feedback. Tagging the family `[EXPERIMENTAL — not enforced]` was the fallback the ruling did not take: it is a human-only signal, and an AI generating from the schema still writes the key and believes it. The deadline-key tombstones of the 2026-09-02 per-family ruling (six sites, `RETIRED_KEYS_BY_MAJOR[18]`, D3 `incident-response-deadline-keys-retired`) leave with their defs' source; their registry entries stay as history. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections; none of these schemas is either, so a conversion would be a transform with no seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent), and with no carrier key there is no shape on which a tombstone could sit." + }, + { + "surface": "object.fields..inlineColumns[].scale on an inline grid column that declares `type: 'currency'` — any declared value, `scale: 0` included, computed or not. `scale` on a `number` column is untouched. A column that declares no `type` is judged as the type it renders as: over a `currency` field of the child object it is entry `inline-grid-column-identity-only-currency-scale-refused`", + "replacement": "no `scale` on a currency inline grid column. DELETE the key — that is the whole migration: a currency amount's decimal places are its currency's, not a column setting. The currency's ISO 4217 minor unit decides how the cell displays the amount and the width a computed amount is rounded to. ⛔ Nothing replaces the key: do not re-declare its value under any other key.", + "migrationId": "inline-grid-column-currency-scale-refused", + "toMajor": 18, + "rationale": "The maintainer's ruling of 2026-09-23 (option B) retired `scale` from the `currency` field type, and the ruling of 2026-09-24 (option 乙 — a currency's ISO 4217 minor unit decides its display) worded the remedy. Neither reached the inline grid column, the strict mirror of the console grid's column, which still offered per-column decimals on a `currency` column; triage read the column as inherited from both rulings, so `InlineGridColumnSchema` now refuses the key on a column declaring `type: 'currency'` at parse, with the field refusal's first sentence and remedy. ⛔ No alias and no grace window, per ruling B. NOT mechanically converted, deliberately, for the reason the field entry `field-currency-scale-refused` gives: a conversion that dropped the key would accept it on every load, which is the grace window the ruling refused; the refusal names the key and its one-line fix instead. The same change rewords the column's `prefix` description: it replaces the resolved currency's symbol and has no default (the grid no longer falls back to a fixed yen sign). Reach: the column schema judges only a DECLARED column `type` — a column that declares none takes its type from the child field when the console hydrates it, which the schema cannot see; `defineStack` judges that column instead (entry `inline-grid-column-identity-only-currency-scale-refused`). Population measured at the change, on origin/main 1c8b320a89: one authored `inlineColumns` block in the tree (the showcase invoice, seven identity-only columns, none declaring `type` or `scale`), no platform object, skill, documentation example or JSON fixture declaring an inline grid column at all, and one test fixture carrying `scale: 2` on a currency column, re-judged in the same change. Deployed metadata NOT MEASURED." + }, + { + "surface": "object.fields..inlineColumns[].scale and view.form.subforms[].columns[].scale (and each formViews entry) on a column that declares NO `type` and whose `name` is a `currency` field of the child object — any value, `scale: 0` included. A column declaring `type: 'number'`, and a column over a field of any other type, keep `scale`", + "replacement": "no `scale` on the column. DELETE the key — that is the whole migration: the column renders as a currency column, and a currency amount's decimal places are its currency's. The currency's ISO 4217 minor unit decides how the cell displays the amount and the width a computed amount is rounded to. ⛔ Nothing replaces the key: do not re-declare its value under any other key, and do not add `type: 'number'` to keep it on a currency amount.", + "migrationId": "inline-grid-column-identity-only-currency-scale-refused", + "toMajor": 18, + "rationale": "The refusal of `scale` on a currency inline grid column (entry `inline-grid-column-currency-scale-refused`, under the maintainer's rulings of 2026-09-23, option B, and 2026-09-24, option 乙) reached only a column that DECLARES `type: 'currency'`, because the column schema cannot see the child field. An identity-only column — the recommended form — over a currency field renders as a currency column all the same, so it published green carrying the refused key, and the console ignored it. `defineStack`'s cross-reference check, which holds the child object's fields, now judges such a column as the type it renders as and refuses it with the column schema's own message. Reach: the child object must be declared in the same stack; a column naming no field of it, or a subform whose child object comes from another package, is not judged there. Population measured at the change, on origin/main cb4c31dd52: one authored `inlineColumns` block (the showcase invoice, seven identity-only columns, none carrying `scale`) and zero authored `subforms`. Deployed metadata NOT MEASURED." + }, + { + "surface": "job.timeout — the per-attempt time limit of a scheduled job", + "replacement": "`timeoutMs` — the same per-attempt limit, in milliseconds, beside the sibling `retryPolicy.backoffMs` that already spelled its unit.", + "migrationId": "job-timeout-unit-in-key", + "toMajor": 18, + "rationale": "The D2 conversion `job-timeout-to-timeout-ms` renames `timeout` to `timeoutMs` in author sources and wherever the chain is replayed, keeping the value, and the rename is lossless: the key always meant milliseconds. The judgment is whether the author knew that. The unit lived only in the description while `retryPolicy.backoffMs` beside it spelled its own, so one job definition carried two conventions; a seconds value copied in — `timeout: 300` for a five-minute job — became a 300-millisecond limit with no error, and the rename carries the 300 over unchanged. A limit that short fails every attempt and burns the retry budget, which is easy to misread as a flaky job. Only the author can say which unit each value was written in, and code that builds job definitions in TypeScript is outside the chain's reach." + }, + { + "surface": "CompatibilityMatrixEntry.estimatedMigrationTime, the migration effort estimate whose unit lived only in a source JSDoc (kernel/plugin-versioning.zod.ts)", + "replacement": "estimatedMigrationTimeHours — rename the key AND state the unit in the describe; the value (hours) is unchanged", + "migrationId": "kernel-compatibility-matrix-estimated-migration-time-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling A of 2026-09-17 on the last two duration keys no closed duration type could express (this one in hours, the other in fractional seconds): rename the key and record an ADR-0087 conversion-layer entry, with no new closed type and no narrowing of anything already stored. The key said \"Estimated migration time in hours\" in a source JSDoc and carried no .describe() at all — the JSDoc-channel shape (a unit stated only in a source comment the reference page never prints), one def over. The JSDoc stops at the source file; .describe() is what content/docs/references/** renders, so the published page printed a bare number directly beside migrationComplexity, whose scale IS named (trivial/simple/moderate/complex/major). A reader comparing \"major\" with \"40\" had no way to know whether 40 was minutes, hours or days. The remedy is BOTH halves, and the second is not optional: renaming alone would leave the two channels that name the unit — the key name and a source comment — agreeing about something the published page does not print, which check:duration-unit-keys refuses as unit-in-jsdoc-not-in-describe (that agreement shape — a unit in the key name and the JSDoc, none in the describe — was ruled an offence on 2026-09-18). So the unit moves INTO the describe and the key name carries it too. HOURS is kept rather than converted to seconds: the value is unchanged, the ruling forbade narrowing, and an effort estimate is authored in hours by the human who writes the plugin manifest. Tombstoned with retiredKey(): CompatibilityMatrixEntrySchema is a plain z.object, not strict, so a bare deletion would strip the old spelling in silence and a manifest would lose its one effort figure with no error anywhere. Why a semantic entry and not a D2 conversion: a compatibility matrix is a plugin-published version manifest — stack.zod.ts declares no collection of them and it is not a registered metadata kind stored as a sys_metadata row — so the chain has no seam that sees one. ADR-0087." + }, + { + "surface": "context.mode — the value 'preview' left the RuntimeMode enum — and context.previewMode, the whole PreviewModeConfig block it keyed (autoLogin / simulatedRole / simulatedUserName / readOnly / expiresInSeconds / bannerMessage, declared on KernelContext and on the TenantRuntimeContext extension). The exported PreviewModeConfigSchema / PreviewModeConfig / PreviewModeConfigParsed names left with the def", + "replacement": "nothing declarative — the capability the block described was never implemented by any layer, so there is no working configuration to migrate to. Preview/demo DEPLOYMENTS belong to the deployment layer, which owns auth per-project (ArtifactKernelFactory in the cloud distribution); the OS_PREVIEW_MODE environment variable stays exactly as it is — deployment ROUTING (widening the trusted-origin list for preview subdomains), unrelated to identity. If a preview experience becomes a product capability it re-declares fresh, with the production-posture hard-refusal as the first-landed half (as the removal ruling recorded)", + "migrationId": "kernel-context-preview-mode-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-08-27 (Option A: remove). The declaration was the sharpest declared-≠-enforced shape on a SECURITY surface: the schema promised \"bypass auth, simulate admin identity\" and named a production guard \"the runtime must enforce\", and NO code path implemented either half. Measured zero consumers in all three repos, each leg with positive controls: objectstack — no runtime branches on the mode; the only non-declaration hits for RuntimeMode or mode === 'preview' are the schema unit test, a type-alias pin and a measurement-test comment (re-verified at dispatch, 2026-08-27, origin/main 15bf9e8). objectui — zero consumers, measured when the removal was ruled. cloud — a census closed 2026-08-26: OS_PREVIEW_MODE there is a routing-only switch (the same switch this repository's serve.ts reads only to add preview-domain wildcards to better-auth's trusted origins); RuntimeMode has zero hits repo-wide; the positive control ArtifactKernelFactory (where serve.ts predicted preview auto-login would live if it existed) has 20+ hits and never touches previewMode. An author — very often an AI (ADR-0033) — could write the six-key block per the reference docs, parse cleanly, and get no behaviour and no diagnostic, while a reader of the docs had no way to tell the block from the keys that work. Bookkeeping: the enum-VALUE half ('preview') puts nothing in RETIRED_KEYS_BY_MAJOR and leaves the four surface ratchets untouched by itself — its prescription hangs on the enum's own error map (the HookBodyCapability precedent); the KEY half is tombstoned with retiredKey() on the non-strict KernelContextSchema (both walked-shape copies registered in RETIRED_KEYS_BY_MAJOR[18]); the DEF half (kernel/PreviewModeConfig, with no carrier left) is registered in RETIRED_DEFS_BY_MAJOR[18]. It is a SEMANTIC entry rather than a D2 conversion because there is no source to rewrite: a kernel context is constructed by host code at boot — not a stack collection member, never stored as a sys_metadata row — so the conversion chain has no seam that would ever see one (the kernel/Manifest:loading disposition). ADR-0049 / ADR-0087." + }, + { + "surface": "the two event-bus retention windows whose name carried no unit: EventPersistence.retention (kernel/events/handlers.zod.ts) and EventSourcingConfig.retention (kernel/events/queue.zod.ts)", + "replacement": "retentionDays on both — rename each key; both values are unchanged, and so is the 365 default on EventSourcingConfig", + "migrationId": "kernel-event-bus-retention-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. What makes these two one entry rather than two is the neighbour they share and the one they do not. Both hang off EventBusConfig, so an author configuring a bus met the same bare word twice and had to learn the unit twice; and on EventSourcingConfig the bare retention sits two keys below snapshotRetention, which is a COUNT of snapshots to keep, not a span of time. `retention: 365` and `snapshotRetention: 10` read as the same kind of number and are not. Suffixing the duration separates the families at the authoring site; snapshotRetention keeps its name, because a count has no unit to carry. Both are retiredKey() tombstones — neither shape is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: an EventBusConfig is the event bus construction argument a host builds in code (stack.zod.ts declares no eventBus key and no metadata kind is bound to one), so it is never a stack collection member and never a stored sys_metadata row, and the conversion chain has no seam that would see one. That is what ruling B prescribes for a key that is not authorable metadata, and the disposition the epoch-instant renames on this same kernel took (epoch-instant-keys-renamed). ADR-0087." + }, + { + "surface": "the three plugin-lifecycle durations whose unit lived in a source JSDoc only: PluginHealthCheck.interval, PluginHealthCheck.timeout and HotReloadConfig.debounceDelay (kernel/plugin-lifecycle-advanced.zod.ts)", + "replacement": "intervalMs, timeoutMs and debounceDelayMs — rename each key; all three values (milliseconds) and their 30000 / 5000 / 1000 defaults are unchanged", + "migrationId": "kernel-health-check-and-hot-reload-durations-unit-in-key", + "toMajor": 18, + "rationale": "Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the maintainer's 「同意」: a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands — the duration-unit rule (the unit lives in the key name or its value type, never in prose alone) executed file by file. Each key named milliseconds in its JSDoc — \"Health check interval in milliseconds\", \"Timeout for health check in milliseconds\", \"Debounce delay before reloading (milliseconds)\" — and the JSDoc above a key is NOT what `content/docs/references/**` renders; `.describe()` is. Measured on this tree by the gate's own census (check-duration-unit-keys --list): all three read [name: -] [prose: -] — no unit in the name and none in the published prose either. `interval` is the sharpest of the three: its describe carried one unit-shaped token, the parenthetical \"(default: 30s)\", which names SECONDS for a value the schema bounds and defaults in MILLISECONDS (min 1000, default 30000). That is the 1000x confusion the rule exists for, published to the one reader who cannot see the source. The suffix is the family's own spelling, counted on this tree: 100 key-position *Ms declarations across packages/spec, timeoutMs 29 of them and intervalMs 3, so both renames land on names the surface already uses. debounceDelay takes the plain suffix rather than a shortened form: it is the only debounce-shaped key spelling in the whole repo (5 key-position occurrences, all of this one key and its fixtures, no debounceMs variant anywhere), while the Delay-plus-Ms pairing is already attested (maxDelayMs, initialDelayMs, retryDelayMs, delayMs) — so unlike the Ttl-versus-TTL question the sibling round had to settle, there is no competing family spelling to choose between. All three old spellings are retiredKey() tombstones: neither PluginHealthCheckSchema nor HotReloadConfigSchema is .strict(), so a bare deletion would be a SILENT STRIP (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — and here the stripped value lands on a setInterval period, a race deadline and a setTimeout delay. Why a semantic entry and not a D2 conversion: the conversion chain walks a normalized STACK, and neither def is an authorable surface — no metadata-type binding, stack collection or manifest embed carries either, and both are library parameters a host passes to PluginHealthMonitor / HotReloadManager in TypeScript (kept twice: as the hot-reload vocabulary that had an implementation when the manifest-side copy was removed, and as a host-driven library when the declarative lifecycle config container was retired) — so a conversion would be a transform with no seam that ever runs. That is the same disposition plugin-auto-restart-never-reinitialised and hot-reload-watch-placeholder-retired recorded for keys on these two defs. The registration-time refusals in PluginHealthMonitor.registerPlugin and HotReloadManager.registerPlugin are the door for the audience that does not parse. Measured on 884e8347d: the only in-repo readers are packages/core/src/health-monitor.ts and packages/core/src/hot-reload.ts, both moved in this same change; and the pinned objectui checkout — the pin this repo builds against, `.objectui-sha` = `f0268ad784854568aa58a2aa791f6a7502259186` — names neither def and neither key: all thirteen exports of plugin-lifecycle-advanced.zod.ts and the string debounceDelay each occur 0 times across its 8234 tracked files (0 across the 7754 at a58626c88, the 7650 at 0abd4f9f8, the 7632 at 9dfaca654, the 7579 at 2e818d0b5, the 10267 at ab1879721, the 10071 at 89cad75d5, the 9912 at 31971ff1e, the 9800 at e420df310, the 9546 at db11afd49, the 9283 at dd3f7e1be, the 8512 at f8a9d0fb0 and the 8303 at 62597c588 too), against lit controls objectstack 12966 and @objectstack/spec 4997 on the same corpus at 87af769e9, which re-count to 13125 and 5043 respectively at 62597c588, to 13347 and 5123 at f8a9d0fb0, to 13745 and 5466 at dd3f7e1be, to 14704 and 5545 at db11afd49, to 15352 and 6024 at e420df310, to 15691 and 6206 at 31971ff1e, to 16044 and 6461 at 89cad75d5, to 16377 and 6665 at ab1879721, to 17227 and 7134 at 2e818d0b5, to 17313 and 7186 at 9dfaca654, to 17390 and 7209 at 0abd4f9f8, to 17468 and 7246 at a58626c88 and to 17956 and 7522 at this pin (git grep -o -F, the method that reproduces every earlier count)." + }, + { + "surface": "the three package and version lifecycle durations whose name carried no unit: UpgradePlan.estimatedDuration (kernel/package-upgrade.zod.ts), PackageDependencyResolutionResult.resolvedIn (kernel/plugin-security.zod.ts) and MultiVersionSupport.rollout.duration (kernel/plugin-versioning.zod.ts)", + "replacement": "estimatedDurationSeconds, resolvedInMs and durationMs — rename each key; every value is unchanged", + "migrationId": "kernel-package-lifecycle-durations-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. These three are one entry because they are one story told to one audience — a package being planned, resolved and rolled out — and because the group is precisely where the unit SPLITS: estimatedDuration is SECONDS while resolvedIn and rollout.duration are MILLISECONDS, three adjacent measurements of the same install, two units, none of them named. A reader who learned the unit from one of these three learned it wrongly for the other two. The rollout case adds a second confusion of its own: duration sat directly beside the unit-less percentage, so one block carried a proportion and a span as indistinguishable bare numbers; percentage keeps its name, because a proportion has no time unit to carry. All three are retiredKey() tombstones; no shape here is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: an UpgradePlan is GENERATED by IPackageService.planUpgrade() before an upgrade runs, a PackageDependencyResolutionResult is emitted by a resolution run, and MultiVersionSupport is a version-routing argument a host constructs — none is a stack collection member or a stored sys_metadata row, so the conversion chain has no seam that would see one. That is what ruling B prescribes for a key that is not authorable metadata. ADR-0087." + }, + { + "surface": "the two plugin health-report metrics whose name carried no unit: PluginHealthReport.metrics.uptime and PluginHealthReport.metrics.responseTime (kernel/plugin-lifecycle-advanced.zod.ts)", + "replacement": "uptimeMs and responseTimeMs — rename each key; both values are unchanged", + "migrationId": "kernel-plugin-health-report-durations-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. uptime is the case this rule was written for, and this repo had already paid for it in documentation: the platform serves a SECONDS-valued uptime on GET /health and stores a MILLISECONDS-valued uptime on this report, so the protocol lifecycle page carried a standing paragraph whose whole job was telling the two apart (\"metrics.uptime is in milliseconds, unlike the seconds-valued uptime of GET /health above\"). A prose warning that has to exist is the symptom; the key name is where the fix belongs. responseTime moves with it because it is a sibling in the same metrics block and because the identical bare name means HOURS on PluginSecurityManifest.vulnerabilityDisclosure.responseTime, renamed by this same card. The other metrics keep their names, deliberately: memoryUsage is bytes, cpuUsage is a percentage, activeConnections is a count and errorRate is a rate — none is a duration, and this rule reaches durations only. Both are retiredKey() tombstones inside the live metrics block, whose siblings must keep parsing. Why a semantic entry and not a D2 conversion: a health report is EMITTED by the monitor each round (packages/core/src/health-monitor.ts) and kept in memory — never authored into a metadata document, never a stored sys_metadata row — so the conversion chain has no seam that would see one, the same disposition HealthStatus.timestamp took (epoch-instant-keys-renamed). ADR-0087." + }, + { + "surface": "the four plugin-security durations whose name carried no unit: SandboxConfig.process.timeout, KernelSecurityPolicy.authentication.tokenExpiration, KernelSecurityPolicy.auditLog.retention and PluginSecurityManifest.vulnerabilityDisclosure.responseTime (kernel/plugin-security-advanced.zod.ts)", + "replacement": "timeoutMs, tokenExpirationSeconds, retentionDays and responseTimeHours — rename each key; every value is unchanged", + "migrationId": "kernel-plugin-security-durations-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. These four are one entry because they are one document — everything here hangs off a PluginSecurityManifest — and because together they are this rule's clearest case in the whole spec: FOUR durations on one manifest carried FOUR DIFFERENT units (milliseconds, seconds, days, hours) and not one of them said so in its name. The sharpest pair is responseTime. On this manifest it means HOURS (how fast a publisher promises to answer a vulnerability report); on PluginHealthReport.metrics, renamed by the same card, the identical bare name meant MILLISECONDS. So `responseTime: 24` was a day on one kernel shape and a fortieth of a second on another, with nothing at the authoring site to tell them apart. The policy was already inconsistent with itself, too: its rate-limit window two blocks above tokenExpiration was ALREADY spelled windowMs, so one security policy carried both conventions. All four are retiredKey() tombstones inside live blocks whose siblings must keep parsing; no shape here is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: a PluginSecurityManifest is a package artifact a publisher ships and a SandboxConfig is the isolation argument a host constructs, so neither is a stack collection member or a stored sys_metadata row and the conversion chain has no seam that would see one. That is what ruling B prescribes for a key that is not authorable metadata. One key deliberately left alone: RuntimeConfig.resourceLimits.timeout on this same file names its unit only in the JSDoc above it (\"Execution timeout in milliseconds\"), a channel the gate does not read: it reads `.describe()` and `.meta({ description })`, and that key's describe (\"Maximum execution time\") names none. So the gate lists it among the duration-shaped keys without judging it — neither an offender nor an exemption — and it is outside this rename; that JSDoc-channel gap was filed as a finding of its own and is closed for this key by kernel-runtime-config-timeout-unit-in-key. ADR-0087." + }, + { + "surface": "RuntimeConfig resourceLimits.timeout (kernel/plugin-security-advanced.zod.ts)", + "replacement": "resourceLimits.timeoutMs — rename the key; the value (milliseconds) is unchanged", + "migrationId": "kernel-runtime-config-timeout-unit-in-key", + "toMajor": 18, + "rationale": "This entry COMPLETES what the kernel-directory duration renames deliberately left alone, and the two are meant to be read as a sequence. That round renamed the four plugin-security durations on this same file (`kernel-plugin-security-durations-unit-in-key`) and recorded, accurately, that one key was out of its scope: RuntimeConfig.resourceLimits.timeout named its unit only in the JSDoc above it (\"Execution timeout in milliseconds\"), a channel check:duration-unit-keys does not read — it reads `.describe()` and `.meta({ description })` — and that key's describe (\"Maximum execution time\") named none, so the gate listed it among the duration-shaped keys without judging it, neither an offender nor an exemption. That JSDoc-channel gap was filed as a finding of its own, and that round's statement about its own scope stays true. The finding is now ruled and this is its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer's 「同意」, which keeps the refusal of a duration key whose JSDoc names a unit its describe does not, remediates the 21-row JSDoc-channel population per file, and lands that widened gate last, into a tree already clean. So the reader who most needs the unit — the reader of the published reference page, who never sees the source JSDoc — got a bare integer on content/docs/references/kernel/plugin-security-advanced.mdx and could not tell 60000 milliseconds from 60000 seconds. The key is renamed and the describe is corrected in the same stroke, because under the duration-unit rule (the unit lives in the key name or a unit-carrying value, never in the describe prose alone) moving the unit into the describe alone is itself a violation (unit in prose, none in the name). Spelled Ms, the same token SandboxConfig.process.timeoutMs on this very file already carries: counted on this tree, the suffixed family spells it that way in every member (29 key-position `timeoutMs` declarations across packages/spec/src/**/*.zod.ts, 40 distinct *Ms keys) and there is no timeoutMillis, timeout_ms or timeoutMS variant anywhere in packages/spec/src. Tombstoned with retiredKey() because the nested resourceLimits object is not strict, so a bare deletion would silently strip the key. Why a semantic entry and not a D2 conversion: a RuntimeConfig is the engine block of the SandboxConfig a host or a plugin security manifest constructs — stack.zod.ts declares no sandbox, security-policy or runtime-config collection and it is not a stored sys_metadata row — so the conversion chain has no seam that runs on it; the same reading the kernel-directory round recorded for the four keys it renamed. Measured on 146c291943: no in-repo runtime reads the key — packages/core/src/security/sandbox-runtime.ts, the one consumer of this shape, reads resourceLimits.maxCpu (3 occurrences of resourceLimits) and spells timeout 0 times; outside the zod file and its test the only live occurrences are the generated rows in content/docs/references/kernel/plugin-security-advanced.mdx, which this rename regenerates. The pinned objectui checkout — this is the pin we build against, `.objectui-sha` = `f0268ad784854568aa58a2aa791f6a7502259186`, re-read from this tree — spells resourceLimits.timeout 0 times across 8234 tracked files, against lit controls timeout 1658, RuntimeConfig 337 and resourceLimits 2 on the same corpus (0 across 7754, and 1431 / 299 / 2, at a58626c88; 0 across 7650, and 1360 / 293 / 2, at 0abd4f9f8; 0 across 7632, and 1360 / 293 / 2, at 9dfaca654; 0 across 7579, and 1351 / 276 / 2, at 2e818d0b5; 0 across 10267, and 1348 / 273 / 2, at ab1879721; 0 across 10071, and 1331 / 273 / 2, at 89cad75d5; 0 across 9912, and 1303 / 273 / 2, at 31971ff1e; 0 across 9800, and 1293 / 273 / 2, at e420df310; 0 across 9546, and 1197 / 273 / 2, at db11afd49; 0 across 9283, and 1172 / 263 / 2, at dd3f7e1be; 0 across 8512, and 1096 / 245 / 2, at f8a9d0fb0; 0 across 8303, and 1086 / 240 / 2, at 62597c588); both resourceLimits hits are prose in packages/app-shell recording that objectui's own AppShellRuntimeConfig shares not one key with the spec's RuntimeConfig, so nothing there authors this key and no pin bump is owed. ADR-0087." + }, + { + "surface": "the three startup-orchestration durations whose name carried no unit: StartupOptions.timeout, PluginStartupResult.duration and StartupOrchestrationResult.totalDuration (kernel/startup-orchestrator.zod.ts)", + "replacement": "timeoutMs, durationMs and totalDurationMs — rename each key; every value is unchanged, and so is the 30000 default on StartupOptions", + "migrationId": "kernel-startup-orchestrator-durations-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. These three are one entry because they are one boundary: a host passes StartupOptions in, and the orchestrator hands PluginStartupResult and StartupOrchestrationResult back from the same call. The file already contained its own counter-example — IStartupOrchestrator.startWithTimeout(plugin, context, timeoutMs) named its parameter timeoutMs while the options object beside it said timeout, so one contract carried both conventions and the suffixed one was already the honest half. totalDuration is the sum of the per-plugin durations, so the two had to move together or the aggregate would have been spelled unlike its parts. All three are retiredKey() tombstones; none of these shapes is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: StartupOptions is a boot-time call argument and the two result shapes are emitted measurements, so none is ever a stack collection member or a stored sys_metadata row and the conversion chain has no seam that would see one — the same disposition HealthStatus.timestamp took on this very file (epoch-instant-keys-renamed), and what ruling B prescribes for a runtime-emitted key. ADR-0087." + }, + { + "surface": "view.list.navigation.view", + "replacement": "page assignment — assign a `record` page to the object and let `isDefault` pick the one that opens. That is the machinery that resolves a detail layout by name; a list view's `navigation` block decides only HOW the detail is surfaced (`mode`, `size`, `preventNavigation`, `openNewTab`, `width`), and every one of those keys is unchanged", + "migrationId": "list-view-navigation-view-retired", + "toMajor": 18, + "rationale": "DECLARED, CONSUMED, AND WRONG — which is why this is a semantic TODO rather than a mechanical strip. The key's describe promised \"the form view to use for details\" and no layer from spec to console ever resolved a view by name. Its only read in the shipped console put the value in the SECOND argument of `onNavigate`, the slot that otherwise carries the navigation-MODE token: an authored `view` did not select a view, it SUBSTITUTED for the mode. At least one consumer in the same bundle reads that argument against a closed two-value vocabulary (`edit` / `view`), so any other authored value matched neither branch — invisible on grids whose handler takes one argument, a dead row click on the ones that do not. The enumeration behind the removal was exhaustive rather than sampled: every `.view` property read in the bundle (three) and every `formViews` read, and NO read anywhere is keyed by an authored view name, so there is no path by which this key or any sibling could have resolved one. ADR-0049 enforce-or-remove; zero authored instances in this repo and the one external author removed its occurrence, so the pull that would justify ENFORCE is zero. A mechanical D2 strip was weighed and declined with the direction: deleting the key silently discards the author's actual intent — \"open the detail in THIS layout\" — and leaves no record of which list view carried it, which is exactly the judgement a semantic TODO exists to hand back. Should \"open the detail in a chosen view\" ever be pulled, it belongs to the page-assignment machinery (`record` pages, `isDefault`), not to a string on a list view." + }, + { + "surface": "view.list / view.listViews.* — the list-view type page and its pageName binding", + "replacement": "Publish the page and give the app a navigation item for it — `{ type: 'page', pageName: '' }` under the app `navigation`, the page mount that has always rendered. Keep the list view only if it should draw rows of its object, as a `grid` or one of its siblings.", + "migrationId": "list-view-page-mount-retired", + "toMajor": 18, + "rationale": "The D2 conversion `view-page-mount-removed` deletes `type: 'page'` (the schema default then parses the view as `grid`) and `pageName` from every view payload in `stack.views[]`, in all three persisted spellings, and the delete is lossless in pixels: no renderer ever routed the page member, so a page view has always drawn an empty grid, and it still does. The author wanted a PAGE in front of users at that place in the app, and the view never showed it. Whether to reach the page through a navigation item, and whether the now-plain grid view should exist at all, are the author's decisions. One boundary is theirs by construction: a page mount declared under `objects[].listViews` is reached by no conversion, so it is refused at its own door until edited by hand." + }, + { + "surface": "view.list.sort / view.listViews.*.sort — the bare string sort clause", + "replacement": "The structured array, `sort: [{ field, order }]`, with `order` written out — a bare field name meant ascending — and one entry per key of a comma-separated clause, in the same order.", + "migrationId": "list-view-sort-string-clause-retired", + "toMajor": 18, + "rationale": "The D2 conversion `list-view-sort-string-clause-to-array` rewrites a string clause in the grammar the wire normalizer splits on — `'created_at desc'`, a bare field name, a comma-separated list — into the array, losslessly, across every view payload in `stack.views[]`. Two cases are deliberately left for the author. A string that does NOT parse as that grammar — above all the leading-minus dialect, `'-created_at'` — is left alone and refused at the door, because guessing a direction would invent an ordering the author never wrote. And a clause under `objects[].listViews` is reached by no conversion, so it is refused at its own door until rewritten by hand. The clause was minted by the schema and refused by the renderer that lowers it into a query, so a view carrying one may already have been failing to load; which order the author meant is theirs to state." + }, + { + "surface": "view.list.tabs / view.listViews.*.tabs — the list view's own tab definitions", + "replacement": "One named list view per tab, under the object's `listViews` — the saved-view switcher above the object's records renders every entry as a tab. The tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry, beside the `columns` the tab should show. A tab whose `view` already named a list view needs nothing more.", + "migrationId": "list-view-tabs-retired", + "toMajor": 18, + "rationale": "The D2 conversion `view-list-tabs-removed` deletes `tabs` from every list payload in `stack.views[]`, in all three persisted spellings, and the delete is lossless in pixels: no renderer ever mounted a tab bar for the key, so a view that declared tabs has always drawn without them, and it still does. The judgment the conversion cannot make is the author's intent: each tab was a named preset the author wanted end users to switch to, and the platform delivers that as a named list view, not as a sub-key of one. Which tabs deserve an entry, what each should filter and show, and whether the switcher already lists an equivalent, are the author's decisions. The tab keys with no list-view counterpart — `icon`, `order`, `pinned`, `isDefault`, `visible` — never had an effect either. One boundary is the author's by construction: tabs declared under `objects[].listViews` are reached by no conversion, so such an object is refused at its own door until edited by hand." + }, + { + "surface": "HttpDestinationConfig `batch.flushInterval` / `retry.initialDelay` / `timeout` and LoggingConfig `buffer.flushInterval` (system/logging.zod.ts)", + "replacement": "`batch.flushIntervalMs` (default 5000) / `retry.initialDelayMs` (default 1000) / `timeoutMs` (default 30000) on HttpDestinationConfig, and `buffer.flushIntervalMs` (default 1000) on LoggingConfig — rename the keys; every value (milliseconds) is unchanged", + "migrationId": "logging-durations-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling A, 2026-09-11: the gate that reads a duration key's JSDoc lands last, after its offenders are fixed file by file — so this entry executes, per file, the rule that a duration number key carries its unit in its name. All four keys named milliseconds in a source JSDoc — \"Flush interval in milliseconds\", \"Initial retry delay in milliseconds\", \"Timeout in milliseconds\" — and the JSDoc above a key is not what `content/docs/references/**` renders; `.describe()` is, and none of the four carried one at all. Measured by the `check:duration-unit-keys` census on this tree before the change, all four read `[name: -] [prose: -]`: no unit in the key and no published prose to supply it, so `content/docs/references/system/logging.mdx` printed a bare 5000 / 1000 / 30000 / 1000 and nothing on the page decided milliseconds from seconds. Under that rule's gate, moving the unit into the describe alone is itself a violation (unit in prose, none in the name), so each key is renamed and given the describe it never had in the same stroke. ⚠️ `flushInterval` was declared TWICE on this file, in two different defs and with two different defaults — 5000 on the HTTP destination's batch and 1000 on the logging buffer — so they are two keys, each with its own tombstone and its own registered row; the prescriptions name their def so a reader who lands on one is not sent to the other. The `Ms` suffix is the family's own spelling, counted in key position on this tree: 272 `*Ms:` declarations in `packages/spec/src` against 75 `*Seconds:`, and the only competing unit spellings are 3 `*MS:` and 9 `*Millis:` — every one of them a name fixed outside this repo (MongoDB's `maxCommitTimeMS` and `connectTimeoutMS`, node-postgres's `idleTimeoutMillis` and `connectionTimeoutMillis` on `PoolConfigSchema`), so unlike the `Ttl`-versus-`TTL` question a sibling round settled there is no in-repo alternative to choose between. All three target spellings were already attested as key-position `*.zod.ts` declarations before this change: `flushIntervalMs` 1 (`kernel/events/integrations.zod.ts`, same 1000 default), `initialDelayMs` 5, `timeoutMs` 30. Tombstoned with `retiredKey()` rather than deleted because none of the four enclosing objects — `HttpDestinationConfig` itself and its nested `batch` and `retry`, and `LoggingConfig`'s nested `buffer` — is `.strict()`, so a bare deletion would have stripped the value in silence. Why a semantic entry and not a D2 conversion: `stack.zod.ts` declares no logging collection and neither `LoggingConfigSchema` nor `HttpDestinationConfigSchema` is referenced anywhere in `packages/spec/src` outside `system/logging.zod.ts`, so the chain has no rehydration seam that runs on an authored logging document — the same reading `tenant-schema-cache-ttl-unit-in-key` recorded for its sibling key. Measured on 4dab2bc5c: no in-repo runtime reads any of the four — outside `packages/spec/src/system/logging.zod.ts` and its test the only occurrences are the generated rows in `content/docs/references/system/logging.mdx`, which this rename regenerates; and the pinned objectui checkout — `.objectui-sha` = `f0268ad784854568aa58a2aa791f6a7502259186` — spells `flushInterval` 0 times, `initialDelay` 0, `HttpDestinationConfig` 0 and `LoggingConfig` 0 across its 8234 tracked files, against lit controls `useState` 2622 and `timeout` 1658 on the same corpus (all four 0 across 7754, against 2491 and 1431, at a58626c88, 0 across 7650, against 2478 and 1360, at 0abd4f9f8, 0 across 7632, against 2477 and 1360, at 9dfaca654, 0 across 7579, against 2477 and 1351, at 2e818d0b5, 0 across 10267, against 2476 and 1348, at ab1879721, 0 across 10071, against 2470 and 1331, at 89cad75d5, 0 across 9912, against 2469 and 1303, at 31971ff1e, 0 across 9800, against 2464 and 1293, at e420df310, 0 across 9546, against 2449 and 1197, at db11afd49, 0 across 9283, against 2435 and 1172, at dd3f7e1be, 0 across 8512, against 2391 and 1096, at f8a9d0fb0, and 0 across 8303, against 2389 and 1086, at 62597c588)." + }, + { + "surface": "the manage_org_presentation platform capability (its PLATFORM_CAPABILITIES entry in @objectstack/spec security, the ORG_PRESENTATION_AUTHORING_CAPABILITY constant exported by @objectstack/metadata-core) and the arm of metaWriteCapabilityVerdict that admitted its holders to org-scoped writes of the five org-overridable types through the /meta item doors", + "replacement": "grant `manage_metadata` to whoever must author views, dashboards, reports, translations or email templates through Studio or `PUT /api/v1/meta//`; such a write now lands environment-wide (`organization_id` NULL) and is served to every organization of the deployment. There is no organization-bounded authoring capability: delete `manage_org_presentation` from every permission set's `systemPermissions`, and delete any import of `ORG_PRESENTATION_AUTHORING_CAPABILITY`. `metaWriteCapabilityVerdict` takes `{ isSystem, systemPermissions, operation }`: drop the `canonicalType` and `activeOrganizationId` members from the call", + "migrationId": "manage-org-presentation-retired", + "toMajor": 18, + "rationale": "ADR-0131 D6 retires the per-organization overlay axis, and the /meta doors stop carrying an organization into a metadata write (the companion entry meta-doors-organization-scope-retired). The capability admitted an organization admin to exactly the writes those doors threaded into the admin's own organization; with no organization threaded, keeping it would have admitted its holders to environment-wide authoring, which is the reach of manage_metadata and a wider one than the capability ever granted. It was granted by no shipped permission set, so a deployment that never granted it by hand observes nothing." + }, + { + "surface": "manifest.id — `ObjectStackManifest.id`, i.e. `defineStack({ manifest: { id } })` and the `id:` key of a package manifest — and its registry face `PackageSchema.manifestId` (`marketplace/package.zod.ts`)", + "replacement": "a reverse-domain identifier matching `MANIFEST_ID_PATTERN` (`kernel/manifest.zod.ts`): two or more lowercase dot-separated segments of letters, digits and inner hyphens, each opening with a letter or a digit, never a hyphen — `com.acme.crm`, `org.apache.superset`. ⛔ Underscores are not admitted, so `manifest.namespace` is never a legal id and never a legal last segment of one: `com.acme.my_app` becomes `com.acme.my-app`. A bare word gains a prefix: `blank` becomes `com.example.blank`. The refusal carries the repaired value it has already checked against the pattern, so the prescription is in the error text, not only here.", + "migrationId": "manifest-id-reverse-domain-required", + "toMajor": 18, + "rationale": "Two declarations named one identity and drifted. `PackageSchema.manifestId` — what the registry stores and addresses a package by — has always carried the reverse-domain regex; `ManifestSchema.id`, the key an author actually writes, was `z.string()` and accepted anything. So a package scaffolded, validated, built and booted with an id the publish path would refuse, and the author met the rule for the first time at the one moment it was most expensive to meet. The two sites now reference ONE exported constant, which is what makes a future divergence a visible edit rather than a silent one. Why the rule holds for a package nobody publishes: the TSDoc's own words are \"unique across the entire ecosystem\" — an id names the artifact for the ecosystem it may one day join, so a private app is named under the same rule as a listed one. Why it is a D3 semantic TODO and not a D2 conversion: the value IS the identity. A mechanical rewrite would re-point every install, dependency declaration and stored `manifest_id` row at a package that, to the registry, is a different one — and the safe choice between \"rename the package\" and \"keep the id and change nothing that depends on it\" is not derivable from the metadata." + }, + { + "surface": "manifest.permissions as a flat list of permission strings (and packages[].manifest.permissions) — the legacy arm of ManifestPermissionsSchema left; the schema is now the structured plugin permission block alone", + "replacement": "the structured block `permissions: { services, hooks, network, fs }` — each a list naming the platform services the plugin resolves, the lifecycle hooks it registers, the network hosts it reaches and the filesystem paths it touches; or no `permissions` key when the plugin needs none", + "migrationId": "manifest-permissions-string-list-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove: the flat list was parsed and never acted on. The loader registers the consented grant set on the environment artifact with the permission enforcer, never the manifest's request, so a list granted, refused and requested nothing at load; the only code that met one was two reports saying it had been skipped. The D2 conversion `manifest-permissions-string-list-removed` deletes the list from existing sources and stored artifacts, losslessly for every load. What it cannot do is translate: a capability string such as `system.user.read` names no service, hook, host or path, so whether the plugin needs a grant at all, and which, is the author's judgement. Authoring now refuses a list at parse with that prescription, and TypeScript rejects it" + }, + { + "surface": "manifest.version — `ObjectStackManifest.version`, i.e. the `version:` key of `defineStack({ manifest })` and of a package manifest — and its three sibling declarations `MetadataPluginManifestSchema.version` (`kernel/metadata-plugin.zod.ts`), `PluginRegistryEntrySchema.version` (`kernel/plugin-registry.zod.ts`) and `PluginMetadataSchema.version` (`kernel/plugin-validator.zod.ts`), plus the `PATCH /api/v1/packages/:id` door in `@objectstack/runtime`", + "replacement": "a SemVer 2.0.0 string matching `SEMVER_2_0_0_VERSION_PATTERN` (`kernel/version-grammar.ts`). ⭐ This is a WIDENING for almost every author: prerelease and build suffixes are accepted for the first time, so `2.0.0-beta.1`, `17.0.0-rc.5`, `1.0.0+20230101` and `1.0.0-rc.1+exp.sha.5114f85` now pass a key that refused all of them, and identifiers may carry either ASCII case. ⛔ The one thing that stops being accepted is a leading zero in the numeric core: `01.1.1` becomes `1.1.1` — or a different version, if the padded form was standing in for one.", + "migrationId": "manifest-version-semver-2-0-0", + "toMajor": 18, + "rationale": "One concept — \"the version of a package or plugin\" — was judged by four different grammars across ten carriers in two repositories, and the strictest of them, this one, refused `2.0.0-beta.1`: the exact string a sibling declaration documented as an example of itself. The contradiction was observable between doors on the same resource, not merely between schema files — the build step refused a prerelease the publish door accepted, while the install door parsed nothing at all. The maintainer ruled one canon, and named it after the standard the repository already claimed in this key's own `.describe()`, in the generated reference docs, in the Studio help text and in two ADRs: SemVer 2.0.0. Why the narrowing is not losslessly convertible: a version is an identity. `01.1.1` and `1.1.1` are the same release to a reader and different strings to every registry row, dependency declaration and installed artifact that stored one of them, and which of the two an author meant is not derivable from the metadata." + }, + { + "surface": "mapping.fieldMapping[].params.object / .fromField / .toField / .autoCreate — the per-entry reference-resolution keys of a lookup mapping", + "replacement": "Nothing on the mapping. A `lookup` entry copies the cell through, and reference resolution runs afterwards off the TARGET field's own metadata: its `reference` names the object searched, and the cell is matched as a display value (a name, an email or a record id). Records a row points at must exist before the import runs.", + "migrationId": "mapping-lookup-params-retired", + "toMajor": 18, + "rationale": "The D2 conversion `mapping-lookup-params-removed` deletes the four keys from every mapping entry's params, and the delete is lossless: the import path never read them, so stripping them changes no imported row. The judgment is about what the author believed. `autoCreate` read as \"create the referenced record when nothing matches\", and nothing was ever created — an unresolved cell fails its row with `import_reference_not_found`, with or without the key. An import pipeline built on that belief has been losing those rows, and now needs the referenced records seeded first. `object`, `fromField` and `toField` read as the target and the matching columns, and were never consulted: where they named something OTHER than the target field's own `reference` or a column the resolver matches on, the rows were linked by the field's metadata, not by the mapping — and only the author knows which one they meant." + }, + { + "surface": "datasource.config.persistence.autoSaveInterval on the memory driver — the file and auto persistence arms", + "replacement": "`autoSaveIntervalMs` — the same interval, in milliseconds, on both arms; the minimum of 100 and the file arm's 2000 default are unchanged.", + "migrationId": "memory-persistence-auto-save-interval-unit-in-key", + "toMajor": 18, + "rationale": "The D2 conversion `memory-persistence-auto-save-interval-to-ms` renames the key on both persistence arms of every memory-driver datasource and on stored datasource rows, keeping the value, and leaves a string persistence mode, a custom adapter and every other driver's config alone; the rename is lossless because the key always meant milliseconds. The judgment is whether each value was written in that unit. Nothing in the old name said so, and the auto arm's description named no unit at all. A seconds value below 100 was already refused by the bound, but one above it was not — `autoSaveInterval: 300` meant as five minutes saved every 300 milliseconds, and the rename keeps 300. The interval also bounds how much in-memory data a crash can lose, so the author is choosing a durability trade-off, not only a number." + }, + { + "surface": "memory driver config `persistence.path` (file persistence and the `auto` override) and `persistence.key` (localStorage and the `auto` override) — values containing `${…}` placeholder syntax", + "replacement": "the literal path or key. For environment-specific destinations, leave the key unset and let the shared datasource factory scope the default per datasource, or compute the config value in code before it enters `defineStack`", + "migrationId": "memory-persistence-placeholder-refused", + "toMajor": 18, + "rationale": "The unresolved-placeholder defect one surface over from the datasource connection keys, where it is already refused: a `${…}` placeholder in memory persistence config is resolved by NOTHING — the driver would create and write a literal `./${DATA_DIR}/…` path, or write under the literal placeholder-bearing localStorage key, so the dump lands in a wrongly-named location with no error naming the unresolved placeholder (authored under the same false belief the 2026-08-13 ruling closes: placeholder syntax in connection-material keys is refused at publish, because nothing resolves it). These two keys are config-material like the connection keys, so the parent adjudication applies with its reason intact; the memory driver's `initialData` stays deliberately UNJUDGED — it carries arbitrary record values, where a literal `${…}` may be legitimate data. There is no mechanical rewrite: the placeholder names a value that exists only in the author's intended deployment environment, which a source-file transform cannot know." + }, + { + "surface": "the organization the /meta doors of @objectstack/rest and of the runtime dispatcher thread into a metadata write (PUT, DELETE, publish, rollback) or read (item, list, layered, published, drafts, history, audit, diff, diagnostics, references) of the five org-overridable types; the organizationIdForMetaWrite export of @objectstack/metadata-core; the metaReadOrganizationId export of @objectstack/rest; and the Default Organization read of the email-template boot sweep in @objectstack/plugin-email", + "replacement": "nothing to write: every `/meta` write now lands environment-wide (`organization_id` NULL) and every `/meta` read resolves environment → code, for every caller and every tenancy posture. Delete any import of `organizationIdForMetaWrite` (a write carries no organization) or `metaReadOrganizationId` (a read carries none either). A caller that needs the vetted organization of a request for another purpose still reads `metaCallerOrganizationId`", + "migrationId": "meta-doors-organization-scope-retired", + "toMajor": 18, + "rationale": "ADR-0131 D6 retires the per-organization overlay axis: environment metadata written by Studio, by the cloud build agent or by a template install belongs to the whole deployment. The doors threaded the active organization for view, dashboard, report, translation and email_template, so under the single posture, where the Default Organization is active, every Studio save of those types was stored under that organization. The read and the write flip together: reads-first would hide the organization rows the doors still wrote, writes-first would let those rows shadow new environment saves." + }, + { + "surface": "kernel.cluster metadata change event payload (`MetadataChangedEventPayloadSchema` in kernel/cluster.zod.ts — 2 defs, 4 exported names: `MetadataChangedEventPayloadSchema`, `MetadataChangedEventPayload`, `MetadataChangeOperationSchema`, `MetadataChangeOperation`)", + "replacement": "Nothing to migrate to, because nothing ever emitted or consumed it. The cluster invalidation channels that actually run are the three lanes documented in content/docs/kernel/cluster.mdx §6.2: `metadata.changed` (`ClusterMetadataChangedPayload` in `@objectstack/metadata` — the origin node, the metadata type and the replayed watch event), `metadata.mutated` (`ClusterMetadataMutationPayload` in `@objectstack/metadata-protocol`) and `datasource.mutated` (`ClusterDatasourceMutationPayload` in `@objectstack/service-datasource`). A host that needs cross-node cache invalidation subscribes to one of those; a host that held the retired type for a transport of its own keeps a local type — the spec no longer declares one.", + "migrationId": "metadata-changed-event-payload-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove (triage ruling 2026-09-02 on the spec seat: remove via the ADR-0087 route, not \"make a consumer\" — that is contract growth with no pull). The docblock declared that all metadata persistence layers MUST emit a `metadata:changed` event with this payload and that every reader MUST subscribe and compare `version` before invalidating. Measured at the retirement's base commit with positive controls: zero runtime producers, zero subscribers, zero imports outside packages/spec (its own unit test, the isomorphic alias pin and the generated artifacts) in objectstack, and nothing in objectui at the pinned sha. It was unenforceable by construction — the `version` field is `z.bigint()`, which the standard JSON serializer refuses, so the payload as declared could not cross any pubsub transport without a codec no driver ships: a MUST-emit contract no conforming emitter could satisfy. The shipped channels all carry an address-only signal whose receiver re-reads its own store (the 2026-09-01 ruling for the registry lane), the opposite of the declared version-compare receipt, so the one plausible future consumer was decided against; the 2026-08-27 ruling on transitions removes a staged window. `MetadataChangeOperationSchema` existed only to type the payload's `operation` field and leaves with it as its orphan value schema (the `DistributedStateConfig` precedent). Route 3: not an authorable surface — no metadata-type binding, stack collection or manifest embed ever carried it, and nothing parsed it outside its own unit test — so no tombstone and no D2 conversion; `RETIRED_DEFS_BY_MAJOR[18]` (`kernel/MetadataChangedEventPayload`, `kernel/MetadataChangeOperation`) plus this entry ARE the declaration." + }, + { + "surface": "the paper metadata-customization protocol: `kernel/metadata-customization.zod.ts` whole (`MetadataOverlay`, `FieldChange`, `CustomizationOrigin`, `MergeConflict`, `MergeStrategyConfig`, `MergeResult`, `CustomizationPolicy`) / the section-5 Overlay/Customization API contracts (`api/MetadataOverlayResponse`, `api/MetadataOverlaySaveRequest`, `api/MetadataEffectiveResponse`) / the optional `getOverlay`/`saveOverlay`/`removeOverlay`/`getEffective` members of `contracts/metadata-service.ts` / the authorable keys `MetadataPluginConfig.customizationPolicies`, `MetadataPluginConfig.mergeStrategy` and `MetadataManagerConfig.persistence.overlayWritable` (tombstoned; see `RETIRED_KEYS_BY_MAJOR[18]`)", + "replacement": "nothing to re-declare — delete any authored keys. The customization mechanisms that actually ship: ADR-0005's org-scoped overlay (opt-in via `allowOrgOverride` on `DEFAULT_METADATA_TYPE_REGISTRY`, stored as `sys_metadata` org rows, written through the REST meta write doors and read back through `getMetaItemLayered`'s `code`/`overlay`/`effective` layers), and ADR-0126's packaged-metadata customization model (clone with a new machine name + ledger disable — never a field-level patch overlay)", + "migrationId": "metadata-customization-protocol-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; the maintainer's ruling of 2026-08-29 adopted retirement and rejected a re-scope, and it was executed widened to the full coupling set the fork report on that ruling measured: the module declared a three-layer platform/user patch-overlay protocol with field-level change tracking and a 3-way-merge story, published reference docs described it as the customization architecture — and nothing reachable implemented it. The one implementation (`packages/metadata`'s manager limb) was served by no route and called only by its own unit tests; no merge engine ever existed; no code read a `CustomizationPolicy`. ADR-0126 §6 wall 4 supersedes the protocol as a matter of record (\"nothing may build against it\") — the per-field overlay layer it described is precisely what the 2026-08-24 lock-and-clone ruling (lock the packaged base, customize a clone) left deliberately unchartered. Why D3 semantic and not a D2 conversion: the defs leave with no carrier key in any stack collection, and the three tombstoned keys live on plugin/manager configs, which are not stack collection members (`PLURAL_TO_SINGULAR` has no `plugins` entry) — a MetadataConversion would be a transform with no seam that ever runs (the `kernel/Manifest:loading` precedent)." + }, + { + "surface": "restServer.metadata.endpoints.items / restServer.metadata.endpoints.item", + "replacement": "An `endpoints.*` switch now gates exactly the face its name states, reads and writes alike. `items` gates `GET {prefix}/:type` and nothing else; the whole-store operations it used to take with it — `GET {prefix}/diagnostics`, `GET {prefix}/_drafts` and the `POST {prefix}/_migrate-stored` write door — answer to the new key `endpoints.maintenance` (default `true`). `item` now gates the WHOLE per-item face: `GET` / `PUT` / `DELETE {prefix}/:type/:name`, `/references`, `/layers`, the history family (`/history`, `/audit`, `/diff`, `/published`, `/publish`, `/rollback`) and `GET {prefix}/book/:name/tree`. ⇒ An embedder that authored `endpoints: { items: false }` to close the whole-store family writes `endpoints: { items: false, maintenance: false }`. An embedder that authored `endpoints: { item: false }` to close only the per-item READS has no key that keeps the writes: the per-item face is one face, so leave `item` on and close the surface at `api.enableMetadata`, or per object at `enable.apiEnabled` / `enable.apiMethods`. `types` is unchanged and `api.enableMetadata` remains the master switch above all four.", + "migrationId": "metadata-endpoints-switch-radius-repartitioned", + "toMajor": 18, + "rationale": "Not losslessly convertible, and not compiler-carried either — the two channels that would otherwise reach a consumer are both blind here. No key is renamed, removed or retyped: every one is an optional boolean, so `{ items: false }` compiles and parses exactly as before and simply mounts a different route table. A D2 conversion would have to GUESS which of the four routes the author meant to close, and the two readings differ by a write door — rewriting `{ items: false }` to `{ items: false, maintenance: false }` preserves the old mounts but presumes an intent the author never expressed, while leaving it alone re-mounts `POST {prefix}/_migrate-stored`. That is a judgment, so it is delegated rather than automated. The change itself is the ADR-0049 declared-vs-enforced defect in the direction the liveness ledger structurally cannot look: all three keys were genuinely live, and what had drifted was each one's RADIUS against its own `describe()` — `items` gated a migration write door while naming a listing read, and `item` gated four reads while its own `PUT` / `DELETE` and the history family answered to `api.enableMetadata` alone. The maintainer ruled the two together on 2026-09-06 as one principle: every `endpoints.*` switch gates exactly the face its name states, and the whole-store family gets a key of its own. Measured population at the time of the move: ZERO — no shipped boot path constructs a `RestServerConfig`, so only programmatic embedders can have authored these keys at all." + }, + { + "surface": "metadata item names (the `name` half of the `type`/`name` addressing pair — `saveMetaItem` / `publishMetaItem`, `PUT /api/v1/meta/:type/:name` and the compound `:type/:section/:name` fold)", + "replacement": "lowercase snake_case segments, optionally dot-qualified — the pattern family of METADATA_ITEM_NAME_PATTERN, i.e. one or more [a-z][a-z0-9_]* segments joined by single dots (`crm_lead`, `crm_lead.pipeline`). A name that spelled a sub-resource with a slash (`views/all_leads`) is re-authored with a dot qualifier (`crm_lead.pipeline` — the `ViewItemNameSchema` convention, now enforced with the qualifier optional) or flattened with an underscore (`views_all_leads`); containment is expressed by structure, never by a separator inside the identity string.", + "migrationId": "metadata-item-name-grammar-enforced", + "toMajor": 18, + "rationale": "Maintainer ruling (2026-08-25): metadata item names must not contain `/` — identity-with-separator is the measured root cause of a defect family (URL arity mismatches, dual-arity route-mount obligations, route shadowing, a two-rule URL spelling split in one SDK file). The grammar was entirely unconstrained at the door: the empty string, `//` and `Views/All Leads` were all accepted and stored as item names, and a slash in the name bypassed the unrecognised-metadata-type refusal (`type=fieldz name=a/b` was accepted while `type=fieldz name=a` was 400). Whether a stored slash-name (out-of-repo deployments only — the in-repo census measured zero) should be renamed, and to what, is a judgment the chain cannot make, so no mechanical conversion ships with the narrowing." + }, + { + "surface": "MetadataManagerConfig `cache.ttl` / `cache.databaseLoader.ttl` (kernel/metadata-loader.zod.ts)", + "replacement": "`cache.databaseLoader.ttlMs` (milliseconds, default 60000) — rename the nested key; the value is unchanged. The outer `cache.ttl` has NO replacement: its respelling `ttlSeconds` was retired before it shipped (see `metadata-manager-config-inert-cache-keys-retired`) — delete the key; nothing ever read it", + "migrationId": "metadata-manager-config-cache-ttl-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B of 2026-09-02 on duration-shaped keys (no grandfathered baseline): the unit of a duration-shaped `z.number()` key lives in the key NAME or in a unit-carrying value, never only in the description. This block was the founding specimen: two keys spelled `ttl` fourteen lines apart, the outer one in SECONDS (3600) and the nested DatabaseLoader one in MILLISECONDS (60000), each unit named only in `.describe()`. An author who copied the outer number into the inner block got a 3.6-second cache with no error anywhere — the number was valid, the type was right, the cache was simply cold. Both keys are retiredKey tombstones (the nested objects are not strict; a bare deletion would strip the old key in silence). Why a semantic entry and not a D2 conversion: `MetadataManagerConfig` is the runtime MetadataManager's constructor config, not a stack collection member and never a stored row, so the chain has no seam that ever runs on it (the `kernel/Manifest:loading` and `metadata-plugin-additional-types-retired` precedent). The one in-repo reader, `DatabaseLoader` (`packages/metadata`), reads `cache.databaseLoader.ttlMs` at the same magnitude it read `ttl`; the outer `cache.ttl` had no runtime reader (measured on ca46f8f12, and retired on its own under ADR-0049 before this rename shipped — so this entry's outer half is a deletion, not a rename, and the `ttlSeconds` spelling never reached a published release)." + }, + { + "surface": "MetadataManagerConfig `cache.enabled` / `cache.ttlSeconds` (formerly `cache.ttl`) / `cache.maxSize` (kernel/metadata-loader.zod.ts; tombstoned, see `RETIRED_KEYS_BY_MAJOR[18]`)", + "replacement": "nothing to re-declare — delete the three outer keys. The cache that actually runs is the DatabaseLoader read-through LRU under `cache.databaseLoader`: its `enabled` (default true) is the switch, `ttlMs` (milliseconds, default 60000) the TTL and `maxSize` (an entry count, default 500) the cap", + "migrationId": "metadata-manager-config-inert-cache-keys-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove (the owning seat's ruling, conditioned on the measurement below and re-taken on the merged ref): the outer `cache` block of `MetadataManagerConfig` advertised three knobs — `enabled` (default true), `ttlSeconds` (default 3600; `ttl` until the duration-unit rename) and `maxSize` (\"bytes\") — that no runtime read. The only consumer of the block is `MetadataManager` (`packages/metadata`), which hands `cache.databaseLoader` and nothing else to `new DatabaseLoader({ cache })`; a repo-wide reader census over `packages/**` (tests and changelogs excluded) found no runtime reader of any outer key, while the same grep shape found the nested `cache?.databaseLoader` read twice (the positive control). An author writing `cache: { enabled: false }` or `cache: { ttlSeconds: 60 }` got a clean parse and a cache that behaved exactly as before, and the published reference page documented all three as if they configured something. All three are retiredKey tombstones (the nested object is not strict; a bare deletion would strip them in silence — the same no-op one layer down). The duration-unit ruling's `ttl` → `ttlSeconds` rename, registered under this same major and never shipped, is folded into the removal: `cache.ttl`'s tombstone now prescribes deletion rather than a rename to a key that is itself retired, so a 17.x author sees one hop. Why a semantic entry and not a D2 conversion: `MetadataManagerConfig` is the runtime MetadataManager's constructor config, not a stack collection member and never a stored row, so the chain has no seam that ever runs on it (the `kernel/MetadataManagerConfig:persistence.overlayWritable` precedent). The other candidate — wiring readers for a second cache layer — was not taken: no consumer for one exists, and an implementation for an unmeasured need is the shape ADR-0049 refuses." + }, + { + "surface": "metadata plugin `config.additionalTypes` (on `MetadataPluginConfig`)", + "replacement": "nothing to re-declare — delete the key. There is no declared-kind channel: a kind enters the live metadata-type set as a side effect of registering an ITEM of that kind (`SchemaRegistry.registerItem` during app/manifest registration, or `MetadataManager.register` at runtime). Bind the kind's schema with `registerMetadataTypeSchema(type, schema)` from the plugin's `init(ctx)` so `GET /api/v1/meta` serves a real JSON Schema for it", + "migrationId": "metadata-plugin-additional-types-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling of 2026-08-14: remove the key, jointly with refusing unknown types at the `/meta` boundary by the static registry. The key was declared, authorable, on the published authorable surface, and documented on four docs pages as THE way a plugin registers a custom metadata type — and read by NOTHING. The only production writer of the manager's type registry is `setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY)` (`packages/metadata/src/plugin.ts`), called exactly once outside tests, and it REPLACES the array outright; nothing ever merged `additionalTypes` into it. Measured against the real `MetadataManager`: declared count == live count (27 == 27), `getRegisteredTypes()` sorted equals the built-in registry sorted. So an author who followed the published instructions wrote the key, got no error, and nothing happened — the same silence trap as the plugin lifecycle's `onInstall` (a documented hook with no invocation site), one level down, in exactly the AI-authoring path (ADR-0033). The joint consequence: with this plugin-declared channel removed, the static registry is the total universe of legal metadata kinds, which makes refuse-by-static-registry at the /meta boundary safe by construction. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections. A metadata-plugin config is neither — `PLURAL_TO_SINGULAR` has no `plugins` entry, so it is not a stack collection member and a conversion would be a transform with no seam that ever runs (the `kernel/Manifest:loading` precedent)." + }, + { + "surface": "The ADR-0087 migration chain and change manifest, imported from the package root @objectstack/spec: MIGRATIONS_BY_MAJOR, MIGRATION_MAJORS, MIGRATION_SUPPORT_FLOOR, RETIRED_KEYS_BY_MAJOR, RETIRED_DEFS_BY_MAJOR, applyMetaMigrations, composeMigrationChain, MigrationFloorError, composeSpecChanges, composeReleaseChanges, the seven change-manifest schemas (SpecChangesSchema, SpecConvertedSchema, SpecMigratedSchema, SpecSurfaceAddSchema, SpecSurfaceRemoveSchema, SpecReleaseChangesSchema, SpecReleaseSurfaceSchema), and the types MigrationStep, MigrationApplication, MigrationChainResult, MigrationHopResult, MigrationTodo, SemanticMigration, SpecChanges, SpecConverted, SpecMigrated, SpecSurfaceAdd, SpecSurfaceRemove, SpecReleaseChanges, SpecReleaseSurface, SurfaceDiff, ReleaseSurfaceDiff and PreviousReleaseRegistries", + "replacement": "the same names, unchanged, imported from `@objectstack/spec/migrations` — change the import path and nothing else. The chain, its steps and semantic entries, the retired-key and retired-def tables and the change-manifest schemas are the same objects, and `objectstack migrate meta` replays the same chain. The ADR-0087 conversion layer stays on the package root: `ALL_CONVERSIONS`, `CONVERSIONS_BY_MAJOR`, `applyConversions`, `applyConversionsToFlow`, `applyConversionsToStoredItem`, `collectConversionNotices`, the three `CONVERSION_*_CODE` constants and their types still import from `@objectstack/spec`.", + "migrationId": "migrations-entry-split", + "toMajor": 18, + "rationale": "The maintainer ruled that the console first-screen size ceiling is raised now and paid back at the source; this split is that payback. The migration registry is mostly the guidance text `objectstack migrate meta` prints, and the package root re-exported it. The registry does work when its module loads (the list of majors and each step's rationale are computed then), so no bundler could prove it unused, and all of that text rode in every bundle of the root, whatever the consumer imported: 1,761,987 of the root ESM bundle's 3,766,221 bytes. With the chain on its own subpath the CommonJS root is 2,009,810 bytes instead of 3,780,033, and a browser bundle of the ten names the Studio console imports from the root drops from 700,884 to 301,287 bytes gzipped. The conversion layer does not move: `defineStack` and `normalizeStackInput` read it at run time, so moving its names would narrow the root and shrink it by under two kilobytes. The split moves an import path, which is TypeScript source rather than metadata — nothing authors, stores or parses it — so there is no source a D2 conversion could rewrite, and the move is recorded here." + }, + { + "surface": "The `sort` prop of `object-grid` and `object-calendar` in `ComponentPropsMap` (the FORM: the accept-anything `z.unknown()` at both block doors, vs the `SortItem` array `[{ field, order }, ...]`)", + "replacement": "`z.array(SortItemSchema)` at both doors — the array `ElementDataSourceSchema.sort`, `ListPageSchema.sort` and `element:record_picker`'s flat `sort` shorthand already carry. The legacy OData-ish clause `sort: 'created_at desc'` becomes `sort: [{ field: 'created_at', order: 'desc' }]`; a bare field name `sort: 'created_at'` meant ascending and becomes `sort: [{ field: 'created_at', order: 'asc' }]` — `order` is required in `SortItemSchema`, so it is written out rather than omitted. A comma-separated clause becomes one array entry per key, in the same order. `record:related_list` is NOT moved by this entry: its string is the `'field'` / `'-field'` dialect read by `RelatedList.normalizeSortSpec`, which never reaches `convertSortToQueryParams`, and retiring it was not ruled. `object-grid.defaultSort` is a different key, retired separately by the `ui__ObjectGridProps__defaultSort` entry.", + "migrationId": "object-block-sort-item-array", + "toMajor": 18, + "rationale": "One `sort` spelling platform-wide, the array: the maintainer's ruling of 2026-09-07 (option B) retired the legacy string `sort` clause, and its consumer half is the objectui change that drops the string arm from `convertSortToQueryParams`. One item of that ruling is this entry's subject: 「`ComponentPropsMap` for `object-calendar` and `object-grid` constrains the `sort` value to the array shape (today it accepts anything), so the spec, the registrations and the helper agree; that is a pull-back to the declared contract, ordinary tier」. The `z.unknown()` at both doors was a read-point record from the change that brought the `object-*` blocks into `ComponentPropsMap` (the maintainer's ruling of 2026-08-12), the same vintage as the `filter` doors the `element-data-source-and-object-block-filter-rule-array` entry moved, and not an exception to the ruling: measured on `@objectstack/spec` 17.2.0 an array, a string and a bare NUMBER all returned `success: true` while `bogusProp` was refused by name on the same call, so key checking was live and only the VALUE was unheld. Meanwhile objectui's own html tier has published `type: 'array'` for the grid all along (`plugin-grid/src/index.tsx:222`) and answered `type-mismatch` on the string — a spelling `@object-ui/core` implemented, the docs taught and the validator refused, which is what made this a ruling rather than a mechanical widening. Sequenced measurement-first: at the objectui pin this repo builds against (`53ded82b`) the string is still lowered — `ObjectGrid.tsx:1844-1851` carries an explicit `typeof === 'string'` arm onto `$orderby`, and `ObjectCalendar.tsx:431` hands `schema.sort` to `convertSortToQueryParams`, whose string arm is still present at `sort-query.ts:66-70`. So this declaration lands AHEAD of the pinned consumer, which the ruling permits explicitly (either order; the registrations already declare the array). The in-repo sweep found ZERO authored `sort` on either block — the two showcase pages that author `object-grid` (`command-center.page.ts`, `my-work.page.ts`) declare none — with the same grep shape finding 40+ string `sort` values at OTHER doors (view definitions, ObjectQL `query.sort`) as the control that the sweep fires; so this entry carries the prescription for authors outside the repo. ⚠️ Metadata AT REST is deliberately NOT rewritten and this disposition adds no D2 conversion: `os migrate meta --stored` replays D2 conversions only, and the read path does not re-validate stored rows (`applyConversionsToStoredItem` replays the chain without validating, by its own contract), so a stored page carrying a string `sort` keeps loading and is still rendered by objectui at the pinned `.objectui-sha`. What changes is that RE-SAVING it is refused at the `sort` door, on its next save and not before. ADR-0049, ADR-0087." + }, + { + "surface": "`object-grid` component props — `data` (the KIND: bare array `z.array(z.unknown())` vs the `ViewDataSchema` provider object)", + "replacement": "`ViewDataSchema` — the provider-discriminated object (`provider: 'object' | 'api' | 'value' | 'schema'`). Static inline rows move from `data: [...]` to `data: { provider: 'value', items: [...] }` — the same rows, wrapped in the one arm that means \"hardcoded data array\". The other three arms are unchanged `ViewDataSchema` semantics; `staticData` (the deprecated bare-array shortcut the renderer still reads) keeps its shape but is not the prescription", + "migrationId": "object-grid-data-view-data-converged", + "toMajor": 18, + "rationale": "Two entries of one contract disagreed on the KIND (contract-vs-contract, found by objectui's declared-arm parity gate): `ComponentPropsMap['object-grid'].data` said bare array ('Static inline rows — bypasses the object query') while `ViewDataSchema` — the authority objectui aligned the grid's registry declaration to, pinned by `gridDataInputContract.test.ts`, and what `ObjectGridSchema.data` resolves to — is an object discriminated on `provider`. Measured on @objectstack/spec@17.2.0: `{ provider: 'value', items: [] }` — the pinned-legal form — was REFUSED by the props-map entry (`expected array, received object`) while the bare array parsed. Whichever authority a value satisfied, the other refused it, and the objectui parity gate had to carry the reasoned exemption `object-grid.data:object` to look away. The maintainer's ruling of 2026-08-25 (option A) converged the props-map entry onto `ViewDataSchema`; the bare-array form is the deprecated `staticData` shortcut that objectui's deprecated-alias carve-out already refuses to publish as authoring surface. The ruled migration check ran with the change: the sweep of generated artifacts, templates and first-party corpora (examples/, skills/, create-objectstack, spec fixtures) found ZERO bare-array `data` authors, so no rewrite ships — this entry carries the prescription for authors outside the repo." + }, + { + "surface": "the object-grid page block's defaultFilters property — the legacy base-filter fallback in ComponentPropsMap, which was z.unknown and therefore accepted a bare string, a number, a MongoDB-style record, an ObjectQL AST tuple array and a list of malformed rules alike", + "replacement": "the same ViewFilterRule array form its sibling filter takes — [{ field, operator, value }, ...]. A record-form fallback { status: \"active\" } becomes [{ field: \"status\", operator: \"equals\", value: \"active\" }] and several record keys become several rules, which AND; an operator object { amount: { $gt: 100 } } lifts the operator into the rule, becoming [{ field: \"amount\", operator: \"greater_than\", value: 100 }]; an AST tuple array [[\"owner_id\", \"=\", \"{current_user_id}\"]] becomes [{ field: \"owner_id\", operator: \"equals\", value: \"{current_user_id}\" }], value placeholders and date macros unchanged. Legacy operator shorthands are accepted and normalized on parse. Better still, write the rules on filter and delete this key: it is read only when filter is absent, and its own description has prescribed filter all along", + "migrationId": "object-grid-default-filters-rule-array", + "toMajor": 18, + "rationale": "The protocol half of the maintainer's ruling C-prime of 2026-09-20 on objectui's render-time filter converter — the protocol is the only refusal set, so a document it accepts never throws at render time — verbatim, untranslated: 「the differences are the protocol's to close」. This is the SAME value in the SAME role as filter — the key's own description says it is read only when filter is absent — and the consumer reads it through the SAME lowering sink, so every refusal that sink can give was reachable from a document the protocol had just accepted. filter converged on the rule array with the rest of its family; this key was not named by that ruling and kept the pre-convergence read-point shape, which left the block with one declared door and one undeclared door onto one seam. The parse receipt said nothing about what the grid would then do with the value, and in the objectui version this release pins that depended on the shape: ObjectGrid lowers defaultFilters through toFilterNode whenever filter lowers to nothing, so a record form and an AST tuple array were lowered and applied as declared; a bare string or a number was dropped without a word, so the grid sent no filter and listed its rows unfiltered; and a list of malformed rules was refused — on the wire with 400 INVALID_FILTER, or by the client before any request for the value shapes it judges itself. ⛔ This entry is a NARROWING and deliberately not a retirement. Refusing the key outright — the other arm the finding offered — removes an accepted shape and needs its own ruling; the deprecation already stated in the description is unchanged and still says to prefer filter. Metadata AT REST: the record form and the AST tuple array at this key are rewritten to the rule array by the same D2 conversion as its sibling filter, page-component-filter-record-to-rule-array, wherever the mapping is lossless — by os migrate meta --stored, and on every stored-row read until it runs. What it cannot map losslessly is left exactly as stored and keeps rendering as it does today — a combinator, a null value, an operator the rule vocabulary does not spell, or the bare string or number this key also took — and its door refuses such a value only as the component-props gate's advisory finding (os validate, os build, os lint), since a re-save through the metadata API is not refused there: a record form with the message the filter door gives, a worked rewrite computed from the author's own keys and a pointer to this entry's conversion table, and a bare string or number or an AST tuple array with the schema's plain type refusal. ADR-0049 / ADR-0087." + }, + { + "surface": "page.component.object-grid.defaultSort — the legacy single-pair second spelling of the grid sort", + "replacement": "`sort: [{ field, order }]` — the array every read path honours; a single pair is a one-entry array.", + "migrationId": "object-grid-default-sort-retired", + "toMajor": 18, + "rationale": "The D2 conversion `object-grid-default-sort-removed` follows the renderer's own precedence: where `sort` was absent the `defaultSort` pair WAS the grid's sort, so it moves to `sort` as a one-entry array; where `sort` was present the pair was never read, so it is deleted. Both are behaviour-preserving, and the second is where the judgment sits. A grid that authored both keys with DIFFERENT orders has always loaded in the `sort` order while its author may believe `defaultSort` governed the initial load — the key's name says it should have. The conversion keeps the order users have been seeing and discards the one the author wrote; only the author can say which one they meant. Code that builds object-grid props (a host, a generator) must also stop emitting the key, which no conversion reaches." + }, + { + "surface": "page.component.object-grid.resizableColumns — the legacy second spelling of the grid column-resize switch", + "replacement": "`resizable: true | false` — the one spelling the grid reads; the value is the same boolean.", + "migrationId": "object-grid-resizable-columns-retired", + "toMajor": 18, + "rationale": "The D2 conversion `object-grid-resizable-columns-removed` follows the renderer's own precedence, `resizable ?? resizableColumns`: where `resizable` was absent the legacy value WAS the grid's setting, so it moves to `resizable` unchanged; where `resizable` held a value the legacy key was never read, so it is deleted. Both are behaviour-preserving, and the second is where the judgment sits. A grid that authored both keys with DIFFERENT values has always behaved as `resizable` said, while its author may believe the other key governed it. The conversion keeps what users have been seeing and discards the value the author also wrote; only the author can say which one they meant. Code that builds object-grid props (a host, a generator) must also stop emitting the key, which no conversion reaches." + }, + { + "surface": "object `indexes[]` entries (`IndexSchema`) — undeclared keys", + "replacement": "the declared surface: `name` / `fields` / `unique` (ADR-0120 scope). A key that names no declared capability is simply removed. `where` — the console fallback editor's drifted spelling for a partial-index predicate, removed when objectui converged that editor onto `IndexSchema` — gets a curated prescription: partial indexes are built at the database layer (`CREATE [UNIQUE] INDEX … WHERE` from a runtime migration), never declared here", + "migrationId": "object-index-unknown-keys-refused", + "toMajor": 18, + "rationale": "The unknown-key strictness campaign held this site open on a measured risk, the kind that had already made a console save answer 422 (a strict schema refusing a key the console itself writes): objectui's embedded index editor shipped a drifted hand-copied schema offering `where` and `brin`, spliced its output into `object.indexes[]` and PUT the whole object, so closing the shape would have 422'd a control the console itself rendered. objectui then converged that editor to the declared surface, spending the hold's evidence. Before this close an undeclared key on an index parsed clean and was silently dropped — an admin filling the old \"Partial-index predicate\" control got a green save while no driver ever read the predicate (`syncDeclaredIndexes` consumes `name`/`fields`/`unique` only). Undeclared keys are now refused at parse time with a prescriptive message; the protocol-17 `type`/`partial` tombstones keep answering their own migration text." + }, + { + "surface": "page.component.object-kanban.quickAdd — the per-column quick-add switch on the metadata-driven board", + "replacement": "(removed from the metadata board.) Delete the key; `object-kanban` offers no quick-add control. On a metadata board, records are created through the object's ordinary create action.", + "migrationId": "object-kanban-quick-add-retired", + "toMajor": 18, + "rationale": "The D2 conversion `object-kanban-quick-add-removed` deletes `quickAdd` from every `object-kanban` component, and the delete is lossless: the board forwarded the flag, but the control also needs a host-supplied `onQuickAdd` function that JSON cannot carry and no producer ever put on an object-kanban node, so the gate was permanently false and no board ever showed the control. The residue is the requirement behind the flag. An author who set `quickAdd: true` wanted users to add a card inside a column; that never happened and still does not. Whether the board can live without it is a product decision about that board — not something a key delete can make." + }, + { + "surface": "page.component.object-master-detail-form.details[].sortField — a detail entry's authored line-position field", + "replacement": "Nothing on the entry: delete the key. The line grid stamps each line's position into the child object's own field, derived from the child object: its first field named `position`, `sort_order`, `sequence`, `line_no`, `line_number` or `sort`. To keep the line order a drag-reorder sets, give the child object one of those fields (under the name the deleted key named, when it is one of them).", + "migrationId": "object-master-detail-form-detail-sort-field-retired", + "toMajor": 18, + "rationale": "The D2 conversion `object-master-detail-form-detail-sort-field-removed` deletes `sortField` from every `object-master-detail-form` detail entry, and the delete is lossless: the console stopped reading the authored override, and the line grid stamps the field it derives from the child object whatever the entry says. What the conversion cannot decide is where the line order lives. An entry whose key named a field the derivation does not pick — a name outside that list, or a second sort-named field after the first — saves its line order into the derived field instead, or nowhere when the child object has none. An entry that names `relationshipField` and at least one column and gives every column a `type` is kept exactly as authored: no child schema is loaded for it, so no line position is stamped and a drag-reorder is not saved, before and after the upgrade alike." + }, + { + "surface": "object.tenancy.organizationField — the column a platform row is stamped from, as distinct from the column the object is walled by", + "replacement": "`tenancy.tenantField` — one column that both walls the object and stamps its platform rows. The stamp-only divergence is a platform-internal fact now, kept for the platform's own credential table.", + "migrationId": "object-tenancy-organization-field-retired", + "toMajor": 18, + "rationale": "The D2 conversion `object-tenancy-organization-field-removed` deletes the key from every object's `tenancy` block in author sources and on stored object rows, and the delete is lossless: the key's only readers were three platform-row writers, pinned by name to platform tables, so an application that declared it was never read. The judgment is what the declaration was for. An author who set `organizationField` to a column other than `tenantField` asked for platform rows (audit stamps, approval rows, automation-run records) to carry a different organization column than the one walling the data — and never got it. If the object's real tenant column is not `organization_id`, the fix is `tenancy.tenantField`, which moves the wall as well as the stamp; whether moving the wall is correct for that object is a data-isolation decision only its author can make." + }, + { + "surface": "metrics.slis[].successCriteria, the CEL predicate arm of the union (the structured { threshold, operator, percentile? } arm is untouched) / tracing.sampling.composite[].condition, the CEL predicate arm of the union (the structured filter arm is untouched). Both arms were reachable in two spellings: the bare-string shorthand and the { dialect: 'cel', source } envelope. Reachable wherever metadata is authored or stored: defineStack sources, an exported stack passed to objectstack validate, a POST body on a metrics or tracing config, and a row already sitting in sys_metadata", + "replacement": "the structured arm each slot already carried, or your observability infrastructure. On `successCriteria` write the threshold rule — `{ threshold: 300, operator: 'lt', percentile: 0.95 }` — which is the shape an SLO product consumes. On a composite sampling `condition` write a structured filter: a plain object of match criteria carrying no `dialect` key, e.g. `{ service: 'api', attributes: { 'http.route': '/v1/orders' } }`. ⚠️ Neither replacement is mechanical, and neither is a like-for-like: a criterion or a sampling rule the structured shape cannot express has no home in application metadata at all and belongs in the SLO product or the OpenTelemetry sampler configuration that actually evaluates it", + "migrationId": "observability-cel-predicates-retired", + "toMajor": 18, + "rationale": "DECLARED, DOCUMENTED, AND EVALUATED BY NOTHING — which is why this is a semantic TODO rather than a mechanical strip. Both arms parsed, normalized a bare string to `{ dialect: 'cel', source }`, registered and were served back, and no service, plugin, runtime or CLI path ever read either key: an identity scan over the whole tree finds every hit for `successCriteria`, `ServiceLevelIndicatorSchema` and `TraceSamplingConfigSchema` outside `packages/spec/src` to be a generated artefact or prose, and inside it the only readers are the schemas' own unit tests plus the two census tests that enumerate expression slots. So an author — very often an AI reading the generated reference page, ADR-0033 — who wrote `successCriteria: 'p95 < 300ms'` got a green parse and no signal, indistinguishable from a predicate that ran and answered. ADR-0049 enforce-or-remove, ruled A by the maintainer on 2026-09-18: by the standing criterion that a declared-but-unread capability is kept only when mainstream platforms in the domain have it, application platforms do not carry SLI success criteria or trace-sampling conditions as authorable application metadata — that lives in observability infrastructure (SLO products, OTel sampling policy) and is structured there, not a free expression. The `cron-declared-unwired` family was retired outright under the same ADR after the same measurement. A mechanical D2 strip was weighed and declined: a predicate is an intent no threshold/operator pair or attribute filter records, so stripping the key would delete what the author meant and leave no trace of which SLI or which sampling branch lost it — exactly the judgment a semantic TODO exists to hand back. ⚠️ And a strip here is not merely lossy, it is INVALID: `successCriteria` is a REQUIRED key, so removing it leaves an SLI that no longer parses, and a composite sampling branch that loses its `condition` declares no condition at all — inert today, and the moment a sampler is wired it reads as UNCONDITIONAL. That is the difference from the two error-map precedents this retirement copies its MECHANISM from — `crypto.hash` on HookBodyCapability and `managedBy: 'system'` — both of which also registered a D2 conversion, because for each of them a mechanical rewrite existed. Here none does, which is what makes D3 the right disposition rather than merely an available one. ⚠️ The structured arm of each union is NOT decided here: it is equally unread today, and it is measured on its own card. ADR-0087, ADR-0058 D7, ADR-0049." + }, + { + "surface": "api.PackageApiContracts.upgradePackage / api.PackageApiContracts.resolveDependencies / api.PackageApiContracts.uploadArtifact — the three contract-map entries that bound POST /api/v1/packages/upgrade, POST /api/v1/packages/resolve-dependencies and POST /api/v1/packages/upload", + "replacement": "nothing — no route serves any of the three paths, so there is no entry to read instead. Delete every read of `PackageApiContracts.upgradePackage`, `PackageApiContracts.resolveDependencies` and `PackageApiContracts.uploadArtifact`, and every URL built from them or from the three hard-coded paths: a request to any of them was never answered. The per-route request/response schemas (`PackageUpgradeRequestSchema`, `PackageUpgradeResponseSchema`, `ResolveDependenciesRequestSchema`, `ResolveDependenciesResponseSchema`, `UploadArtifactRequestSchema`, `UploadArtifactResponseSchema`) stay published, bound to no route. The four surviving entries (`listPackages`, `getPackage`, `installPackage`, `uninstallPackage`) are unchanged. If the platform later serves a package upgrade, dependency-resolution or upload route, its entry arrives in the same change that mounts it.", + "migrationId": "package-api-contracts-unmounted-entries-retired", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-09-23 (option A: retire the three contract-map entries that name paths nothing mounts). The contract map is the declaration SDKs, codegen and AI clients are entitled to trust, and three of its seven entries named paths the composed runtime mounts nowhere: the package dispatcher has no branch for a single-segment POST under /packages and `@objectstack/rest` mounts only /packages/publish there, so all three answered handled=false while the four surviving entries answer 200/201 (measured on one HttpDispatcher over a real SchemaRegistry) — and the generated reference page printed all three as live endpoints. Unlike `installPackage` (rebound by an earlier fix onto the serving POST /api/v1/packages), no serving door existed to rebind them onto, and mounting three capabilities with zero measured pull was ruled out (ADR-0049 enforce-or-remove). Zero consumers measured at the retiring PR's base: across this repository the three paths occur only in the declaring file, its unit test and the generated page, and the pinned objectui checkout names none of the three keys, none of the paths and not `PackageApiContracts` itself. A contract-map entry is not metadata — nothing authors, stores or parses it — so there is no source a D2 conversion could rewrite, and the removal is recorded here." + }, + { + "surface": "api.installPackage request body, WRAPPED form — an undeclared TOP-LEVEL key beside manifest on POST /api/v1/packages (PackageInstallRequestSchema, the wrapped branch of PackageInstallBodySchema)", + "replacement": "the declared wrapped body: `manifest`, plus any of the declared install options (`settings`, `enableOnInstall`, `overwrite`, `platformVersion`, `artifactRef`). A misspelled option is respelled as the option it meant — `enabledOnInstall` → `enableOnInstall`, which the refusal itself offers — and any other undeclared key is removed. The bare form (a manifest as the whole body) is unchanged: it was already closed, and it still carries no install options.", + "migrationId": "package-install-request-unknown-keys-refused", + "toMajor": 18, + "rationale": "One rule for the whole install contract (the maintainer's ruling of 2026-09-27, option A: the wrapped form refuses an unknown top-level key by name). The manifest and the bare form already refused an unknown key by name; the wrapped top level was the one position still declared strip mode, so `{ manifest, enabledOnInstall: false }` — a misspelled `enableOnInstall` — parsed green with the key DROPPED, and the install door, which answers exactly what this declaration says since it parses the whole body (c02fa1276), installed the package ENABLED: the caller's explicit `false` inverted, with no word said. The sentence that had forbidden this close rested on «the declaration must not refuse a body the door answers 201 to», which held only while the door did not parse its body; with the door answering per declaration the premise became circular and constrains nothing. No alias and no grace window. Not losslessly convertible: an unknown key has no mapping target, and auto-deleting it would repeat the silent drop this closes, so each occurrence needs the caller's decision — respell or remove. First-party reach measured before the close: the SDK install call sends only `manifest`, `settings`, `enableOnInstall` and `overwrite`, and the objectui package dialog sends only `{ manifest }`, so no in-repo caller breaks. Out-of-repo callers are NOT MEASURED — a caller that sends a private top-level key now gets a 400 naming it." + }, + { + "surface": "PackageManifestSchema.version (`marketplace/package-version.zod.ts`) — the `version` key inside the manifest snapshot frozen into `sys_package_version.manifest_json` at publish time", + "replacement": "a SemVer 2.0.0 string matching `SEMVER_2_0_0_VERSION_PATTERN` (`kernel/version-grammar.ts`). This key was a bare `z.string()`, so it is the one carrier where the grammar is entirely new: `latest`, `v1.0.0`, `1.0`, the empty string, a trailing space and `2.0.0-beta.1extra!` were all accepted and sealed into a published snapshot, and each is refused now. A dist-tag becomes the version it pointed at (`latest` → `1.4.2`); a `v`-prefixed string drops the prefix (`v1.0.0` → `1.0.0`); a two-segment string gains its patch (`1.0` → `1.0.0`).", + "migrationId": "package-manifest-version-grammar-enforced", + "toMajor": 18, + "rationale": "A downstream told \"the spec validated it\" got no validation at all from this carrier. The sibling key it belongs to — `PackageVersionSchema.version`, the row this manifest hangs off — enforced a grammar the whole time, so the SAME release was judged by a rule in one field and by nothing in the adjacent one, and the unjudged value is the one that got frozen and shipped. That is the shape Prime Directive #10 refuses: a declaration advertising a constraint the runtime never applies. The canon ruling gave every carrier of this concept one grammar, and a carrier with no grammar could not be left out of it without keeping the hole open under a new name. Why a D3 semantic TODO rather than a D2 conversion: the repairs above are one-directional guesses. `latest` names whichever release was current when the snapshot was sealed, which is not recoverable from the snapshot, and `1.0` may mean `1.0.0` or the newest `1.0.x` — a transform that picked either would seal a different release under the same checksum." + }, + { + "surface": "api.packageRollbackResponse (`PackageRollbackResponseSchema` in api/package-api.zod.ts — 1 def, 3 exported names: `PackageRollbackResponseSchema`, `PackageRollbackResponse`, `PackageRollbackResponseParsed` — plus the `PackageApiContracts.rollbackPackage` contract-map entry that bound it to `POST /api/v1/packages/:packageId/rollback`)", + "replacement": "`RollbackToPackageCommitResponseSchema` (api/package-lifecycle.zod.ts) — the transcription of what the live route actually answers: the dispatcher routes `POST /packages/:id/rollback` (body `{ commitId }`) to `rollbackToPackageCommit`, the ADR-0067 COMMIT rollback, whose declared return is `{ success, revertedCommits: string[], failed: Array<{ commitId, error }> }`. Consumers of the retired type were reading a VERSION-rollback shape (`restoredVersion`) the route has never answered; read `revertedCommits`/`failed` instead. `PackageRollbackRequestSchema` stays published (ruled out of the retirement), bound to no route.", + "migrationId": "package-rollback-response-retired", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-08-27 on the client SDK's unbound response contracts, sub-question 3A: retire this false declaration first, then author the true one. The schema declared a version rollback — `{ success, restoredVersion?, message? }`, matching its file header \"Rollback a package\" — while the live path it was contract-bound to serves the ADR-0067 commit rollback: a different operation with a different result. Binding it in the SDK would compile and be false (the change that typed the SDK's un-annotated return values left a compile-time guard against exactly that substitution). Zero consumers measured across objectstack, objectui and cloud (the ruling's own survey, re-verified at the retiring PR's base): only its own unit test and that negative guard. A published declaration that outran the implementation is the hazard of response bodies never checked against the schemas that declare them, realised in the opposite direction — not \"no declaration\" but a WRONG one — and it is retired BEFORE the true schema is authored so no window exists in which both claims are published." + }, + { + "surface": "PackageVersionSchema.version (`marketplace/package-version.zod.ts`) — the `version` column of a `sys_package_version` row, and through `CreatePackageVersionRequestSchema.version`, which references it, the version a draft release is created with", + "replacement": "a SemVer 2.0.0 string matching `SEMVER_2_0_0_VERSION_PATTERN` (`kernel/version-grammar.ts`). Two changes, opposite in direction. ⭐ WIDER: suffix identifiers may now carry either ASCII case, because SemVer 2.0.0 is case-preserving — `1.0.0-Beta.1` and `1.0.0+Build.5` are accepted where this key used to demand lowercase, and the plugin boot path has always accepted them. ⛔ NARROWER: the forms the standard forbids are refused — `01.1.1` (§2), `1.0.0-0123` and `1.0.0-alpha..1` (§9), `1.0.0+.` (§10).", + "migrationId": "package-version-row-semver-2-0-0", + "toMajor": 18, + "rationale": "This key's own docstring advertised `2.0.0-beta.1` as an example of itself while a sibling carrier of the same concept refused that exact string — the contradiction the canon card was filed over. The lowercase restriction was the narrowest published accept set of the four and had no standard behind it: it made a release row refuse a version the runtime that loads the release accepts, so a publisher could be turned away for a capitalisation the loader would never have noticed. Why the narrowing is a D3 semantic TODO rather than a mechanical rewrite: a published version row is immutable by contract — `manifestJson` and `checksum` freeze on transition to `published` — so a stored degenerate version is not edited in place at all. It is republished under a version that sorts, and whether the old row should be deprecated or left standing is a release decision the chain cannot make." + }, + { + "surface": "api.listPackages limit and cursor — the two query parameters of GET /api/v1/packages declared by ListInstalledPackagesRequestSchema. The same entry covers the limit default: the request schema no longer declares default(50)", + "replacement": "the `status`, `type` and `enabled` filters — this route answers the whole installed set and has no page 2. There is no replacement for `cursor`, deliberately: nothing ever minted one, so no caller holds a value to carry over, and the response `nextCursor` it would have paired with was never emitted. Callers that looped on it were re-reading the first and only page. For the removed `limit` default, there is nothing to send instead and nothing to restore: the server has never capped this list, so a caller that omitted the key received every installed row before this change and receives every installed row after it. A client that sized a buffer to the declared 50 should size it to the installed set instead", + "migrationId": "packages-list-pagination-retired", + "toMajor": 18, + "rationale": "One capability, both halves, never half-deleted (maintainer ruling 2026-09-13, route 2 of three; routes 1 — build paging — and 3 — refuse unknown names — were considered and refused). `limit` and `cursor` were declared on the request and honoured on neither: the serving door filters on `status`, `type` and `enabled` and then returns every remaining row, and no emit site has ever written the response half `nextCursor`. `limit` is the sharper of the two because the repo's own ingress rule names it as the parameter whose silent drop is worst, and it is the silent-WIDENING half that was live: a caller asking for one row was handed the whole table alongside a `hasMore: false` that agreed with it. The `.default(50)` goes with the key because the FICTION WAS THE MECHANISM, not the number: nothing parses a query string through this schema, so the default has never stamped anything onto anything, while a reader of the published contract was entitled to believe an unparameterised list is capped. Re-spelling it as the real cap was not available — there is no cap. Pagination was removed rather than implemented because the installed-packages list is a small bounded collection and paging is not part of its meaning: route 1 would have grown a cursor protocol for a table of tens of rows, and the dispatch checked first whether a platform-wide cursor convention already existed that this door could have joined by reuse. It does not — no REST list door in the tree paginates, the one encode/decode cursor pair in the repo belongs to the storage-adapter list contract and is imported by no door, and the travel of this platform is the other way: `data.query.cursor` and `api/ListNotificationsRequest:cursor` were both retired before this one, for the same reason. Route 2, and the bookkeeping splits exactly as the notifications `cursor` retirement did. There IS a tombstone: the schema is non-strict, so a bare deletion would have made Zod SILENTLY STRIP whatever a generated client kept sending — a clean parse and a parameter that never takes effect, which is this issue's own defect re-created one layer down (ADR-0104). So both keys are `retiredKey()`, typed `never` for tsc and raising the prescription at any parse, and both are registered in RETIRED_KEYS_BY_MAJOR[18]. There is NO D2 conversion: a conversion rewrites an authored source or a stored `sys_metadata` row, and this shape is HTTP-only — nobody authors a `ListInstalledPackagesRequest` and nothing persists one. There is no `acceptRetiredDefaultResidue` stage either, for the same reason one layer along: nothing ever parsed this schema, so the retired default materialized into no artifact and there is no residue to accept. The same card closes the divergence in the OTHER direction, which is not a migration for anyone and is recorded here only so the two are not read apart: `type` (list), `version` (by-id) and `keepData` (uninstall) are query parameters the doors already executed and no request schema declared, and they are now declared where they are executed. No accept set moves — the doors served them before and serve them identically now. ADR-0049 / ADR-0087." + }, + { + "surface": "`page.assignedProfiles` — the per-page audience list (REMOVED)", + "replacement": "the object's permission sets, bound to people through positions. The page shows DATA; gate that data with the permission sets on the objects it reads (`objects..allowRead` and the field-level bits), and bind each set to the people who should hold it through a position (`sys_position_permission_set`). There is no per-page audience key to move the list into, and ADR-0090 D2 deleted the Profile concept the old list was written in, so each name in a retired `assignedProfiles` list has to be re-expressed as a permission set + position pair.", + "migrationId": "page-assigned-profiles-audience-to-permission-set", + "toMajor": 18, + "rationale": "The D2 conversion `page-assigned-profiles-removed` STRIPS the key mechanically, but the strip is not the whole migration and must not read as one: the author who wrote the list was declaring an intent (\"only these people see this page\") that the platform never honoured. Measured at the ruling: zero readers in this repository and zero in objectui — no renderer, route or metadata read door consulted the key — so the page has been open to every caller who could reach it for as long as the key existed. Deleting it therefore changes no behaviour and closes no hole; it makes an unkept promise stop being made. Which permission set corresponds to a given profile name is a judgement no walker can derive, which is why this is a TODO rather than a rewrite." + }, + { + "surface": "page.components[].responsive — the per-breakpoint columns / order / hiddenOn block, and the exported ResponsiveConfig shape with its breakpoint maps", + "replacement": "The sibling `responsiveStyles` (ADR-0065): per-breakpoint CSS maps compiled to id-scoped CSS at render — for example `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens.", + "migrationId": "page-component-responsive-retired", + "toMajor": 18, + "rationale": "The D2 conversion `page-component-responsive-removed` deletes `responsive` from every page component wherever one can be authored, and the delete is lossless: no renderer ever read the block, so the per-breakpoint columns, order and visibility it declared parsed, validated and did nothing. This was also the block an earlier tombstone prescribed as the live alternative for dashboard widgets, so an author who followed that advice moved an inert key to an inert key and may still believe their page adapts to small screens. What remains is theirs to decide: whether the layout they declared is one they still want, and if so how to say it in CSS that is applied — `hiddenOn` maps to a `display` rule per breakpoint, while column spans and order are layout choices with no one-to-one CSS rewrite. Code that imported the retired shape (ResponsiveConfigSchema, BreakpointName, the breakpoint maps) must drop the import; nothing replaces it." + }, + { + "surface": "page.component.page:header.breadcrumb — the page header's \"Show breadcrumb\" switch", + "replacement": "Nothing: delete the key, whether it was `true` or `false`. The navigation trail is drawn once, by the app shell's header, and is unchanged.", + "migrationId": "page-header-breadcrumb-retired", + "toMajor": 18, + "rationale": "The D2 conversion `page-header-breadcrumb-removed` deletes `breadcrumb` from every page header, and no trail is lost: the renderer drew an empty slot for it and nothing ever filled that slot. The slot was the only thing either value changed — present for `true` and for an absent key, gone for `false` — so a header that said `false` reads as absent after the strip and shows the empty slot's spacing again until the renderer stops drawing it. What the conversion cannot decide is whether a page needs a trail of its own: inside an app the shell already draws one, and a page outside the shell that needs one is a feature to ask for, not a key to keep." + }, + { + "surface": "page.requires on a page whose kind is react, full or slotted — a page that omits kind included, since its kind is full", + "replacement": "Nothing: delete the key. On an html page (and its deprecated jsx alias) the platform derives `requires` from the source at save and stores it, so it is omitted there too; on a react, full or slotted page nothing ever derived or enforced it, and nothing takes its place.", + "migrationId": "page-requires-non-compiled-kind-refused", + "toMajor": 18, + "rationale": "`PageSchema` admitted `requires` on every page kind, but the platform derives it only on the kinds whose source the metadata save door compiles: saving an html page (alias jsx) on a server that has the deployment's SDUI component manifest compiles the source, stores the plugin namespaces it uses as `requires`, and refuses a written list that disagrees. A react source is executed at render and never compiled at save, and full and slotted pages have no source, so on those kinds nothing derived the key, the Studio page editor dropped it on every save, and its one reader was a load-time warning. The maintainer ruled (2026-10-03) that the key is accepted only on html and jsx pages. The parse now refuses it on react, full and slotted pages, and a page that omits kind is a full page: `objectstack validate`, the metadata save door (a 422) and every other door that parses a page name the key, the page's kind and the compiled kinds. An empty list is refused like a full one, because the key is what is refused. No page body authoring the key on those kinds was measured in this repository, cloud, hotcrm or objectui. The D2 conversion `page-requires-non-compiled-kind-removed` deletes it from such pages: stored rows and built artifacts replay it at load, with a notice, and `objectstack migrate meta --from 17` lists the edit for authored sources, which the parse refuses until it is made. The delete loses nothing a page did. What it cannot decide is whether the page should have been an html page: an author who wrote the list to have plugin presence checked gets that check only on an html page, where the platform derives the list from the source and judges it at save and load." + }, + { + "surface": "permission.objects..allowRestore / permission.objects..allowPurge — the object-permission bits for undelete and hard delete", + "replacement": "(removed — the `restore` and `purge` operations they claimed to gate do not exist.) A dispatched `restore` or `purge` is denied fail-closed by the permission evaluator's destructive-operation backstop for every principal. The bits return together with the operations they gate. `allowTransfer`, the third lifecycle bit, is enforced and stays.", + "migrationId": "permission-restore-purge-bits-retired", + "toMajor": 18, + "rationale": "The D2 conversion `permission-allow-restore-purge-removed` deletes both keys from every object permission in author sources (both values, `true` included), and the delete is lossless: no destructive lifecycle verb is in the engine's dispatch vocabulary, so a grant delivered nothing and a denial locked nothing — every such request was, and stays, denied. The judgment is about what people believed. An admin who wrote `allowPurge: false` believed a lock existed; an admin who wrote `allowPurge: true` for a compliance role believed that role could hard-delete a record on request — a GDPR erasure, for instance. Neither was ever true. Any process, runbook or audit statement that relies on either belief needs another path, and deciding that path is a governance decision no conversion can make. Separately, an artifact built by the 17.x toolchain carries both keys materialized as the literal `false`; that one value is tolerated at load as inert residue and stripped, and every other value — `true`, or a string or number spelling — is refused with the prescription." + }, + { + "surface": "permission.rowLevelSecurity[].tags — the free-form categorization tags on a row-level security policy", + "replacement": "(removed — no mainstream platform tags a row-level policy, and nothing here ever read one.) A policy is identified by its `name` and its `object`, and reported by those and its predicate; its purpose belongs in `description`. Whom a policy applies to is decided by `positions`, never by a tag.", + "migrationId": "permission-rls-tags-retired", + "toMajor": 18, + "rationale": "The D2 conversion `permission-rls-tags-removed` deletes `tags` from every row-level security policy in author sources and in stored permission rows, and the delete is lossless: the RLS compiler never consulted the key and nothing else acted on it — no report, audit filter or review queue selected on it — so no access decision changes. The judgment is about what people believed. An admin who tagged a policy `gdpr` or `pci` may have expected a compliance report, an audit filter or a review queue to pick it up; none ever did. An author who wrote a tag such as `managers_only` may have believed it scoped the policy; it never did — only `positions` narrows whom a policy applies to. Any report, runbook or control that relies on either belief needs another path, and choosing that path is a governance decision no conversion can make." + }, + { + "surface": "OrgScopingEntitlement.platformGlobalObjects — an object a deployment declares platform-global no longer keeps its injected organization_id column with the organization wall stood down over it; on that deployment the injected-columns plan withholds the column, and the engine registers the object with no organization_id and declaring systemFields.tenant false", + "replacement": "Nothing to rewrite where no deployment declares the object. On the declaring deployment, the declared object has no `organization_id`: rewrite any authored filter, list-view column, report grouping, formula or seed key that names `organization_id` on it, or drop it; a write naming it is refused `INVALID_FIELD` and a filter `INVALID_FILTER`. The object is governed by object permission, not by the organization wall", + "migrationId": "platform-global-object-organization-column-retired", + "toMajor": 18, + "rationale": "ADR-0131 D7: \"an object a deployment declares platform-global gets no organization column on that deployment (the injected-columns plan reads the declaration), so Layer 0 and the driver agree by having nothing to scope\". Before this, the declaration stood the security layer's organization wall down for the object while the column stayed, so the SQL driver went on scoping a read by the caller organization that the wall had stopped scoping — measured on a booted kernel with a fixture provider, before the change. ADR-0131 retires that stand-down (\"replaced by D7's no-column\"). The engine reads the declaration at its plugin start(), before the first schema sync: every plugin init() has completed by then (ADR-0116, the Phase 1/2 split) and the org-scoping provider registers the service in its init(), declared in providesServices, so an object registered earlier is re-planned before its table is created. An absent declaration leaves every object's plan byte-identical; a malformed one is refused loudly and declares nothing. An object that declares its own organization_id keeps it and stays walled on it. Existing databases: schema sync is additive, so the physical column stays on a declaring deployment and the boot drift report names it orphaned; the operator removes it, and nothing moves at boot." + }, + { + "surface": "The two platform audit time-zone columns — `sys_job.timezone` and `sys_report_schedule.timezone` — carrying a string that is not a member of the IANA time-zone database (`Asia/Shangai`, `Europe/Munich`, `UTC+8`, `PST`).", + "replacement": "The canonical IANA zone id the deployment meant, written in the spelling the tzdb uses: `Asia/Shanghai`, `Europe/Berlin`, `America/Los_Angeles`. `UTC` is a member and is admitted — membership is the shared `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf('timeZone')` enumeration, which omits `UTC` and would refuse the one fallback this contract names. ⚠️ A non-member is RE-AUTHORED, never repaired on the deployment's behalf: the correct zone behind a typo is a fact only the deployment holds, which is what makes this entry semantic rather than a D2 conversion.", + "migrationId": "platform-timezone-columns-iana-domain-refused", + "toMajor": 18, + "rationale": "The change validating `sys_job.timezone` and `sys_report_schedule.timezone` against the IANA domain gave both columns `valueDomain: 'iana_time_zone'`, which had been declared on `sys_business_unit.timezone` / `sys_organization.timezone` since those two objects first gained a timezone column. It is a WRITE-TIME narrowing of the `min`/`max`/`maxLength` transition-gate class: a value already stored outside the domain is never re-read against it, no DDL is planned, and `objectstack migrate meta` has nothing to rewrite — the changeset that shipped it says so in those words, and this entry does not contradict it. What the changeset had no way to carry is that a deployment holding such a value now has WORK TO DO: the next write of that row is refused with the ADR-0114 field code `value_domain`, and until then `sys_report_schedule.timezone` keeps doing the thing the narrowing exists to stop — `ReportService.nextRunAt` hands a non-member zone to croner, whose throw was caught and turned into a silent fall back to `interval_minutes`, so \"every weekday 09:00 Asia/Shanghai\" became \"every 1440 minutes, forever\". Not a throw and not a fall back to UTC: the wrong instant, permanently. ⛔ It went out with NO `**BREAKING**` marker, so the repo's own breaking-change detector classified it non-breaking and asked for no ADR-0087 disposition at all — measured on the shipped changeset. A ruling closed that hole (the declaration now carries a `(narrowing)` arm the gate reads instead of a prose banner) and this row is the other half of the same ruling: the narrowing that already shipped is RECORDED, ⛔ not re-released and ⛔ not ratified in silence. Maintainer ruling 2026-09-07, option B: the clause-② declaration gains a widen / narrow arm that the ADR-0087 classifier reads, and each narrowing that already shipped without a banner is recorded as one ledger row. The direct precedents for registering a change no transform can apply are `schedule-flow-acting-organization-required` (protocol 18) and `rest-requireauth-default-flip` (protocol 12) — behaviour-only, a deployment judgement, registered anyway because the prescription is real." + }, + { + "surface": "`PluginHealthCheck.autoRestart`, `PluginHealthCheck.maxRestartAttempts` and `PluginHealthCheck.restartBackoff`, and the `PluginHealthMonitor.attemptRestart` path that read them", + "replacement": "Poll `PluginHealthMonitor.getHealthStatus(pluginName)` / `getHealthReport(pluginName)` and act on `unhealthy` / `failed` in the HOST. There is no in-tree replacement for the keys, because restarting a plugin is the host's job in this host-driven library and the monitor could not do it even in principle: `Plugin.init(ctx)` needs a `PluginContext`, which only the kernel constructs and which it exposes to nobody (`ObjectKernel.context` is private, `KernelBase.createContext` is protected). Recreate the kernel, or let your supervisor restart the process — whichever level actually owns the plugin's lifetime. The monitor reports; it does not act.", + "migrationId": "plugin-auto-restart-never-reinitialised", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove, applied one class over from the two hot-reload retirements in the same host-driven lifecycle library — the file-watching placeholder whose `startWatching` logged success while watching nothing, and the `'disk'` / `'distributed'` state strategies that fell back to memory in silence — and for a sharper reason than either: this key HAD a reader that acted, and what it did was not what the key declared. `attemptRestart` called `plugin.destroy()` and stopped there. The comment above the call read \"Call destroy and init to restart\", and `init` appeared in `health-monitor.ts` ONLY inside that comment. So what a plugin actually got was: destroy, a log line reading 'Plugin restarted', status `recovering`, and periodic health checks continuing to run against the destroyed instance — which the default check when no `checkMethod` resolves (`{ name: 'plugin-loaded', status: 'passed' }`) passes indefinitely. The TERMINAL report on a destroyed, never-re-initialised plugin was therefore `healthy`, reproduced at ee3595cefd with `successThreshold: 3` as failed -> recovering (destroyed=1, alive=false) -> recovering -> recovering -> healthy (destroyed=1, alive=false). The fix that made `successThreshold` bind from every status that records a failure made that MORE convincing rather than less, because reaching `healthy` now costs `successThreshold` CONSECUTIVE passing rounds, so the plugin has to earn a declared number of passes to be misreported. Meanwhile `restartAttempts` was incremented as though a restart had occurred, and `maxRestartAttempts` / `restartBackoff` scheduled further \"restarts\" of a plugin that was never brought back up. Neither of the other two ADR-0049 states was available: ENFORCE would have to BUILD the restart, and the class cannot host one — `Plugin.init(ctx)` needs a `PluginContext`, and the only two `plugin.init(...)` call sites in the tree are the kernel's own boot loops over the full plugin list, with a context that is private on `ObjectKernel` and protected on `KernelBase`, so a host-provided re-init hook would have had nothing to call (positive control: the same scan resolves five real non-test `plugin.destroy()` call sites, so it sees lifecycle drivers). Building that API for a caller that does not exist — no runtime constructs `PluginHealthMonitor`, which is why the maintainer retired its declarative config container on 2026-08-25 and kept the classes as a host-driven library — is exactly the speculation ADR-0049's staged decision names as the wrong default at this milestone, where the shippable liability is the false promise and not the missing feature. EXPERIMENTAL requires a roadmap, and a scan of the whole `docs/` planning + ADR corpus returned ZERO mentions of plugin auto-restart against 118 control hits for \"health\" and 13 for \"hot reload\" in the same corpus. The other two keys leave with the first rather than as a tidy-up: with no restart, \"Maximum restart attempts before giving up\" and \"Backoff strategy for restart delays\" have nothing left to be the vocabulary OF — the same test that took `distributedConfig` out with the `stateStrategy` value it was documented as being required for (ruled 2026-08-26: a vocabulary of nothing is not a vocabulary). All three are TOMBSTONED rather than deleted, for the reason the file-watching retirement recorded: a key leaving a SURVIVING def has no route-3 exit, and `PluginHealthCheckSchema` is not `.strict()`, so a bare deletion would be a silent strip (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — a milder form of the very defect being retired. There is no D2 conversion, because `PluginHealthCheck` is not an authorable surface: no metadata-type binding, stack collection or manifest embed ever carried it, so there is no authored document to rewrite. This entry IS the declaration." + }, + { + "surface": "manifest.contributes.events / manifest.contributes.menus / manifest.contributes.themes / manifest.contributes.translations / manifest.contributes.actions / manifest.contributes.drivers / manifest.contributes.fieldTypes / manifest.contributes.functions / manifest.contributes.commands (nine of the block's eleven members; `kinds` and `routes` are NOT part of this retirement)", + "replacement": "delete the keys — each capability already has its one enforced channel: `events` → subscribe imperatively in plugin code (`ctx.hook('kernel:ready', …)` from `init`/`start`); `menus` → the app `navigation` tree or `manifest.navigationContributions` (ADR-0029 D7); `themes` → the stack-level `themes` metadata collection (an unrelated `ThemeSchema` surface); `translations` → the `translation` metadata type, authored with `defineTranslationBundle` in `defineStack({ translations })`; `actions` → the stack `actions` collection or `engine.registerAction`; `drivers` → register a kernel service named `driver.*` (objectql calls `registerDriver` on it); `fieldTypes` → nothing (no registration seam exists; the vocabulary is the spec `FieldType` enum); `functions` → `defineStack({ functions })` → `engine.registerFunction`; `commands` → oclif native plugin auto-discovery (an `oclif` section in the plugin's own `package.json`; see `cli-extension.zod.ts`)", + "migrationId": "plugin-manifest-contributes-dead-members-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove: the nine members retire together, once the cloud half of the census below had come back clean. A census, monorepo-wide and non-test with control probes, measured that the ENTIRE monorepo contains exactly one read of `manifest.contributes` — `packages/objectql/src/engine.ts`, member `kinds` — so all nine members above parsed, entered the manifest, and changed nothing. The census stands on three repos: objectstack (re-verified on current main at claim time), objectui (0 property reads; control: 63 files carry the bare word), and cloud (measured clean 2026-08-24 at `5b5925a`: zero `manifest.contributes` reads, controls held). Several members were actively misleading: `events` was authored in-repo by a plugin that already subscribes imperatively; `commands` documented Commander.js resolution the CLI dropped for oclif auto-discovery; `fieldTypes` advertised a registration seam that has never existed. Why D3 semantic and not a D2 conversion: the conversion chain walks a normalized STACK and `PLURAL_TO_SINGULAR` has no `packages` / `plugins` entry, so a manifest is not a stack collection member and a conversion would be a transform with no seam that ever runs (the `kernel/Manifest:loading` precedent, recorded verbatim in its retired-key entry)." + }, + { + "surface": "manifest.contributes.routes (the one member the nine-member retirement deliberately left to its own fork; `kinds` is now the block's sole surviving live member)", + "replacement": "delete the key. A route that needs real handler CODE is mounted imperatively: resolve the `http.server` service from the plugin context and register the handler on `kernel:ready` (plugin-hono-server registers the service; `examples/app-showcase` mounts POST /api/v1/showcase/recalc that way). A declarative endpoint over a pipeline the platform already runs — query/return records, trigger a flow — is `defineStack({ apis })` (live since protocol 17, once the declarative endpoint executor was built and the loud refusal of a non-empty `apis:` became execution)", + "migrationId": "plugin-manifest-contributes-routes-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-08-22 (Option B of the enforce/remove/enforce-later fork, accepted verbatim 「接受所有」 on the decision batch carrying the four-axis analysis): remove the key, and redirect every author-facing recommendation of it to the imperative `http.server` mount. A monorepo-wide census with control probes measured zero readers of the key: the HttpDispatcher never registered a prefix from the declaration, so an entry parsed cleanly and served nothing — while FOUR published surfaces presented it as working machinery, one of them a customer-published skill (`skills/objectstack-api` told authors to choose it when \"the endpoint needs real handler CODE\"). That is ADR-0049's silent no-op with a published recommendation attached. Per the ruling's own sequencing the author-facing corrections landed FIRST (the skill's decision table, the dispatcher protocol doc, ADR-0088:40 and app.mdx, each redirected to the imperative mount), and the two remaining teaching sites (the plugin-rest-api.zod.ts worked manifest example, the metadata-plugin.zod.ts `router` delivered-form comments) are redirected in the removal PR itself. The cloud precondition was discharged first: a census of the cloud repository at 5b5925a found zero `manifest.contributes` reads, controls green. Enforce (fork A) was weighed and rejected on all four facets: net-new execution surface plus a prefix-claim authority question (who may claim `/api/v1/…`) for a declarative spelling with zero measured authors, while the capability is already reachable imperatively. Why D3 semantic and not a D2 conversion: a manifest is not a stack collection member (`PLURAL_TO_SINGULAR` has no `packages`/`plugins` entry), so a conversion would be a transform with no seam that ever runs." + }, + { + "surface": "manifest.capabilities / manifest.configuration / manifest.extensions (three top-level containers; retiring the container settles every key beneath it — `capabilities.{implements,provides,requires,extensionPoints,extensions}` and `configuration.{title,properties}` — at once)", + "replacement": "delete the keys — each declared purpose either has its one enforced channel or never existed: `configuration` (a `{ title, properties }` settings surface no UI rendered and no loader resolved) → pass options to the plugin's constructor in `defineStack({ plugins: [new MyPlugin({ … })] })`, the channel hosts already use; `capabilities` (protocol/interface declarations sold as \"interoperability and automatic discovery\") → nothing — no discovery path ever existed; real dependency resolution runs off top-level `manifest.dependencies`, which stays; `extensions` (an untyped `z.record(z.string(), z.unknown())` catch-all) → the enforced extension channels: `contributes.kinds` registers metadata kinds, `navigationContributions` (ADR-0029 D7) injects navigation, and code-level extension lives in the plugin itself (`init`/`start`)", + "migrationId": "plugin-manifest-dead-containers-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove, dispatched once the cloud half of the census below came back clean. A census, monorepo-wide and non-test with control probes, measured ZERO reads of each container itself, which settles all eight keys beneath them — a key cannot be read if the object holding it never is. The census stands on three repos: objectstack (re-verified on current main at claim time; every bare `.capabilities` hit classifies to a different surface — driver loader contracts, the QuickJS sandbox argument set, REST discovery, the ADR-0066 stack-level `capabilities` collection), objectui (0 container reads; control: `manifest.(id|name|namespace|version)` reads findable), and cloud (measured clean 2026-08-29 at `15f55df`: zero reads of all three, controls positive). `configuration.properties.secret` made this false compliance rather than tidying: its describe() promised \"value is encrypted/masked (e.g. API Keys)\" and nothing ever encrypted, masked or parsed it, so the key's own text was an unkept assurance about credential handling. Why D3 semantic and not a D2 conversion: the conversion chain walks a normalized STACK and `PLURAL_TO_SINGULAR` has no `packages` / `plugins` entry (re-verified — its `capabilities` entry is the unrelated ADR-0066 stack collection), so a manifest is not a stack collection member and a conversion would be a transform with no seam that ever runs (the `kernel/Manifest:loading` precedent, recorded verbatim in its retired-key entry). `PluginCapabilityManifestSchema` stays published: the plugin-registry surface (`plugin-registry.zod.ts`) still declares it, so this is a carrier-key tombstone with no def removal." + }, + { + "surface": "manifest.contributes.kinds[].globs (the `kind` bucket itself and its `id` are untouched)", + "replacement": "delete the key — a kind entry is `{ id, description? }`. File-type discovery is single-channel on the metadata type registry's `filePatterns` (`MetadataTypeSchema`, registered via `registerMetadataTypeSchema` / the default registry), which `contributes.kinds` never extended; if plugin-extensible discovery is ever wanted, it gets designed against that registry, not revived here", + "migrationId": "plugin-manifest-kind-globs-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-08-24 (「接受你的建议。」) on the aligned four-facet analysis: remove, through the full ADR-0049 ceremony. The sub-field was declared-but-unenforced on an authorable published surface: the schema promised that declaring `globs` \"enables the system to parse and validate new file types\" (its own example: a BI plugin handling `*.report.ts`), and the platform accepted it, stored it, and served it back through `GET /metadata/kind` — while the discovery the description promised never ran, because real glob-driven artifact discovery reads `filePatterns` off the metadata type registry and `metadata-plugin.zod.ts` records outright that `contributes.kinds` does not extend it. Measured by the engine-lane fix that made kind registration log its declared `id` (which also found the `kind` bucket itself reachable through `GET /metadata/:type`), and re-verified at claim time with a positive control: zero value reads anywhere (the only non-test occurrences of the path are the schema declaration and two type positions), and no in-repo manifest authors the key outside test fixtures. Enforce was weighed and rejected on all four facets: it would build a SECOND discovery channel parallel to `filePatterns` for a spelling with zero pull. Why D3 semantic and not a D2 conversion: a manifest is not a stack collection member (`PLURAL_TO_SINGULAR` has no `packages`/`plugins` entry), so a conversion would be a transform with no seam that ever runs." + }, + { + "surface": "the plugin-security scan-result family: the defs KernelSecurityScanResult and KernelSecurityVulnerability (kernel/plugin-security-advanced.zod.ts), their two authorable carriers on PluginSecurityManifest — scanResults and vulnerabilities — and the sibling verdict block PluginQualityMetrics.securityScan (kernel/plugin-registry.zod.ts)", + "replacement": "nothing to re-declare — delete the keys and every import of the two types. Plugin security scanning is not a platform capability and there is no replacement schema. What the platform does still enforce, and what to reach for instead: `permissions` and `sandbox` on the same PluginSecurityManifest are unchanged, and artifact provenance is answered by `verifyPluginArtifactIntegrity` and the plugin signature verifier — which tell you an artifact is the one its publisher signed, and never that it is safe. For dependency vulnerabilities use the tools built for it against your own project (npm audit / pnpm audit, Dependabot, the GitHub Advisory Database, OSV) and treat an unaudited third-party plugin as untrusted code. A publisher who used scanResults to advertise diligence keeps the surviving securityContact and vulnerabilityDisclosure blocks, which are contact terms rather than a verdict.", + "migrationId": "plugin-security-scan-result-surface-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-09-07 (adopted verbatim 「同意」): retire the scan-result family and its securityScan sibling, because once the scanner was gone nothing so much as imported their types. This is the second half of the scanner retirement recorded as plugin-security-scanner-retired. That retirement removed PluginSecurityScanner — a @objectstack/core class that shipped as a SECURITY control and could not fail, whose verdict was status \"passed\" for every plugin it was ever handed. The SCHEMAS the scanner fed survived it, and the scanner had been their only importer of any kind (a type-only import in packages/core/src/security/security-scanner.ts), so the family went from one type-only importer to zero consumers while staying fully published: 27 authorable rows across kernel.json, six api-surface exports, two authorable defaults and two json-schema manifest keys. An author could write any of it, be accepted, and get nothing — declared-not-enforced, Prime Directive #10, one layer out from the class removed for the same reason. The census was taken on origin/main after that removal landed, with a lit control (five hits for PluginSecurityManifest inside the declaring module) proving the file greppable, and found no .parse or .safeParse site against either schema anywhere in packages/**. securityScan is the sharpest member: scanResults published a report, but securityScan.passed published a VERDICT, so a plugin could declare itself clean with nothing behind it. Route: the two defs leave the build whole (RETIRED_DEFS_BY_MAJOR[18]) because nothing parses them and a prescription nobody can receive is not worth its cost; the three authorable keys are retiredKey() tombstones (RETIRED_KEYS_BY_MAJOR[18]) because both carrying shapes are non-strict, where a bare deletion is a silent strip (ADR-0104). Why this entry and not a D2 conversion: a plugin security manifest and a plugin registry entry are package artifacts a publisher ships, never stack collection members and never stored sys_metadata rows, so the conversion chain has no seam that would see one — the disposition the sibling kernel-plugin-security-durations-unit-in-key entry already records for this same manifest. No deprecation window (maintainer 2026-08-27: 「项目在创业阶段,用户也很少,短期不考虑渐进」). Scope note, recorded rather than acted on: PluginSecurityManifest.vulnerabilities is a forced consequence rather than a name the ruling listed — it was the last authorable referent of KernelSecurityVulnerability and could not outlive the def. Two neighbours the ruling made CONDITIONAL are deliberately untouched here because the repository the condition names, objectstack-ai/cloud, is not reachable from the session that executed this: the marketplace \"scanning\" status stays exactly as it is — unremoved, and NOT recorded as checked. Its two siblings were MEASURED rather than assumed, and the record is corrected here: both were ALREADY GONE when that ruling was written. The incident \"malware\" type was a member of system/IncidentCategory, and the whole incident-response family was retired whole, with the training and change-management families (maintainer ruling 2026-09-05: not roadmapped, so retired rather than marked experimental — two days BEFORE the 2026-09-07 ruling that made it conditional); see incident-response-family-retired. And marketplace-admin.zod.ts was deleted outright with the cloud subpath (ruled 2026-09-07: cloud does not re-host the control-plane files it never consumed); see cloud-subpath-retired. Verified on this tree by shape: both files return zero tree entries and no *.zod.ts names malware at all, against a lit control where \"scanning\" still returns a live declaration in marketplace.zod.ts. So the conditional question is ONE enum member wide, not three, and the objectstack-ai/cloud producer grep it still owes is that much smaller. ⚠️ The out-of-repo consumer population is NOT MEASURED. @objectstack/spec is published, so this removal is breaking for consumers no download, dependent or source telemetry was consulted for — accepted as an input to the ruling, exactly as that retirement states of its own three exports, and not a reason to soften the removal. ADR-0049, ADR-0087." + }, + { + "surface": "`@objectstack/core` runtime exports: `PluginSecurityScanner`, and the two types declared only to feed it, `ScanTarget` and `SecurityIssue`", + "replacement": "nothing to re-declare — delete the import and every call. Plugin security scanning is not a platform capability and there is no replacement export. A caller that branched on `result.status === \"passed\"` takes that branch unconditionally, because it is the only branch the scanner ever produced. What the platform does still enforce, and what to reach for instead: artifact integrity and signatures (`verifyPluginArtifactIntegrity`, the plugin signature verifier) answer \"is this the artifact the publisher signed?\" and never \"is this artifact safe?\"; plugin permissions and the sandbox resource limits are unchanged. For dependency vulnerabilities use the tools built for it against your own project — `npm audit` / `pnpm audit`, Dependabot, the GitHub Advisory Database, OSV — and treat an unaudited third-party plugin as untrusted code.", + "migrationId": "plugin-security-scanner-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05: retire the class and its two companion types with no replacement export, rather than repair it. The class shipped on `@objectstack/core`'s public barrel as a SECURITY control and could not fail. `scan()` composed five private scanners: four of them (`scanCode`, `scanMalware`, `scanLicenses`, `scanConfiguration`) allocated an empty issue array, logged and returned it with no code in between, so none could report a finding for any input; the fifth, `scanDependencies`, ran a real loop but matched only against an in-memory vulnerability database whose sole writer, the public `addVulnerability`, had zero callers in objectstack, in objectui at the pinned sha, or in the one demonstration that constructed the scanner, and `updateVulnerabilityDatabase()` logged twice and fetched nothing. The database was therefore empty on every code path that has ever executed: no issue was ever produced, the score stayed 100, and the verdict was `status: \"passed\"` for every plugin the scanner was ever handed — a malicious one as readily as a benign one. Repair was refused by name: a real vulnerability scanner is a feature with a design surface, not a defect fix. Why this entry exists at all, and why D3 semantic rather than a D2 conversion: `PluginSecurityScanner` has no spec schema and never had one — it is a runtime TS class, so there is no authorable key to tombstone with `retiredKey()`, no stored `sys_metadata` row that could carry it (a scanner was constructed per call and every result lived in a per-instance Map discarded with the object), and hence no seam `applyConversionsToStoredItem` would ever reach. The enforced channel is tsc, at the consumer's own import site; for anyone it does not reach, this ledger entry and the generated upgrade guide are the only channel there is. That is the disposition of `contracts.IDataDriver.findStream` (removed with no tombstone, because nothing parses a driver object) and of `actor-user-roles-to-positions` (the `ctx.user` `roles` alias, closed at once on the maintainer's word rather than given a window) — a TS/API contract, no stored source, no tombstone, tsc at the call site — applied to a surface one layer further out than either: those are declared in `packages/spec`, this one only in `packages/core`. ⚠️ The out-of-repo consumer population is NOT MEASURED. Zero constructors were found in objectstack, in objectui at the pinned sha, and in the deleted example, but no download, dependent or source telemetry was consulted for consumers of the published package, so this is breaking for an unmeasured population rather than a removal proven to break nobody." + }, + { + "surface": "plugin.version — `PluginSchema.version` (`kernel/plugin.zod.ts`), the key a plugin object carries into `kernel.use()`, and the boot-path predicate that judges the same string in `@objectstack/core` (`plugin-loader.ts`)", + "replacement": "a SemVer 2.0.0 string matching `SEMVER_2_0_0_VERSION_PATTERN` (`kernel/version-grammar.ts`). ⭐ Only EIGHT strings stop loading, all of them forms the standard forbids: `01.1.1`, `1.01.1`, `1.1.01` (§2, a leading zero in a numeric identifier — drop it); `1.0.0-0123`, `1.0.0-alpha..1`, `1.0.0-alpha..`, `1.0.0-.` (§9, a prerelease identifier that is empty or carries a leading zero — name it, or remove the empty segment); `1.0.0+.` (§10, an empty build identifier — name it or drop the `+` suffix). ⛔ Nothing else moves: every valid prerelease and build form this key accepts today it still accepts, `1.0.0-alpha.1` and `1.0.0-rc.1+exp.sha.5114f85` included.", + "migrationId": "plugin-version-semver-2-0-0", + "toMajor": 18, + "rationale": "The canon ruling made one grammar serve every carrier of \"the version of a package or plugin\", and named it after the standard: SemVer 2.0.0. This key had the widest of the four accept sets, which is why it is the only one that narrows without also widening. The narrowing is bounded deliberately, and the bound is what keeps the earlier widen-never-narrow ruling on this path honoured rather than reversed: that ruling's subject is what LOADS, and none of the eight is a valid prerelease. What they have in common is that no precedence order exists for any of them — `dependency-resolver.ts` in `@objectstack/core` can place none of them in an order — so a plugin versioned this way could be published and never compared against its own successor, which is a worse outcome than the refusal. Why it is a D3 semantic TODO and not a D2 conversion: each of the eight has several defensible repairs and the metadata does not say which was meant, and a version is how a release is addressed — rewriting one silently re-points whatever already resolved the old string." + }, + { + "surface": "the data write doors — a predicate-scoped (multi) update or delete, on every object and for every principal", + "replacement": "read a predicate update or delete as reaching only the rows the caller can read: the result counts those rows alone, a predicate that reaches only hidden rows succeeds with zero rows, and a predicate whose readable match exceeds one write's row ceiling is refused with 400 `INVALID_FILTER` — narrow it and write in batches", + "migrationId": "predicate-write-unreadable-row-not-matched", + "toMajor": 18, + "rationale": "A WRITE-DOOR ANSWER, made one with the read door's, on the predicate door as on the by-id door. The rows a predicate update or delete matched came from its write scope alone, so a row the caller cannot read was matched whenever that scope reached it: a per-row gate then refused the write with a 403, or the row was written and counted. Either answer told a hidden row apart from no row. The write middleware now asks the read door which rows the caller's own predicate returns — a read in the caller's context that every data middleware's visibility applies to — and narrows the matched set to them, so a row the caller cannot read is not written, not counted and not refused. A read the read door refuses keeps the write's previous answer, and a readable match larger than one predicate write's row ceiling is refused rather than cut off. A caller who can read a matched row but may not write it keeps its answer. Writes the platform issues under the caller's context — a cascade, a hook's own write, the referential clear of a lookup — keep their previous answer, and by-id writes are unchanged." + }, + { + "surface": "qa.scenarios[].requires.plugins", + "replacement": "`requires.services` — the discovery service keys the scenario needs (for example `auth`, `analytics`, `automation`, `ai`), each judged against the target's discovery document: met only when the target declares the service `enabled` with status `available`. The plugin → service mapping follows the provider table discovery itself reports (`CORE_SERVICE_PROVIDER`): `@objectstack/plugin-auth` fills `auth`, `@objectstack/service-analytics` fills `analytics`, `@objectstack/service-automation` fills `automation`, and so on. A plugin that fills no discovery service slot has no service to require.", + "migrationId": "qa-scenario-requires-plugins-retired", + "toMajor": 18, + "rationale": "`requires.plugins` was declared as a precondition and checked by nothing: `os test` reaches its target over HTTP, no served surface lists the loaded plugins, and the plugin spelling (package name or `plugin.name`) was never defined — so a scenario naming a missing plugin ran anyway and failed, or passed, on whatever the missing plugin caused. The block is now enforced (ADR-0049): core's TestRunner judges `requires` before the first step, and an unmet entry makes the scenario SKIPPED with a reason, counted separately and never as passed. `plugins` could not join that judgement honestly, so it retires into `services`, which the target's discovery document already answers (ADR-0076 D12: advertise only what is mounted). The consumer still owes the judgement because a plugin name does not always map to one service — a plugin that fills no discovery slot was never a checkable precondition, and only the suite's author knows what the scenario needed it for." + }, + { + "surface": "api.RealtimeEventType — the values 'record.created', 'record.updated', 'record.deleted' and 'field.changed' left the enum. It types SubscriptionEvent.type, so it reaches Subscription.events[].type and RealtimeConfig.subscriptions[].events[].type", + "replacement": "the names the runtime emits, which are now the whole enum: 'data.record.created' / 'data.record.updated' / 'data.record.deleted' for a single-record write, and 'data.records.updated' / 'data.records.deleted' for a predicate write (multi: true), which carries a count and no record. 'record.created' becomes 'data.record.created'; 'record.updated' and 'record.deleted' become their data.record twins, plus the data.records twin where a predicate write must be heard too; 'field.changed' becomes 'data.record.updated', whose DataEvent payload lists the changed fields in changes", + "migrationId": "realtime-event-type-unemitted-values-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove. RealtimeEventType was published in the generated API reference as the vocabulary of a realtime subscription, and no producer anywhere emitted any of its four values. What the runtime publishes is the DataEventType / BulkDataEventType vocabulary: the ObjectQL engine sends data.record.created, data.record.updated and data.record.deleted for each written record and data.records.updated / data.records.deleted for a predicate write, and it parses every event through DataEventSchema / BulkDataEventSchema before publishing. A subscription written with the only names the reference showed could therefore never fire, and nothing said so. The direction was settled before this change: the enum moves to the emitted names, and the runtime keeps publishing exactly what it published — changing the runtime's live event names to match an enum nothing had ever used would break every real subscriber. field.changed is the same dead spelling that DataEventType already dropped in protocol 17 (the entry data-field-changed-event-retired): no per-field event exists, because an update's per-field detail rides on data.record.updated as changes. Metadata change events (metadata.{type}.{action}) were not added: a subscription event is record-shaped (object names a data object, filters narrows records), and metadata events have their own MetadataEventType contract and client primitive. Bookkeeping: an enum VALUE puts nothing in RETIRED_KEYS_BY_MAJOR and leaves the four surface ratchets untouched; its prescription hangs on the enum's own error map (the HookBodyCapability precedent). It is a SEMANTIC entry rather than a D2 conversion because there is no source to rewrite: stack.zod.ts has no realtime key, no metadata type holds a subscription, and the open framework mounts no realtime transport that would parse one (maintainer ruling of 2026-09-04: realtime stays out of open core) — so the conversion chain has no seam that would ever see a subscription. ADR-0049 / ADR-0087." + }, + { + "surface": "`record:chatter` / `record:discussion` component props (one shared schema object): `position` vocabulary, and the schema defaults on `position` / `collapsible` / `defaultCollapsed` (DROPPED)", + "replacement": "`position: 'bottom' | 'right' | 'left'` — the renderer's own vocabulary (`right`/`left` dock a side panel, `bottom` renders in flow). 'sidebar' → 'right', 'inline' → 'bottom', 'drawer' → 'right' (no overlay drawer ever existed). No key replaces the dropped schema defaults: an unset key now stays unset and the renderer's own fallbacks apply (position 'bottom', collapsible off, defaultCollapsed off)", + "migrationId": "record-chatter-position-vocabulary-converged", + "toMajor": 18, + "rationale": "The schema declared a `position` vocabulary no read point ever compared (`sidebar`/`inline`/`drawer`), while the renderer chain — panel branches, designer registration, merge fallback, three sites in agreement, measured at objectui pin `665661ab0932` — speaks exactly `bottom`/`right`/`left`. So the spec-valid `sidebar` (the schema's own DEFAULT, materialized onto every parsed node that said nothing) silently fell through to the in-flow render, and the value that actually docks the panel (`right`) was refused at publish — declared ≠ enforced in both directions on the same key. The maintainer ruling of 2026-08-15 on this row converged it on the renderer's vocabulary with no mapping layer, and dropped all three schema defaults per the `maxVisible` principle (renderer fallbacks stay the renderer's facts): the old `collapsible` default (`true`) additionally INVERTED the renderer merge's own fallback (`false`), so \"the author said nothing\" parsed into \"the author asked for collapsible\". The mechanical rewrite is the ADR-0087 D2 conversion `record-chatter-position-vocabulary` (retired from the load path — the enum refuses the old spellings at parse with a per-value prescription; stored rows replay clean via the rehydration seam). This semantic entry exists for the two judgements the chain cannot make: whether `drawer` → `right` (a docked panel standing in for a never-implemented overlay) is the presentation the author wants, and whether a page that relied on the old materialized `collapsible: true` default should now author it explicitly. ADR-0087, maintainer ruling 2026-08-15." + }, + { + "surface": "page.component.record:highlights.fields[].icon — the per-chip icon on an object entry of the highlights field list", + "replacement": "(removed — the highlight chip has no icon slot.) Carry whatever the icon was meant to signal in what the chip does render: its label, or the field's own value.", + "migrationId": "record-highlights-field-icon-retired", + "toMajor": 18, + "rationale": "The D2 conversion `record-highlights-field-icon-removed` deletes `icon` from the object entries of every `record:highlights` field list, and the delete is lossless: the chip renders a label and a value and nothing else, the registration path carries field names only, and the Studio designer publishes the list as plain strings, so an authored icon was accepted and drawn by nothing. The residue is the author's intent. Six author-facing surfaces advertised the key, so an author may have chosen an icon to carry meaning — a warning glyph beside a risk score, a flag beside a region — and designed the page assuming a reader would see it. That meaning was never shown and is not shown now; only the author can say whether it matters and where it should live instead." + }, + { + "surface": "restServer.api.responseFormat / restServer.api.documentation.enabled", + "replacement": "(removed — delete each key; neither had an effect to preserve. Whether the server publishes its OpenAPI document and the docs viewer is `api.enableOpenApi`, the switch the mount already reads. Response shapes are fixed — each route answers in the response schema `@objectstack/spec/api` declares for it — and are not a server-wide option, so there is no replacement for `responseFormat`.)", + "migrationId": "rest-api-config-dead-keys-retired", + "toMajor": 18, + "rationale": "The `rest_api` liveness census found every member of these two keys `dead`: `normalizeConfig` parsed them, applied their defaults and copied them into the REST server's config, and no site ever read them back. So `responseFormat.envelope: false` unwrapped no response, `includeMetadata` and `includePagination` gated nothing, and `documentation.enabled: false` turned no document off — the document's existence was, and is, decided by `api.enableOpenApi` at the mount. Enforce-or-remove (ADR-0049) resolved both to REMOVE: mainstream data APIs keep a fixed response envelope that no administrator toggles server-wide, a configurable envelope would fork the declared response shapes the client SDK parses and the served /openapi.json describes, and `documentation.enabled` duplicates a switch that is already enforced. `RestApiConfigSchema` and its inline `documentation` block are non-strict `z.object()`s, so each key is a `retiredKey()` tombstone and its ledger row stays `dead` with a REMOVED note. No stored or built artifact carries either key, so no emitted default needs to be tolerated as residue: the config is a construction argument that is parsed and consumed in the same process. The consumer still owes the judgment because a host that WROTE `envelope: false` or `documentation.enabled: false` believed its clients saw a different shape or no document, and only that host knows which clients were built on the belief." + }, + { + "surface": "restServer.api.documentation.version", + "replacement": "(removed — delete the key. The served OpenAPI document's `info.version` is the protocol version, i.e. the version of the `@objectstack/spec` package that generated the document, with no configured override. An app that wants to publish its own release number writes it into `api.documentation.description`, which the served `info.description` now carries.)", + "migrationId": "rest-api-documentation-version-retired", + "toMajor": 18, + "rationale": "The `rest_api` liveness census found `documentation.version` `dead`: `normalizeConfig` parsed it and copied it into the REST server's config, and no site read it back, so `version: '2.3.0'` never reached the served document. Enforce-or-remove (ADR-0049) split the `documentation` block by who owns each field. The title, description, terms of service, contact and license are the publisher's identity and are now enforced. `info.version` is a fact of the protocol: an earlier ruling made the served `info.version` equal the published artifact's, so an integrator can read which protocol version they are talking to, and it removed the serve-time override that had made the field mean the route identifier. A publisher-set version would give the field a third meaning, so the key is retired instead of enforced. `RestApiConfigSchema`'s inline `documentation` block is a non-strict `z.object()`, so the key is a `retiredKey()` tombstone and its ledger row stays `dead` with a REMOVED note. The consumer still owes the judgment because a host that WROTE `documentation.version` believed its integrators read that number from the document, and only that host knows whether any client was built on the belief and where the number should be published instead." + }, + { + "surface": "RestApiEndpoint.handlerStatus (the implemented / stub / planned marker an endpoint in a REST API plugin route registration could carry), the HandlerStatusSchema / HandlerStatus value def it was typed with, and the RouteCoverageEntrySchema / RouteCoverageReportSchema report shapes (with their RouteCoverageEntry / RouteCoverageReport types) that re-declared it", + "replacement": "nothing declarative — the key never changed what the platform served, so there is no working configuration to migrate to. Delete the key; an endpoint that has no handler yet is simply not registered. Route readiness that IS measured is unchanged and lives elsewhere: the discovery payload reports each service's status and handlerReady (api/discovery.zod.ts), and packages/runtime/src/route-ledger.ts asserts per-route coverage in CI. A declared-but-unbuilt route answering 501 instead of 404 is a new capability the ruling explicitly excluded (zero pull); if it is ever wanted it re-declares fresh under its own ruling, executor first", + "migrationId": "rest-api-endpoint-handler-status-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling of 2026-09-01 on this key: remove it with a tombstone; enforce excluded. The key was DOCUMENTED to cause a specific runtime behaviour — its docstring said a stub handler \"returns 501 Not Implemented\" — and that behaviour has a different cause: every DispatcherErrorCode.enum.NOT_IMPLEMENTED site (runtime/src/endpoint-executor.ts ×3, runtime/src/api-mapping.ts, runtime/src/api-endpoint-step.ts) is the declarative-endpoint executor refusing a target or mapping it cannot serve, and none of them consults handlerStatus. Measured at the retirement base (origin/main a9b2be0b0, 2026-09-02, skills/** and tests excluded): the only identifier hits were the declaration on RestApiEndpointSchema, the re-declaration on RouteCoverageEntrySchema and a docblock saying adapters SHOULD warn on it; RouteCoverageReportSchema — the one shape that would have carried the status outward — had zero constructors in objectstack, objectui (pinned sha) and cloud. So an author who wrote handlerStatus: 'stub' expecting the dispatcher to answer 501 got an ordinarily served route, and the declaration reported progress to nobody — a declared ≠ enforced gap on the same endpoint vocabulary whose ApiEndpointSchema had already been closed strictly once `api` became a registered metadata type, and the surface a published skill had been teaching as working machinery (this finding came out of correcting that skill sentence, in a factual sweep of the API skill). Bookkeeping: the KEY is tombstoned with retiredKey() on the non-strict RestApiEndpointSchema (api/RestApiEndpoint:handlerStatus in RETIRED_KEYS_BY_MAJOR[18]); the DEFS leave whole — api/HandlerStatus (orphan value enum once both carriers are gone — an exported value schema with no consumer reads as a capability, so it leaves with its key), api/RouteCoverageEntry and api/RouteCoverageReport (route 3: nobody ever parsed or constructed one) — all three in RETIRED_DEFS_BY_MAJOR[18]. It is a SEMANTIC entry rather than a D2 conversion because there is no source to rewrite: nothing in the tree parses RestApiEndpointSchema outside its own unit tests — a REST API plugin route registration is not a stack collection member and never a sys_metadata row — so the conversion chain has no seam that would ever see one (the kernel/Manifest:loading disposition). ENFORCE was excluded by the ruling: mounting a 501 stub for stub / planned endpoints is a zero-pull new capability, not a repair. The same ruling records the class direction for two sibling ADR-0049 findings (the unbound branded identifier schemas and the event-name schema no runtime reads; not ruled by it): a declared-but-unenforced key with no pull retires; enforce/bind only on a named consumer or measured pull. ADR-0049 / ADR-0087." + }, + { + "surface": "the three REST-plugin durations whose name carried no unit: RestApiEndpoint.timeout, RestApiEndpoint.cacheTtl and RestApiPluginConfig.performance.defaultCacheTtl (api/plugin-rest-api.zod.ts)", + "replacement": "timeoutMs (milliseconds), cacheTtlSeconds (seconds) and defaultCacheTtlSeconds (seconds, default 300) — rename each key; every value is unchanged", + "migrationId": "rest-api-plugin-durations-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. RestApiEndpoint is this rule's clearest specimen after the founding one: `timeout` in MILLISECONDS and `cacheTtl` in SECONDS sat three lines apart on one shape, each unit named only in its describe, so the two numbers were indistinguishable at the authoring site and a value copied from one to the other was off by 1000x with no error anywhere. performance.defaultCacheTtl travels with them rather than in its own entry because it is the plugin-wide DEFAULT behind the per-endpoint override: renaming the override and leaving the default bare would have spelled one value two ways across one config. All three are retiredKey() tombstones — these shapes are not strict, so a bare deletion would strip the old key in silence, and defaultCacheTtl is a tombstone INSIDE the live `performance` block, whose siblings must keep parsing. Why a semantic entry and not a D2 conversion: a RestApiPluginConfig is the REST plugin's construction argument and a RestApiEndpoint is a route registration inside it — neither is a stack collection member or a stored row, so the chain has no seam that ever runs on them. That is the disposition api/RestApiEndpoint:handlerStatus already carries on this very shape (rest-api-endpoint-handler-status-retired), and what ruling B prescribes for a key that is not authorable metadata. ADR-0087." }, { - "surface": "flow.node.waitEventConfig", - "to": "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", - "conversionId": "flow-node-wait-timeout-keys-removed", - "toMajor": 17 + "surface": "restServer.crud.patterns / restServer.crud.objectParamStyle / restServer.metadata.cacheTtl / restServer.metadata.endpoints.schema / restServer.batch.operations.upsertMany / restServer.batch.defaultAtomic / restServer.routes.includeObjects / restServer.routes.excludeObjects / restServer.routes.nameTransform / restServer.routes.overrides", + "replacement": "(removed — delete each key; none had an effect to preserve. Per-object API exposure is declared on the object: `enable.apiEnabled: false` hides it from the REST data surface (404) and `enable.apiMethods` whitelists its operations (405). The data base path is `crud.dataPrefix`, deployment-wide. An endpoint on a custom path or method — or one that needs its own `summary` / `description` / `cacheTtl` — is a declarative `api` endpoint (`type: 'object_operation'`). Batch atomicity is the per-request `options.atomic` (ADR-0119 D4); upsert is an operation type of the generic `POST /data/:object/batch` endpoint, gated by `batch.enableBatchEndpoint`.)", + "migrationId": "rest-server-config-dead-keys-retired", + "toMajor": 18, + "rationale": "The liveness census that enrolled the four `RestServerConfig` sub-objects found 15 of their 32 rows `dead`: parsed, defaulted and normalized into the REST server's config by `normalizeConfig` (which parses them, rather than casting them, since an earlier fix) and never read back. `crud.patterns` and `routes.overrides` described route customization the server mounts from fixed pairs; `routes.includeObjects` / `excludeObjects` and `overrides.enabled` / `operations` duplicated the object's own enforced exposure keys; `nameTransform` and `objectParamStyle` were enums validated and then ignored; `metadata.endpoints.schema` and `batch.operations.upsertMany` gated routes that were never built; `metadata.cacheTtl` fed no cache and no header; `batch.defaultAtomic` would have silently overridden a per-request contract ADR-0119 D4 had deliberately set. Enforce-or-remove (ADR-0049) resolved every family to REMOVE because each promised capability either already exists at its proper seat (the object, the declarative endpoint, the batch request) or would contradict a fixed contract (the client SDK, the discovery document and the served /openapi.json all describe the mounted CRUD paths; the object `name` is the REST path segment). All four schemas are non-strict `z.object()`s, so each key is a `retiredKey()` tombstone and its ledger row stays `dead` with a REMOVED note; `api/CrudEndpointPattern`, the value def of `crud.patterns`, leaves with it. No D2 conversion: a `RestServerConfig` is plugin TS configuration, never a stack collection member or a `sys_metadata` row (the `openApi31` precedent). A closed-set sweep of the cloud repository at 9b6abe0f2fd5: zero hits, structural — cloud never authors a `RestServerConfig`." }, { - "surface": "datasource.readReplicas", - "to": "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)", - "conversionId": "datasource-read-replicas-removed", - "toMajor": 17 + "surface": "security.PermissionSet rowLevelSecurity[].check (RowLevelSecurityPolicySchema) on a policy whose operation is select or delete. A blank check (empty or whitespace only) declares nothing and is not refused", + "replacement": "what the predicate was meant to guard, written where it runs. To limit which rows a select policy lets a caller read, or which rows a delete policy lets a caller delete, write the predicate as `using` on that policy (remove `check`; if the policy already has a `using`, AND the two with &&). To validate rows as they are written, declare the `check` on a policy whose `operation` is `insert`, `update` or `all` instead. The refusal lands at rowLevelSecurity[N].check, names the operation, and states both rewrites", + "migrationId": "rls-check-on-select-or-delete-policy-refused", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove and ADR-0058 D4. A `check` judges the post-image of a write: the new row of an insert, the changed row of an update. A select or delete writes no row, and the plugin-security write gate collects only the policies whose operation is the write's own or `all`, so a `check` on a select or delete policy was accepted, stored and never evaluated. Measured on main before this change: a policy carrying only check record.status != 'archived' on select or delete admitted every insert and update of an archived row, and beside a USING-only `all` sibling it did not replace that sibling's `using` default the way a `check` on an insert, update or all policy does. An author (an AI author above all) who wrote a check on a delete policy believed deletes were guarded by it. The refusal is a non-transforming refinement on the policy schema, so it reaches every door that parses a permission set: defineStack, os validate, and the metadata save path, whose permission type validates against PermissionSetSchema. Metadata AT REST is not rewritten and this entry adds no D2 conversion: dropping the key would silently discard the predicate the author wrote, and moving it to `using` would start filtering reads or deletes the policy never filtered before — both change which rows the policy admits, which is the policy author's decision. Ships at once, no transition window and no advisory lint phase." }, { - "surface": "datasource.capabilities", - "to": "datasource key 'capabilities' removed (eleven flags no code read; pushdown comes from the driver's own supports.*, and `readOnly` never made anything read-only)", - "conversionId": "datasource-capabilities-removed", - "toMajor": 17 + "surface": "security.PermissionSet rowLevelSecurity[].check — a CEL predicate comparing a field with != or == against a list, a list literal or a current_user membership array, and the negation of such an ==. They lowered to { field: { $ne: [...] } }, { field: [...] } and { $not: { field: [...] } }, which the @objectstack/formula evaluator matchesFilterCondition now refuses, together with { field: { $eq: [...] } }, at any depth under $and / $or / $not, the empty array included", + "replacement": "the list operator the comparison was standing in for. \"One of these values\" is in: record.status in [\"open\", \"pending\"]. \"None of these values\" is the negated in: !(record.status in [\"closed\", \"archived\"]). Scalar != and ==, null, Date comparands, and { $field } references between single-valued columns evaluate exactly as before", + "migrationId": "rls-predicate-array-comparand-refused", + "toMajor": 18, + "rationale": "Ruling A of 2026-09-24 refuses an array comparand under $ne, and ruling 乙 of 2026-09-23 refuses one in the implicit-equality slot, each for every driver at once; this change lands both on the formula face, the evaluator plugin-security runs against the post-image of an insert or update to enforce a row-level check. It compared strictly, and no stored value ever equals an array, so a check written record.status != [\"closed\", \"archived\"], or != against a current_user membership array, matched EVERY post-image, and a check written !(record.status == [\"closed\", \"archived\"]) did the same: every write such a policy was written to refuse was admitted and stored. The positive record.status == [\"open\", \"pending\"] refused every write (403). The evaluator now refuses all of these shapes before any record is judged. The message withholds the field, the operator and the value. Metadata AT REST is not rewritten and this entry adds no D2 conversion: the platform cannot tell which list operator a list comparison was standing in for, and a policy rewritten on the author's behalf would change which writes it admits (the negated forms would start refusing writes they admitted, the positive form would start admitting writes it refused), which is the policy author's decision. ADR-0058 D4 / ADR-0087 / ADR-0112." }, { - "surface": "datasource.retryPolicy / datasource.healthCheck / datasource.external.label / datasource.external.requirePermission", - "to": "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)", - "conversionId": "datasource-inert-blocks-removed", - "toMajor": 17 + "surface": "security.PermissionSet rowLevelSecurity[].using and .check, and sharingRules[].condition — a CEL predicate comparing a field with another field (==, !=, >, >=, <, <=) where the two declared columns share no comparison class: text against a number, a date against a datetime, a boolean against text, and any column against a file field (file, image, avatar, video, audio) or a formula field. In a filter passed to matchesFilterCondition together with the object's declared columns (options.fields), a { $field } comparison under $eq, $ne, $gt, $gte, $lt or $lte between two such columns, and between a column and a json or multiple field", + "replacement": "a comparison between two columns of one comparison class: a number with a number (number, currency, percent, rating, slider, progress, summary), text with text (the string types, autonumber, a single select or radio, a single lookup or user, a master_detail or a tree), a boolean with a boolean, a date with a date, a datetime with a datetime, a time of day with a time of day. A file field and a formula field cannot be compared with another column at all: compare the field with a literal or test it for null. If the two columns do hold comparable values, one of them is declared with the wrong type, so correct that declaration rather than the predicate. Comparisons between two columns of one class lower and evaluate exactly as before", + "migrationId": "rls-predicate-cross-class-field-comparison-refused", + "toMajor": 18, + "rationale": "A column-to-column comparison has one meaning only within one comparison class: across classes SQLite orders every TEXT above every INTEGER while the in-process evaluator coerces (\"open\" > 5 is false). A formula field is virtual, with no stored column to reference. The file family is refused by name, whatever the deployment stores: during the ADR-0104 dual-encoding window one media column can hold a bare id and another the JSON-quoted form of the same id, so no comparison against the family is provably one answer on every path. driver-sql has refused such a comparison on the read since it first compiled a { $field } reference to a column-to-column comparison (a text column ordered against a number answered differently on SQLite than in memory, so the pushdown refused it), so a policy written record.status != record.amount (text and a number), record.status != record.photo (text and an image) or record.status != record.is_open (text and a formula field) got three answers, measured through the real plugin-security on driver-sql, on SQLite and PostgreSQL: os validate called it valid, every read it scoped answered INVALID_FILTER / 400 and every by-id update or delete it scoped 403, and an insert or update its check judged, or its using standing in as the check, was admitted and stored, because the write check compared the two raw values. The classification is now exported once from @objectstack/spec/data (crossFieldComparisonVerdict) and read by every judge. The authoring arm: the rls-predicate-unenforceable rule refuses the comparison in using and check, on every operation, at os validate, build and lint and at the metadata save door for a permission set, and the sharing-rule-unlowerable-condition rule refuses it in a sharing-rule condition at os validate, build and lint. The write-check arm: the row-level write gate hands matchesFilterCondition the object's declared columns, and a comparison the classification does not define is refused INVALID_FILTER / 400 for every insert and update the check judges, before any record is read, with nothing stored; the message withholds the columns and the server log names the policy and both. A comparison against a json or multiple field is now refused by its declared type on the write too, where the earlier refusal of an array comparand under $ne judged it by the value each record held. driver-memory, a test driver with no field-reference arm, still reads such a comparison as a literal. Shipped producers were counted before the change: no shipped row-level policy or sharing-rule condition compares two fields of different classes. Metadata AT REST is not rewritten and this entry adds no D2 conversion: the platform cannot tell which comparison the author meant, and rewriting it on the author's behalf would change which rows and writes the policy admits, which is the policy author's decision. ADR-0058 D4 / ADR-0087 / ADR-0112." }, { - "surface": "mapping.extractQuery / mapping.errorPolicy / mapping.batchSize", - "to": "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)", - "conversionId": "mapping-inert-keys-removed", - "toMajor": 17 + "surface": "security.PermissionSet rowLevelSecurity[].check, and .using where it stands in as the check — a CEL predicate ordering a field against a bound (>, >=, <, <=) where the field holds a list or an object on the record being written, as a json column or a multiple lookup does, or as a list written into a text or number field does. In a filter passed to matchesFilterCondition, $gt / $gte / $lt / $lte and $between on a field whose value on the record is a list or a plain object, whatever the comparand", + "replacement": "a comparison that names one value. Order a single-valued column (record.priority > 2), or test membership in the list with in (record.status in [\"open\", \"pending\"]); a json or multiple field has no ordering. A record whose json column holds one scalar is compared exactly as before, and so are null and Date values, and every equality (==, !=, in) against a stored list", + "migrationId": "rls-predicate-stored-list-ordering-refused", + "toMajor": 18, + "rationale": "The mirror, with the list on the record's side, of the earlier refusal of an ordering operator against an array comparand (one of the same-class leaks that followed the 2026-09-24 ruling refusing an array under $ne), measured through the real plugin-security on driver-sql and driver-memory. record.tags > \"a\", with tags a json column holding [\"m\"], lowered to { tags: { $gt: \"a\" } }, and the write-check evaluator compared the list's JavaScript string form (\"m\" > \"a\"), so the check admitted and stored the write; record.meta < \"a\" with meta holding { a: 1 } compared \"[object Object]\" and did the same, and so did a multiple lookup. driver-sql's read refuses every ordering comparison, and $between, on a column it stores as JSON text, by declared type (400), because such a comparison can never mean what the caller wrote; the in-process write check now follows it, per record: INVALID_FILTER / 400 and nothing stored, on an insert and on a by-id update, including one that edits another field of a row whose stored column holds a list. A list written into a text or number field under an ordering check, admitted before and stored as the text \"[500]\" by driver-sql, is refused the same way. driver-memory, a test driver, still compares a stored list element by element on a read, so there the write and the read part. Shipped producers were counted before the change: no shipped row-level or sharing-rule predicate orders a field at all. Metadata AT REST is not rewritten and this entry adds no D2 conversion: the platform cannot tell which comparison an ordering over a list was standing in for, and rewriting it on the author's behalf would change which writes it admits, which is the policy author's decision. ADR-0058 D4 / ADR-0087 / ADR-0112." }, { - "surface": "book.translations / book.groups.translations", - "to": "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", - "conversionId": "book-translations-removed", - "toMajor": 17 + "surface": "the saved-report stack, whole: the `reports` platform capability token (`requires: ['reports']`, its `PLATFORM_CAPABILITY_TOKENS` member and its `PLATFORM_CAPABILITY_PROVIDERS` row); the saved-report service contract in `@objectstack/spec/contracts` (`IReportService`, `SavedReport`, `ReportSchedule`, `ReportQuery`, `ReportFormat`, `ReportRunResult`, `SaveReportInput`, `ScheduleReportInput`); the `sys_saved_report` and `sys_report_schedule` platform objects (`SysSavedReport` / `SysReportSchedule` in `@objectstack/platform-objects/audit`) and their names in `PLATFORM_PROVIDED_OBJECT_NAMES`; the eight `/api/v1/reports` routes (list, save, get, delete, run, schedule, list schedules, unschedule); the `reports` namespace of `@objectstack/client`; and the `@objectstack/plugin-reports` package that served them. NOT the `report` metadata kind (`ReportSchema`, `/meta/report`, datasets, analytics), which is unchanged.", + "replacement": "Delete `'reports'` from `requires` — `defineStack` now refuses it with this prescription. A report is `report` metadata: `ReportSchema` over a dataset (ADR-0021), served by the analytics service every server mounts. A saved ad-hoc object query — what a `sys_saved_report` row held — is a ListView on that object. Code that imported the contract types or called the client namespace deletes those lines; there is no successor API and no scheduled-delivery replacement.", + "migrationId": "saved-report-stack-retired", + "toMajor": 18, + "rationale": "Maintainer ruling 2026-09-25 (verbatim: 「A. 退役」, then 「你直接派发处理这个退役任务。」). The stack persisted a raw object query (`object_name` plus `{filter, fields, orderBy, limit, groupBy}`) with a render format and an owner — the same object-plus-raw-query shape ADR-0021 removed from the `report` kind as its legacy inline query form, alive in a parallel table under the same word. Measured on the main branch of all three repos before removal: zero callers of the routes, the client namespace or the service contract outside their own tests, and no app declaring the capability. A declared capability with no consumer is a surface an author (most often a model) reaches for and confuses with the real report kind, so it is retired at once, with no deprecation window." }, { - "surface": "job.id", - "to": "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)", - "conversionId": "job-id-removed", - "toMajor": 17 + "surface": "The START NODE `config.organization` key of every time-triggered flow — a `type: 'schedule'` flow carrying a `config.schedule` cadence, and the `timeRelative` sweep that carries its cadence in the same slot (`FlowTriggerKind` `schedule` / `time_relative`) — TOGETHER WITH the deployment variable that decides whether such a flow arms at all, `OS_AUTOMATION_SCHEDULED_WORK_ENABLED`. Nothing is renamed, retired or re-typed: the start node's `config` is an OPEN record (ADR-0018), so the key is an ADDITION to a slot that already accepted it, and every flow that parses today parses byte-identically after the change. What narrows is the BIND-time accept set and the RUN-time data plane — and what the 2026-09-12 and 2026-09-16 amendments narrow further is WHERE that narrowing applies: the declaration is required under tenancy posture `isolated` only, is OPTIONAL under `group` (where an undeclared run acts as the swept record's own organization), is not read under `single`, and no time-triggered flow arms anywhere until the deployment switches package-authored scheduled work on.", + "replacement": "Two deployment decisions, in this order. (1) DECIDE WHETHER THIS DEPLOYMENT RUNS PACKAGE-AUTHORED SCHEDULED WORK AT ALL: `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true` arms time-triggered flows and packaged `defineJob` cron jobs; unset — the global default, in every posture and every kernel — arms neither, and every such flow is listed by `getTriggerBindingAudit()` and the CLI startup summary as DISABLED BY DEPLOYMENT POLICY rather than as a binding failure. Platform-internal jobs (approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership backfill) are NOT gated by it: the boundary is \"authored by a package\", not \"runs on the job service\". (2) ONLY IF THE SWITCH IS ON AND THE POSTURE IS `isolated`, declare the organization each flow runs as, on the start node beside the cadence: `config: { schedule: { … }, organization: '' }`. There is deliberately NO fan-out — a sweep wanted in N organizations is N flows, one per organization — and deliberately no fallback: nothing on this path ever chooses an organization, because a wrong `organization_id` is silently authoritative to every report, export and cleanup that filters by organization, while a refusal is visible at boot and names its flow. Under the `single` posture with the switch on, declare NOTHING: the run carries no organization and every tenant-scoped insert beneath it resolves the deployment's one organization through the guard that makes a system-context write resolve the install's organization. Under the `group` posture with the switch on, declaring is OPTIONAL and both shapes are supported: a declared flow behaves exactly as under `isolated` (the declaration bounds SELECTION and identity alike), while an UNDECLARED flow arms, reads group-wide — which ADR-0105 D1 makes inherent to the posture — and stamps each run it launches with the SWEPT RECORD's own organization, the same subject-first order `sys_automation_run` already uses. ⚠️ An undeclared `group` flow that reaches a tenant-scoped write with NO record to derive from — a record-less cron emitting a notification — is REFUSED at that write (`walled-posture`, ADR-0112), loudly and by name; declare `config.organization` on that flow, which is the remedy the refusal itself prints. ⚠️ Three consequences apply to an `isolated` deployment that splits one flow into N, and each is deployment work: (1) rows whose tenant column is NULL stay visible to a scoped read (`org = :tenant OR org IS NULL`), so after the split each such row is matched ONCE PER FLOW — N runs and N notifications for one row, each acting as a different organization; (2) the dispatch-claim key embeds the flow name (`schedule::`, `time-relative:::`), so renaming one flow into N abandons the current window's claims and a window already delivered under the old name can deliver once more under the new ones; (3) a run SUSPENDED before the upgrade rehydrates its context from `context_json`, which carries no `tenantId`, so it resumes org-less — drain or accept in-flight suspended runs rather than assuming the upgrade confines them retroactively.", + "migrationId": "schedule-flow-acting-organization-required", + "toMajor": 18, + "rationale": "Three maintainer rulings, all verbatim and untranslated, in the order they were given. 2026-09-08: 「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。」 A time-triggered run is launched from a job tick and a job tick carries no identity, so the run reached the tenancy guard with nothing to offer it: the notification wrote `organization_id = NULL`, every tenant-scoped row beneath it was refused, and the tick still summarised itself as healthy. 2026-09-12, on the same surface: 「schedule 是风险很大的模型,尤其在云端,无算是单独多租户还是每库一租户,可能造成极大的资源浪费。对于单租户或着集团版私有部署,我觉得不需要做限制。定时任务 如果不好处理,现在也没想清楚,有没有可能定义为一个环境变量,根据环境变量控制?」 and 「group 默认也关,云端每库一租户全局默认关」. Whether clock-driven work is affordable is a fact about the DEPLOYMENT — its database, its tenants, its budget — that no author can know and no metadata key should ask them for, so the gate is a deployment variable read at boot and the global default is OFF. 2026-09-16, reopening the `group` half of that amendment and nothing else: 「group 模式是本地部署的,运行 schedule 应该是可以的,但是你没有权限,可以单独开一个决策卡」 — ruled A′ the same day: under `group` with the switch on, a flow binds without a declaration. The 2026-09-08 ruling was made for the MULTI-TENANT shape, and `group` is not one: ADR-0105 D1 defines it as one legal group over one database with group-wide visibility and cross-org workflow INHERENT to the shape, so a group-level batch job is a capability of the posture rather than the cross-organization task the ruling forbids. What was genuinely unanswered — recorded as unanswered by ruling G item 3 — was which organization such a run's inserts belong to, and the answer is the one `sys_automation_run` was already ruled to use: the SUBJECT RECORD's organization, with the acting context as the fallback and never the primary. Filling the acting context the same way makes the inbox, delivery and history rows of one run agree about its owner; leaving them to disagree was the defect, not the fix. ⛔ The rejected arm is recorded too, because it is the one a later reader will re-propose: falling back to the bootstrap organization (`slug='default'`) for a record-less run. Under a wall that organization is minted ADMIN-KEYED by the enterprise organizations runtime and may not exist at all, and where it does it is whichever organization the platform owner registered under — plausibly one plant of many. That is the silently-authoritative wrong owner this entry already forbids, so a record-less undeclared run is refused instead. Where the switch is on, the 2026-09-08 ruling therefore stands unchanged under `isolated`, is satisfied per-record under `group`, and is moot under `single`, which holds exactly one organization and therefore has no cross-organization task to forbid. ⛔ NOT losslessly convertible, and the reason is that both remedies are values only the deployment holds: an organization id is minted per install at runtime and the switch is an operator decision about cost, so there is no authored artifact and no stored representation a transform could rewrite — `objectstack migrate meta` cannot know which organization a given sweep belongs to, nor whether this deployment wants scheduled work at all, and inventing either is precisely what the rulings forbid. Registered under ADR-0087 D3 rather than left silent because the change DOES carry a prescription — \"decide the switch, then declare one flow per organization under a wall\" is deployment work a human must do, which is what D3 says a structured TODO is for. The direct precedent is `rest-requireauth-default-flip` (protocol 12): behaviour-only, no shape moved, a deployment judgement no transform can make, registered anyway." }, { - "surface": "translation.validationMessages", - "to": "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.._validations..message`, which the write path resolves (17.3.0, a translation key shipped together with its reader)", - "conversionId": "translation-validation-messages-removed", - "toMajor": 17 + "surface": "the `sys_scim_provider` platform object (`SysScimProvider` in `@objectstack/platform-objects/identity`, re-exported from the package root) and its name in `PLATFORM_PROVIDED_OBJECT_NAMES` (`@objectstack/spec/system` constants). The rc.1-era `@better-auth/scim` connection row: one row per SCIM bearer connection, written only by the retired `/scim/generate-token` endpoint.", + "replacement": "(removed — no direct replacement row. The stable `@better-auth/scim` 1.7.x line, which the platform adopted as one whole-model migration, derives no `scimProvider` model: SCIM state lives in the seven stable platform objects (`sys_scim_connection_binding`, `sys_scim_group`, `sys_scim_group_member`, `sys_scim_identity_tombstone`, `sys_scim_projection_grant`, `sys_scim_subject`, `sys_scim_user`) and connection credentials in the ObjectStack-owned `sys_scim_connection_credential`, minted/verified by `scim-connection-service.ts` behind the application-owned `verifyBearerToken`. A SCIM-enabled deployment re-registers its connections on the stable surface; rc.1 token digests are not portable on any path, so the IdP reissues its token — a migration-day operator action, not a code rewrite.)", + "migrationId": "scim-provider-object-retired", + "toMajor": 18, + "rationale": "Maintainer ruling 2026-08-24 on the disposition of `sys_scim_provider` (verbatim, in part: 「不需要考虑历史数据」) — disposition A: retire, with no data-migration path owed for existing rows (reaffirmed 2026-08-25: SCIM has no real customers; the binding constraint is a smooth upgrade). Executed as a retirement of its own after the stable-1.7.1 migration landed: the installed library derives no `scimProvider` model, so the object backed nothing — nothing could write a row to it any more. Retiring it also removes its `provider_id` unique index, whose stricter-than-upstream uniqueness (one `provider_id` across every organization, where upstream scopes it per organization) was flagged while the SCIM upgrade was parked, and left pending exactly this retirement." }, { - "surface": "datasource.config", - "to": "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)", - "conversionId": "datasource-config-driver-key-aliases", - "toMajor": 17 + "surface": "The `reference` key of a `type: 'lookup'` field on a `screen` node — `flows[].nodes[].config.fields[]` where the node `type` is `screen` and the field `type` is `lookup` (`ScreenFieldConfigSchema`). Nothing is renamed, retired or re-typed and the key set does not move: `reference` was already declared and already optional in the shape. What narrows is the ACCEPT SET for one value of the sibling `type` — a `lookup` field with no `reference`, or with a blank one, parsed before this major and is refused now. Every other widget hint is untouched, and a `lookup` field that already names its target parses byte-identically.", + "replacement": "Name the object whose records the picker offers, beside the type: `{ name: 'resolved_by_article', type: 'lookup', reference: 'crm_knowledge_article' }`. The value is an object NAME (the canonical id — same string `FieldSchema.reference` carries), not a label and not a record id. ⚠️ There is deliberately no default and no inference: a picker pointed at the wrong object is worse than one that refuses to load, because it offers a human a plausible list of the wrong records and the flow stores the id it is given. Where the field genuinely has no target object — the author was using `lookup` to mean \"type an id here\" — the fix is the other direction: change `type` to `'text'`, which is what that field actually was, and keep the prose that asked for an id in `inlineHelpText`.", + "migrationId": "screen-field-lookup-reference-required", + "toMajor": 18, + "rationale": "Maintainer ruling A′, 2026-09-13, verbatim, untranslated: 「同意」. ADR-0078 forbids metadata that parses, carries no marking and does nothing — and its own worked example of that state is a `lookup` with no `reference`: the field renders a picker, the picker has no object to query, and nothing anywhere says so. The key shipped OPTIONAL on this surface one release earlier, on the argument that flows declaring a bare `lookup` already exist; the ruling reversed that, holding that a degraded shape which ships is not a reason to bend the contract to it. ⛔ NOT losslessly convertible, and the reason is the same one `schedule-flow-acting-organization-required` gives: the remedy is a value the artifact does not contain. A bare lookup records the field name and nothing about its intended object, so `objectstack migrate meta` can identify every site but can answer none of them — and a conversion that guessed (the first object with a matching-looking name, the flow's trigger object) would write an authoritative wrong answer into metadata a human then trusts. Registered under ADR-0087 D3 rather than left silent because the change DOES carry a prescription a human can execute, which is what D3 says a structured TODO is for." }, { - "surface": "datasource.driver", - "to": "datasource driver id 'mongo' → 'mongodb' — the canonical id both boot hosts, the driver package and the published DRIVER_CATALOG already used, so the id that selects a driver and the id that selects its config contract are one string with no mapping between them", - "conversionId": "datasource-driver-mongo-to-mongodb", - "toMajor": 17 + "surface": "contracts.emailService.sendTemplate input.org", + "replacement": "(removed — never implemented; delete the key from the call. It is NOT replaced by `organizationId`: that member is the delivery row's tenant stamp (`sys_email.organization_id` pass-through, added so the email writer stamps a delivery row's organization at the source) and opts into no template overlay resolution)", + "migrationId": "send-template-input-org-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove. `SendTemplateInput.org` was declared as \"Tenant id for org-overlay resolution (when supported)\" and no implementation ever read it: `@objectstack/plugin-email` — the only IEmailService implementation — resolves templates on `(name, locale)` only, so a caller passing `org` got no org-overlay resolution and no error; the \"(when supported)\" hedge was the declaration admitting the gap. After the delivery-row stamp landed `organizationId` beside it, the input carried two org-shaped keys of which one did nothing — exactly the shape that invites an AI author to pick the wrong one. There is no behaviour to preserve and nothing stored to rewrite: the key only ever appeared in a call-time input bag (the `data.engine.update options.upsert` precedent), which is why this is a D3 semantic entry with no D2 conversion — no metadata seam ever runs on it. Org-overlay template resolution, if it ever earns a measured business pull, is a new capability with its own ruling — not this key revived." }, { - "surface": "flow.node.script.config.actionType / flow.node.script.config.template / flow.node.script.config.recipients / flow.node.script.config.variables / flow.node.script.config.script", - "to": "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", - "conversionId": "flow-node-script-branch-keys-removed", - "toMajor": 17 + "surface": "GET /api/v1/auth/get-session -> user.positions[] (and the client CEL root `current_user.positions` bound from it)", + "replacement": "the SAME key, carrying the SECURITY positions — the set `/auth/me/permissions` reports and `resolveUserAuthzGrants` resolves. A reader that wanted the better-auth role scalar reads `user.role`, which is unchanged and still published", + "migrationId": "session-payload-positions-security-axis", + "toMajor": 18, + "rationale": "A MEANING change, not a rename: no key moved, so nothing in this entry can be found by grepping for a removed spelling — which is exactly why it needs a ledger row. `customSession` built `user.positions` from the better-auth `sys_user.role` scalar split on commas, PLUS the active membership mapped to `org_*`, PLUS `platform_admin`, and read NOTHING from `sys_user_position` (ADR-0057 D4), the source of truth for custom positions. The Console binds that array straight through as the CEL root `current_user` (objectui `expressionUser.ts`: `positions: user.positions ?? []`), so an `action.visible` / `visibleWhen` / nav `visible` narrowed by a business position answered FALSE for EVERYONE, including the user who genuinely held it. ⭐ The failure was silent and in the invisible direction: the root was bound and the key was present, so `has(current_user.positions)` was true and CEL raised nothing — the predicate simply returned FALSE. A predicate that FAULTS fails OPEN in the shell and would have shown the button; a successful FALSE shows nothing and reports nothing. The documented example `'org_admin' in current_user.positions` kept working throughout, because `org_admin` is the one name that sits on BOTH axes — which is why no example, test or doc could reveal the split. This was a DECLARED contract being violated rather than an ambiguous name: `EvalUserSchema` already specified `positions` as \"built-in identity names + position names\", exposed to \"every predicate surface (server formula, server RLS, client UI gates) ... with an identical shape\" so a predicate \"evaluates identically wherever it is written\". `/auth/me/permissions` and every server-side evaluator (`ExecutionContext.positions`) already resolved the security axis; the session payload was the one producer that did not, because it derived the value itself instead of asking the authority `resolve-authz-context.ts` reserves that job for. ⚠️ NO renamed auth-role array accompanies this, and that is a measured disposition rather than an omission: everything the old union contributed beyond the security axis was the `sys_user.role` scalar's own tokens, and that scalar is ALREADY published unchanged as `user.role` — the single exception ADR-0090 D3's \"role\" word ban carves out for third-party schema. Minting a `roles` array would revive the exact banned identifier `check:role-word` ratchets against, to publish information the payload already carries. Maintainer ruling 2026-09-05: option A, one name, one meaning — `current_user.positions` means the security positions everywhere. ADR-0068 D1/D2, ADR-0090 D3/D5, ADR-0057 D4." }, { - "surface": "flow.errorHandling.retryDelayMs / flow.node.config.retry.retryDelayMs / job.retryPolicy.maxRetries / job.retryPolicy.backoffMultiplier", - "to": "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", - "conversionId": "retry-policy-converged", - "toMajor": 17 + "surface": "api.session.user.language", + "replacement": "`GET /auth/me/localization` → `locale` (the user's own `sys_user.locale` when set → the request's `Accept-Language` → the deployment default)", + "migrationId": "session-user-language-retired", + "toMajor": 18, + "rationale": "`SessionUserSchema.language` was declared with a permanent default of `'en'` and described as \"Preferred language\", and had no producer and no consumer anywhere: no session endpoint wrote it, no client read it (objectui measured zero readers at its pinned sha), so a reader trusting the published contract received a constant that was not the user's language. Meanwhile the user's real preference landed as the first-class column `sys_user.locale` (ruled 2026-09-01 once measured demand for a per-user notification locale arrived), which the session type could not see — three spellings of one concept on the published surface, none of them right. The maintainer ruled option D (2026-09-03): retire the dead key under ADR-0049 enforce-or-remove and make `GET /auth/me/localization` the ONE read face, with its `locale` projecting the user column first. This is a RESPONSE surface — the server mints a `SessionUser` and nobody authors or persists one — so there is no source for the chain to rewrite; the schema tombstones the key via retiredKey() and consumers move their read to the endpoint. No replacement field joins the session contract until a session endpoint really produces one (no dual-spelling window, 不渐进). ADR-0049, ADR-0087." }, { - "surface": "object.managedBy", - "to": "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)", - "conversionId": "object-managed-by-system-to-system-data", - "toMajor": 17 + "surface": "the default export of objectstack.config.ts when it is not the value defineStack or composeStacks returned: a plain object literal, a spread or Object.assign copy of a built stack, a JSON copy of one, a module with no default export — and each input handed to composeStacks", + "replacement": "export what the producer returned: `import { defineStack } from '@objectstack/spec'; export default defineStack({ … });` with every stack key inside the call (`api`, `plugins`, `requires`, …), or `export default composeStacks([defineStack({ … }), …])`. Named exports beside it (`onEnable`, `functions`) are unaffected. `defineStack(config, { strict: false })` also satisfies the doors — it is still the producer — but skips its judgement, so reserve it for sources a strict parse cannot yet read", + "migrationId": "stack-config-default-export-unbuilt-refused", + "toMajor": 18, + "rationale": "The stack family's cross-field refusals — unknown `requires` capability, cross-references to objects the stack does not define, the namespace prefix, one app per app package, the hierarchy-scope and trigger capability requirements — run inside `defineStack` and nowhere else. A config exporting a plain object skipped all of them: `objectstack validate` and `objectstack build` ran only the schema parse, answered success, and the build shipped the artifact, so the defect surfaced at deploy or never (a trigger flow that silently never fires). Judging the export at the door instead is not possible: a built stack carries each bound standalone action twice (top level and merged into its object), so re-running the family on `defineStack` output refuses every correct project with a bound action. So both producers stamp a non-enumerable provenance mark on what they return (`hasStackProvenance`), and `objectstack validate` / `objectstack build` refuse an unmarked default export right after load with `STACK_PROVENANCE_MISSING` (exit 1), before any other judgement; `composeStacks` refuses an unmarked input with the same code. A copy of a built stack is refused too, because the mark does not survive a spread or JSON round-trip — by design, since the copy is not what the producer judged. ⚠️ No D2 conversion: the module shape is source code, not metadata. `objectstack serve`, `objectstack migrate` and `objectstack lint` load the config as before. ADR-0087." }, { - "surface": "object.enable.trash / object.enable.mru", - "to": "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)", - "conversionId": "object-enable-trash-mru-removed", - "toMajor": 17 + "surface": "stack `themes` (the carrier collection, and `ThemeSchema` with its sub-blocks)", + "replacement": "delete the `themes:` key (and any `defineTheme` calls). To colour the shipped console, set `app.branding.primaryColor` / `accentColor` — the one live colour surface (read by objectui, driving `--primary`, `--accent` and their derived CSS variables). A palette value your own stylesheet consumed has no spec slot any more: move it into your own CSS.", + "migrationId": "stack-themes-carrier-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21 (disposition B: 退役授权面 — objectui engine code and its unit tests are retained). The pipeline was live from the authoring gate (`ObjectStackDefinitionSchema.themes`, `defineTheme`) through artifact ingest (`ARTIFACT_FIELD_TO_TYPE.themes`) and stopped there, measured: zero non-test readers of `.themes` or stored `theme` items across core/runtime/rest/services/plugins; `theme` never in `MetadataTypeSchema`, `DEFAULT_METADATA_TYPE_REGISTRY` or `BUILTIN_METADATA_TYPE_SCHEMAS`; the only mounted ThemeProvider is the app-shell chrome light/dark toggle, unrelated to `ThemeSchema`; and no key anywhere selected an active theme. So an author (human or AI) who wrote a theme shipped it through every green gate and saw nothing change — the declared-but-unenforced shape ADR-0049 exists to delete. What colours a console today is `app.branding`, and that path is live and untouched." }, { - "surface": "hook.body.capabilities / action.body.capabilities", - "to": "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)", - "conversionId": "hook-body-crypto-hash-removed", - "toMajor": 17 + "surface": "top-level stack definition keys (`ObjectStackDefinitionSchema`) — undeclared keys", + "replacement": "the declared top-level surface. A key the schema does not declare is refused at parse with a prescriptive message naming the key, suggesting the closest declared key on a near miss (`objectz` → `objects`, `flow` → `flows`), and carrying a curated prescription for the known retirements (`approvals`/`approvalProcesses` → Approval-node flows per ADR-0019; `workflows` → `state_machine` validation rules per ADR-0020; `portals` removed with the dead `PortalSchema`; `storage` is deployment config, OS_STORAGE_*; `onDisable` was never invoked, and left with the lifecycle-hook family the kernel never implemented). `onEnable` is now DECLARED rather than silently stripped — the runtime has always executed it off the authored bundle (a config-booted app keeps it too, since the fix that stopped the loader dropping it and every script action handler it registered)", + "migrationId": "stack-top-level-unknown-keys-refused", + "toMajor": 18, + "rationale": "The outermost authoring door was the last strip-mode surface of the unknown-key strictness campaign: an unknown top-level stack key parsed green and its value was silently dropped. Measured on 17.0.0 GA: three injected bogus top-level keys added ZERO warnings to `os validate` and exited 0 — even `--strict` could not catch them, because the `defineStack:` naming diagnostic printed at load, outside the warning tally. The failure population is a typo or stale key (`flow` for `flows`, `approvalProcesses` after the 7.4 removal) shipping an artifact with a whole metadata family absent at runtime, debugged from the far end — the root of a downstream application's report of a top-level typo that shipped an artifact minus a whole family with `validate` and `build` both green. Unknown top-level keys are now refused at parse time, which fails `validate` (and every other path through this one parse) outright; the near-miss guidance that used to arrive as a load-time warning now rides the refusal itself." }, { - "surface": "dataset.measures[].aggregate", - "to": "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)", - "conversionId": "dataset-measure-array-string-agg-removed", - "toMajor": 17 + "surface": "`error.code` values `BATCH_PARTIAL_FAILURE`, `BATCH_COMPLETE_FAILURE` and `TRANSACTION_FAILED` — three `StandardErrorCode` members retired from the closed catalog (ADR-0112 amendment 2026-08-18), so constructing or parsing an ApiError with any of them now refuses at the vocabulary boundary", + "replacement": "branch on the codes the batch surface actually speaks: a rolled-back atomic batch marks each row `errors[0].code = ROLLED_BACK`, rows the abort never reached `NOT_ATTEMPTED`, and the causal row keeps its own error — all per row, at HTTP 200, both codes ledger-registered. Delete any branch on the three retired spellings outright: it never fired, because nothing ever emitted them", + "migrationId": "standard-error-code-batch-members-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove applied to the error vocabulary. No producer has ever emitted any of the three — measured when a sweep of the error catalogue found these three entries publishing no HTTP status: outside the enum declaration the only occurrences in the whole repo were two spec tests using them as arbitrary fixture strings, and `git log -S` shows they never had a producer since ADR-0112 introduced the vocabulary. A catalog member no producer can speak teaches an AI author a branch that can never fire; after removal the wrong spelling fails parse at authoring time instead. This is a WIRE vocabulary, not stored metadata — no `sys_metadata` row exists for the D2 chain to rewrite, so (like `driver-sql-upsert-cross-row-identity-merge-refused`) this entry is the notification channel. No mechanical rewrite exists: a dead branch has no correct mechanical target — the per-row codes carry strictly more information than the envelope code the branch expected. Maintainer ruling 2026-08-18: option A, retire all three from `StandardErrorCode`. ADR-0112, ADR-0049." }, { - "surface": "connector.rateLimitConfig", - "to": "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)", - "conversionId": "connector-rate-limit-config-removed", - "toMajor": 17 + "surface": "error.code value CONCURRENT_LIMIT_EXCEEDED — a StandardErrorCode member retired from the closed catalogue, so constructing or parsing an error with it now refuses at every catalogue door (StandardErrorCode, ErrorCode / ApiErrorSchema.code, makeApiErrorSchema) with the removal prescription", + "replacement": "delete any branch on `CONCURRENT_LIMIT_EXCEEDED` — a branch on a catalogue code no producer emits has nothing to match. For request pacing branch on `RATE_LIMIT_EXCEEDED` (HTTP 429; wait `retryAfterSeconds` before retrying). A service that enforces its own concurrency limit registers a code for it in its own error-code ledger rather than reusing the retired spelling. `QUOTA_EXCEEDED`, its catalogue neighbour, is unchanged.", + "migrationId": "standard-error-code-concurrent-limit-exceeded-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove applied to the ADR-0112 error catalogue. Ruling A of 2026-09-13 (maintainer 「同意」) retired both producerless 429 members; the closure-review ruling of 2026-09-24 (letter 留·收窄, maintainer 「其他同意」) narrowed it to this code alone after `QUOTA_EXCEEDED` was found emitted by a hosted AI agent route and read by the console chatbot plugin. The ledger doctrine in error-code-ledger.zod.ts names a producerless row with no card behind it as the registered-but-unemittable retirement class, and a catalogue member no producer speaks teaches an author a branch that cannot fire; after removal the stale spelling fails parse with its prescription instead. An error code is WIRE vocabulary, not a metadata key, so there is no authored source for a D2 conversion to rewrite and this entry is the notification channel, as it was for `standard-error-code-batch-members-retired`. No mechanical rewrite exists: a dead branch has no correct mechanical target." }, { - "surface": "connector.fieldMappings[].transform / externalLookup.fieldMappings[].transform", - "to": "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)", - "conversionId": "field-mapping-transform-removed", - "toMajor": 17 + "surface": "the startup-ORCHESTRATION surface of kernel/startup-orchestrator.zod.ts and contracts/startup-orchestrator.ts — 3 emitted defs and 8 exported names: StartupOptionsSchema / StartupOptions / StartupOptionsParsed, HealthStatusSchema / HealthStatus, StartupOrchestrationResultSchema / StartupOrchestrationResult, and the IStartupOrchestrator interface (orchestrateStartup / rollback / checkHealth / startWithTimeout). The startup RESULT survives, re-declared: PluginStartupResultSchema and PluginStartupResult stay on both entries", + "replacement": "(removed — there is no declarative replacement, because nothing ever implemented the interface or parsed the schemas. Plugin startup is the kernel own boot loop: ObjectKernel.start() calls startPluginWithTimeout() per plugin, which races that plugin start() against PluginMetadata.startupTimeout and, when KernelConfig.rollbackOnFailure is set, destroys the already-started plugins and rethrows the original error as the new error cause. So: instead of StartupOptions.timeoutMs declare startupTimeout on the plugin; instead of StartupOptions.rollbackOnFailure set rollbackOnFailure on the kernel config; instead of StartupOrchestrationResult.results read the per-plugin durations through ObjectKernel.getPluginStartupDurations(). StartupOptions.healthCheck and HealthStatus have NO replacement at all — no startup probe system exists, and one returns only through the enforce route of ADR-0049 with a new ADR, the probe first and the vocabulary second. StartupOptions.parallel and StartupOptions.context likewise: the kernel starts plugins sequentially and passes its own PluginContext)", + "migrationId": "startup-orchestrator-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-09-06, option 3: keep a startup-result contract re-declared as the shape the kernel ships, and retire the rest. The module declared an orchestration design that never landed, and the spec and the kernel had already drifted into disagreement about the one shape that did: PluginStartupResultSchema described a plugin object, a required durationMs and a health member, while @objectstack/core shipped pluginName, an optional durationMs and timedOut. The ruling keeps a startup-result contract that describes what the kernel actually produces, and retires the rest. Re-measured on this card: zero implementers and zero consumers of the four retired surfaces in this repository and in the pinned objectui checkout, with lit same-corpus controls (defineStack, ManifestSchema); every remaining reference was a generated artifact or a released CHANGELOG.md. healthCheck and HealthStatus are the sharpest of the four: they name a per-plugin health probe the runtime has never had, the shape of the plugin sandboxing / integrity / approval config that was never wired to anything, which an AI author (ADR-0033) reads as proof the capability exists. With no authored document carrying any of the three defs there is no seam for a D2 conversion and no author to tombstone for: route 3, the shape of the dynamic plugin-loading family's removal and the advanced plugin-lifecycle config's retirement — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. The two keys of the SURVIVING result schema that leave (plugin, health) are tombstoned instead, and registered in RETIRED_KEYS_BY_MAJOR, because that def keeps emitting and its type is imported by @objectstack/core. A third key arrives on the spec surface only to leave it: core deprecated startTime alias, which held the same elapsed milliseconds as durationMs under a name that promises an instant. The re-declaration had to either mirror it or tombstone it, and mirroring is refused by check:duration-unit-keys (ruling B on duration-shaped number keys: the unit lives in the key name) since it is an elapsed number whose key name carries no unit and matches neither of that rule two schema-declared exemptions. So the L1 window closes here and the kernel stops populating it in the same change." }, { - "surface": "theme.typography.fontSize / theme.typography.fontWeight / theme.typography.lineHeight / theme.typography.letterSpacing / theme.typography.fontFamily.heading / theme.typography.fontFamily.mono / theme.animation / theme.zIndex", - "to": "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)", - "conversionId": "theme-inert-token-scales-removed", - "toMajor": 17 + "surface": "StrategyContext.executeAggregate aggregations[].method (contracts/analytics-service.ts, exported from @objectstack/spec/contracts) - the parameter type, declared as bare string", + "replacement": "AggregationFunction (count | sum | avg | min | max | count_distinct, data/query.zod.ts) - the same closed vocabulary IDataEngine.aggregate already declares for the identical slot (AggregationNodeSchema.function; the analytics bridge renames method to function and forwards). A caller filling method from a string-typed value narrows the value to the enum - typing it AggregationFunction, or parsing with the spec's own AggregationFunction zod enum where the value enters from data. Values outside the six were never served: the bridge has parsed-and-refused them at runtime since it stopped declaring its own engine type and began parsing the method with the spec enum, and that refusal stays as defence in depth", + "migrationId": "strategy-context-aggregation-method-narrowed", + "toMajor": 18, + "rationale": "Maintainer ruling 2026-08-28 (option A, census-first): one slot, one declaration. Two spec-declared surfaces described the same value and disagreed about its type: IDataEngine.aggregate's aggregations[].function is the closed six-value AggregationFunction enum while StrategyContext.executeAggregate declared the same slot aggregations[].method: string, so nothing on the analytics side of that seam was compile-checked against the engine's vocabulary - an author, very often an AI (ADR-0033), writing an analytics strategy got no compile-time help and could carry any method name all the way to the bridge's runtime refusal. One slot now has one declaration. Bookkeeping: this is a TYPE narrowing on a runtime TS interface member - no authorable metadata key, no wire shape and no walked-shape def changed, so nothing lands in RETIRED_KEYS_BY_MAJOR / RETIRED_DEFS_BY_MAJOR and the surface ratchets are expected byte-identical. It is a SEMANTIC entry rather than a D2 conversion because there is no authored document or sys_metadata row for the chain to rewrite: the only consumers are TypeScript call sites, and the compile error is the channel that reaches them. In-repo census at the ruling (hard precondition, measured before the narrowing landed): every implementor and every call site filling method is legal under the enum - ObjectQLStrategy.resolveMeasureAggregation emits only the six once it refuses a custom-SQL measure up front, the two literal producers write count, and every test fixture is implementor-side and stays assignable by contravariance." }, { - "surface": "page.component.page-header.description", - "to": "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)", - "conversionId": "page-header-subtitle-alias", - "toMajor": 17 + "surface": "The BODY of every ADR-0031 structured region — `loop.config.body`, each `parallel.config.branches[]`, and `try_catch.config.try` / `.catch` — at every depth the flow parse walks. Two node populations become undeclarable there: a node whose TYPE parks the run on EVERY execution (`screen`, `wait`, `approval`, `approval_revise`) and an `end` node, whatever its `outcome`. ⛔ `subflow` and `map` are NOT in the population, although their executors also declare `supportsPause: true`: they pause exactly when the child flow their `config.flowName` names pauses, which is a DIFFERENT metadata record and is not in hand while this flow is parsed. Refusing them by type would also refuse `loop { map(synchronous child) }`, a shape that runs correctly today, so a region-nested `map` or `subflow` still parses and is met at RUN time instead.", + "replacement": "Move the node onto the TOP-LEVEL graph and route the region's exit to it. For an `end`: delete it from the body, give the region a normal exit, and put the terminator (with its `outcome` / `message`) on the top-level graph — `loop { body: [ …, end ] }` becomes `loop { body: [ … ] } → end`. For a pausing node: hoist it out of the container — `loop { body: [ try_catch { try: [ approval ] } ] }` becomes a top-level `approval` with the loop fanning out around it, or the pausing half of the branch is split into a `subflow` the top-level graph calls; where the repetition is genuinely needed, make the TOP-LEVEL graph the repeating construct with the pause on it rather than nesting the pause inside a region. A `try_catch` whose only purpose was to contain the region's refusal has nothing left to contain and is deleted with it.", + "migrationId": "structured-region-body-pause-and-end-refused", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-09-17, verbatim and untranslated: 「同意,其他也同意」, carrying the presented option C (a durable pause inside a structured region is refused at authoring time); extended the same day by a second ruling, which attached the `end` half (an `end` node inside a region body is refused as well) and ruled 禁 on building durable pause into structured regions — structured regions do not support durable pause and a region body cannot terminate the run, so this is that limit's authoring-time enforcement rather than an interim. The POPULATION was then fixed by the 2026-09-18 ruling, letter D (maintainer 「其他同意」): 「inside `loop` / `parallel` branch / `try_catch` (try and catch) bodies at any depth, the node types `screen`, `wait`, `approval`, `approval_revise` and `end` are refused by `FlowSchema.superRefine` … `map` and `subflow` are ⛔ not refused by type.」 A parse-time rule refuses what is STATICALLY wrong; refusing `map` / `subflow` by type would refuse a correct working shape on a guess about another record. The refusal already existed AT RUN TIME and said nothing an author could act on: the engine converts a suspension raised inside a region into an error at the region boundary, AFTER the executor has written its progress state into the enclosing scope, so a `try_catch` that contains that error leaves residue the next entry reads back as progress. Measured on a real `AutomationEngine`, `loop { try_catch { map(pausing child) } }` over 3 iterations x 2 items: not one item's subflow ever completed, only two of three iterations reached the catch, and iteration 3 read `started === collection.length`, ran nothing, and returned SUCCESS with `summary.failed = 0`. ⚠️ Read that measurement for the MECHANISM: the shape it was taken on is a `map`, which this parse rule deliberately does not reach — making the run-time refusal of a region-contained node that durably suspends LOUD is the second half of ruling D and ships as its own `domain:services` change. ⛔ NOT losslessly convertible: hoisting a node out of a region is a GRAPH REWRITE — new edges, a changed exit, sometimes a deleted container — and which of several shapes the author meant is an intent no artifact records, so a transform that picked one would be inventing the design. That leaves D3, a structured TODO naming each node to edit. ⚠️ Two further boundaries this refusal deliberately does NOT reach, because a parse cannot: a pausing node type contributed by a PLUGIN (ADR-0018 left the node-type namespace open and a parse has no registry), and a region nested past `MAX_REGION_DEPTH` (32), where the walk stops. For both, the engine's run-time refusal is still the only one — unchanged by this step, not fixed by it." }, { - "surface": "object.indexes[].type / object.indexes[].partial", - "to": "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)", - "conversionId": "object-index-type-partial-removed", - "toMajor": 17 + "surface": "`sys_account.issuer` — the column, its `{ fields: ['issuer', 'account_id'], unique: true }` index, its label in the four generated translation bundles, and the `@objectstack/plugin-auth` symbols that existed only to serve it (`backfillAccountIssuer`, `CREDENTIAL_ISSUER`, `oauthIssuerFor`, `ResolvedSocialProvider`, `BackfillAccountIssuerOptions`, `BackfillAccountIssuerResult`). The `accounts.list()` client type loses `issuer` with the route that stopped returning it.", + "replacement": "nothing — account identity is `(provider_id, account_id)`, which `sys_account` has declared UNIQUE since the object was created. A caller that read `account.issuer` reads nothing in its place: the authority is `sys_sso_provider.issuer`, resolved through the account's `provider_id`, which is unique per environment. A host that called `backfillAccountIssuer` on its own schedule deletes the call; there is no successor pass. Existing deployments run the ceremony below before the column is dropped.", + "migrationId": "sys-account-issuer-retired", + "toMajor": 18, + "rationale": "better-auth 1.7.3 removed the issuer-scoped account identity outright: `createLocalAccountIssuer` is deleted, `accountSchema.issuer` is gone, `AccountKey` is `(providerId, accountId)` again, and the `account.issuer` column and its unique index are gone from `get-tables`. There is no drop-in replacement. Maintainer ruling 2026-09-10: adopt the rollback rather than own a fork of an identity model the vendor abandoned — a permanent fork on the authentication library was refused, and staying pinned was refused as the durable answer (the exact pin to 1.7.2, taken after a floating 1.7.3 broke a fresh seeded boot, was the stopgap and has done its job). The column was a net liability in its own right: a credential row whose `issuer` was not the local credential issuer was invisible to `findAccountByKey`, so sign-in failed `INVALID_EMAIL_OR_PASSWORD` behind a \"User not found\" warn pointing at the `sys_user` row rather than at the account — four checklist items rediscovered that independently. Its discriminating power here was near zero: `sys_sso_provider` declares `{ fields: ['provider_id'], unique: 'global' }`, so `provider_id → issuer` is a function within an environment." }, { - "surface": "page.component.element:record_picker.displayField", - "to": "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)", - "conversionId": "record-picker-display-field-to-label-field", - "toMajor": 17 + "surface": "sys_audit_log.organization_id — the injected organization column left the compliance ledger (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts, which now declares systemFields.tenant false); the organization a row is about stays in the attribution field tenant_id, and an organization reader is scoped on it by a platform row policy", + "replacement": "`sys_audit_log.tenant_id`, the attribution field every writer stamps. Rewrite any authored filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_audit_log` to name `tenant_id`. A row about a deployment-level action leaves it empty. Under an organization wall an organization reader is scoped to the rows about its active organization by the platform row policy `sys_audit_log_org`, and a platform administrator reads every row", + "migrationId": "sys-audit-log-organization-column-retired", + "toMajor": 18, + "rationale": "ADR-0131 D7: the audit ledger may hold rows about deployment-level actions, so the organization a row is about becomes a plain attribution field under a name the tenant-field resolver does not claim, never the tenancy anchor, and the object is governed by object permission, not by the wall. Writer census at commit 3ae59661dc of this repository's main branch: the record mirror, the record-view writer and the sign-in writer in plugin-audit, and the settings change writer in service-settings, stamp tenant_id and stamped the injected column with the same value; the platform-admin standing writer in plugin-security stamps both NULL, by ruling; the two administrative user writers in plugin-auth stamp neither. So the attribution field already carries every organization the column did. Under a walled posture the tenant wall compared the column to the caller organization, which hid every row about no organization from every reader, platform administrators included. The read scope moves to the security layer, where the engine computes it once: the platform row policy tenant_id equal to the caller organization, shipped in organization_admin, member_default and viewer_readonly and stripped when no wall is enforced, plus an explicit organization_admin entry for the ledger without viewAllRecords or modifyAllRecords, because the wildcard superuser bypass would otherwise skip the policy on an object with no tenant column and hand each organization administrator every organization's rows. Per-tenant retention windows partition on tenant_id. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; once its values are confirmed equal to tenant_id, the operator drops it with os migrate apply --allow-destructive, and any row where they differ is reported rather than dropped." }, { - "surface": "page.component.element:record_picker.searchFields / page.component.element:record_picker.multiple", - "to": "record-picker component props 'searchFields'/'multiple' removed (the control is a plain single-select with no search box; neither key had a reader)", - "conversionId": "record-picker-inert-keys-removed", - "toMajor": 17 + "surface": "sys_flow_dispatch.organization_id — the injected organization column left the flow trigger dispatch claim ledger (packages/services/service-automation/src/sys-flow-dispatch.object.ts, which now declares systemFields.tenant false), and reading the table now requires the manage_platform_settings capability", + "replacement": "nothing on this table — `sys_flow_dispatch` is deployment-level state (ADR-0131 D7) and no organization owns a row. Delete any authored filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_flow_dispatch`. A principal that must read the table needs the `manage_platform_settings` capability, which platform administrators hold", + "migrationId": "sys-flow-dispatch-organization-column-retired", + "toMajor": 18, + "rationale": "ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level and loses the column; the writer decides membership, not the name. Writer census at commit e67ba80049 of this repository's main branch: the sole writer is ObjectStoreFlowDispatchStore in @objectstack/service-automation: two write sites (the claim insert and the settle update), each under a system context whose row is a dispatch key and its outcome, naming no organization. So the injected column only ever held NULL. The census is the same procedure that reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the caller organization and hid every row from every reader, platform administrators included; with no column there is no wall, and the table is governed by object permission instead (D7). The capability gate is part of the same change, not a follow-up: the organization_admin grant carries the superuser bits on every object, so without it a walled deployment would hand each organization administrator every other organization's rows. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, the remedy the drift report names." }, { - "surface": "page.component.page:card.body", - "to": "page:card component prop 'body' → 'children' (one composition key across every container; the card renderer already reads both)", - "conversionId": "page-card-body-to-children", - "toMajor": 17 + "surface": "sys_job.organization_id — the injected organization column left the platform background-job catalogue (packages/platform-objects/src/audit/sys-job.object.ts, which now declares systemFields.tenant false), and reading the table now requires the manage_platform_settings capability", + "replacement": "nothing on this table — `sys_job` is deployment-level state (ADR-0131 D7) and no organization owns a row. Delete any authored filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_job`. A principal that must read the table needs the `manage_platform_settings` capability, which platform administrators hold", + "migrationId": "sys-job-organization-column-retired", + "toMajor": 18, + "rationale": "ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level and loses the column; the writer decides membership, not the name. Writer census at commit e67ba80049 of this repository's main branch: the sole writer is DbJobAdapter in @objectstack/service-job: four write sites (the update and insert arms of the schedule upsert, the active toggle and the run summary bump), each under a system context whose row literal names no organization. So the injected column only ever held NULL. The census is the same procedure that reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the caller organization and hid every row from every reader, platform administrators included; with no column there is no wall, and the table is governed by object permission instead (D7). The capability gate is part of the same change, not a follow-up: the organization_admin grant carries the superuser bits on every object, so without it a walled deployment would hand each organization administrator every other organization's rows. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, the remedy the drift report names." }, { - "surface": "page.component.element:button.action.params", - "to": "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)", - "conversionId": "inline-action-api-params-to-body-extra", - "toMajor": 17 + "surface": "sys_job_queue.organization_id — the injected organization column left the durable job and message queue (packages/platform-objects/src/audit/sys-job-queue.object.ts, which now declares systemFields.tenant false), and reading the table now requires the manage_platform_settings capability", + "replacement": "nothing on this table — `sys_job_queue` is deployment-level state (ADR-0131 D7) and no organization owns a row. Delete any authored filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_job_queue`. A principal that must read the table needs the `manage_platform_settings` capability, which platform administrators hold", + "migrationId": "sys-job-queue-organization-column-retired", + "toMajor": 18, + "rationale": "ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level and loses the column; the writer decides membership, not the name. Writer census at commit e67ba80049 of this repository's main branch: the sole writer is DbQueueAdapter in @objectstack/service-queue: nine write sites (the publish insert and the worker update and delete paths), each under a system context whose row literal names no organization. So the injected column only ever held NULL. The census is the same procedure that reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the caller organization and hid every row from every reader, platform administrators included; with no column there is no wall, and the table is governed by object permission instead (D7). The capability gate is part of the same change, not a follow-up: the organization_admin grant carries the superuser bits on every object, so without it a walled deployment would hand each organization administrator every other organization's rows. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, the remedy the drift report names." }, { - "surface": "page.component.page:tabs.type", - "to": "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)", - "conversionId": "page-tabs-type-to-tab-style", - "toMajor": 17 + "surface": "sys_job_run.organization_id — the injected organization column left the platform job run history (packages/platform-objects/src/audit/sys-job-run.object.ts, which now declares systemFields.tenant false), and reading the table now requires the manage_platform_settings capability", + "replacement": "nothing on this table — `sys_job_run` is deployment-level state (ADR-0131 D7) and no organization owns a row. Delete any authored filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_job_run`. A principal that must read the table needs the `manage_platform_settings` capability, which platform administrators hold", + "migrationId": "sys-job-run-organization-column-retired", + "toMajor": 18, + "rationale": "ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level and loses the column; the writer decides membership, not the name. Writer census at commit e67ba80049 of this repository's main branch: the sole writer is DbJobAdapter in @objectstack/service-job: two write sites (the run start insert and the run finish update), each under a system context whose row literal names no organization, including for a job that declares the organization it runs as, whose stamp reaches the job data writes and never this ledger. So the injected column only ever held NULL. The census is the same procedure that reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the caller organization and hid every row from every reader, platform administrators included; with no column there is no wall, and the table is governed by object permission instead (D7). The capability gate is part of the same change, not a follow-up: the organization_admin grant carries the superuser bits on every object, so without it a walled deployment would hand each organization administrator every other organization's rows. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, the remedy the drift report names." }, { - "surface": "page.component.page:header.icon / page.component.page:card.actions", - "to": "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)", - "conversionId": "page-structure-inert-keys-removed", - "toMajor": 17 + "surface": "sys_migration_journal.organization_id — the injected organization column left the migration run journal (packages/platform-objects/src/system/sys-migration-journal.object.ts, which now declares systemFields.tenant false), and reading the table now requires the manage_platform_settings capability", + "replacement": "nothing on this table — `sys_migration_journal` is deployment-level state (ADR-0131 D7) and no organization owns a row. Delete any authored filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_migration_journal`. A principal that must read the table needs the `manage_platform_settings` capability, which platform administrators hold", + "migrationId": "sys-migration-journal-organization-column-retired", + "toMajor": 18, + "rationale": "ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level and loses the column; the writer decides membership, not the name. Writer census at commit e67ba80049 of this repository's main branch: the sole writer is the @objectstack/core migration runner: one append site, under a system context or under the transaction it opened with one, and the row contract MigrationJournalEventSchema has no organization field to carry. So the injected column only ever held NULL. The census is the same procedure that reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the caller organization and hid every row from every reader, platform administrators included; with no column there is no wall, and the table is governed by object permission instead (D7). The capability gate is part of the same change, not a follow-up: the organization_admin grant carries the superuser bits on every object, so without it a walled deployment would hand each organization administrator every other organization's rows. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, the remedy the drift report names." }, { - "surface": "page.component.record:details.layout", - "to": "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)", - "conversionId": "record-details-layout-removed", - "toMajor": 17 + "surface": "sys_migration.organization_id — the injected organization column left the deployment data-migration flag ledger (packages/platform-objects/src/system/sys-migration.object.ts, which now declares systemFields.tenant false), and reading the table now requires the manage_platform_settings capability", + "replacement": "nothing on this table — `sys_migration` is deployment-level state (ADR-0131 D7) and no organization owns a row. Delete any authored filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_migration`. A principal that must read the table needs the `manage_platform_settings` capability, which platform administrators hold", + "migrationId": "sys-migration-organization-column-retired", + "toMajor": 18, + "rationale": "ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level and loses the column; the writer decides membership, not the name. Writer census at commit e67ba80049 of this repository's main branch: eleven write sites in six files (the platform-objects migration flag helpers, the ObjectQL lax-deviation and boot-admission revocation writes, and the seed-tenancy, membership-backfill and flow-credential receipts), each under a system context, and the row contract DataMigrationFlagSchema has no organization field to carry. So the injected column only ever held NULL. The census is the same procedure that reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the caller organization and hid every row from every reader, platform administrators included; with no column there is no wall, and the table is governed by object permission instead (D7). The capability gate is part of the same change, not a follow-up: the organization_admin grant carries the superuser bits on every object, so without it a walled deployment would hand each organization administrator every other organization's rows. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, the remedy the drift report names." }, { - "surface": "app.hidden", - "to": "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)", - "conversionId": "app-hidden-to-unpublished", - "toMajor": 17 + "surface": "sys_presence.organization_id — the injected organization column left the realtime presence table (packages/services/service-realtime/src/objects/sys-presence.object.ts, which now declares systemFields.tenant false), and reading the table now requires the manage_platform_settings capability", + "replacement": "nothing on this table — `sys_presence` is deployment-level state (ADR-0131 D7) and no organization owns a row. Delete any authored filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_presence`. A principal that must read the table needs the `manage_platform_settings` capability, which platform administrators hold", + "migrationId": "sys-presence-organization-column-retired", + "toMajor": 18, + "rationale": "ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level and loses the column; the writer decides membership, not the name. Writer census at commit e67ba80049 of this repository's main branch: nothing writes the table through ObjectQL at all (presence travels the realtime path, and the generic data door exposes reads only), and a person present in several organizations is one person. So the injected column only ever held NULL. The census is the same procedure that reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the caller organization and hid every row from every reader, platform administrators included; with no column there is no wall, and the table is governed by object permission instead (D7). The capability gate is part of the same change, not a follow-up: the organization_admin grant carries the superuser bits on every object, so without it a walled deployment would hand each organization administrator every other organization's rows. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, the remedy the drift report names." }, { - "surface": "action.locations[]", - "to": "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: []`)", - "conversionId": "action-global-nav-location-removed", - "toMajor": 17 - } - ], - "migrated": [ + "surface": "sys_setting.scope global — the settings cascade global rung left the tenant-scoped settings table: a value for a key declared at global scope is stored in the new tenant-less object sys_platform_setting (packages/platform-objects/src/system/sys-platform-setting.object.ts), and the global option of sys_setting.scope is retired", + "replacement": "`sys_platform_setting`, one row per `(namespace, key)` for the deployment, with the same `value`, `value_enc`, `encrypted`, `locked`, `locked_reason` and `updated_by` columns and no `scope`, `user_id` or `organization_id`. Write it only through the settings door (`/api/settings/:namespace`), which routes a global-scope key there. Reading it through the generic data API requires the `manage_platform_settings` capability. Delete any authored filter, list-view column or seed that names `scope = global` on `sys_setting`", + "migrationId": "sys-setting-global-rung-moved", + "toMajor": 18, + "rationale": "ADR-0131 D7: deployment-level runtime settings leave the tenant-scoped table, and a tenant-less object holds the values an operator must change without a restart. A census of every manifest at commit 51290bca2c of this repository's main branch found seven namespaces whose keys sit at the global rung (ai, auth, knowledge, mail, sms, storage and the ObjectQL lifecycle defaults), every one edited live in Setup, so none of them moves to boot configuration. The settings service is the only writer of a global row, it writes under a system context, and the row names no organization, so on sys_setting the injected organization column only ever held NULL there and a walled posture hid the row from every reader. The resolver reads the rung from the new object alone and excludes scope global from its sys_setting reads, so a row a pre-v18 database still holds there is not a second source; no write path produces one any more, which is why the select option retires rather than staying a declared value no write can reach. The cascade order, the lock semantics, SpecifierScopeSchema and the global resolution source are unchanged. An encrypted value moves without re-encryption: the ADR-0128 AAD binds the settings scope, namespace and key, never the holder object or an organization, so a sys_secret handle copied into the new row opens as it did. Existing databases: nothing moves automatically (ADR-0131 D14). The v18 upgrade ceremony moves each sys_setting row at scope global into sys_platform_setting by namespace and key, value_enc handle included; until it runs, those values read as their next rung or the manifest default." + }, { - "surface": "ActionDescriptor.isAsync (the descriptor an executor publishes via `registerNodeExecutor` / `defineActionDescriptor`)", - "replacement": "nothing to re-declare — delete the key. Suspension is `execute()` RETURNING `suspend: true`, and permission to suspend is `supportsPause: true` on the same descriptor (with the `resumeAuthority` its pauses need)", - "migrationId": "action-descriptor-is-async-retired", - "toMajor": 17, - "rationale": "ADR-0049 enforce-or-remove. `isAsync` declared \"this action suspends the flow awaiting an external reply\" and NOTHING read it: a fresh three-repo measurement (taken when the key was filed for retirement, and re-run at pickup) found zero property reads across objectstack, objectui and cloud — every hit was the declaration itself, a generated baseline, one of five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. So declaring it never made a node suspend and omitting it never stopped one, which is the silently-inert declaration ADR-0049 exists to end. It was always a second, weaker spelling of the capability `supportsPause` states, and the two diverged in exactly the way a duplicated declaration does: `screen` declared both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling — `AutomationEngine` now refuses a suspension whose type does not declare `supportsPause: true` — so the capability this key gestured at is now a real, enforced fact under one name. This one had no consumer to grow into and takes the remove leg. Why D3 semantic and not a D2 conversion: an ActionDescriptor is published from an executor's TypeScript, never stored in stack metadata — no stack, example or template carries the key — so there is no source for the chain to rewrite and `os migrate meta` cannot reach it. The schema tombstones it via `retiredKey()` and descriptor authors delete the key themselves; that rejection (a `tsc` error at the authoring site, and a parse error inside `defineActionDescriptor`) is the channel a third-party plugin author actually meets. The `EnhancedApiError.fieldErrors` disposition, one layer down." + "surface": "the sys_view_definition platform object (SysViewDefinitionObject, exported by @objectstack/metadata-core and re-exported by @objectstack/platform-objects and its metadata subpath), its registration by MetadataPlugin and by the metadata protocol assembly, its name in PLATFORM_OBJECTS_BY_PACKAGE (@objectstack/spec system constants), its kernel:ready active-row index migration and that migration's exports from @objectstack/metadata-protocol (ensureViewDefinitionActiveIndex, resolveIndexExec, buildActiveIndexSql, VIEW_DEFINITION_TABLE, VIEW_ACTIVE_INDEX_NAME, VIEW_ACTIVE_PROBE_INDEX_NAME, VIEW_ACTIVE_INDEX_COLUMNS and the EnsureViewIndex types), and its idx_sys_view_def_active entry in the os migrate duplicates runtime-index pre-flight", + "replacement": "nothing replaces the table — a runtime-authored view is a `view` metadata item in `sys_metadata`, written through `PUT /api/v1/meta/view/` (the client's `meta.saveItem` for type `view`), which is what every framework and Studio view door already does. Delete any import of the removed symbols; `classifyIndexFailure` and the `IndexExec` type are still exported by `@objectstack/metadata-protocol`, from the shared index-migration module. A stack that names `sys_view_definition` (a lookup target, a flow trigger, a permission entry, a platform-global declaration) removes the reference: the name no longer resolves to a platform object", + "migrationId": "sys-view-definition-retired", + "toMajor": 18, + "rationale": "ADR-0131 D13: an object no framework code writes or reads is inert and retires. Census at commit 41d0d4038c of this repository's main branch, run with the glob pathspec over packages/**/src (41 files; control word sys_metadata 769) and repo-wide (65 files): no framework writer of the table's rows and no reader of them — the only statements that touched its rows were the active-row index migration's own presence and duplicate probes and the os migrate duplicates pre-flight's copy of the latter. The sibling Studio repository never referenced it (0 hits against 93 for sys_metadata, at its main branch and at the pinned console commit): its view create, update and list doors write the ADR-0005 view overlay through the metadata API. The only way a row could ever have reached the table was a caller using the generic data door on the object by name. Keeping it registered kept an API-enabled table, a boot-time index migration and a pre-flight probe alive for no consumer, and kept the name resolving as a real platform object for authored metadata that named it." }, { - "surface": "automation.ActionDescriptor.resumeAuthority — an OMITTED value on a pausing node descriptor (supportsPause: true, or any executor whose execute() returns suspend: true)", - "replacement": "an explicit resumeAuthority: 'any' on the descriptor, for a pausing node whose pauses really are meant to be continued through the generic resume route (POST /automation/:name/runs/:runId/resume) — a screen-style collected-input pause, or a signal wait an external producer resumes. Declare 'service' instead if continuing is the tail of a decision your own service must authorize and record first. Either value is a one-line addition; only the silence changed meaning", - "migrationId": "action-descriptor-resume-authority-default-flip", - "toMajor": 17, - "rationale": "A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as protocol 12's `rest-requireauth-default-flip`, and it is registered here for the same reason: whether a given pause is genuinely open to the generic route is a trust judgment no transform can make. The generic resume route's authorization gate keys on the SUSPENDED NODE, and `ActionDescriptor.resumeAuthority` used to default to `'any'`, so a pausing node type shipped raw-resumable unless its author remembered the field. It now resolves to `'service'` when absent: an unclaimed pause is refused on the generic route with `PERMISSION_DENIED` / 403 until its descriptor states who may continue it. The revise-window incident decided the direction — ADR-0044 pointed an approval's revise edge at a generic `wait`, `wait` is legitimately `'any'`, and the pause standing in a service-owned position inherited a fail-open value nobody chose; the demonstrated cost was an unaudited resubmit plus a destroyed remote run. The two possible mistakes are asymmetric, which is the whole argument: guessing `'any'` walks past a decision nothing recorded and is silent, while guessing `'service'` returns a refusal naming the missing field. ⚠️ The surface is a DESCRIPTOR FIELD set in plugin CODE, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — the disposition `data-driver-find-stream-retired`, `storage-service-list-retired` and `actor-user-roles-to-positions` already carry. It differs from those in one way a reader should not have to infer: nothing is REMOVED, so tsc reports nothing at all — the field was already optional after step one and an omission still compiles. The enforced channels are all run-time: a registration warning naming the node type (once per type per engine), the refusal message on the resume itself, and `check:resume-authority-declared` for executors living in this repo. For a third-party plugin the generated upgrade guide is the only channel that arrives BEFORE a user hits a run that will not continue. In-tree the flip moves nothing: all six shipped pausing types (screen, wait, subflow, map, approval, approval_revise) declare their authority explicitly. ADR-0044 amendment (2026-07-28) and its 2026-08-08 landing section, and the 2026-07-28 resume-seam addendum to ADR-0019 (approval as a flow node)." + "surface": "the two cache durations whose name carried no unit: CacheTier.ttl and CacheAvalanchePrevention.circuitBreaker.resetTimeout (system/cache.zod.ts)", + "replacement": "ttlSeconds and resetTimeoutSeconds — rename each key; both values, the 300 TTL default and the 30 reset default are unchanged", + "migrationId": "system-cache-durations-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. These two are one entry because they are one file and one authoring session: a cache tier and the avalanche-prevention block that protects it. Each sat beside a number in a DIFFERENT unit with nothing at the authoring site to separate them — CacheTier.ttl (seconds) beside maxSize (megabytes), and circuitBreaker.resetTimeout (seconds) beside lockout.lockTimeoutMs (milliseconds) on the very same schema. That last pair is the sharpest case on this file: one shape already carried both conventions, and the suffixed one was the honest half. Both are retiredKey() tombstones; neither shape is strict, so a bare deletion would strip in silence and the unknown-key error could not carry the rename. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no cache collection, and neither a cache tier nor an avalanche-prevention block is a registered metadata kind stored as a sys_metadata row, so the conversion chain has no seam that would see one. ADR-0087." }, { - "surface": "ui.actionSession.roles", - "replacement": "ui.actionSession.positions (an action body reads `ctx.session.positions`)", - "migrationId": "action-session-roles-to-positions", - "toMajor": 17, - "rationale": "The MIRROR-IMAGE sibling of `actor-user-roles-to-positions`, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. The maintainer ruled contract-first on 2026-08-06 (\"C skeleton + A semantics\": declare the shape as it stands first, then rename on the typed face): phase 1 declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it; the producer emits both for one deprecation window (the runtime half of the same ruling), after which `roles` is removed on the path the v16 session-alias removal already walked (the hook session's `tenantId` alias: deprecated first, removed in the next major). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` / `activationEvents` / `hook-context-session-roles-retired` shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087." + "surface": "the two collaboration-session durations whose name carried no unit: CollaborationSessionConfig.idleTimeout and CollaborationSessionConfig.snapshot.interval (system/collaboration.zod.ts)", + "replacement": "idleTimeoutMs and snapshot.intervalMs — rename each key; both values and the 300000 idle-timeout default are unchanged", + "migrationId": "system-collaboration-durations-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. idleTimeout is the collision that got this whole population ruled rather than merely noted: it is MILLISECONDS here, while the tenant surface carried its own idleTimeout in SECONDS at the same time — so the identical bare name meant five minutes on one shape and three and a half days on the other, a 1000x divergence no parse could catch because both readings are positive integers. The tenant half was already renamed, in the same change that landed the duration gate itself; this is the half that remained. snapshot.interval rides in the same entry because it is the same object graph and the same authoring session — leaving one bare beside the other would have preserved exactly the ambiguity the rename removes. Both are retiredKey() tombstones; the shapes are not strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no collaboration collection, and a session config is a runtime call argument rather than a stored sys_metadata row, so the conversion chain has no seam that would see it. ADR-0087." }, { - "surface": "action body / AI route: ctx.user.roles (req.user.roles)", - "replacement": "ctx.user.positions (an AI route handler reads `req.user.positions`) — the same array, under the one spelling ADR-0090 D3 sanctions", - "migrationId": "actor-user-roles-to-positions", - "toMajor": 17, - "rationale": "The THIRD face of the ADR-0090 `roles` → `positions` rename, and the only one whose surface the spec never declared. `ActorUser` (`packages/runtime/src/security/actor-user.ts`) is the ONE producer of the `user` envelope handed to an action body as `ctx.user` and to an AI route handler as `req.user`; it declared `positions` and `roles` side by side and filled them from a SINGLE assignment (`roles: core.positions`), so the two keys were verbatim identical on every dispatch — a second spelling of the vocabulary ADR-0090 D3 reserves and bans, published straight into author-written code. The maintainer ruled it closed IMMEDIATELY (2026-08-06 14:49Z, on the finding that this alias had no closing date): no deprecation window, no dual-emit, the alias simply gone in 17. ⚠️ Do not read this entry across to its sibling `action-session-roles-to-positions`: `action-session-roles-to-positions` governs `ctx.session`, a DIFFERENT object reached through the same `ctx`, and that one KEEPS its one-window dual-emit. Same word, same dispatch, two faces, two schedules — `ctx.user.roles` is absent in 17 while `ctx.session.roles` still answers for the length of its window. What makes this entry different in KIND from both session-side siblings: `ctx.user` has no spec schema and never had one. It is a runtime TS interface, so unlike `HookContext.session.roles` (tombstoned on a deliberately non-strict `HookContextSchema` once it had no producer and no consumer left) and unlike `ActionSessionSchema` (declared contract-first, as it stood, as the first stage of the session rename, precisely so its key could be renamed), there is no schema key here to tombstone and no `retiredKey()` prescription that could reach anybody — nothing ever ran an `ActorUser` through a `.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it reports at the READ site inside the author's own body; for an untyped or sandboxed body there is no enforced channel at all, which is exactly why this ledger entry has to exist — `spec-changes.json` and the generated upgrade guide are the ONLY way such a reader learns of the rename. It is the `findStream` (no caller) / `IStorageService.list` (no consumer) disposition — a TS/API contract, no stored source, no tombstone, tsc at the call site — applied to a surface that lives one layer further out than either: those two are at least DECLARED in `packages/spec/src/contracts`, this one only in `packages/runtime`. Why it is a D3 semantic TODO and not a D2 conversion, on the same two independent grounds as its session sibling: FIRST, there is no source to convert — an `ActorUser` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key (the `openApi31` / `activationEvents` / `hook-context-session-roles-retired` shape). SECOND, the only place the key is ever SPELLED is inside an action body or an AI route handler: author-written JS/TS, or a sandboxed script. A declarative transform cannot safely rewrite an identifier inside free-form code — the same reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. The removal's hard precondition was met before it landed, and the result is recorded here because the ledger is where an upgrading consumer meets it: the declaration's own comment claimed the alias was \"kept for the REST/AI shapes\", and that claim was DISPROVEN face by face against `origin/main` — repo-wide `user.roles` was 4 hits, all of them in the pins the removal flipped; the four `ActorUser` construction sites build server-side envelopes that never enter a response body; objectui's `.roles` reads belong to two unrelated producers (the better-auth session, and the `/auth/me/permissions` payload). The `cloud` repo was NOT reachable in that session and is the one consumer face left unverified — this entry, and the changeset's FROM/TO prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087." + "surface": "FailoverConfig.healthCheckInterval, the disaster-recovery health-check period whose name carried no unit (system/disaster-recovery.zod.ts)", + "replacement": "healthCheckIntervalSeconds — rename the key; the value and the 30 default are unchanged", + "migrationId": "system-failover-health-check-interval-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. It stands alone because its file has exactly one offender left — and because the key directly beside it is the counter-example that shows where the line falls. FailoverConfig.dns.ttl is also a bare-named duration in seconds, and it is NOT renamed: it carries an externalVocabulary marker because it mirrors the DNS resource-record TTL field (RFC 1035 section 4.1.3), spelled ttl by every provider API the value is forwarded to (Route 53, Cloudflare). healthCheckInterval mirrors nothing outside this repo, so the exemption does not reach it. Tombstoned with retiredKey(); the shape is not strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no disasterRecovery collection and a failover config is host configuration, never a stored sys_metadata row. ADR-0087." }, { - "surface": "data.query.aggregations[].distinct", - "replacement": "the `count_distinct` aggregation FUNCTION for a deduplicated count — the one deduplicating spelling every face computes, lowered to `COUNT(DISTINCT field)` on both SQL faces since it took the enforce leg of the 2026-08-07 ruling that kept it declared and retired `array_agg` / `string_agg`. `SUM(DISTINCT …)` / `AVG(DISTINCT …)` get no replacement: no backend ever computed them here, and a per-row measure that needs deduplicating before summing is a modelling problem to fix in the data", - "migrationId": "aggregation-node-distinct-retired", - "toMajor": 17, - "rationale": "A DIVERGENCE, not an inert declaration — which is why it outlived the sweep of the `QueryAST` members no executor runs, the one that dispositioned every other `data.query.*` member. That sweep asked which keys no executor reads; this one HAD an executor, exactly one out of six. The engine's in-memory fallback (`objectql/src/in-memory-aggregation.ts`) deduplicated the values before applying the function, while `SqlDriver.aggregate`, the Turso `RemoteTransport.aggregate`, `driver-mongodb`'s `buildAggregationStage`, `driver-memory`'s `computeAggregate` and service-analytics' `AGGREGATE_SQL` all ignored the key. So `{ function: 'sum', field: 'amount', distinct: true }` answered a deduplicated sum when the engine fell back in memory and an ordinary sum on every SQL datasource: one query, two numbers, chosen by which backend happened to serve it — and unlike the divergences closed earlier on the same axis (an aggregate function name the remote Turso face compiled and the local face refused, and an unsupported function thrown as a bare error with no code on both SQL faces), the wrong answer here is a plausible NUMBER rather than a refusal, so nothing surfaced it to the author. Measured blast radius inside the fallback: `sum` and `avg` only — `count` returned from its own branch before reaching the dedupe, `count_distinct` fed the values into a Set (dedupe-then-Set is Set), and dedupe does not move `min`/`max`. ENFORCE was weighed and rejected (maintainer ruling 2026-08-09): `count_distinct` already covers the only spelling anyone has measured demand for, and lowering `SUM(DISTINCT …)` across five faces — two of them, driver-memory and driver-mongodb, then under the maintainer's 2026-08-05 investment freeze, a freeze lifted 2026-08-11, after this ruling — buys a shape that is near-universally a modelling mistake. A REQUEST surface — `QueryAST` is the client SDK builder's output and the `POST /data/:object/query` body, never stored in stack metadata — so there is no source for the chain to rewrite and callers move their own queries: the disposition that sweep gave `joins`/`cursor`/`distinct`/`windowFunctions`, applied verbatim one level down. ADR-0049." + "surface": "the five remaining metrics durations whose unit lived in a source JSDoc only: MetricDefinition.summary.maxAge, ServiceLevelObjective.errorBudget.burnRateWindows[].window, MetricExportConfig.interval, MetricsConfig.collectionInterval and MetricsConfig.retention.period (system/metrics.zod.ts)", + "replacement": "summary.maxAgeSeconds, errorBudget.burnRateWindows[].durationSeconds, intervalSeconds, collectionIntervalSeconds and retention.durationSeconds — rename each key; every value is unchanged", + "migrationId": "system-metrics-jsdoc-durations-unit-in-key", + "toMajor": 18, + "rationale": "This entry FINISHES what system-metrics-window-durations-unit-in-key started on this file, and the two are meant to be read as a sequence — this one does not amend that record, which stays a true account of what the system-directory duration round did. That round renamed the three metrics window and period lengths whose describe named no unit, and recorded that the error-budget burn-rate window was \"outside this rename, not outside the gate population\", naming the JSDoc-channel gap as where it would be settled. That gap is now ruled and this is its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer's 「同意」, which keeps the refusal of a duration key whose JSDoc names a unit its describe does not, remediates the 21-row JSDoc-channel population per file, and lands that widened gate last, into a tree already clean. ⚠️ One consequence for readers of the older entry: its acceptanceCriteria says the burn-rate window keeps its name and that a sweep renaming it has over-applied the rule. That sentence was true of that round and is superseded here, by the ruling it itself pointed at; the other key it names, the exporter batch size, is a COUNT of records and still does not move. All five keys here share one defect: the unit (seconds) was stated in the JSDoc above the key, a channel check:duration-unit-keys does not read — it reads .describe() and .meta({ description }) — and four of the five carried no describe at all while the fifth read \"Window size\". So the reader who most needs the unit, the reader of the published reference page, got a bare integer: 600, 3600, 60, 15 and 604800 are each a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. Each key is renamed and its describe corrected in the same stroke, because under the duration-unit rule (the unit lives in the key name or a unit-carrying value, never in the describe prose alone) moving the unit into the describe alone is itself a violation. Three of the five spellings are not the mechanical suffix, and each departure has a reason this file already supplied: burnRateWindows[].window becomes durationSeconds, not windowSeconds, because the enclosing array is already called burnRateWindows so the key would stutter — the objection the system-directory round recorded against window.windowSeconds — and because on this tree windowSeconds is not an authorable key at all, its only key-position occurrence being an alias-map entry in ServerRateLimitConfigSchema that maps the spelling AWAY to windowMs; retention.period becomes durationSeconds, not periodSeconds, because period is calendar vocabulary elsewhere in this spec (ServiceLevelObjective.period.type selects rolling or calendar, PluginRegistryEntry.pricing.billingPeriod is monthly or yearly) so periodSeconds would keep the ambiguous half of the name; and collectionInterval keeps its qualifier as collectionIntervalSeconds so it stays distinct from the MetricExportConfig.intervalSeconds this same card creates one def over. The two mechanical spellings are attested: maxAgeSeconds is the token AccessControlConfig.maxAgeSeconds already carries after this same rule renamed it on system/object-storage.zod.ts, and it keeps the age stem the sibling ageBuckets counts buckets of; intervalSeconds is the token four seconds-valued cadences already carry. Counted in key position across packages/spec/src at fc28c1d38, the base of this change, the seconds suffixes run Seconds 40, Sec 1 (maxExecutionTimeSec) and S 0 — the two bare S keys on that corpus, maxCommitTimeMS and enableRLS, are a millisecond spelling and a boolean — so Seconds is the family; this change takes Seconds to 45 at 9b62f54671. All five are retiredKey() tombstones; none of the five enclosing shapes is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no metrics collection, and none of a metric definition, an SLO, an export config or a metrics config is a registered metadata kind stored as a sys_metadata row — the same reading the system-directory round recorded for the three keys it renamed. Measured on fc28c1d38: no in-repo code consumer reads any of the five — outside packages/spec the only occurrences of every distinctive key on these shapes (burnRateWindows, errorBudget, downsampling, collectionInterval, cardinalityLimits, maxLabelCombinations, ageBuckets) are in the generated content/docs/references/system/metrics.mdx, which this rename regenerates, against a lit control of 1195 defineStack occurrences on that same corpus at fc28c1d38 (1195 again at 9b62f54671); and the objectui checkout this repo builds against — this is the pin, `.objectui-sha` = `f0268ad784854568aa58a2aa791f6a7502259186`, re-read from this tree — spells all six metrics def names and both distinctive keys 0 times across 8234 tracked files at that sha, against lit controls window 4430, timeout 1658, period 249, interval 213 and metrics 404 on that same corpus and sha (0 across 7754, against 4255 / 1431 / 247 / 200 / 401, at a58626c88, 0 across 7650, against 4194 / 1360 / 238 / 195 / 401, at 0abd4f9f8, 0 across 7632, against 4193 / 1360 / 238 / 195 / 401, at 9dfaca654, 0 across 7579, against 4175 / 1351 / 238 / 195 / 374, at 2e818d0b5, 0 across 10267, against 4044 / 1348 / 231 / 196 / 354, at ab1879721, 0 across 10071, against 4002 / 1331 / 231 / 196 / 355, at 89cad75d5, 0 across 9912, against 3916 / 1303 / 234 / 196 / 354, at 31971ff1e, 0 across 9800, against 3873 / 1293 / 233 / 196 / 352, at e420df310, 0 across 9546, against 3772 / 1197 / 228 / 196 / 341, at db11afd49, 0 across 9283, against 3681 / 1172 / 183 / 176 / 340, at dd3f7e1be, 0 across 8512, against 3581 / 1096 / 171 / 179 / 326, at f8a9d0fb0, and 0 across 8303, against 3526 / 1086 / 170 / 179 / 324, at 62597c588), so no pin bump is owed. ADR-0087." }, { - "surface": "api.analyticsQueryRequest.query", - "replacement": "bare AnalyticsQuery body (top-level cube/measures/dimensions/where/...)", - "migrationId": "analytics-query-request-envelope-retired", - "toMajor": 17, - "rationale": "The { cube, query: {...} } envelope was an HTTP-wire dialect of the retired degraded analytics shim (the fallback that answered /analytics/query when no analytics service was installed, and dropped the caller's identity and its `where` filter at the door), never stored in stack metadata — there is no source for the chain to rewrite. Callers of POST /analytics/query and /analytics/sql must move the query.* fields to the body top level themselves." + "surface": "the three metrics window/period lengths whose name carried no unit: MetricAggregationConfig.window.size, ServiceLevelIndicator.window.size and ServiceLevelObjective.period.duration (system/metrics.zod.ts)", + "replacement": "window.durationSeconds, window.durationSeconds and period.durationSeconds — rename each key; every value is unchanged", + "migrationId": "system-metrics-window-durations-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. The three are one entry because they are one measurement expressed three times on one file: how long a window or period is. The new name is deliberately NOT the mechanical sizeSeconds the gate prints. size means a byte or row count everywhere else in this spec — CacheTier.maxSize is megabytes, RegistryConfig.cache.maxSize is bytes, and this very file spells a batch row count size — so sizeSeconds would have kept the misleading half of the name and bolted a unit onto it, leaving a reader to decide whether a window is measured in bytes-per-second or in time. windowSeconds was rejected for a plainer reason: the parent key is already window, so it would read window.windowSeconds. durationSeconds names what the number IS, and the file itself supplied the precedent — ServiceLevelObjective.period already called its length a duration, so after the rename all three read alike instead of one borrowing byte vocabulary. All three are retiredKey() tombstones; the shapes are not strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no metrics collection, and none of an aggregation config, an SLI or an SLO is a registered metadata kind stored as a sys_metadata row. ADR-0087." }, { - "surface": "api.analyticsQueryRequest.format", - "replacement": "(removed — responses are always the JSON envelope; use the export surface for CSV/XLSX)", - "migrationId": "analytics-query-request-format-retired", - "toMajor": 17, - "rationale": "The `format` key was declared but never implemented (declared ≠ enforced): every response is the JSON envelope regardless of the requested value, so there is no behaviour to preserve and nothing stored to rewrite." + "surface": "the two object-storage durations whose name carried no unit: AccessControlConfig.maxAge and StorageConnection.timeout (system/object-storage.zod.ts)", + "replacement": "maxAgeSeconds and timeoutMs — rename each key; both values are unchanged", + "migrationId": "system-object-storage-durations-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. AccessControlConfig.maxAge is the one key in this stack where the two structural exemptions and the rename look alike from a distance, so the reasoning is recorded rather than assumed. It was CONSIDERED for an externalVocabulary marker and demoted on evidence: every bucket-CORS standard the value is forwarded to spells the field WITH its unit — S3 MaxAgeSeconds, GCS maxAgeSeconds, Azure MaxAgeInSeconds — so marking it would have exempted a DEVIATION from the cited standard rather than a mirror of it, which is the opposite of what the marker declares. Its twin shared/CorsConfig.maxAge DID get the marker and keeps its bare name, because the Fetch response header that one mirrors, Access-Control-Max-Age, genuinely carries no unit token. Two maxAge keys on opposite sides of the same line; the asymmetry is the point and must not be harmonised. StorageConnection.timeout rides along as the plain case on the same file. Both are retiredKey() tombstones; the shapes are not strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no objectStorage collection, and neither shape is a registered metadata kind stored as a sys_metadata row. ADR-0087." }, { - "surface": "PUT /api/v1/meta/api/{name} (runtime-authored `api` endpoints, draft and active alike)", - "replacement": "Declare the endpoint as a stack artifact (`**/*.api.ts`, or `defineStack({ apis })`) and ship it through `publishPackage`", - "migrationId": "api-runtime-create-withdrawn", - "toMajor": 17, - "rationale": "The `api` registry entry declared `allowRuntimeCreate: true` and the runtime never honoured it. Measured on a real showcase boot: `PUT /api/v1/meta/api/e8_backdoor` answered 200 with `{\"success\":true,…,\"message\":\"Saved …\"}`, and the declared route then answered 404 forever — with NO `[EndpointMatcher] … EXCLUDED` line, because the endpoint was never in the index to be excluded from. The serving criterion belongs to `IMetadataService.matchEndpoint` -> `EndpointMatcher` -> `MetadataManager.listForIndex('api')`, which reads the manager's registry plus its registered loaders (`[\"filesystem\",\"memory\"]` on dev/serve); a runtime write lands in `sys_metadata`, which is in neither. A declared capability the runtime does not honour is ADR-0049 false compliance, and a write that answers \"Saved\" and then 404s forever is its most dangerous shape for the AI authors ADR-0033 targets. The maintainer ruled REMOVE on 2026-08-07 rather than converge the read path, because making the matcher read `sys_metadata` re-opens cache, invalidation, tenancy and the ADR-0110 D3 miss-vs-outage distinction on a new read path, and there is no business pull for Studio-authored endpoints today (zero `.api.*` artifacts author them at runtime; showcase uses the artifact route, and its declared endpoints serve live). There is NO D2 conversion, for the reason this list exists: nothing in an authored source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an authorable one, and the artifact route it points authors toward is untouched — a `**/*.api.ts` file valid before this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, so it is one semantic TODO for operators and Studio callers rather than a stack conversion — the same disposition `BatchOptions.validateOnly` takes. Consequently `gateApiDraftsForPublish` is retired with it: it gated a promotion into a state the matcher can never read, and with the inlet closed no `api` draft can exist for it to judge. Re-entry is recorded in the ruling: if the Studio metadata-coverage work promotes `apis` to a registered type WITH A REAL CONSUMPTION PATH, the flag flips back then — implementation first, declaration second. The same refusal closes the direct-active write too, which had been a third path past the endpoint namespace and duplicate-path gates. ADR-0049 / ADR-0121." + "surface": "the three package-registry durations whose name carried no unit: RegistryUpstream.syncInterval, RegistryUpstream.timeout and RegistryConfig.cache.ttl (system/registry-config.zod.ts)", + "replacement": "syncIntervalSeconds, timeoutMs and cache.ttlSeconds — rename each key; every value, the 30000 timeout default and the 3600 TTL default are unchanged", + "migrationId": "system-registry-config-durations-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. The three are one entry because they are one file and, for the first two, one object: RegistryUpstream declared a SECONDS interval and a MILLISECONDS timeout twenty-five lines apart, both bare. That pair carries the clearest demonstration in this card of why a bound is no substitute for a name — timeout is min(1000), which reads as one second under the right unit and as sixteen minutes under the wrong one, and both readings satisfy the validator. The cache TTL is the same defect one schema over, beside a maxSize measured in bytes. All three are retiredKey() tombstones; the shapes are not strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no registry collection, and a registry config is host configuration read at startup rather than a stored sys_metadata row, so the conversion chain has no seam that would see it. ADR-0087." }, { - "surface": "data.object.enable.apiMethods (the eight legacy non-primitive values)", - "replacement": "the six primitives only — `get` / `list` / `create` / `update` / `delete` / `bulk`: replace each legacy value with the primitives it derives from, de-duplicate, and delete the key entirely if the result names all six", - "migrationId": "apimethod-enum-shrink", - "toMajor": 17, - "rationale": "The authored `enable.apiMethods` enum is now exactly the six primitives. The eight legacy values — `upsert`, `aggregate`, `history`, `search`, `restore`, `purge`, `import`, `export` — are no longer authorable, because they are DERIVED effective operations resolved by the server's single derivation table, and an enum that lets an author name both a primitive and something derived from it has two spellings for one fact. The FROM → TO is a table rather than a rename: `upsert` → `create` + `update`; `import` → `create` + `update`; `export`, `aggregate` and `search` → `list`; `history` → `get`; and `restore` / `purge` map to NOTHING — they never derived, because `enable.trash` was retired with the other dead `enable.*` flags in the 11.0 ADR-0049 removal of dead author-facing properties, so the value is deleted outright. That last row is why this is a semantic entry and not a mechanical conversion, and the reason is a security one: the mapping WIDENS. An allowlist naming `history` was granting read of one record's audit trail; rewritten to `get` it grants ordinary record reads, and an allowlist naming `search` becomes a grant of full `list`. A transform that applied the table silently would broaden real API permissions without anyone reading the diff, so the rewrite is delegated to the author with the widening flagged. The reporter codemod exists for exactly that shape: `node scripts/codemod/apimethods-legacy-to-primitives.mjs` scans, reports the exact replacement per site, and FLAGS the allowlists the mapping would widen so the edit stays reviewable — it reports, it does not rewrite. Stored metadata keeps parsing (permanent tolerance, narrowing only), so nothing breaks at rest; what changes is what an author may newly write. Registered late, by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger: the enum shrink (phase 2 of the programme that made UI action buttons agree with the `apiMethods` allowlist) predates the gate that makes a breaking changeset state its ledger disposition. ADR-0087." + "surface": "the four tracing-configuration durations whose unit lived in a source JSDoc only: OpenTelemetryCompatibility.exporter.timeout, OpenTelemetryCompatibility.exporter.batch.exportTimeout, OpenTelemetryCompatibility.exporter.batch.scheduledDelay and TracingConfig.performance.exportInterval (system/tracing.zod.ts)", + "replacement": "timeoutMs, exportTimeoutMs, scheduledDelayMs and exportIntervalMs — rename each key; all four values (milliseconds) and their 10000 / 30000 / 5000 / 5000 defaults are unchanged", + "migrationId": "system-tracing-otel-exporter-durations-unit-in-key", + "toMajor": 18, + "rationale": "Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the maintainer's 「同意」: a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands — the duration-unit rule (the unit lives in the key name or its value type, never in prose alone) executed file by file. It follows system-tracing-span-duration-unit-in-key on this same file and does not amend it: that entry retired Span.duration under ruling B, whose population was the describe channel, and these four keys were never in it — they are the JSDoc-only channel that finding opened, which is why one file carries two rounds. Each key named milliseconds in its JSDoc — \"Timeout in milliseconds\", \"Export timeout in milliseconds\", \"Scheduled delay in milliseconds\", \"Background export interval in milliseconds\" — and the JSDoc above a key is NOT what content/docs/references/** renders; .describe() is. Measured on this tree: all four carried NO .describe() at all, so the published reference row for each was a bare integer with no unit anywhere on the page — a strictly worse channel than the unit-in-prose shape the duration-unit rule already refuses, since here the reference reader had no prose to misread. The magnitudes make the guess plausible in both directions: 10000, 30000, 5000 and 5000 are all defensible as seconds and as milliseconds, and an operator who reads seconds sets an exporter deadline 1000x short. The suffix is the family spelling, counted in key position at 98bd7986fe over packages/spec/src *.ts (reproduce with git grep -hoE on that ref): 281 *Ms declarations over 42 distinct names, timeoutMs 65 of them and intervalMs 14, against 0 key-position timeoutSeconds and 77 *Seconds of any name; the Delay-plus-Ms pairing is likewise already attested on that same ref (maxDelayMs 9, initialDelayMs 9, maxRetryDelayMs 5, debounceDelayMs 2, delayMs 2, retryDelayMs 1) with 0 occurrences of any competing exportTimeout, scheduledDelay or exportInterval spelling, suffixed or Seconds. Note this file is milliseconds throughout and its own landed precedent is Span.duration to durationMs, the opposite of the sibling metrics card whose rows were seconds. exporter.timeoutMs and exporter.batch.exportTimeoutMs are deliberately allowed to sit one nesting level apart: the pair pre-exists the rename — the batch sub-object is the OpenTelemetry batch span processor's own four knobs (max batch size, max queue size, scheduled delay, export timeout) beside the exporter's own request deadline — so renaming either to something more distinctive would depart from the vocabulary the shape mirrors, and the nesting already disambiguates every read point (exporter.timeoutMs vs exporter.batch.exportTimeoutMs). All four old spellings are retiredKey() tombstones: neither OpenTelemetryCompatibilitySchema nor TracingConfigSchema nor any object nested inside them is .strict(), so a bare deletion would be a SILENT STRIP (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — and the stripped value lands on an export deadline and a background export period. Why a semantic entry and not a D2 conversion: the conversion chain walks a normalized STACK, and neither def is an authorable surface — stack.zod.ts declares no tracing collection, no metadata-type binding or manifest embed carries either, and a tracing configuration is never a stored sys_metadata row — so a conversion would be a transform with no seam that ever runs. That is the same disposition system-tracing-span-duration-unit-in-key recorded for the other key on this file. Measured at 98bd7986fe: NO in-repo reader exists outside packages/spec — OpenTelemetryCompatibility, TracingConfig and all three batch key names occur 0 times across the whole tree at that ref excluding packages/spec and content/docs/references, against a lit control of 18920 Schema occurrences on exactly that corpus and ref — both counts from one git grep -o over 98bd7986fe with those two pathspec exclusions — and a dark control of 0; inside packages/spec the only occurrences are tracing.zod.ts, its test, and the generated rows in content/docs/references/system/tracing.mdx, which this rename regenerates. And the pinned objectui checkout — `.objectui-sha` = `f0268ad784854568aa58a2aa791f6a7502259186` — names none of it: all 37 exports of tracing.zod.ts and each of the four key names occur 0 times across the 8234 files tracked at that sha (the 517 Span and 57 SpanSchema hits are objectui's own HTML text-span component, TextSpanSchema, an unrelated name, plus colSpan and prose), against two lit controls on that same corpus and sha: 17956 hits for the bare token objectstack, and 7522 for the package specifier @objectstack/spec (at a58626c88: 0 across 7754, Span 509, 17468 and 7246; at 0abd4f9f8: 0 across 7650, Span 508, 17390 and 7209; at 9dfaca654: 0 across 7632, Span 508, 17313 and 7186; at 2e818d0b5: 0 across 7579, Span 505, 17227 and 7134; at ab1879721: 0 across 10267, Span 491, 16377 and 6665; at 89cad75d5: 0 across 10071, Span 489, 16044 and 6461; at 31971ff1e: 0 across 9912, Span 486, 15691 and 6206; at e420df310: 0 across 9800, Span 486, 15352 and 6024; at db11afd49: 0 across 9546, Span 486, 14704 and 5545; at dd3f7e1be: 0 across 9283, Span 485, 13745 and 5466; at f8a9d0fb0: 0 across 8512, Span 488, 13347 and 5123; at 62597c588: 0 across 8303, Span 486, 13125 and 5043)." }, { - "surface": "automation.ApprovalEscalation.enabled — an OMITTED value inside an approval node's escalation block", - "replacement": "nothing, for the common intent (escalate on timeout): an escalation block carrying timeoutHours is live by default. To declare an SLA OFF while keeping its configuration, write enabled: false explicitly — which is now the spelling the escalation sweep actually reads", - "migrationId": "approval-escalation-enabled-default-flip", - "toMajor": 17, - "rationale": "A DECLARED-DEFAULT CORRECTION plus the enforcement that makes the key real (maintainer ruling 2026-08-27, which moved the declared default to what the sweep had always done) — the same category as protocol 17's `import-run-automations-declared-default-corrected`: the schema promised `enabled` defaults to `false` (SLA off) while the plugin-approvals sweep never read the key at all — any escalation block with a positive `timeoutHours` escalated, and with `action: 'auto_approve'` that silently approved requests their author had declared off the clock. The flip moves the default to `true` and, in the same change, the sweep starts honouring an explicit `enabled: false`. The feature-level switch is whether an `escalation` block exists at all; within a block carrying `timeoutHours`, escalation is on unless explicitly turned off. Deployed metadata that OMITS `enabled` does not change behaviour: it escalated before (the sweep ignored the key) and escalates after (the parse materializes `true`). Stored request snapshots written before the flip carry a MATERIALIZED `enabled: false` (the approval-node executor parses config through the old schema before snapshotting), so the sweep keeps a read-side legacy window keyed on the snapshot's `created_at`: pre-flip snapshots keep escalating exactly as they do today, and the window retires itself as those pending requests drain. What DOES change is that an explicit `enabled: false` finally binds — a flow that authored it (e.g. the console toggle switched off after a timeout was set) stops escalating on requests opened after the upgrade, which is the declared intent being honoured." + "surface": "Span.duration, the emitted trace-span length whose name carried no unit (system/tracing.zod.ts)", + "replacement": "durationMs — rename the key; the value is unchanged", + "migrationId": "system-tracing-span-duration-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. It stands alone because it is the only offender on its file and the only one in this card that is a pure runtime-emitted measurement: a span is written by an exporter and read by a backend, never authored by hand. That is also why it is a rename and not an externalVocabulary mirror, which is the exemption a tracing shape would most plausibly claim: OpenTelemetry, whose model this schema follows, carries span length as a start/end nanosecond PAIR and declares no key named duration at all, so there is no external spelling for the marker to point at. The shape already spells its two instants startTime and endTime, so the bare duration was the one measurement on the span that did not say what it was. Tombstoned with retiredKey(); the shape is not strict, so a bare deletion would strip in silence and an exporter emitting the old spelling would lose the value without an error. Why a semantic entry and not a D2 conversion: an emitted span is never a stack collection member and never a stored sys_metadata row — the same disposition every runtime-emitted measurement in this stack has taken. ADR-0087." }, { - "surface": "sys_audit_log.action — the values 'export' and 'permission_change' left the select enum declared by plugin-audit (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts). The same two values also left the shipped list-view filters on that object: 'permission_change' from the auth_events view and 'export' from the config_changes view", - "replacement": "nothing, for either value — both are removed rather than renamed, because neither named an event this platform records. For permission changes, read the ordinary `create` / `update` rows on the permission objects themselves: a grant or binding write is an ordinary record write and the generic audit writer already ledgers it, so a second semantically-duplicate row was never minted. For `export` there is no replacement and nothing is lost: no export feature ever wrote an audit row. A consumer filtering `sys_audit_log` on either value was reading an empty result set on every deployment, and still is — what changed is that the contract no longer promises otherwise", - "migrationId": "audit-log-action-enum-retired", - "toMajor": 17, - "rationale": "Maintainer ruling 2026-08-12 on the audit log's writerless actions, the retirement half of a two-half verdict: the cheap writers get built (`login` / `logout` on the auth session hooks, `config_change` from the settings service) and the enum values with no feature behind them are retired. 原则记录:空 widget + 永远查不到东西的过滤器是可见产品缺陷;审计面宁窄勿谎. The defect was false compliance on a COMPLIANCE surface, which is the sharpest form of ADR-0049 declared-≠-enforced: an auditor reading the action enum believed the platform captured permission changes and data exports, and the shipped list views and dashboard widgets showed them a filter and a tile for exactly those events. Both were permanently empty. Measured by enumerating every `sys_audit_log` writer in the repo — there are exactly two: plugin-audit`s generic hook writer, whose `actionFor` maps afterInsert/Update/Delete to create/update/delete and nothing else, and plugin-auth`s admin user-import. Neither has ever emitted `export` or `permission_change`. This is an enum-VALUE retirement, so the bookkeeping differs from a key retirement in the two ways `hook-body-crypto-hash-removed`, `dataset-measure-array-string-agg-removed` and `action-global-nav-location-removed` already record: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) and the four surface ratchets are expected to be byte-identical (no def changed). It differs from all three in being a SEMANTIC entry rather than a D2 conversion, and the reason is that there is no source to rewrite: `sys_audit_log` is a platform-owned, append-only object whose every field is `readonly: true`. Nobody authors an audit row and nobody authors this enum — the values appear only in rows the runtime writes and in queries consumers send. A conversion rewrites authored metadata or a stored `sys_metadata` row; this surface is neither, so the disposition is the one `BatchOptions.validateOnly` and the notification cursor already take in this major. ⚠️ Historical ROWS are deliberately untouched. A deployment that somehow holds a row with either value keeps it, and keeps reading it back: the enum is not enforced on this object at all (`validateRecord` skips `readonly` fields, and every field here is readonly), so nothing rejects stored history and no backfill is required or wanted. Deleting audit history to satisfy a schema narrowing would be the one genuinely destructive reading of this change. ADR-0049 / ADR-0087." + "surface": "QueueConfig.rateLimit.duration, the worker rate-limit window whose name carried no unit (system/worker.zod.ts)", + "replacement": "durationMs — rename the key; the value is unchanged", + "migrationId": "system-worker-queue-rate-limit-duration-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. It stands alone because it is the only offender left on its file, and the file itself is what makes it a drift rather than a convention: TaskResult.durationMs, declared ninety lines earlier in the SAME source, already spelled the identical measurement with its unit. One file, one unit, two spellings, and the correct one was already there — so this rename removes an internal inconsistency rather than imposing an external one. Tombstoned with retiredKey(); the shape is not strict, so a bare deletion would strip in silence and a queue would fall back to no rate limit at all without an error. Why a semantic entry and not a D2 conversion: stack.zod.ts declares jobs, not queues, so a QueueConfig is worker host configuration rather than a stack collection member or a stored sys_metadata row, and the conversion chain has no seam that would see it. ADR-0087." }, { - "surface": "sys_audit_log.action — the value 'restore' left the select enum declared by plugin-audit (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts). It also left the shipped writes_only list-view filter on that object, and the generated option label in all four plugin-audit translation bundles", - "replacement": "nothing — the value is removed rather than renamed, because it never named an event this platform records. There is no undelete or restore capability to point at: deletes are hard deletes, and the record-level audit writer maps the ObjectQL lifecycle to `create` / `update` / `delete` only. A consumer filtering `sys_audit_log` on this value was reading an empty result set on every deployment, and still is — what changed is that the contract no longer promises otherwise. If you were counting on a restore trail, the capability itself is the missing piece (an undelete / purge permission lifecycle and a soft-delete recycle bin, neither built yet), not this enum row", - "migrationId": "audit-log-action-restore-retired", - "toMajor": 17, - "rationale": "The same maintainer ruling as `audit-log-action-enum-retired`, carried to the one value that ruling's own survey did not name (triage 2026-08-13). 原则记录:空 widget + 永远查不到东西的过滤器是可见产品缺陷;审计面宁窄勿谎. `restore` is the least ambiguous member of the family: the record-level writer could not have produced it even by accident, because `actionFor()` in audit-writers.ts is typed `'create' | 'update' | 'delete' | null` and its caller early-returns on null. A tree-wide search finds no other producer. What made it a card rather than a tidy-up is that TWO shipped declarations asserted the opposite, so a declaration-reading audit scored the action as covered: the `writes_only` list view offered it as a filter value, and the module docblock of auth-event-audit.ts named it among the actions the writer emits. The comment is the ADR-0049 declared-≠-enforced shape in its purest form (the shape a credential-storage audit had to settle by re-measuring two \"hashed at rest\" comments) — a sentence next to a mechanism, contradicted by the type signature of that very mechanism, with nothing in CI able to tell. Both declarations are corrected in one change, and the invariant behind the comment (every declared action has a writer) now has a pin test under it rather than prose. Bookkeeping is identical to the sibling entry, for the same reasons: an enum-VALUE retirement puts nothing in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) and leaves the four surface ratchets byte-identical (no def changed), and it is a SEMANTIC entry rather than a D2 conversion because there is no source to rewrite — `sys_audit_log` is a platform-owned, append-only object whose every field is `readonly: true`, so nobody authors an audit row and nobody authors this enum. ⚠️ This is a statement about the WRITER, not a product stance against undelete. Soft delete/restore is parked, not rejected: the undelete / purge lifecycle and the recycle bin are both held open, not declined. If that capability lands, this value returns WITH its writer — the emission point, its tests, and the view that surfaces it — never as a bare enum row again. ⚠️ Historical ROWS are deliberately untouched, exactly as for the sibling entry: the enum is not enforced on this object at all (`validateRecord` skips `readonly` fields), so any stored row keeps parsing and reading back, and no backfill is required or wanted. Deleting audit history to satisfy a schema narrowing would be the one genuinely destructive reading of this change. ADR-0049 / ADR-0087." + "surface": "SchemaLevelIsolationStrategy `performance.schemaCacheTTL` (system/tenant.zod.ts)", + "replacement": "`performance.schemaCacheTtlSeconds` (default 3600) — rename the key; the value (seconds) is unchanged", + "migrationId": "tenant-schema-cache-ttl-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling A, 2026-09-11: the gate that reads a duration key's JSDoc lands last, after its offenders are fixed file by file — so this entry executes, per file, the rule that a duration number key carries its unit in its name. The key carried its unit (seconds) in a source JSDoc only — \"Schema cache TTL in seconds\" — while `.describe()`, the text `content/docs/references/**` publishes, said \"Schema cache TTL\" and named no unit at all. So the reader who most needs the unit, the reader of the published reference page, was the only reader who never saw it: 3600 is a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. Under that rule's gate, moving the unit into the describe alone is itself a violation (unit in prose, none in the name), so the key is renamed and the describe is corrected in the same stroke. Spelled `Ttl` and not `TTL`: counted on this tree, the suffixed family already spells it that way in every member (`cacheTtlSeconds` 11, `ttlSeconds` 3, `defaultCacheTtlSeconds` 1) and no key-position `TtlSeconds` variant spells it otherwise. Tombstoned with `retiredKey()` because the nested `performance` object is not strict, so a bare deletion would silently strip the key. Why a semantic entry and not a D2 conversion: `stack.zod.ts` declares no tenancy collection and a tenant isolation strategy is not a stored metadata row (it describes cloud tenancy configuration), so the chain has no seam that runs on it — the same reading `tenant-timeouts-unit-in-key` recorded for the two sibling keys on this file. Measured on bd25e897dc: no in-repo runtime reads the key — outside `packages/spec/src/system/tenant.zod.ts` and its test the only occurrences are the four generated rows in `content/docs/references/system/tenant.mdx`, which this rename regenerates; and the pinned objectui checkout — `.objectui-sha` = `f0268ad784854568aa58a2aa791f6a7502259186` — spells it 0 times across 8234 tracked files, against lit controls `TTL` 184 and `tenant` 1338 on the same corpus (0 across 7754, against 184 and 1319, at a58626c88; 0 across 7650, against 182 and 1318, at 0abd4f9f8; 0 across 7632, against 182 and 1318, at 9dfaca654; 0 across 7579, against 182 and 1317, at 2e818d0b5; 0 across 10267, against 180 and 1238, at ab1879721; 0 across 10071, against 181 and 1237, at 89cad75d5; 0 across 9912, against 181 and 1237, at 31971ff1e; 0 across 9800, against 181 and 1235, at e420df310; 0 across 9546, against 181 and 1200, at db11afd49; 0 across 9283, against 181 and 1185, at dd3f7e1be; 0 across 8512, against 156 and 1034, at f8a9d0fb0; 0 across 8303, against 156 and 987, at 62597c588)." }, { - "surface": "api.authConfig.features.passkeys / api.authConfig.features.magicLink", - "replacement": "(removed — no replacement flag; the capabilities are not advertised)", - "migrationId": "auth-config-unadvertised-reserved-features", - "toMajor": 17, - "rationale": "Both flags were served by `GET /api/v1/auth/config` from introduction and read by no client: no login UI anywhere renders a passkey or magic-link affordance off them, so the payload advertised two sign-in methods a user could never reach, and a deployer setting `plugins.passkeys` / `plugins.magicLink` flipped a switch with no observable effect (ADR-0049 enforce-or-remove; the maintainer ruling of 2026-08-11 chose remove over keep-as-reserved, so that a deployer cannot flip a flag that does nothing anywhere). The two are not equally empty: nothing at all is wired behind `passkeys`, whereas `magicLink`'s better-auth endpoints are live and only their advertisement was withdrawn. This is a RESPONSE surface — nobody authors or persists an `AuthFeaturesConfig` — so there is no source for the chain to rewrite; the schema tombstones both keys via retiredKey() and consumers drop their read. The withdrawal is conditional: both return to the payload in the change that ships the login UI (flag-gated passkey and magic-link entry points, which objectui defers until the maintainer schedules them). ADR-0049." + "surface": "DatabaseLevelIsolationStrategy `connectionPool.idleTimeout` / TenantSecurityPolicy `accessControl.sessionTimeout` (system/tenant.zod.ts)", + "replacement": "`connectionPool.idleTimeoutSeconds` (default 300) and `accessControl.sessionTimeoutSeconds` (default 3600) — rename each key; the values (seconds) are unchanged", + "migrationId": "tenant-timeouts-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling 2026-09-02, B: a duration number key carries its unit in its name, enforced by a gate with no grandfathered baseline — folding in the finding that these two descriptions named no unit. Both keys carried their unit (seconds) in a source JSDoc only; `.describe()` — the text `content/docs/references/**` publishes — said \"Idle pool timeout\" and \"Session timeout\" with no unit at all. So the one reader who most needs the unit, the reader of the published reference page, was the only reader who never saw it: 300 is a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. That finding proposed adding the unit to the two descriptions; under the ruled gate that exact fix is a violation (unit in prose, none in the name), so the keys are renamed instead — one breaking change per key, and the tree never passes through a state the gate refuses. Both are retiredKey tombstones (the nested objects are not strict). Why a semantic entry and not a D2 conversion: neither schema is a stack collection member or a stored row (they describe cloud tenancy configuration), so the chain has no seam that runs on them (the `kernel/Manifest:loading` precedent). Measured on ca46f8f12: no in-repo runtime reads either key." }, { - "surface": "the protocol-17 authoring schemas closed against undeclared keys by the unknown-key strictness wave — `automation/` (flow and its six nested blocks, control-flow, state-machine, webhook, time-relative trigger, flow function), `security/` (permission sets, RLS policies, sharing rules) and `identity/position`, `ui/` (responsive, theme, chart, `AriaProps`, fifteen `view` sub-blocks, `ViewItem`, `userFilters`) — plus the `view` write-path identity precondition one level above them", - "replacement": "declared keys only. Each rejection names the surface, echoes the offending key and — where the word is recognisable — gives the canonical spelling, a retired-key tombstone, or a prescription where a rename would be wrong. A `view` body must additionally carry at least one key some union member declares, discounting the identity keys the write path stamps itself (`VIEW_WRITE_PATH_IDENTITY_KEYS`)", - "migrationId": "authoring-schemas-strict-unknown-keys", - "toMajor": 17, - "rationale": "zod's default `.strip` discarded any key these schemas did not declare and let the parse SUCCEED, so the author — increasingly an AI — got a success envelope and shipped metadata that quietly ignored what they wrote. Closing them turns that into a loud parse error (ADR-0049 enforce-or-remove, ADR-0078 no-silently-inert). It is not losslessly convertible for the same reason the two precedent entries at majors 15 and 16 are not: an arbitrary unknown key has no mapping target, and auto-deleting it would be exactly the silent data loss ADR-0078 bans — so each occurrence needs the author to decide, fix the typo, move it to the layer that owns it, or delete dead metadata. The named renames the errors carry are a help, not a transform: a large share of this wave is PRESCRIPTIONS rather than renames precisely because renaming would be wrong (`inputSchema.optional` is the opposite polarity of `required`; `errorHandling.maxAttempts` counts the first attempt where `maxRetries` counts the ones after it; a `responsiveStyles` bucket written on `responsive` is a wrong-layer pointer, and the two breakpoint vocabularies sixteen lines apart cannot be bridged by edit distance; `aria.live` is real on exactly one renderer and `ariaLabelledBy` has nothing to rename to; `finally` on `try_catch` and `context` on a state machine have no key at all). The eleventh member is not an unknown-key close but the same defect one level up — the `view` union had an arm that both stripped and required nothing, so it matched every object and `saveMetaItem` persisted garbage as an ACTIVE view overlay that read back badged valid. This is ONE entry for the whole major by the maintainer's 2026-08-12 ruling that the wave is registered one entry per major, not one per batch, mirroring the registry's only two precedents of this shape; the eleven batches it folds are the changesets `unknown-key-strictness-tier-a`, `-step2`, `-automation-batch11`, `-ui-batch13`, `-ui-batch15`, `-ui-batch16`, `strict-automation-control-flow-state-machine`, `view-subblock-strictness-batch18`, `rare-jars-shave`, `user-filters-allow-add-tab-promote-and-close` and `view-union-identity-precondition`, each carrying its own FROM → TO table in `CHANGELOG.md`. One batch promoted a key before closing its block: `userFilters.allowAddTab` was already read by objectui, so the maintainer's 2026-08-04 ruling declared it in the spec rather than let a correct-looking refusal tell authors to delete a working capability. The entry was registered in the backfill of the v17 train's breaking changesets, which had never been compared against the ledger. ADR-0049 / ADR-0078 / ADR-0087." + "surface": "a literal `defaultValue` with a `Z` or a UTC offset on a `time` field, or on an action param typed `time`", + "replacement": "the wall clock itself, `HH:MM` or `HH:MM:SS` with no zone, or a `datetime` field when the value is an instant. The conversion drops a `Z` or a zero offset, which names the same wall clock. It does not touch a non-zero offset (`08:00+08:00`): whether that meant 08:00 or the UTC 00:00 only the author knows, so rewrite it by hand", + "migrationId": "time-default-zone-refused", + "toMajor": 18, + "rationale": "A `time` value is a zone-less wall clock (ADR-0053 D-C1), and the record validator already refuses a zone-suffixed time of day on write. The stored form still admitted one, so a field default such as `10:00Z` parsed clean and every insert that fell back to it was then refused `invalid_time` on a field the caller never sent, and an action param default or submitted value passed the dispatcher. The stored form now refuses the zone, so the field and action-param default gates refuse it when it is authored and the dispatcher refuses it at submit." }, { - "surface": "api.batchOptions.validateOnly", - "replacement": "(removed — no dry-run today; open an issue to design a no-commit batch preview)", - "migrationId": "batch-options-validate-only-retired", - "toMajor": 17, - "rationale": "The `validateOnly` key promised a dry-run (\"validate records without persisting\") but no batch surface ever read it — updateManyData / deleteManyData / batchData persist regardless. There is no behaviour to preserve and nothing stored to rewrite (it only ever appeared in an HTTP request body). Callers must stop sending it." + "surface": "`TimeUpdateInterval` — the `/analytics/query` body's `timeDimensions[].granularity` and an analytics cube dimension's `granularities[]`. The three sub-day members `second`, `minute` and `hour` are retired; `day`, `week`, `month`, `quarter` and `year` are unchanged and parse byte-identically", + "replacement": "the coarsest declared interval that still answers the question — `day` is the finest bucket the platform labels. A caller who wants raw per-instant rows drops `granularity` entirely, which groups on the unbucketed timestamp deliberately rather than by accident. There is no mechanical replacement that preserves a sub-day bucket, because no backend ever produced one", + "migrationId": "time-update-interval-sub-day-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove — the spec-side narrowing promised by the fix that made driver-memory's analytics face bucket by its declared granularity. The rest of the contract never carried these three: `DateGranularity` (`data/query.zod.ts`) — the vocabulary a `groupBy` entry and every driver's bucket expression are typed by — declares five, `@objectstack/core`'s `BUCKET_GRANULARITIES` labels the same five, and `DriverCapabilitiesSchema.supports.queryDateGranularity` is a `z.record(DateGranularity, boolean)`, so a driver could not advertise sub-day bucketing even if it had one. Measured on the shipped faces before the narrowing: `driver-memory`'s analytics face answered NOT_IMPLEMENTED/501, `driver-mongodb`'s bucket builder answered NOT_IMPLEMENTED/501, and the engine's in-memory aggregation — the fallback every SQL/ObjectQL analytics query carrying a granularity lands on, since `NativeSQLStrategy` declines on a granularity — answered 200 with one group per distinct timestamp, echoing the raw instant back as its own bucket label. Two honest refusals and one silently wrong answer, and no third behaviour anywhere. ⚠️ This retires the NAMES, not the idea: offering sub-day analytics means widening `DateGranularity`, the `queryDateGranularity` record, the canonical bucket-key vocabulary and every driver's bucket expression together — new capability, decided as such" }, { - "surface": "api.batchOperationResult — the per-row `results` entries of BatchUpdateResponse (`POST /data/:object/batch`, `/updateMany`, `/deleteMany`)", - "replacement": "`errors: ApiError[]` (was `error: string` — read `row.errors?.[0]?.message`, branch on `row.errors?.[0]?.code`), `data` (was `record`), and `index` (new — the row's position in the request array)", - "migrationId": "batch-row-result-schema-shape", - "toMajor": 17, - "rationale": "The rows the three bulk-write endpoints emitted had drifted from the schema that declared them: `BatchOperationResultSchema`, the client SDK's exported `BatchOperationResult` type and the reference docs all said `errors: ApiError[]` / `data` / `index`, while the wire carried `error: string` / `record` and never sent `index` at all. A TypeScript consumer written against the published type compiled, validated and read `undefined` at runtime — the declared-but-not-delivered shape this registry exists to close, on the response envelope (ADR-0119 D4 deferred the reconciliation off a bug fix; this is that tracked change, shipped in the 17 major window). The ADR-0119 rollback marking, which the fix making `deleteManyData` and `updateManyData` honour `atomic` carried to those two endpoints, is structured in the same move: the `ROLLED_BACK:` / `NOT_ATTEMPTED:` message-string prefixes become registered `ApiError.code` values (message keeps the human-readable cause and causal row index), so \"attempted and undone\" vs \"never ran\" is machine-readable instead of a regex convention. A RESPONSE surface — nothing stored in stack metadata carries a batch row, so there is no source for the chain to rewrite; consumers of the legacy keys move their reads themselves. Off-contract readers only: the legacy keys were never in the schema or the SDK types, so a typed consumer needs no change. Ruled 2026-08-03: the implementation moves to the schema's shape as a hard cut in the 17 major, with no dual-emit transition." + "surface": "training duration and deadline keys: `TrainingCourse.durationMinutes` / `validityDays`, `TrainingPlan.recertificationIntervalDays` / `gracePeriodDays` / `reminderDaysBefore`", + "replacement": "nothing to re-declare — delete the keys. No training-management engine exists on the platform: nothing schedules or times a course, computes a certification expiry, re-assigns training on an interval, escalates an expired certification or sends a reminder, so there is no live mechanism to declare a duration or deadline to", + "migrationId": "training-deadline-keys-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on the unread deadline keys (ruled A: retire per family). Five minute/day-shaped keys sat on the published authorable surface and in the generated reference docs — an author could write `validityDays: 365` and reasonably expect a certificate to expire — and read by NOTHING: the schemas are exported from `@objectstack/spec/system`, mounted by no stack key, registered as no metadata type, absent from the 2026-06 liveness ledgers, and the reader census over every package outside `packages/spec` (tests and changelogs excluded) and over objectui at the pinned sha returned zero hits for every key. Three of the five carried defaults (365, 30 and 14 days) that were materialized into every parsed plan without ever being consulted. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections; none of these schemas is either, so a conversion would be a transform with no seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent)." }, { - "surface": "client.DeleteDataResult.deleted (the return of `client.data.delete()`)", - "replacement": "`success` — `r.deleted` → `r.success`. Same call, same wire body, declared name", - "migrationId": "client-delete-result-success", - "toMajor": 17, - "rationale": "`DeleteDataResult` carried the comment `Spec: DeleteDataResponseSchema` above a declaration that contradicted it: the interface declared `deleted: boolean` while `DeleteDataResponseSchema` declares `{ object, id, success }`. `deleted` has never been declared by any schema and no server path has ever returned it on `/data/:object/:id`. Both delete surfaces — `client.data.delete()` and the project-scoped `client.project(id).data.delete()` — are pure `unwrapResponse` / `_unwrap` passthroughs, so the interface is a CLAIM about the wire, never a rewrite of it, and the claim was false in the one direction that matters: the compiler endorsed the wrong spelling. `if (r.deleted)` compiled, read `undefined` at runtime, and the branch was never taken; `if (r.success)` was rejected by the compiler and correct on the wire. So this rename REVEALS a defect rather than breaking working code — every reader of the old key was already reading `undefined`, on every deployment and not just some, because the protocol path has always answered `success`. It is registered as a semantic entry rather than a mechanical conversion for the reason the rewrite itself does not capture: the key is one token, but a call site that branched on `r.deleted` has been taking the FALSE branch unconditionally since it was written, and whatever that branch did — or skipped — is what actually has to be re-read. There is no authored source for the chain to rewrite either; this is a published TypeScript surface whose enforced channel is tsc at the call site, and for an untyped JS caller there is no constrained channel at all, which is why the ledger entry is the only notification that reaches them. ⛔ Do not write `r.success ?? r.deleted`: there is one producer shape, and a consumer accepting two spellings is what contract-first exists to prevent (the same rule already moved the runtime's ObjectQL fallback from answering `deleted: true` to the declared `success`, on the producer side). No deprecated `deleted?: boolean` transition key ships, for the same reason — a transition period is for keys that WORKED, and this one never did. Registered by the stock reconciliation of the v17 train's breaking changesets, which had never been compared against the ledger. ADR-0087." + "surface": "the training family, retired whole: the five defs system/TrainingCategory, system/TrainingCompletionStatus, system/TrainingCourse, system/TrainingPlan and system/TrainingRecord, and every name system/training.zod.ts exported from @objectstack/spec/system (the five *Schema consts, their z.input aliases and the two *Parsed aliases)", + "replacement": "nothing to re-declare — no training-management engine exists on the platform, so there is no working configuration to migrate to. Nothing assigned a course, tracked a completion, sent a reminder or expired a certification; a training record the organisation keeps is ordinary object data, declared as an object with its own fields and enforced by the object engine. If training management becomes a product capability it re-declares fresh, through the enforce route of ADR-0049 — the engine first, the vocabulary second", + "migrationId": "training-family-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 on the families' remaining keys and defs (ruled A: retire the three compliance-shaped families whole via RETIRED_DEFS_BY_MAJOR, the integration/ErrorMappingConfig precedent; not roadmapped). Five defs and roughly twenty-five declared keys sat on the exported surface and in the generated reference docs, and were read by NOTHING: the schemas were exported from `@objectstack/spec/system`, mounted by no `stack.zod.ts` key, registered as no metadata type, absent from the 2026-06 liveness ledgers, and the reader census over every package outside `packages/spec` (tests and changelogs excluded), over `examples/**` and `skills/**`, and over objectui at the pinned sha returned zero hits for every exported name, with a lit control. `TrainingCourse.mandatory`, `TrainingPlan.trackCompletion` and `TrainingPlan.sendReminders` were boolean capability claims of exactly the shape ADR-0049 names: an author could write them, parse clean, and get no behaviour and no diagnostic. Tagging the family `[EXPERIMENTAL — not enforced]` was the fallback the ruling did not take (a human-only signal). The deadline-key tombstones of the 2026-09-02 per-family ruling (five sites, `RETIRED_KEYS_BY_MAJOR[18]`, D3 `training-deadline-keys-retired`) leave with their defs' source; their registry entries stay as history. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections; none of these schemas is either, so a conversion would be a transform with no seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent), and with no carrier key there is no shape on which a tombstone could sit." }, { - "surface": "connector.authentication on AUTHORED entries (defineStack `connectors:`, `PUT /meta/connector/:name`) — previously refused only on provider-bound instances (ADR-0097 §3), now refused on catalog descriptors too", - "replacement": "a catalog descriptor drops `authentication` (or sets `{ type: \"none\" }`) and documents the auth scheme in `description`; a dispatchable instance declares `provider` and references its credential with `auth: { type, credentialRef }` (ADR-0097 §3). Runtime `registerConnector` calls are unaffected — the runtime shape still carries resolved secrets inline.", - "migrationId": "connector-inline-authentication-publish-refused", - "toMajor": 17, - "rationale": "A published connector row lands whole in `sys_metadata`, so an inline `token` / `key` / `password` / `clientSecret` is cleartext at rest, readable through the data API (the class a credential-persistence survey measured: any authored artefact whose schema permits an inline credential lands it there). No mechanical rewrite exists: whether the entry should become a `none` descriptor or a provider-bound instance with a `credentialRef` — and which secret store receives the credential — is a judgment about the connector, not a rename." + "surface": "translation.pages..components..submitLabel — the component-copy key of the retired element:form", + "replacement": "The live form surface's submit copy: `submitText` on the `object-form` component, an I18nLabel localized at its own authoring site.", + "migrationId": "translation-component-submit-label-retired", + "toMajor": 18, + "rationale": "The D2 conversion `translation-component-submit-label-removed` deletes `submitLabel` from every translation bundle and stored translation item, and the delete is lossless: the key's only declarer, `element:form`, retired whole, so no resolver has overlaid the string since and it was read by nothing. What the delete drops is translation WORK. A translator who localized a submit button for each locale did so because a user was meant to read it; if the page's form now lives on `object-form`, its submit copy is `submitText`, and that key is not filled by moving the old strings mechanically — the component ids differ, and a retired `element:form` may have no successor on the page at all. Only the author can say which form each string belonged to and whether it still exists." }, { - "surface": "dashboard.widgets[].compareTo: { offset: '7d' | '1M' | … } (every duration except '1y')", - "replacement": "compareTo: { kind: 'previousPeriod' } plus an explicit window on the widget's own `filter`", - "migrationId": "dashboard-widget-compareto-offset", - "toMajor": 17, - "rationale": "The widget declared three comparison arms; the analytics executor implements one shape, `{ kind, dimension? }`, with no `offset` concept in it at all. On the ADR-0021 dataset path — the spec's single author-facing analytics shape — `{ offset }` was forwarded verbatim into that contract and threw `compareTo requires a timeDimension \"undefined\"`, taking the widget down; the arm ever only ran on the legacy inline chart path (measured when all three declared arms were found dead on the dataset path: two silently dropped, this one throwing). The conversion rewrites `{ offset: '1y' }`, which IS `previousYear` by definition. Every other duration has NO faithful target: `previousPeriod` shifts by the length of whatever window the widget's filter resolves to, which equals `7d` only when that window happens to be seven days long. Rewriting mechanically would silently change which rows the comparison column counts — a wrong number rather than a missing one, which is strictly worse and exactly the class this convergence exists to end. Re-stating the intended window is a judgment about the presentation, not a transform." + "surface": "stack.translations[]..settings and translation.settings — the settings group on the per-app bundle and on the registered `translation` item", + "replacement": "Delete the group from the per-app bundle and from every `translation` item. There is no application-side replacement key: settings copy is not application-authorable at either door. `settings` is keyed by `SettingsManifest.namespace`, and only platform code declares a manifest (`packages/services/service-settings/src/manifests/*.manifest.ts`), so the only namespaces an application could ever address were the platform’s own. Platform settings copy is translated in the PLATFORM bundle — `@objectstack/service-settings`’s `settingsBuiltinTranslations`, typed `PlatformTranslationData` — which is where a correction to a platform string belongs. An application’s own copy goes in the 11 groups the per-app bundle and the `translation` item still declare, in the order they declare them: `objects`, `picklists`, `apps`, `messages`, `globalActions`, `dashboards`, `datasets`, `pages`, `flows`, `metadataForms`, `settingsCommon`. Note `settingsCommon` among them: it IS on both faces, so the Settings UI shell strings an application may translate (the source badges, under `settingsCommon.sourceLabels`) are NOT what is being removed here — only the per-namespace manifest copy under `settings` is.", + "migrationId": "translation-per-app-settings-platform-only", + "toMajor": 18, + "rationale": "Not losslessly convertible, and NOT because the content was inert: what it did differs by door, and both effects are visible on screen. THE PER-APP BUNDLE — measured on this tree before the split: `AppPlugin.loadTranslations` hands each `stack.translations` bundle entry WHOLE to `II18nService.loadTranslations`, the adapter deep-merges it into the one per-locale tree, and every platform plugin contributes into that same tree — so `settings` from an app bundle and `settings` from `@objectstack/service-settings` land in one place. `resolveSettingsTitle` and the rest of the `resolveSettings*` family read it (`pickSettingsEntry` → `pickData(bundle, locale)?.settings`), and so does the console's `useSettingsLabel`, which scans every namespace carrying a `settings` branch. ORDER decides the rest, and it runs against the application: `AppPlugin` loads the app’s bundles in its own `start()` (kernel Phase 2), `SettingsServicePlugin` contributes the platform’s settings translations from a `kernel:ready` hook (Phase 3), and `deepMerge` gives the LATER source the leaf — `AppPlugin`’s own comment says as much (“the platform bundles have not arrived yet at this point in the lifecycle”). So the platform won every key both bundles defined, and what a per-app bundle actually had was a GAP FILLER on a namespace it does not own: the entry rendered only where the platform bundle carried no string for that key and locale (the platform ships en / zh-CN / ja-JP / es-ES), silently, with no way for the author to tell a filled gap from an ignored override. Dropping it takes those gaps back to the manifest’s own literal — the `?? fallback` every `resolveSettings*` helper ends in, which is English. THE `translation` ITEM went further: a stored item is not loaded into the static tree at all but into the runtime-authored layer (`authored-translation-sync` → `replaceAuthoredTranslations`), and both i18n adapters read that layer OVER the shipped bundles (`deepMerge(static, authored)`), whatever order they loaded in. So an item’s `settings` OVERRODE the platform’s own copy for its locale — a published item could rewrite a platform Settings screen — which is exactly what the ownership ruling says an application must not do. Dropping it takes each overridden key back to the platform bundle’s string, and each key it had filled back to the manifest literal. A mechanical notice reading \"(removed)\" conveys neither. The two bundles are separate namespaces from this major on (ruling of 2026-09-13, letter ②: a platform bundle schema and a per-app bundle schema, `settings` absent from the per-app one), and the item door follows the file door (ruling of 2026-09-22, letter B: the file door and the item door are two authoring surfaces for ONE app metadata type, so they accept one shape; an admin override of platform copy, if ever wanted, is a platform-level feature, not app metadata). ADR-0049 enforce-or-remove supplied the question, not the answer — `settings` stays a LIVE platform key. No deprecation window: both doors refuse the key by name from this major, with the prescription on the rejection." }, { - "surface": "contracts.IDataDriver.findStream / data.DriverInterfaceSchema.findStream", - "replacement": "find() with limit/offset — the paged read whose determinism IS enforced (IDataDriver.find, data/pagination-conformance.ts)", - "migrationId": "data-driver-find-stream-retired", - "toMajor": 17, - "rationale": "`findStream` was a REQUIRED contract method documented as \"optimized for large datasets to avoid memory overflow\", and in two of its three implementations it delivered the opposite: `SqlDriver` and `InMemoryDriver` both awaited `find()` for the ENTIRE result set and then yielded it row by row, so the peak memory a caller was promised protection from was already reached before the first yield. The third (`MongoDBDriver._findStream`) did walk a cursor, but it was the one read path in that driver never routed through `buildFindOptions`, so it hardcoded `projection: { _id: 0 }` and silently discarded `query.fields`. None of it was ever observed, because the method had NO caller in either repository: the engine exposes no stream entry, and the REST export, import and bulk-read paths all go through `find()`. The ~20 driver test doubles that existed only to satisfy a required method almost all threw `not implemented`, and nothing ever noticed — which is the proof, not the anecdote. Being REQUIRED, it also taxed every new driver and every test double with an implementation of a capability the platform does not have. Rather than build a caller to justify three implementations, the method is retired; a real cursor-based read should return WITH the caller that needs it (ADR-0049 enforce-or-remove). This is a TS/API contract surface — a driver is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone either: nothing ever ran a driver object through `DriverInterfaceSchema.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it points at callers. ADR-0049 / ADR-0078." + "surface": "translation.dashboards..widgets..subCaption — the metric sub-caption overlaid onto a widget's options.description", + "replacement": "The widget's one authored description, `widget.description`, rendered as the card-header subtitle and translated by `dashboards..widgets..description`.", + "migrationId": "translation-widget-sub-caption-retired", + "toMajor": 18, + "rationale": "The D2 conversion `translation-widget-sub-caption-removed` deletes `subCaption` from every translation bundle and stored translation item, and `translateDashboard` no longer overlays anything onto a widget's `options`. The sub-caption was the string under a metric's value; the dashboard schema never declared `options.description` and no authored widget wrote it, so a translated sub-caption existed only because this key put it there. What the delete drops is translation WORK: a translator who wrote a caption per locale meant a user to read it. The conversion cannot move those strings to `description`, because `description` already translates the card-header subtitle — a different string a widget may also carry — and only the author can say whether the caption's wording belongs in that subtitle or is no longer needed." }, { - "surface": "contracts.IDataDriver query parameter — find / findOne / count / updateMany / deleteMany / explain", - "replacement": "`DriverQuery` (`Omit`): delete the redundant `object:` key from the query literal at the call site — the object name is already the FIRST argument", - "migrationId": "data-driver-query-omit-object", - "toMajor": 17, - "rationale": "Every one of these methods takes the object name as its first argument, and then required a `QueryAST` that lists `object` as mandatory — the same fact demanded twice, with two places for it to disagree. The layers above had already paid for that ambiguity: the objectql engine deliberately writes its key order as `{ ...query, object }` so a smuggled `query.object` cannot override the resolved name, and the wire layer spends a named 400 (`QUERY_OBJECT_MISMATCH`) refusing the inconsistency. The driver side paid in blanket casts: a direct caller holding only a `where` could not name the type, wrote `as any`, and switched off checking for `where` / `orderBy` / `fields` along with it — 20 such sites were measured in the downstream cloud codebase, and a `$like` the type layer would have caught reached runtime there through exactly that hole. This is a TS contract surface with no authored source for the chain to rewrite, which is why it is a semantic entry and not a D2 conversion; for a typed caller the compiler names every site (TS2353 `'object' does not exist in type 'DriverQuery'`), and for an untyped JS caller there is no constrained channel at all, which is exactly why this ledger entry must exist. Neither side is forced to move: a caller holding a `QueryAST` VALUE passes it unchanged (excess properties are only rejected on fresh literals), and an implementation still declaring `query: QueryAST` keeps compiling under parameter bivariance. What an implementation may no longer do is READ `query.object` — callers are now entitled to omit it. Registered by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger. This change was that audit's CONTROL sample, drawn to show that not every flagged candidate is an omission, and it was one: the seven `IDataDriver` hits in the ledger are all prose inside other entries, and the only subject-level hit on this interface is `data-driver-find-stream-retired` — a DIFFERENT member. Two later, smaller driver call-parameter changes both registered, and both cite this narrowing as background; the larger sibling they derive from never got its own entry. ADR-0087 (backfilled by that reconciliation)." + "surface": "a retry policy carrying a key it does not declare (a typo such as maxRetry, a key borrowed from another retry vocabulary such as baseDelayMs or maxAttempts, or a key nothing reads), wherever the policy is written: a job retryPolicy, and the retry block of a try_catch flow node; and a try_catch flow node whose config carries a key beside try, catch, errorVariable and retry that its executor contract does not declare. Never a key on the try or catch region object or on its nodes and edges (the region check at registration owns those). Reachable wherever a job or a flow is authored or stored: defineStack sources, defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved from the Studio flow designer, and a flow row already sitting in sys_metadata", + "replacement": "the key the policy declares, or no key: maxRetries (retries after the first attempt), backoffMs (the base delay), backoffMultiplier, maxRetryDelayMs and jitter. Rename a typo to the declared key the refusal's did-you-mean names; write a delay borrowed from another vocabulary as backoffMs or maxRetryDelayMs; write a count of total attempts as maxRetries one lower (maxAttempts 3 is maxRetries 2); delete a key nothing reads. A retryDelayMs is still answered by its own tombstone: rename it to backoffMs", + "migrationId": "try-catch-and-retry-policy-undeclared-keys-refused", + "toMajor": 18, + "rationale": "`RetryPolicySchema` was a plain `z.object`, which strips a key it does not declare. Its defaults are opt-in (`maxRetries` 0, `backoffMultiplier` 1), so a stripped key falls back to \"no retry\" or to a flat delay: a `job.retryPolicy` with `maxRetry: 3` parsed, deployed and never retried, and nothing said so. On a `try_catch` node the strip also kept the flow parse from judging the node's keys: its descriptor closes `retry` to five keys, so `registerFlow`'s undeclared-key walk (`validateNodeConfigKeys`) refused a key the contract would have accepted, and `flow-builtin-node-config-undeclared-keys-refused` left `try_catch` to that walk. A `retry.maxRetry` typo or a `bogusKey` beside `try` therefore passed `objectstack validate` and `objectstack compile` and was refused only when the flow registered. The policy is now a `strictObject`: an undeclared key is refused at parse, naming the key, with a did-you-mean for a near miss. Measured before closing it: every writer of either parser in this repository and in the pinned objectui writes only declared keys, so it is closed on the shared schema. The one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first), `objectstack validate` and the metadata save door share (`flowNodeConfigRefusals`) now judges `try_catch` keys like every other builtin's, as `node-config-refused-by-contract` anchored at the key (`nodes.N.config.retry.maxRetry`), and the descriptor walk stands aside for it, so it keeps plugin node types only. The descriptor's declared key sets equal the contract's at every position the walk descends to, so registration refuses what it refused before. ⚠️ The one key the walk refused that the contract declares is the `retryDelayMs` tombstone. The `retry-policy-converged` conversion renames it before every door that converts first, but keeps it beside a `backoffMs` holding a different value, and leaves it when it is `null`. The key arm refuses what survives at `nodes.N.config.retry.retryDelayMs`, in the tombstone's own words, so registration widens nowhere. A `script` node's retired keys keep the scope they had. ⚠️ No D2 conversion: the platform cannot know what an undeclared key was meant to be. ⚠️ Where such a node already sits, the whole flow is refused, as registration already refused it: from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, while the flows beside it register; a `defineStack` source throws `StackSchemaInvalidError`; a save from Studio answers 422 naming the key. A job whose `retryPolicy` carries such a key is new to refusal (no door judged one before): its `defineStack` source throws `StackSchemaInvalidError` at `jobs.N.retryPolicy`, and an artifact carrying it is refused whole at load. ADR-0087, ADR-0031." }, { - "surface": "contracts.IDataEngine.batch / data.DataEngineBatchRequestSchema", - "replacement": "`IObjectQLEngine.transaction(cb)` for in-process multi-write atomicity; the metadata protocol's `batchData` with `options.atomic: true` for a batch over one object; `POST {basePath}/batch` on the wire", - "migrationId": "data-engine-batch-retired", - "toMajor": 17, - "rationale": "`batch?` was declared on `IDataEngine` for as long as that contract existed and was never implemented by any engine: `ObjectQL` has no `batch` method and there is no other engine in the tree. It also had no caller — `DataEngineRequest` was imported by exactly one file, the contract declaring the member. Its entire specification was a three-word doc comment (\"Batch Operations (Transactional)\"), which settles nothing about partial failure, ordering, cross-object references, rollback scope, or what `transaction: false` was supposed to mean — the questions a batch API exists to answer. Contrast its neighbours `getDefaultDriverName?` / `getDriverByName?`, whose optionality is evidenced: each names its implementer and its probing caller. The tell that nobody ever designed against it is in the schema: `DataEngineBatchRequestSchema.requests` nested the request union RECURSIVELY, so a batch could contain batches, with no statement anywhere about what that meant for ordering or rollback. The only test was a type pin — an ad-hoc object literal carrying a `batch` property, asserting the property was defined — which could not fail while the declaration existed and would have passed unchanged for the member's whole life with no engine implementing it. What it claimed is now covered by members that are real, so the removal deletes a false affordance rather than a capability: ADR-0119 D1 made `transaction` reachable through the contract and D4 made `batchData`'s `atomic` honest, while the wire batch has always validated with `CrossObjectBatchRequestSchema` / `BatchUpdateRequestSchema` from `api/batch.zod.ts` — a different schema entirely, untouched here. TS/API surfaces only: an engine is CODE, never stack metadata, so there is no source for the chain to rewrite. Deliberately no schema tombstone either — nothing ever parsed `DataEngineBatchRequestSchema`, so a `retiredKey()` prescription would have no one to reach; its three `authorable-surface.json` baseline lines and its `json-schema.manifest.json` entry are dropped in the same change, deliberately. The enforced channel is tsc. ADR-0049 / ADR-0078." + "surface": "data.TursoConfig (a turso / libsql datasource.config) and the TursoDriver constructor of @objectstack/driver-turso — mode local beside a non-empty syncUrl is now refused, on mode at authoring and at construction. The published TursoConfigSchema mirror of @objectstack/driver-turso carries the same text for parity but declares no mode key and strips an authored one, so it cannot see a forced mode and still accepts the config as a replica", + "replacement": "the configuration the author meant. An embedded replica drops mode: keep the file: url and syncUrl, for example url file:./data/replica.db with syncUrl naming the remote, and the url and syncUrl select the replica. A plain local database drops syncUrl and sync: a file: url (or :memory:) with no syncUrl is a local database, with or without mode local", + "migrationId": "turso-config-forced-local-with-sync-url-refused", + "toMajor": 18, + "rationale": "A syncUrl names the remote an embedded replica syncs with, and the turso driver syncs whenever it is set on a local engine, whatever mode says. The triage ruling of 2026-09-29 weighed refusing this shape against honouring mode local by skipping the sync, and refused it: honouring it would ignore a declared syncUrl, the same defect with the keys swapped, and a loud contradiction is the author's to resolve. A forced mode local beside a syncUrl parsed clean at authoring, and the driver built it with a local transport label and then ran it as a replica: it synced on connect, started the sync interval and answered true to the sync-enabled check, exactly as the same config with no mode did (measured on the driver source). A declared mode the runtime ignores is the declared-but-not-enforced shape ADR-0049 does not ship, so the datasource contract and the constructor now refuse it together, with one message, which names both ways out. The sibling refusals keep their order: a forced local mode on a remote url or a bare path meets its url refusal first. An empty syncUrl is unset and is not refused. Stored datasource rows are not re-parsed when they load, so a stored row in this shape now fails when its driver is built: the connection service records it as failed-degraded, a test connection answers ok false, and under ADR-0062 D5 the boot fails fast when objects bind to that datasource, unless OS_ALLOW_DRIVER_CONNECT_FAILURE is set. Measured on this tree at the change: no example, template, published skill or hand-written doc authors the shape, and no host default or environment variable sets mode or syncUrl. ADR-0049 / ADR-0087 / ADR-0112." }, { - "surface": "api.DataEventType 'data.field.changed'", - "replacement": "the `data.record.updated` event, whose payload already carries the per-field detail: `changes` (the changed fields), plus `before` / `after`", - "migrationId": "data-field-changed-event-retired", - "toMajor": 17, - "rationale": "`data.field.changed` was declared in `DataEventType` and emitted by nothing — the engine's `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since multi-record predicate writes were given events of their own) `data.records.{updated,deleted}`, and no other producer exists in either repository. A subscriber that switched on it was waiting on an event no producer sends: the branch never ran, and because the surrounding `switch` still compiled, nothing anywhere reported the gap (ADR-0078's silently-inert declaration, on the event vocabulary). `DataEventSchema` could not have carried the semantics even if something had emitted it — the payload is record-shaped (`recordId`, `changes`, `before`, `after`) with no `field` / `oldValue` / `newValue` slot — so the member promised a granularity the contract has no room for. Per-field detail is therefore not lost: it has always ridden on `data.record.updated` as `changes`, which is one event per write rather than N events on a wide table. This is a runtime EVENT surface — no stack, example or template authors an event name (webhooks subscribe through the separate authorable `WebhookTriggerType`, whose vocabulary was already trimmed to producers that exist) — so there is no source for the chain to rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a retiredKey() fix-it error the way an authorable object key can (the same limit the sharing-rule `full` retirement `owd-full-alias-removed` hit). The enforced channels are tsc, which fails any consumer still naming the value in a `DataEventType` position, and the enum parse, which now rejects the name instead of accepting an event that never arrives. A genuine per-field stream, if one is ever wanted, gets its own honest contract the way bulk writes were given theirs. ADR-0049 / ADR-0078." + "surface": "data.TursoConfig (a turso / libsql datasource.config) and the TursoDriver constructor of @objectstack/driver-turso — mode replica with no syncUrl (or an empty one) is now refused, on mode at authoring and at construction. The published TursoConfigSchema mirror of @objectstack/driver-turso carries the same text for parity but declares no mode key and strips an authored one, so it cannot see a forced mode and still accepts the config as a local file", + "replacement": "the configuration the author meant. An embedded replica names the remote it replicates from: keep the file: url and set syncUrl to the libsql or https Turso endpoint, for example url file:./data/replica.db with syncUrl naming the remote. A plain local database drops mode: a file: url with no syncUrl and no mode is a local database", + "migrationId": "turso-config-forced-replica-without-sync-url-refused", + "toMajor": 18, + "rationale": "An embedded replica is a local file kept in sync with the remote named in syncUrl, so a replica is defined by its remote. The ruling of 2026-09-28 weighed refusing this shape against documenting a replica with no remote as a local mode, and refused it: with no remote there is no replica mode to document, only a declaration nothing honours. A forced mode replica with no syncUrl parsed clean at authoring, and the turso driver built it as a replica that never synced: no sync client was created, no sync interval started, the sync call did nothing and the sync-enabled check answered false, while every read and write went to the local file (measured on the built driver). A declared mode the runtime never runs is the declared-but-not-enforced shape ADR-0049 does not ship, so the datasource contract and the constructor now refuse it together, with one message, which names both ways out. The sibling refusals keep their order: a forced replica on a remote url, an in-memory url or a bare path meets its url refusal first, and one with sync meets the sync refusal first. Stored datasource rows are not re-parsed when they load, so a stored row in this shape now fails when its driver is built: the connection service records it as failed-degraded, a test connection answers ok false, and under ADR-0062 D5 the boot fails fast when objects bind to that datasource, unless OS_ALLOW_DRIVER_CONNECT_FAILURE is set. Measured on this tree at the change: no example, template, published skill or hand-written doc authors the shape, and no host default or environment variable sets mode. ADR-0049 / ADR-0087 / ADR-0112." }, { - "surface": "datasource.config.password (postgres / mysql / mongo) and datasource.config.authToken (turso)", - "replacement": "the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` secrets-store reference", - "migrationId": "datasource-config-inline-credential-refused", - "toMajor": 17, - "rationale": "A datasource artefact is persisted whole into `sys_metadata`, which is served back by the ordinary data API — an inline credential is cleartext at rest. The maintainer ruled on 2026-08-12 to close that per artefact: each schema that admitted an inline credential refuses it at publish and points at the secret mechanism the artefact already had, rather than a heuristic guard at the `sys_metadata` write. There is no mechanical rewrite: moving the value requires ENCRYPTING it into a `sys_secret` row through a running secret binder and deleting the cleartext, which a source-file transform cannot do — auto-deleting the key alone would silently drop a live credential instead." + "surface": "datasource.config.timeout on the turso driver — the per-request time limit", + "replacement": "`timeoutMs` — the same limit, in milliseconds, beside the sibling `sync.intervalSeconds` that already spelled its unit.", + "migrationId": "turso-config-timeout-unit-in-key", + "toMajor": 18, + "rationale": "The D2 conversion `turso-config-timeout-to-timeout-ms` renames `config.timeout` to `config.timeoutMs` on every datasource whose driver resolves to turso (a stored `libsql` spelling included) and leaves every other driver's `timeout` alone; the rename is lossless because the key always meant milliseconds. The judgment is whether each value was written in that unit. Two keys above it, `sync.intervalSeconds` spelled SECONDS, so one config block carried both conventions, and the unit of `timeout` lived only in a description and a title no parse reads: `timeout: 30` meant as thirty seconds became a thirty-millisecond limit, short enough to fail a remote request, and the rename keeps 30. Only the author can say which unit they meant. Code that builds a turso driver config in TypeScript is outside the chain's reach." }, { - "surface": "connection-material string keys of the built-in driver configs — postgres/mysql/mongo `url`/`host`/`database`/`username`, postgres `schema`/`applicationName`, mongo `authSource` and the `options` passthrough (judged deep), turso `url`/`syncUrl`/`encryptionKey`, sqlite/sqlite-wasm `filename` — values containing `${…}` placeholder syntax", - "replacement": "the literal value. For secret material, the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` reference. For environment-driven connections, the runtime environment itself: `OS_DATABASE_URL` and friends are translated into driver config by the boot hosts and never pass through the publish door", - "migrationId": "datasource-config-placeholder-refused", - "toMajor": 17, - "rationale": "A `${…}` placeholder in authored datasource config is resolved by NOTHING — it is stored verbatim in `sys_metadata` and handed verbatim to the database client at connect (measured by the credential census while the inline-credential refusal was being built), so the connection fails, or connects somewhere unintended, with no error naming the unresolved placeholder — the masked-failure shape. The syntax looked supported: it parsed green, stored fine, and failed at a distance; two shipped refusal messages (the inline-credential refusal and the URL-userinfo refusal) had to warn \"do NOT substitute a placeholder\" around the broken escape. The maintainer ruled on 2026-08-13 for the second of two directions: refuse the syntax loudly at publish; implementing real resolution was explicitly rejected — a new capability with an env-exfiltration security surface and zero measured pull for actual substitution. There is no mechanical rewrite: the placeholder names a value that exists only in the author's intended deployment environment, which a source-file transform cannot know — substituting anything would invent a connection target." + "surface": "data.TursoConfig (a turso / libsql datasource.config) and the published TursoConfigSchema mirror of @objectstack/driver-turso — combinations of url, syncUrl, mode and timeoutMs that are now refused at parse: a remote url (libsql, https, http, wss, ws, any letter case) beside syncUrl or under a forced local or replica mode; in a local or replica mode, a url that is none of a file: url, :memory: or a remote url (a bare path, another scheme, :MEMORY:, a blank url); a replica on an in-memory url; timeoutMs beside a wss or ws url in remote mode; and syncUrl under a forced remote mode. The driver mirror also refuses sync with no syncUrl, as the spec contract already did", + "replacement": "the configuration the author meant, spelled the way the driver runs it. A remote database is the remote url alone (drop syncUrl and sync, and drop a forced local or replica mode or set it to remote). An embedded replica is a local file written as a file: url beside syncUrl, for example url file:./data/replica.db with syncUrl naming the remote. A local database is a file: url (file:./data/app.db, never the bare path ./data/app.db) or :memory: for a throwaway one. A remote database that needs timeoutMs spells its url libsql or https, or drops timeoutMs. Each refusal names the key it sits on (url, syncUrl or timeoutMs) and prints the spellings above", + "migrationId": "turso-config-transport-mismatch-refused", + "toMajor": 18, + "rationale": "Each key parsed on its own, so the contract accepted configurations the turso driver refuses when it is built (VALIDATION_ERROR / 400 from the constructor, since the fixes that stopped a remote url beside a syncUrl from writing to process memory and an unrecognised url scheme from falling through to an in-memory local engine) — a datasource published clean and then failed at boot or at test connection. One more it built and then ignored until the constructor was taught to refuse it as well: syncUrl under a forced remote mode, where the remote client was created without it, no sync ever ran and the sync call failed as not supported while the driver reported sync as enabled (measured on the built driver). Authoring now refuses exactly the constructor's refused set — the same predicates, a scheme matched in any letter case, the url read trimmed as both datasource loaders hand it over — plus that key, refused at authoring first as the declared-but-not-enforced shape ADR-0049 does not ship, and by the constructor too since that later fix. Nothing the constructor accepts is refused (when authoring first refused that key it was the one exception; since the constructor refuses it too there is none): a forced remote mode keeps its url unjudged, as the constructor does. Stored datasource rows are not re-parsed when they load, so a stored row still reaches the constructor as written; the constructor refuses the first four shapes there already and, since that later fix, also refuses syncUrl under a forced remote mode and sync with no syncUrl when the datasource boots. What changes here is that creating, testing or editing its config through the datasource admin service, defineStack or os validate is refused at the key. Measured on this tree at the change: no example, template, published skill or hand-written doc authors a refused combination. ADR-0049 / ADR-0087 / ADR-0112." }, { - "surface": "datasource.config.url (postgres / mysql / mongo / turso) and datasource.config.syncUrl (turso) — the URL userinfo password segment (`user:password@host`)", - "replacement": "the same URL with its userinfo password removed (a bare `user@host` stays legal), plus the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` secrets-store reference", - "migrationId": "datasource-config-url-userinfo-refused", - "toMajor": 17, - "rationale": "The inline-credential closure refused the credential KEYS, and building it measured that `config.url` still accepted the identical secret one syntax over — `postgresql://user:password@host/db` landed in `sys_metadata` cleartext exactly as `config.password` did, and the key refusal itself steered authors there. The maintainer ruled on 2026-08-12 (Option A) to refuse the URL userinfo password at publish, through one value-level parse the driver schemas share. Runtime-environment DSNs (`OS_DATABASE_URL` and friends) never pass through the publish door and are unaffected by construction. There is no mechanical rewrite, for the same reason as the sibling entry `datasource-config-inline-credential-refused`: moving the value requires ENCRYPTING it into a `sys_secret` row through a running secret binder and stripping the cleartext, which a source-file transform cannot do — auto-stripping the userinfo alone would silently drop a live credential instead. Do not substitute a `${…}` placeholder into the URL: placeholders in authored metadata are resolved by nothing and reach the database client verbatim (measured when the inline-credential refusal was built)." + "surface": "page action:group and action:menu components — a member of properties.actions whose type is not api, and whose params is not an array", + "replacement": "Write `params` as the list of inputs to collect from the user, an `ActionParam[]` array. To run an action with static parameter values, author it as its own `action:button` node, whose `params` object carries them; for a `type: 'api'` member's request body write `bodyExtra`. A member that needs neither drops the key.", + "migrationId": "ui-action-group-menu-member-params-array-only", + "toMajor": 18, + "rationale": "An `action:group` or `action:menu` runs each member itself and forwards an array `params` as the input list. It forwards any other `params` value only for a `type: 'api'` member, as its request payload; for every other `type`, an absent one included, it drops the value, with a development-build warning only. The member declared `params` as any value, so an object `params` on such a member passed the component-props gate and then had no effect: no error and no static values. `params` carries one shape, the input list, and no second value-bag key is declared; a member's `properties.params` is already refused, so static parameter values are not part of the inline action vocabulary at all, and the action that needs them is its own `action:button` node. The member now refuses a non-array `params` on a non-`api` type at the gate, at `actions.N.params`, with that prescription. The `api` member's object `params` is unchanged. It is read where every page component's props are: the component-props gate reports the refusal as an advisory `component-props-invalid` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: the static values belong on a different node, and the census found no writer. Deployed metadata NOT MEASURED." + }, + { + "surface": "page `action:group` and `action:menu` components — each member of `properties.actions` (whose keys used to pass unjudged)", + "replacement": "an inline action with `action:button`'s keys, its executor spelled `type`: `{ name?, label?, icon?, type?, variant?, visible?, disabled?, tags?, params?, description?, target?, openIn?, method?, bodyExtra?, bodyShape?, operation?, patch?, confirmText?, successMessage?, errorMessage?, refreshAfter?, locations?, toast?, resultDialog?, onSuccess?, objectName? }`, plus `size?` on an `action:group` member. Write `actionType` as `type`, `endpoint` (and `url` / `path` / `href`) as `target`, `enabled` as `disabled` with the condition inverted, and `outcomeMessages` as one `successMessage`; drop a member `className`, `properties`, `autoTrigger`, `undoable`, `recordIdField` and an `action:menu` member's `size`.", + "migrationId": "ui-action-group-menu-members-typed", + "toMajor": 18, + "rationale": "An `action:group` or `action:menu` draws and runs each member itself: it draws `label` (or `name`), `icon`, `variant`, `tags` and, on a group's inline buttons, `size`; gates the member on `visible` and `disabled`; places it by `locations`; and forwards its `type` and the rest of `action:button`'s keys to the action runner. The page-component rows declared each member an open record, so a misspelled key, a node-style `actionType` or an `endpoint` no `api` handler reads passed the component-props gate, and the container drew and ran the member without it. The rows now take a closed member: `action:button`'s keys by `type`, with the rows' prescriptions; the keys the rows leave undecided — `outcomeMessages`, a member `className`, a member `properties.params` — are refused, and `outcomeMessages` stays undeclared on all four action blocks as one decision. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and the authored census found no working member to respell. Deployed metadata NOT MEASURED." }, { - "surface": "stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)", - "replacement": "the same declarations, re-read as LIVE HTTP routes: `path` moved under `/api/v1/apps//`, and every entry that declares `authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying `rateLimit: { enabled: true, … }`", - "migrationId": "declarative-apis-endpoints-live", - "toMajor": 17, - "rationale": "This is the one protocol-17 entry that turns metadata ON rather than off, so read it as a SECURITY review item and not as a rename. Before 17 the declarative endpoint surface executed NOTHING: no route was mounted for a declared `path`, no matcher existed, and every key — `authRequired` included — parsed green and gated nothing (which is why the maintainer's 2026-08-04 ruling refused a non-empty `apis:` outright until an executor existed). Protocol 17 ships that executor and narrows the refusal to a per-endpoint publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as soon as the stack is published. So an `apis:` block written against an older major — or one restored from a source older than that refusal, or authored from a doc that predates the refusal — changes meaning without changing a byte: what used to be inert documentation becomes an execution entry point into the data and automation pipelines. Nothing about that transition can be applied mechanically, because the judgment it needs is \"did the author of this endpoint mean for the internet to reach it?\" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT `authRequired: false` is the only thing that opens anonymous access, and under ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key defaults to `false`, so a budget written without it meters nothing) or the stack refuses to publish. ⚠️ If you author endpoints in TypeScript, annotate them with `ApiEndpoint` — the AUTHOR state — so that omitting `authRequired` compiles: `const e: ApiEndpoint = { name, path, method, type, target }` is legal and is the safe shape this paragraph prescribes. `ApiEndpointParsed` is the POST-parse type (defaults materialized, ADR-0122), where `authRequired` is required — annotating a declaration with it forces you to write the key out, and being made to think about a key whose only unrecoverable value is `false` is the one thing this entry is trying to avoid. Hold a parse RESULT with `ApiEndpointParsed`; write declarations as `ApiEndpoint`. Grep every `apis:` entry for `authRequired: false` before you upgrade, delete the ones that were never meant to be public, and arm a budget on the ones that were. The path move is the mechanical-looking half and is still yours: ADR-0121 D1/D2 confine a declared path to your own namespace carve-out (`/api/v1/apps//…`), the namespace comes from an explicit `manifest.namespace` with no derivation fallback, and the subpath is the only part you name — rewriting it for you would silently change a URL third parties call." + "surface": "`action` documents declaring `undoable: true` on a shape no runtime fulfils — `type: 'script'` (the default route) and `type: 'url'`, plus the dormant `type: 'flow'` / `'modal'` / `'form'`, in each case WITHOUT `operation: 'update'`", + "replacement": "either of the two fulfilled shapes — `operation: 'update'` with a `patch`, where the framework runtime snapshots the prior value of every field in the merged write bag, or `type: 'api'`, where the pinned console builds the undo envelope — or no `undoable` at all. ⛔ NOT mechanically convertible: which of the two the author meant is an intent no artifact records (a `script` action with an inline handler and an api action calling an endpoint are different dispatches, not two spellings of one), and dropping the flag silently would remove an Undo the author asked for. The refusal names both shapes and the drop, and the author chooses", + "migrationId": "ui-action-undoable-unfulfillable-refused", + "toMajor": 18, + "rationale": "The key was a plain optional boolean read by no refinement, so every combination parsed clean while only two of them ever produced an Undo — the declared-but-inert shape ADR-0078 refuses at author time. ⚠️ The obvious repair, requiring `operation: 'update'`, was MEASURED WRONG and is deliberately not what this entry records: the pinned console's two readers gate the undo envelope on `action.undoable` alone with zero reads of `action.operation`, and those same two files are the entire recorded evidence for this package's own liveness verdict `action/undoable: live`. A blanket requirement would therefore have refused the published `ReassignLeadAction` skill example (`type: 'api'` + `undoable: true`, no `operation`) at import time, since `defineAction` IS `ActionSchema.parse`, and every console api action with undo along with it. So the accepted set is closed to the two shapes some runtime fulfils rather than to the one the framework runtime fulfils. Stating \"`type: 'api'` is fulfilled by the console\" in the contract is the point, not a leak: the spec is the contract for every runtime including the console, and a closed table of fulfillable combinations is what the declared-is-delivered rule asks for." }, { - "surface": "a `beforeDelete` handler on a BY-ID `delete()` assigning `ctx.input.id` a DIFFERENT id, to move the delete onto that row", - "replacement": "delete the other row explicitly — `ctx.ql.delete(object, otherId)` / `ctx.api` — and let the addressed delete proceed or `throw` from the handler to stop it; to delete MANY rows, have the CALLER pass `{ multi: true, where: … }`. Writing the SAME id back is unaffected and stays legal.", - "migrationId": "delete-by-id-before-hook-repoint-retired", - "toMajor": 17, - "rationale": "The by-id target of an `update()` or `delete()` is now IMMUTABLE inside a `before*` handler, on both verbs, cleared or rebound. `delete()` was the last cell of that table still answering differently: it HONOURED a repoint, re-resolving the new target by re-reading its pre-image and rebinding `previous` (the fix that first made a single-row delete bind `previous` at all), so `afterDelete` and the roll-up recompute saw the row actually deleted. It now refuses with `HookTargetRebindError` / `ERR_HOOK_TARGET_REBIND`, `path: 'by-id'`, exactly as the `update()` twin and both per-row paths (ADR-0058 Amendment II.1 / D4) already did.\n\nRead this as a RULING, not a defect report — that distinction is the reason the entry is worth its length. That re-resolution was internally CORRECT and nothing stale ever leaked from it; the case that retires a rebind on `update()` (the write landing on a row whose pre-image, `readonlyWhen` locks and validation rules were never evaluated) simply did not apply to it. The engine change that dispatches `before*` hooks per matched row on a bulk write therefore left the asymmetry standing on purpose rather than folding a behaviour removal into an ordering change, and filed it as a finding of its own. The 2026-08-09 maintainer ruling on that finding closed it on three measured axes instead: compatibility cost zero (a repository-wide grep for assignments into a hook's `input.id`, re-run on the implementing PR's base, found six sites and ALL SIX are this family's own pins — no consumer anywhere repoints); one rule across both verbs beats two individually-correct rules an author has to memorize, since the justification for the split lived in an ADR rather than at the call site; and \"a hook silently redirects which row gets deleted\" is a top-grade footgun for authored — especially AI-authored — handlers however correctly the redirect is implemented. Correctness of a mechanism does not justify the surface it exposes. Aligning the other way, by building `update()` the same re-resolution, stays excluded by the recorded ruling that extended per-row hook semantics to `before*` hooks on bulk writes (\"do not silently pick re-resolution instead\").\n\nWhy this is a D3 semantic TODO and not a D2 conversion, on the same two grounds as `hook-register-empty-object-target-refused` and `hook-context-session-roles-retired` at this step: FIRST, there is no source to convert — a `HookContext` is constructed per write and never persisted, so no `sys_metadata` row, example or template can carry the assignment. SECOND, the only place it is ever SPELLED is inside a handler body: author-written JS/TS, or a sandboxed script whose context is `unknown`. A declarative transform cannot safely rewrite an assignment inside free-form code, and the intent is not recoverable anyway — only the author knows whether the repoint meant \"delete that row INSTEAD\" or \"delete that row TOO\".\n\nWhat makes this one cheaper to meet than its two siblings, and worth saying because it bounds the work: the removed capability has an ENFORCED channel at run time. The refusal throws before anything is written and its message NAMES the retired capability and the three replacement routes, so a handler that still repoints fails loudly and self-describingly on its first execution rather than going quiet. This ledger entry is the channel that reaches an upgrader BEFORE that first execution. ADR-0058 Amendment II.2." + "surface": "page.component.ai:chat_window — the component node, with every key its props bag declared (`mode`, `agentId`, `context`, `aria`), in regions, named slots and nested containers alike", + "replacement": "Delete the component node and put nothing in its place: AI chat is not a page element, and the floating chat overlay the console mounts on every page is the supported entry point. To choose which platform agent the overlay answers with — what `agentId` reached for — set the app's `defaultAgent` (a platform agent: `ask`, or `build` on an authoring surface). `mode`, `context` and `aria` have no counterpart on the page: none of them was ever read, and the overlay is not configured per page", + "migrationId": "ui-ai-chat-window-retired", + "toMajor": 18, + "rationale": "No renderer for `ai:chat_window` ever shipped in objectui, framework or cloud, and none is wanted: the console leaves it unregistered on purpose so that a page naming it fails loudly, and Studio's page palette excludes it. So the element and its four keys were a capability claim nothing kept — a page that placed one validated clean and drew \"Unknown component type\" in front of an end user. Zero producers were measured in objectstack, cloud and hotcrm (one comment naming it as dropped). The name is now refused at `PageComponentSchema.type`, its `ComponentPropsMap` row refuses every props bag with the same prescription, and the enum no longer lists it; `ai:suggestion` is unchanged. No conversion is registered, because the only edit is deleting the node, and which region closes up, holds something else, or keeps its slot is the author's judgment about a page they composed — this entry is that delegation" }, { - "surface": "driver aggregate() call argument — query.aggregate and aggregations[].func", - "replacement": "query.aggregations and aggregations[].function — the spellings QueryASTSchema and AggregationNodeSchema have always declared", - "migrationId": "driver-aggregate-undeclared-key-aliases-removed", - "toMajor": 17, - "rationale": "`SqlDriver.aggregate` and `RemoteTransport.aggregate` each read two aliases the Query Protocol has never declared: `query.aggregations || query.aggregate` and `agg.function || agg.func`. \"Never declared\" is measured, not assumed — `git log -S` over `data/query.zod.ts` finds no commit that ever introduced either name, there is no `retiredKey()` tombstone and no alias-table entry for them (the file's only alias table is `SortNode`'s `direction` → `order`), and neither appears in any upgrade guide or release note. So this entry does not record a declared surface being withdrawn; it records a LENIENCY being withdrawn, which is why it is here rather than behind a tombstone. The only writers in this repository were the two driver packages' own fixtures — the family of the org-axis red-line gate that read only rejected aliases while its own fixtures spelt them, so its tests stayed green and the rule stayed dead: a fixture spelling the alias keeps the tolerant limb green forever and no test in existence can go red on its deletion — so ADR-0049 enforce-or-remove applies once those are re-spelt. ⚠️ Do NOT read this across to `dashboard`/`page` measures: `aggregate` IS the canonical key there and `func` IS a declared, loudly-suggesting alias (`DatasetMeasureSchema`, ui/dataset.zod.ts). That neighbouring vocabulary is untouched, and it is the most likely reason an off-repo caller ever wrote these keys on a QUERY — one habit, two surfaces, only one of which declared it. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone: nothing ever ran a query through `QueryASTSchema.parse()` on this path. The enforced channel is tsc at the call site, once the parameter is `DriverQuery` — and for an untyped JS caller there is no enforced channel at all, which is exactly why this ledger entry has to exist: the generated upgrade guide is the only way such a reader learns of the rename. Same disposition, and the same reason, as `data-driver-find-stream-retired` (`IDataDriver.findStream`, removed with no tombstone because nothing parses a driver object), `storage-service-list-retired` (the zero-consumer `IStorageService.list`, whose two adapters answered differently and both incompletely) and `actor-user-roles-to-positions` (the `ctx.user` `roles` alias, closed at once on the maintainer's word rather than given a window). The removal ran in a fixed order — the fixtures re-spelt first, the two alias branches deleted second, the parameter narrowed to `DriverQuery` last — because the reverse order yields red nobody can explain. ADR-0049 / ADR-0087." + "surface": "a list view's `bulkActionDefs[].params[]` entry (`BulkActionParamSchema`) — undeclared keys, which this shape accepted and forwarded while it was `.passthrough()`", + "replacement": "the declared shape, now closed to match its single-record twin `ActionParamSchema`: `{ name, type }` plus `label`, `help`, `required`, `default`, `options`, `object`, `labelField`, `multiple`, `placeholder` and — new in this release — `dependsOn`. Every rejection names the surface, echoes the offending key and carries a rename or a prescription: the action-param spellings rename onto this surface's words (`helpText` → `help`, `defaultValue` → `default`, `reference` → `object`, `displayField` → `labelField`); the keys that belong one layer out are pointed there (`visible` and a capability gate belong on the DEF, `visibleWhen` belongs on an `options[]` entry, `field` / `objectOverride` / `carryOver` / `defaultFromRow` / `requiresFeature` are field-backed ACTION-param contracts the bulk surface does not implement); and the widget-config family (`min`, `max`, `step`, `precision`, `scale`, `rows`, `accept`, `maxSize`, and the picker knobs `lookupFilters` / `lookupColumns` / `lookupPageSize` / `descriptionField` / `picker` / `subtitle` / `avatarField` / `idField` / `allowCreate`) is answered with one prescription naming `FieldSchema` as the shape those keys are real on. `dependsOn` needs NO edit — it is declared, in the same shape the field-level key takes (`['parent']`, or `[{ field, param }]` when the remote filter key differs).", + "migrationId": "ui-bulk-action-param-unknown-keys-refused", + "toMajor": 18, + "rationale": "The accept was a NULL READING, and that is what makes this a contract fix rather than a preference. Measured against installed spec 17.4.0, three parses per schema in one process: `BulkActionParamSchema` accepted `zzz_nonsense_key_that_no_producer_emits_8755` in the SAME RUN that it accepted `dependsOn`, while `ActionParamSchema` one surface over refused both with `unrecognized_keys`. A shape that examines nothing cannot license anything — so \"the bulk schema accepts it\" was never evidence a key was authorable, and every misspelling and every invented key shipped silently. Not losslessly convertible for the reason the majors-15/16/17 strictness entries give: an arbitrary unknown key has no mapping target and auto-deleting it would be the silent data loss ADR-0078 bans, so each occurrence needs an author's decision. ⚠️ Two halves of this are worth knowing before you upgrade. (1) `dependsOn` was already LIVE on this surface and is kept: `bulkParamToField` does not destructure it out, so it rides the adapter's spread onto the field bag, where the option widgets read it through `useCascadingOptions` and the reference-bearing pickers lower it into a candidate filter; an ablation removing it from that spread reddened 7 of 12 cases in the consuming repo, so retiring it was measured off the table. (2) the widget-config family rode the same spread and really was honoured by whichever widget read it — those keys are refused now rather than forwarded, which is the accepted cost of closing the shape (maintainer ruling, letter A, 2026-09-17: 「Breaking for authored metadata」, one-shot, no grace window and no dual spelling). ⛔ Do not read their rejection as \"the renderer ignores them\", and ⛔ do not answer it by declaring the key on the object's FIELD: the bulk surface has no field-backed param route, so that value does not reach this dialog either. A census of authored bulk params taken at registration time over the two repositories reachable from that session found ZERO carrying an undeclared key (objectstack@176b03582e: 7 param literals; objectui@3e4f6324f7: 3), so no in-corpus configuration is known to break. ⚠️ That census did NOT cover hotcrm, which was unreachable from the session that took it — that leg is UNMEASURED, not clean, and an upgrader with their own metadata corpus should run the check below rather than inherit this result." }, { - "surface": "data.DriverCapabilities.create / data.DriverCapabilities.read / data.DriverCapabilities.update / data.DriverCapabilities.delete / data.DriverCapabilities.bulkCreate / data.DriverCapabilities.bulkUpdate / data.DriverCapabilities.bulkDelete / data.DriverCapabilities.transactions / data.DriverCapabilities.savepoints / data.DriverCapabilities.isolationLevels / data.DriverCapabilities.queryFilters / data.DriverCapabilities.queryAggregations / data.DriverCapabilities.querySorting / data.DriverCapabilities.queryPagination / data.DriverCapabilities.queryWindowFunctions / data.DriverCapabilities.querySubqueries / data.DriverCapabilities.queryCTE / data.DriverCapabilities.joins / data.DriverCapabilities.fullTextSearch / data.DriverCapabilities.jsonQuery / data.DriverCapabilities.geospatialQuery / data.DriverCapabilities.streaming / data.DriverCapabilities.jsonFields / data.DriverCapabilities.arrayFields / data.DriverCapabilities.vectorSearch / data.DriverCapabilities.schemaSync / data.DriverCapabilities.migrations / data.DriverCapabilities.indexes / data.DriverCapabilities.connectionPooling / data.DriverCapabilities.preparedStatements / data.DriverCapabilities.queryCache", - "replacement": "(removed — delete the keys. A driver advertises a capability by implementing the corresponding IDataDriver method; the three bits that survive because method presence cannot carry the signal are `queryDateGranularity`, `autonumber` and `batchSchemaSync`)", - "migrationId": "driver-capabilities-inert-bits-removed", - "toMajor": 17, - "rationale": "Retiring `IDataDriver.findStream` (it had no production caller, and two of its three implementations read the whole result set into memory before yielding a row) left `DriverCapabilities.streaming` pointing at a capability the contract no longer declares, and the follow-up audit checked every bit in the record the same way, across objectstack and cloud (objectui confirmed clean): of 34 declared bits, THREE have a decision-making reader — `queryDateGranularity` (engine aggregate dispatch + checkDateBucketParity), `autonumber` (engine defers generation to the driver), `batchSchemaSync` (engine ANDs it with method presence, because a subclass can inherit `syncSchemasBatch` from a base whose transport batches while its own cannot) — and THIRTY-ONE were written by every driver and read by nothing. Their `.describe()` strings promised engine adaptation (\"if false, ObjectQL will filter/sort/paginate in memory\") that was never built, and zero readers let the values go WRONG unnoticed: SqlDriver declared `streaming: false` while implementing `findStream`; InMemoryDriver declared `streaming: true` over a full-table read (ADR-0078 false affordance, on the capability record itself). The real mechanism everywhere else is METHOD presence: transactions gate on `driver.beginTransaction`, aggregate pushdown on `typeof driver.aggregate`, schema sync on `typeof driver.syncSchema`, and the REQUIRED CRUD/bulk methods are called unconditionally. A driver is CODE, never stack metadata — `supports` literals live in driver classes and `DriverConfig.capabilities` is plugin TS configuration, neither ever a `sys_metadata` shape (the stack-tree neighbour, `datasource.capabilities`, was retired separately, as a whole block nothing read) — so there is no source for the D2 chain to rewrite and this entry is the D3 record. The keys are tombstoned rather than deleted because `DriverCapabilitiesSchema` is not `.strict()` and IS parsed (DriverConfigSchema / SQLDriverConfigSchema / NoSQLDriverConfigSchema embed it): a plain delete would silently strip a vendor's authored bit, replacing one silent no-op with another. `batchSchemaSync` also drops its `.default(false)` for `.optional()` — absence already meant false at both readers, and the default forced every capability object to spell out 30+ bits. ADR-0049 / ADR-0078." + "surface": "page `cloud-connection:panel` / `marketplace:installed-list` components — `properties` (any key at all: both widgets declare no props)", + "replacement": "an empty `properties` bag (`{}`), or omit `properties` entirely. Neither widget reads any prop: the console registrations discard the schema node (`() => `) and the components take no arguments, so there is no declared key to move to — a key authored on either widget configures nothing and is removed, not renamed. Node-level keys (`visibleWhen`, `id`, `style`, …) stay on the component node, where the page runtime reads them.", + "migrationId": "ui-cloud-connection-widgets-unknown-keys-refused", + "toMajor": 18, + "rationale": "These were two more instances of the class already closed for `record:reference_rail` and then for `record:alert` / `record:quick_actions` / `record:history`, each by declaring a strict `ComponentPropsMap` row measured from the renderer's read points: console-registered widgets on `@objectstack/cloud-connection`'s published Setup pages, reachable through the component type union's open string arm, with registered renderers but no `ComponentPropsMap` row — so the props gate's dispatch (it parses `properties` against the type's row at publish and lint time, and skips a type with no row because the type union is open) skipped them as unregistered and any authored key rode through every validator in silence. The new rows are strict and EMPTY, measured from the renderers' actual read points at the objectui pin (not from the registrations' declared-input lists): both registrations ignore the component node entirely, so the widgets accept no configuration at all, and an authored key is now a publish-time refusal naming the surface instead of a silent no-op." }, { - "surface": "SqlDriver.distinct() third argument — any value", - "replacement": "a bare FilterCondition (@objectstack/spec/data) — the same value find() carries under query.where, never a query envelope", - "migrationId": "driver-sql-distinct-bare-filter-typed", - "toMajor": 17, - "rationale": "This entry records a TYPE being added, not a surface being withdrawn, and it says so up front because the distinction decides who has to do anything. `distinct` is not declared on `IDataDriver`, so neither the narrowing of `IDataDriver`'s query parameters to `DriverQuery` nor the follow-through that brought five drivers' implementations in line ever reached it, and it kept `filters?: any` while its body said something far more specific — `applyFilters(builder, filters)` is handed the ARGUMENT ITSELF, never a `.where` off it. ⚠️ RUNTIME BEHAVIOUR IS UNCHANGED by this entry's change: not one statement moved, so no upgrade breaks at run time and nothing that answered correctly stops. What the annotation removes is a compile-time hole, measured rather than assumed: a truthy NON-OBJECT third argument — `distinct('orders', 'product', 'completed')` — used to type-check and resolve the UNFILTERED set, because `applyFilters` emits no predicate at all for a truthy non-object, non-array filter. A call meaning \"which products among completed orders\" answered with EVERY product, silently. That spelling is now TS2345 at the call site. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone, the disposition `data-driver-find-stream-retired`, `storage-service-list-retired`, `actor-user-roles-to-positions` and `driver-aggregate-undeclared-key-aliases-removed` already carry. ⚠️ It differs from those four in ONE measured way a reader should not have to infer: because nothing changed at run time, an untyped JS caller is not affected BY THE UPGRADE at all. The entry is here for a different reason — such a caller is exactly the one tsc can never reach, and the silent widening above is a defect they may ALREADY be sitting on, before and after this major. The generated upgrade guide is the only channel that reaches them, which is why the fix is written down rather than left to the compiler. ⛔ The reverse mismatch is NOT closed and no type can close it: `FilterCondition` is an open map (`[key: string]: any`) because a filter key IS a field name, so a query envelope `{ object, where }` is structurally a valid filter — one constraining columns named `object` and `where` — and so is a FilterArray. Both reach `distinct` type-checked and are refused at run time, loudly, with INVALID_FILTER / 400. `driver-memory`'s opposite half — where the BARE spelling returns the unfiltered set in silence — stayed open under the maintainer's 2026-08-05 investment freeze on driver-memory, which was lifted on 2026-08-11; it is still open, now unexcused rather than deferred (the measurement that found the two drivers reading this argument differently split the fix: the sql half is this entry, and the memory half was held back by that freeze). ADR-0087." + "surface": "form-view field row `maxLength` / `minLength` declarations (`FormFieldSchema`, the rows inside `FormView.sections[].fields[]`) — `0`, negative or non-integer values", + "replacement": "a positive-integer bound (>= 1), or no declaration at all (\"no minimum\" is expressed by OMITTING `minLength`, never by `minLength: 0`). The row-level key is a per-form override that can only NARROW what the referenced object field already declares (the object field surface tightened first, to a positive integer, by maintainer rulings) — so a malformed row value is deleted, and a bound that was actually wanted is re-declared as a positive integer, or dropped in favour of the object field's own authoritative declaration", + "migrationId": "ui-form-field-length-malformed-refused", + "toMajor": 18, + "rationale": "The form-field row still carried the object field's old shape — bare `z.number()` — after that surface converged (`maxLength` by the maintainer's 2026-08-24 ruling, `minLength` by the 2026-08-25 one, which refused zero too: both `z.number().int().min(1)`). The row keys are LIVE, measured in objectui: the spec bridge (`packages/react/src/spec-bridge/bridges/form-view.ts` mapField, which maps every spec key or explains why it does not, so none is dropped in silence) and plugin-form (`sectionFields.ts` normalizeSectionField) both copy them onto the runtime field, the console FormPage merges `override.maxLength ?? def.maxLength` onto the rendered input (fixed so that a form's own bound wins over the object's, as its docstring promised), and the fields package builds react-hook-form validation rules from `minLength`/`maxLength` — so `maxLength: 0` on a form row reached the DOM as an input that accepts nothing, and the public-form resolve route (`GET /forms/:slug`) serves the rows verbatim to anonymous renderers. The schema now refuses the malformed values at parse (`z.number().int().min(1)`, ADR-0078 declared=enforced). Unlike the object-field twins there is NO type-conditional gate: a form row references its object field by name and usually omits `type`, so the referenced field's type is invisible at parse time — value shape is checkable on this surface, key placement is the object field's own schema's job." }, { - "surface": "engine.find(object, { fields }) and engine.findOne(object, { fields }) carrying a dotted entry (`account.name`) — the direct engine path, not the REST ingress", - "replacement": "read the related record with `expand` (`{ expand: { account: { object: '', fields: ['name'] } } }`), keeping the reference column itself in `fields` — the relation is carried by that column and projecting it away leaves expansion nothing to resolve, the same silent no-op a nested projection omitting the related `id` produced before the engine began keeping that join key itself; or denormalise the value onto the queried object (a stored field, written when the source changes) and name that — the same remedy the REST ingress prescribes when it refuses a dotted projection, and the sort axis when it refuses a dotted sort", - "migrationId": "engine-dotted-projection-refused", - "toMajor": 17, - "rationale": "The REST ingress closed the PROJECTION axis' dotted leg first, refusing a dotted entry instead of widening the response to every field (`assertProjectionFieldsExist`, `400 INVALID_FIELD`), which covers everything reaching `findData`. A caller reaching `engine.find()` / `engine.findOne()` DIRECTLY passed through none of it, and that caller set was measured, not assumed: a flow `get_record` node's authored `fields: ['name', 'account.name']` parses (`GetRecordConfigSchema` restricts nothing), travels verbatim into `data.find(...)`, cleared the engine's head-only projection filter on its head segment (`account` IS a field), and reached the driver as a projection column — where SQL renders `\"account\".\"name\"` against a table that was never joined, the DB answers `no such column`, and the driver's unknown-column recovery ladder — there so an unknown column never reads as \"no rows\" — retries `select('*')`. The caller asked to narrow and silently received EVERY field, byte-identical to no projection at all, pointing away from both FLS and data minimisation.\n\nRuled by the maintainer on 2026-08-12: a dotted entry the engine cannot resolve is refused loudly at the engine's own head-only projection filter, covering every caller that reaches the engine. The check it replaces was justified by a comment claiming the engine resolves relationship paths \"via populate\"; a measurement found that NO populate step exists — once the spec and docs stopped prescribing a dotted `fields` path, that comment was the last place in the repo asserting dotted-path resolution does — so what was removed is not a working feature but a path to widening, kept alive by a false premise. The unknown-PLAIN-column tolerance is explicitly KEPT by the same ruling (an unknown plain name still drops silently; an all-unknown projection still falls back to `*`), a registry-less host gets no verdict (the driver-side recovery ladder remains its documented backstop, and a driver-side carve-out is measured-need only), and a dotted `fields` inside a nested `expand` degrades to an observable warning rather than a refusal — `expandRelatedRecords`' pre-existing graceful-degradation `catch` swallows every expand failure, the same posture the formula-sort refusal (`engine-find-formula-order-by-refused`) records for the same catch.\n\nThis is a CODE-path API, not stored metadata, so — like `engine-find-formula-order-by-refused` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and the ledger entry is the notification channel. No mechanical rewrite exists: the platform cannot decide between `expand` and denormalisation for the caller, and it must not resolve the path itself — no driver ever did, and inventing a join here is a feature decision, not a migration — the line analytics already takes for a relation-traversing dotted measure, refused with a 400 naming the caller's spelling rather than computed against the wrong column. ADR-0112." + "surface": "form-view field row `precision` / `scale` declarations (`FormFieldSchema`, the rows inside `FormView.sections[].fields[]`) — non-integer or negative values (`scale: 2.5`, `precision: -1`)", + "replacement": "a non-negative integer digit count, or no declaration at all. The row-level key is a per-form override of the referenced object field's own declaration (that surface tightened first, to a non-negative integer) — a malformed row value is deleted, and a count that was actually wanted is re-declared as a non-negative integer (`scale: 2.5` was probably `2` or `3`)", + "migrationId": "ui-form-field-precision-scale-integer-refused", + "toMajor": 18, + "rationale": "The form-field row still carried the object field's old shape — bare `z.number()` — after that surface converged on `z.number().int().min(0)` for both digit counts. The row keys are LIVE, measured in objectui: the spec bridge (`form-view.ts` mapField, which maps every spec key or explains why it does not) and plugin-form (`sectionFields.ts`) copy them onto the runtime field, `ObjectForm` derives the number input's step from `precision`, and the `NumberField` widget reads `scale` — so a malformed count flowed into rendering arithmetic (`Math.pow(10, -precision)`) with no defined meaning. The schema now refuses non-integer and negative values for both keys at parse time (ADR-0078 declared=enforced). Same no-type-gate rationale as the length pair entry (`ui-form-field-length-malformed-refused`): the row usually omits `type`, so only value shape is checkable on this surface. ⚠️ The timeline view's `scale` enum (`TimelineConfigSchema.scale`) is a different surface and is unchanged; the gantt view has no `scale` key at all — its own granularity key is `viewMode`. `CurrencyConfigSchema.precision` was also a different surface — retired in this same protocol major by `currency-config-precision-removed`, not enforced here." }, { - "surface": "a `where` / filter naming a `formula` field — at BOTH doors: the REST ingress (`assertFilterFieldsExist`, covering everything that reaches `findData`) and the engine seam itself (`engine.find` / `findOne` / `count` / `aggregate` / `update` / `delete`), which saved reports, flows and dashboard widgets reach directly", - "replacement": "denormalise the value onto the object (a stored field, written when the source changes) and filter that — deliberately the same remedy, in the same words, the SORT axis prescribes when it refuses a formula sort, at the ingress and at the engine, and the SEARCH axis prescribes when it refuses a formula search field; `summary` and `autonumber` fields need NO action, because both get real maintained columns and filter correctly", - "migrationId": "engine-find-formula-filter-refused", - "toMajor": 17, - "rationale": "`formula` is the one field type no driver materialises a column for, and FILTER was the last of the three query axes still fail-open on it: SORT refuses it (at the REST ingress and at the engine) and SEARCH refuses it by name, while a `where` on a `formula` field cleared every gate precisely BECAUSE the object declares the field, reached a driver with no column behind it, and answered 200 with zero rows. Measured on a real `ObjectQL` with `is_open` a `formula` over the stored `status` column: `where {is_open: true}` and `where {is_open: false}` each returned 0 rows with NO error, while the controls `where {status: 'open'}` returned 4 rows and `where {subtask_total: 5}` (a `summary`, which HAS a column) returned 1 row.\n\nBOTH directions are wrong and the `false` one is the dangerous one: the same predicate against a STORED boolean returns every matching row, so a filter meaning \"not yet done\" silently became \"no records at all\" — a row SET changed under a 200, which no amount of inspecting the response can reveal, and the formula READS correctly in that very same response, so the field is visibly populated and simultaneously unfilterable. That is strictly worse than the sort axis it mirrors: a refused sort returns the same rows in a different order, a refused filter changes which rows exist.\n\nBoth doors now refuse it with `400 INVALID_FIELD`, naming the offending key path and carrying the remedy sentence — the ingress gate (`assertFilterFieldsExist`, `@objectstack/metadata-protocol`) for everything reaching `findData`, and `assertFilterIsMaterializable` (`@objectstack/objectql`, `filter-comparand-shape.ts`) at the engine's own filter seam, which every caller-supplied `where` passes through whichever verb it arrived by. Both judge the field by the SAME `@objectstack/spec/data` predicate the SEARCH axis uses (`isVirtualSearchField` / `SEARCH_VIRTUAL_TYPES`, which holds `formula` and nothing else), so gate and drivers cannot disagree about which types have a column: a gate widened to the spec's `COMPUTED_VALUE_TYPES` (the WRITE contract) would refuse two working types. DOTTED filter paths are deliberately not judged on this axis at either door.\n\nThis is a CODE-path API, not stored metadata, so — like `engine-find-formula-order-by-refused` and `engine-dotted-projection-refused` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and this ledger entry is the notification channel. No mechanical rewrite exists in either direction: the platform cannot invent the stored column the remedy prescribes, and it must not filter post-hoc instead — `driver.find` has already applied `limit` / `offset`, so a predicate applied after the formulas are evaluated would filter an ARBITRARY PAGE, which looks correct on small result sets and is wrong the moment pagination is involved.\n\nAUTHOR-REACHABLE SURFACES are why this is not merely a code-side note. A saved report's `query.filter` (`sys_saved_report`) is forwarded VERBATIM into `engine.find` by `plugin-reports` (`report-service.ts`, `where: q.filter`), bypassing the ingress gate entirely; flow node `config.filter` and dashboard widget filters are author-written the same way. A report or flow authored to filter on a formula field used to run and quietly return the wrong row set; it now fails loudly, with the remedy in the message.\n\nRegistered on the ruling inherited from the SORT axis — its engine refusal was registered in this ledger although no stored row needs rewriting, because the ledger is the one channel that carries its rewrite instructions to the author — re-affirmed for this axis at triage on 2026-08-13: the shape is identical to the sort axis and the consequence here is larger. ADR-0112." + "surface": "form `layout` — the `object-form` page component (`ObjectFormPropsSchema.layout`) and the form view (`FormViewSchema.layout`: `view.form`, `view.formViews.*`, a form view item's `config`, a flattened form overlay): the `inline` and `grid` arms (REMOVED)", + "replacement": "`layout: 'vertical' | 'horizontal'`, or no `layout` at all ('vertical' is the renderer default). A multi-column form is `columns` (e.g. `columns: 2`), which the renderer honours under either layout — it was never a layout value. 'grid' → 'vertical' and 'inline' → 'vertical', with any `columns` beside them kept as authored.", + "migrationId": "ui-form-layout-inline-grid-retired", + "toMajor": 18, + "rationale": "Both surfaces declared `vertical | horizontal | inline | grid`, and no renderer ever gave `inline` or `grid` a behaviour of its own. Measured at the objectui pin `f8a9d0fb0`: the simple `object-form` arm folds both to `vertical` under a comment saying exactly that, the drawer and modal arms pass only `vertical` / `horizontal` through, and the tabbed, split and wizard sub-forms hard-code `vertical` — so both values parsed green at the spec door and rendered as the default. The spec admitted them from two declarations (the designer palette and the registry inputs), never from a read. The maintainer's ADR-0049 family criterion asks whether mainstream platforms have the capability — if they do, build the consumer once, correctly; if they do not, retire the key — and not whether anything in this repository reads it. Multi-column, the capability `grid` names, is one they have, and this spec already carries it under another key, `columns`; `inline` is a toolbar / filter-row pattern, not a record-form layout. So the two arms are redundant vocabulary rather than a missing consumer, and are retired with no alias window. The mechanical rewrite is the ADR-0087 D2 conversion `form-layout-inline-grid-to-vertical` (retired from the load path — both enums refuse the two values at parse with a per-value prescription; stored rows and assembled artifacts replay clean). It is behaviour-preserving: the rewritten form renders exactly as before. What it cannot decide is whether an author who wrote `grid` without `columns` wanted a multi-column form they never got — that form always rendered single-column, and only the author knows whether that was the intent." }, { - "surface": "engine.find(object, { orderBy }) and engine.findOne(object, { orderBy }) naming a `formula` field — the direct engine path, not the REST ingress", - "replacement": "denormalise the value onto the object (a stored field, written when the source changes) and sort by that — the same remedy the REST ingress prescribes when it refuses a dotted or formula sort; a `summary` field is unaffected and still sorts, because it gets a real maintained column", - "migrationId": "engine-find-formula-order-by-refused", - "toMajor": 17, - "rationale": "The SORT axis is closed at the REST ingress for an unknown field, a dotted path and a `formula` field alike (`assertSortFieldsExist`, `400 INVALID_SORT`), which covers everything reaching `findData`: the list route, `POST /data/:object/query`, the export route and the RPC dispatcher. A caller reaching `engine.find()` / `engine.findOne()` DIRECTLY passed through none of it, and a `formula` ORDER BY there was dropped in silence. Measured on a real driver: `asc` and `desc` came back BYTE-IDENTICAL, in insertion order, under a success, with the rows carrying the very values they were asked to be ordered by. No column exists to order by (a formula is computed on read, so no driver materialises one), so the ORDER BY reached the driver, found nothing, and the unknown-column backstop returned the rows unordered.\n\nRuled by the maintainer on 2026-08-10: an ORDER BY the engine cannot apply is a 4xx with guidance prose at the public boundary, never a silent drop — the same direction as the analytics dataset refusal envelope and the ingress sort hint's stored-field prescription. The engine's documented internal-caller tolerance (`assertProjectionFieldsExist`'s docblock) was to survive only behind a pinned internal path, and only if a MEASURED internal call site relied on it. The sweep of every in-tree `orderBy` reaching the engine directly — hooks, flows, reports, queue/job adapters, sharing, metadata loaders, expand sub-reads — found NONE: every hardcoded internal sort names a real stored column (`created_at`, `updated_at`, `version`, `priority`, `scheduled_for`, `started_at`, `next_run_at`, `recorded_at`, `id`), and no shipped object in the repo declares a `formula` field at all. So no internal path shipped, and there is no flag to opt back into the drop.\n\nThis is a CODE-path API, not stored metadata, so — like `hook-register-empty-object-target-refused` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and the ledger entry is the notification channel. No mechanical rewrite exists in either direction: the platform cannot invent the stored column the remedy prescribes, and it must not sort post-hoc instead — `driver.find` has already applied `limit` / `offset`, so re-sorting after the formulas are evaluated would reorder an ARBITRARY PAGE, which looks correct on small result sets and is wrong the moment pagination is involved.\n\nONE AUTHOR-REACHABLE SURFACE reaches this indirectly and is why it is not purely a code-side note: a saved report's `query.orderBy` (`sys_saved_report`) is forwarded verbatim into `engine.find` by `plugin-reports`, bypassing the ingress gate. A report authored to sort by a formula field used to run and return rows in an arbitrary order; it now fails loudly, with the remedy in the message. One further path is deliberately NOT a refusal: a nested `expand` sort raises this refusal inside `expandRelatedRecords`, whose pre-existing graceful-degradation `catch` swallows every expand failure and retains the raw foreign keys — so that path moves from silent to OBSERVABLE (a warning naming the field and the fix) rather than refusing. Reversing that backstop is a separate decision on all expand failure modes. ADR-0112." + "surface": "form-view predicates naming the `features.*` scope root — section-level `visibleWhen`, field-level `visibleWhen` at any nesting depth, and per-option `visibleWhen` authored inline in the form view (`FormViewSchema`, including the flattened runtime form overlay and the deprecated `visibleOn` alias spellings)", + "replacement": "gate by record state (`record.*` in runtime forms, `data.*` in metadata forms), or move the feature-gated surface onto an app page component or action — the predicate surfaces where `features.*` stays bound and stays legal. No rewrite is mechanical: a feature-flag gate and a record-state gate answer different questions, so the author chooses which surface the gate belongs on.", + "migrationId": "ui-form-view-predicate-features-root-refused", + "toMajor": 18, + "rationale": "Ruled by the maintainer on 2026-08-27 (option B — vocabulary narrowing: a form view may not name `features.*` in a predicate, and the authoring door refuses it loudly): one authored form view is served on two kinds of route, and a `features.*` predicate got two verdicts from the same text. Inside an app (`/apps/:appName/*`) the root resolves against the real auth-config flags; on the standalone form routes (`/forms/:name`, public `/f/:slug`) no app context exists, the root is UNBOUND, the predicate faults — and `visibleWhen`'s fault fallback is visible, so the field or section a feature flag was meant to hide is shown to everyone (fail-open, on an access-shaped key). Measured before ruling and re-verified at dispatch (2026-08-28): ZERO authored `features.*` form-view predicates exist across objectui apps/examples/content, against an 18-hit positive control on authored `visibleWhen` predicates — so the vocabulary is narrowed at the authoring door instead of building an auth-config fetch plus pre-load semantics on a route with zero consumers. App-context predicate surfaces (page components, actions, bulk-action eligibility) keep `features.*` unchanged." }, { - "surface": "data.engine.update options.upsert", - "replacement": "(removed — never implemented; express create-if-absent explicitly: `findOne` first, then `insert` or `update` on what you find)", - "migrationId": "engine-update-upsert-retired", - "toMajor": 17, - "rationale": "The `upsert` flag promised insert-if-absent on `engine.update()` but no engine or driver path ever read it: the key was declared on both update-options schemas and allowlisted by the unknown-option gate, yet `ObjectQL.update()` never referenced it and it was not a driver pass-through key — `{ upsert: true }` was accepted and silently dropped and the update stayed a plain update (ADR-0049 declared-but-unenforced). There is no behaviour to preserve and nothing stored to rewrite (it only ever appeared in a call-time option bag). Any future first-class upsert must reconcile with the engine's not-found gate — a by-id update whose id names no row throws RECORD_NOT_FOUND rather than inserting — which is why the flag is removed rather than implemented here." + "surface": "kind:'html' page source (and its deprecated kind:'jsx' alias) in a project with no sdui.manifest.json of its own — the div tag, and any other tag or prop the SDUI component manifest shipped in @objectstack/console does not declare", + "replacement": "`box` for a plain wrapper — the one drop-in swap: the same element, your `className` verbatim, the same children, and no layout of its own. Reach for `card`, `flex`, `container`, `stack` or `grid` only where you want their layout. For any other tag or prop the command names, a component and prop the manifest declares; the file is `dist/sdui.manifest.json` inside `@objectstack/console`.", + "migrationId": "ui-html-page-div-refused", + "toMajor": 18, + "rationale": "An html page's source is a JSX string, not a keyed document: `objectstack migrate meta` rewrites stored metadata by key and cannot rewrite a tag inside authored source, so the move is by hand, and which wrapper keeps a page's layout is the author's call. `objectstack validate`, `objectstack compile` (which `dev` and `start` run before they boot only when the artifact is missing or `--compile` is passed, and `dev`'s watch mode when a watched file changes) and `objectstack lint` check that source against an SDUI component manifest: the `sdui.manifest.json` in the directory the command runs in, then the copy `@objectstack/console` ships. The second lookup asked for a file the console package does not export, so it always failed, and a project without its own manifest had its html pages checked at parse level only — syntax and structure, never which components and props they use. It now reaches the shipped copy. That manifest declares the html tier's intrinsic tags but not `div`: the maintainer ruled (2026-09-27) that an html page may author the intrinsic tags its renderer registers and that the published manifest declares that set, while `div` stays deprecated in favour of `box`, and the console's own html-page compile refuses `div` the same way. A `div` in such a page, which used to pass unchecked, now fails the command with `jsx-forbidden-tag` and `jsx-unknown-component`. A project that keeps its own `sdui.manifest.json` is checked against that file, as before. The runtime save door now holds pages to the same manifest: a server that `objectstack serve` runs (`dev` and `start` run it too) resolves the deployment's manifest the same way, from the `sdui.manifest.json` beside the served config and then the copy `@objectstack/console` ships, and the metadata save door compiles an html page's source against it on every publish. A `div` page saved from Studio or through the metadata API is refused with a `422` under the same rule ids, and a draft is stored as written and refused at its publish. A server that resolves no manifest says so once at boot and stores html pages unjudged, as before. Pages already stored are not rewritten; each is judged the next time it is saved." }, { - "surface": "api.enhancedApiError.fieldErrors", - "replacement": "fields", - "migrationId": "enhanced-api-error-field-errors-renamed", - "toMajor": 17, - "rationale": "The wire has always carried `fields` — the validators, import coercion, validation-failure.ts, @objectstack/client and the console's field-error extractor all say `fields`, and nothing ever emitted `fieldErrors`, so a reader keying on it was reading a field no server sent (ADR-0078's silently-inert declaration, on the error envelope). This is a RESPONSE surface: no stack, example or template carries the key, so there is no source for the chain to rewrite — the schema tombstones it via retiredKey() and consumers move their read themselves. ADR-0114 D4 (the field-level error code catalog)." + "surface": "list-view group-by field names — `kanban.groupByField` (`KanbanConfigSchema`, REQUIRED), `gantt.groupByField` and `timeline.groupByField` (`GanttConfigSchema` / `TimelineConfigSchema`, both optional) — values carrying leading or trailing whitespace", + "replacement": "the field name written with no leading and no trailing whitespace — the same spelling the object declares and the server answers under. A padded value is RE-AUTHORED, never trimmed on the author's behalf: `' stage'` becomes `'stage'`. The refusal names the offending spelling verbatim, so the whitespace an author cannot see in an editor is visible in the message, next to the name to write instead.", + "migrationId": "ui-list-view-groupbyfield-padded-refused", + "toMajor": 18, + "rationale": "The same padded-name defect the grouping-level narrowing (`ui-list-view-grouping-field-padded-refused`) refused, on the axis that one scoped out by name, and given the same refusal. All three keys were a bare `z.string()`, so a padded group-by name was valid authored metadata all the way to the renderers. The name is a LOOKUP KEY on every row, measured in objectui at `dda8f3815`: the kanban board resolves its lane as `laneField = groupByField || groupField || detectStatusField(objectDef)` and buckets cards by `card[laneField]`; `ObjectGantt`'s `groupByAccessor` splits the name on `.` and walks the backing record (`resolvePath(task.data, field)`); the timeline groups its rows the same way. The server answers under the unpadded name, so every per-row lookup reads `undefined` and the board collapses into one `Uncategorized` lane — the gantt and the timeline into one ungrouped bucket — holding every record. That is a silent wrong answer that reads as a true statement about the data: one giant bucket is indistinguishable from a dataset where the field genuinely is empty, which is why nothing weaker than a parse refusal is honest here. `packages/lint`'s `validate-list-view-field-refs` already grades this position `error` for the same consequence, but it only runs where an app is validated against its object definitions; the producer accepted the value regardless. ⛔ NOT a `.trim()`: a trimming schema makes `' stage'` and `'stage'` silently equivalent, the consumer-tolerance direction the contract-first rule refuses (fix the metadata, not the renderer: off-spec metadata is refused where it is authored, never coerced into working) — and on the REQUIRED kanban key the author cannot withdraw the value by omitting the key, so a normalising producer would be their only feedback channel and it would say nothing. The narrowing is non-padded ONLY and deliberately not the snake_case machine-name grammar `/^[a-z_][a-z0-9_]*$/` this package spells inline for object/field/tool NAMES: a `groupByField` is authored as a field REFERENCE and a dotted relationship path (`owner.name`) is an in-tree spelling of one. Ships at once, no deprecation window (2026-08-27 maintainer ruling 「短期不考虑渐进」)." }, { - "surface": "automation.etlPipeline / automation.etlPipelineRun / automation.etlSource / automation.etlDestination / automation.etlTransformation (the whole L2 layer of automation/etl.zod.ts, its four enums and the `ETL` factory — 9 defs, 27 exported names)", - "replacement": "(removed — no protocol surface replaces it, deliberately. Layer by layer: connector-attached synchronisation is `ConnectorSchema.syncConfig` (`integration/connector.zod.ts`), which is PARSED AND VALIDATED but NOT EXECUTED — a declared shape, not a running sync. `AutomationEngine.registerConnector` runs `ConnectorSchema.parse` and stores the parsed definition; nothing reads `syncConfig` back off it, and the key has no reader outside `packages/spec` at all — the same measurement that deleted `syncConfig.schedule` in @objectstack/spec 17 under ADR-0049, with the other cron-typed positions nothing reads. What the platform DOES execute on a connector is its `actions`: a flow's `connector_action` node resolves the registered handler and awaits it, so an author who needs data actually moved drives it from there. Per-field value transformation on import is `shared/mapping.zod.ts`, whose `transform` is applied row by row by the REST import path and recorded key by key in `packages/spec/liveness/mapping.json`; scheduling is `system/job.zod.ts`. What has NO replacement is multi-source, multi-stage movement with joins and aggregations — because it never had an implementation either. It returns through the ENFORCE route: the engine first, the vocabulary second)", - "migrationId": "etl-pipeline-layer-retired", - "toMajor": 17, - "rationale": "The reading the spec dual-source cleanup used to retire L1 `DataSyncConfig` (its automation copy deleted as dead), re-measured one layer up and identical: narrative-only. No engine ever parsed, scheduled or executed an `ETLPipeline`. Measured on origin/main immediately before the removal: the only non-spec references in this repo are two fumadocs-generated documentation sources (`apps/docs/.source/*.ts`), not executors; objectui has no reference at all; there is no `liveness/etl.json` or `pipeline.json`, so no ADR-0049 gate ever had a reading on it — while the same file family's EXECUTED half does have one (`liveness/mapping.json`), which is the contrast that makes the absence meaningful rather than an oversight. The `etl` string in this registry was the one untested link the finding named, and it is not a loader path: it was the id of the retry-vocabulary entry for `ETLPipeline.retry` (a third retry-policy vocabulary the retry convergence had not covered), absorbed here. The layer was ADR-0078's asymmetry in its purest form — an author could write a complete ten-stage pipeline, get no error, and get no execution. It was also advertised: `packages/spec/docs/SYNC_ARCHITECTURE.md` named `ETLPipeline` as the recommended destination for authors displaced by the L1 retirement and listed ten transformation types with copyable examples down to `script | Custom JavaScript/Python`. That document is rewritten in the same change; a retirement whose own doc still recommends the retired layer is self-contradictory, and forwarding L1's authors to a second layer with no executor was the defect compounding rather than closing. ⚠️ `etl-retry-converged-onto-retry-policy` is SUBSUMED here, the way the `activationEvents`, dynamic plugin-loading and widget / i18n retirements each let an earlier tombstone go with the shape that carried it: both land in the unreleased protocol 17, so composed, a rename of `retry.maxAttempts` on a shape that does not survive the major has no observable effect — and keeping both would tell an upgrader to rewrite a key on a schema the same upgrade deletes. The `maxAttempts` `retiredKey()` tombstone goes with the shape that carried it, which is strictly stronger than the tombstone: there is no longer a `retry` block to author the key into. Route 3 — no carrier key, no parse site, so no D2 conversion and no tombstone; RETIRED_DEFS_BY_MAJOR plus this entry are the declaration. ADR-0049, ADR-0078." + "surface": "list-view grouping level names — `grouping.fields[].field` (`GroupingFieldSchema`, the rows inside `ListView.grouping.fields[]`) — values carrying leading or trailing whitespace", + "replacement": "the field name written with no leading and no trailing whitespace — the same spelling the object declares and the server answers under. A padded value is RE-AUTHORED, never trimmed on the author's behalf: `' business_unit '` becomes `'business_unit'`. The refusal names the offending spelling verbatim, so the whitespace an author cannot see in an editor is visible in the message.", + "migrationId": "ui-list-view-grouping-field-padded-refused", + "toMajor": 18, + "rationale": "Ruled by the maintainer on 2026-09-10 (「其他同意」): refuse at the producer. `field` was a bare `z.string()`, so a padded grouping level was valid authored metadata all the way to the renderers. Measured on objectui (M1-M11 with live controls): the projection harvester `collectGroupingFieldRefs` TRIMS the name when it builds `$select`, while THREE renderers bucket rows by the RAW name — plugin-grid `usableGroupingFields`, plugin-list `ObjectGallery.groupedItems`, plugin-kanban `effectiveSwimlaneField`. The server therefore answers under `business_unit` while every per-row lookup asks for `' business_unit '`, reads `undefined`, and the view collapses into ONE `(empty)` group (grid, gallery) or ONE `Uncategorized` lane (kanban) holding every record — a silent wrong answer that reads as a true statement about the data, which is why nothing weaker than a parse refusal is honest here. ⛔ NOT a `.trim()`: a trimming schema makes `' a '` and `'a'` silently equivalent, the consumer-tolerance direction the contract-first rule refuses (fix the metadata, not the renderer: off-spec metadata is refused where it is authored, never coerced into working). objectui's harvester trim stays as defence-in-depth; nothing is removed there. The narrowing is non-padded ONLY and deliberately not the snake_case machine-name grammar `/^[a-z_][a-z0-9_]*$/` this package spells inline for object/field/tool NAMES: a grouping level is authored as a field REFERENCE and a dotted relationship path (`owner.name`) is an in-tree spelling of one. The blank name is unchanged here — it is already refused loudly one layer down by `compileListViewGroupQuery`'s `grouping_field_blank`, and this narrowing exists for the SILENT case. Ships at once, no deprecation window (2026-08-27 maintainer ruling 「短期不考虑渐进」)." }, { - "surface": "security.permissionSet.objects[].allowExport (ABSENT — a permission set that never declared the key)", - "replacement": "an explicit `allowExport: true` on the object entry (or the `*` wildcard) of every permission set whose holders are meant to keep exporting", - "migrationId": "export-axis-opt-in", - "toMajor": 17, - "rationale": "A secure-default FLIP, not a shape change — the same class as `rest-requireauth-default-flip` (ADR-0056 D2, protocol 12) and `action-descriptor-resume-authority-default-flip`, and it is registered for the same reason those are: the metadata is UNCHANGED and still parses, so no gate anywhere will tell an upgrader that what it MEANS has inverted. Before 17, `allowExport` unset inherited read; from 17 it denies. Reading a record and taking a bulk machine-readable copy of the whole table are different privileges — Salesforce \"Export Reports\", Dynamics \"Export to Excel\", NetSuite \"Export Lists\" and SAP `S_GUI` 61 all separate them — and the axis now says so. This cannot be a mechanical conversion in either direction: writing `allowExport: true` wherever the key is absent would preserve today's behaviour while silently defeating the entire point of the flip, and writing `false` would revoke a capability the deployment may legitimately want. Whether a given set's holders SHOULD be able to take a bulk copy is exactly the segregation-of-duties judgement the axis exists to make explicit, and it belongs to the operator. Two details decide who is actually affected: package-shipped sets are re-seeded on upgrade, so the built-ins are handled — at 17.0.0 `admin_full_access` and `organization_admin` carried the grant explicitly, ON A `*` WILDCARD, and protocol 18 REMOVES it (see `admin-export-wildcard-removed`: the wildcard made the axis undeniable for an org admin, so from 18 an admin exports only what an app set grants — do not read this clause as a standing promise that the built-ins keep exporting) — while ENVIRONMENT-AUTHORED sets are not and must be edited by hand. `member_default` deliberately does NOT carry the grant, so ordinary authenticated users lose export until an admin grants it; that is the point of the flip, not an oversight. Merge semantics are unchanged and most-permissive, exactly like the CRUD bits: any set granting `true` grants export, and `false` is authoring intent rather than a veto, because permission sets are additive capability containers (ADR-0090). The super-user bits no longer confer it: `viewAllRecords` / `modifyAllRecords` are \"may see all data\", not \"may take a bulk copy\". Registered (backfilled) by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger: the export axis, and its extension to the CSV attachments scheduled reports mail out, both predate the gate that makes a breaking changeset state its ADR-0087 disposition. ADR-0087." + "surface": "page `mcp:connect-agent` component — `properties` (any key at all: the widget declares no props)", + "replacement": "an empty `properties` bag (`{}`), or omit `properties` entirely. The widget reads no prop: the console registration discards the schema node (`() => `) and the component function takes no parameters — every value it renders comes from `/discovery`, i18n and its own state — so there is no declared key to move to; a key authored on it configures nothing and is removed, not renamed. Node-level keys (`visibleWhen`, `id`, `style`, …) stay on the component node, where the page runtime reads them.", + "migrationId": "ui-mcp-connect-agent-unknown-keys-refused", + "toMajor": 18, + "rationale": "This was a third instance of the class closed for the `record:*` blocks by declaring a strict `ComponentPropsMap` row measured from the renderer's read points (the strict, empty `cloud-connection:panel` / `marketplace:installed-list` rows closed the previous two): a console-registered widget on `@objectstack/mcp`'s plugin-shipped Setup page, reachable through the component type union's open string arm, with a registered renderer but no `ComponentPropsMap` row — so the props gate's dispatch (it parses `properties` against the type's row, and skips a type with no row because the type union is open) skipped it as unregistered, any authored key rode through every validator in silence, and door 3 of the canonical-envelope gate `@objectstack/mcp` was given for its shipped page had to carry a standing exemption for the type. The new row is strict and EMPTY, measured from the renderer's actual read points at the objectui pin (not from the registration's declared-input list): the registration ignores the component node entirely, so the widget accepts no configuration at all, and an authored key is now a publish-time refusal naming the surface instead of a silent no-op." }, { - "surface": "@objectstack/rest: ExportFieldMeta.required / .system / .readonly / .hasDefault / .min / .max / .minLength / .maxLength (the map built by `buildFieldMetaMap`, reached as `PreparedImport.metaMap` from `prepareImportRequest`)", - "replacement": "the object schema you already hold — read `fields[name].required` / `.system` / `.readonly` / `.defaultValue` / `.min` / `.max` / `.minLength` / `.maxLength` off the same `ObjectSchema` you passed to `buildFieldMetaMap`, which is where the ENGINE reads them and therefore the only copy that cannot drift", - "migrationId": "export-field-meta-constraints-retired", - "toMajor": 17, - "rationale": "ADR-0049 enforce-or-remove. These eight were never a source of truth: `buildFieldMetaMap(schema)` DERIVED each one from the very `schema` its caller passed in, so the map carried a second copy of facts the caller already held. They existed for exactly one consumer — the import dry run's hand-copied pre-check mirror (`firstMissingRequiredField` / `firstConstraintViolation`, added when the dry run was found skipping the field-level validation the real write ran) — and the maintainer's 2026-08-06 ruling D (a validate-only protocol operation, so the dry run's prediction is the engine's verdict by construction) retired that mirror: the dry run now asks `DataProtocol.validateData` for the engine's verdict, which reads the object's own schema. That left all eight computed on every import and read by NOTHING, which is the declared-and-unread shape ADR-0049 exists for; a constraint vocabulary standing next to the presentation one with no enforcer behind it is precisely the thing an AI-authored consumer mistakes for a contract. Verified zero-reader before removal, per key and by type, across this repo (`packages/rest` itself, and all five in-repo dependents of `@objectstack/rest`: runtime, cli, verify, plugin-auth, plugin-dev) and the `objectui` sibling; plugin-auth's identity import forwards `prepared.metaMap` into `runImport` but reads only the presentation keys through `coerceRow`. Why this needs a ledger entry despite that sweep: it is the `findStream` / `IStorageService.list` / `actor-user-roles-to-positions` disposition — a published TS surface with NO spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry a prescription, and the ledger is the only channel that reaches an upgrader. It is if anything blinder than those three: the keys shipped in a FINAL release (`@objectstack/rest` 14.5.0) and have been published in every release since, and because they were OPTIONAL keys on an interface that itself survives, a JavaScript consumer reading `meta.required` after the upgrade gets `undefined` with no error at all — tsc reports at the read site only for a typed consumer. Why D3 semantic and not a D2 conversion: there is nothing to convert. No authored or stored metadata changes shape — `required` / `min` / `maxLength` and the rest remain fully authorable on a field definition and fully enforced by the engine, which is where they always lived. The only place these eight are ever spelled is inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach them. ADR-0049 / ADR-0087; this is the removal the dry-run change deliberately deferred to a sweep of its own." + "surface": "the grouping property of the object-grid and object-kanban page blocks (ComponentPropsMap['object-grid' | 'object-kanban'].grouping), which was z.unknown and therefore accepted any value: a padded field name such as { fields: [{ field: ' business_unit ' }] }, a number, a bare field-name string, an empty fields list, or keys the grouping config does not declare", + "replacement": "the grouping config a list view carries, `GroupingConfigSchema` from `@objectstack/spec/ui`: `{ fields: [{ field, order?, collapsed? }, ...] }` with at least one entry, each `field` naming the record field exactly as it is stored, with no leading or trailing whitespace, `order` one of `asc` / `desc` and `collapsed` a boolean. A padded name is rewritten unpadded (`' business_unit '` becomes `'business_unit'`); a bare string `'business_unit'` becomes `{ fields: [{ field: 'business_unit' }] }`; an empty `fields` list, a number, or any other value is deleted, since it never grouped anything. On `object-kanban`, `swimlaneField` still wins when both are authored, and deleting `grouping` is the whole migration there when `swimlaneField` is set", + "migrationId": "ui-object-block-grouping-config-typed", + "toMajor": 18, + "rationale": "Both blocks' renderers read the list view's grouping shape and nothing else: the grid groups its rows by every `grouping.fields[i].field` (its server-side group header query and its row projection) and reads `order` and `collapsed` per level, and the kanban board takes `grouping.fields[0].field` as its swimlane field when no `swimlaneField` is authored, looking that raw name up on every card. The list view has refused a padded grouping field name since protocol 17.5, and a list view's `grouping` is a closed shape; these two doors declared the same prop as `z.unknown`, so the same value that list view refuses validated green here and rendered wrong with no error — the grid showed one `(empty)` group holding every row, the board one swimlane holding every card. The prop is kept, not retired: the board's fallback is a live reader of the grouping config. The rewrite is left to the author on purpose: a trimming rule would make a padded and an unpadded name silently equivalent, which is the consumer tolerance the contract refuses, and a bare string, a number or an empty list has no mapping that says what grouping was meant. Metadata AT REST is left exactly as stored — `properties` on a page component is not parsed on the save path, so a stored page keeps loading and renders as it does today; the component-props gate reports such a value as an advisory `component-props-invalid` finding at the offending path on `os validate`, `os build` and `os lint`, a padded name with the received value and the unpadded name to write. ADR-0049 / ADR-0087." }, { - "surface": "data.externalLookup / data.externalDataSource / data.externalFieldMapping (the whole of data/external-lookup.zod.ts — 3 defs, 8 exported names) and system.messageQueue (the whole of system/message-queue.zod.ts — MessageQueueConfig, MessageQueueProvider, TopicConfig, ConsumerConfig, DeadLetterQueue — 5 defs, 14 exported names)", - "replacement": "(removed — there is no replacement key, because there was never a key: neither family was reachable from any metadata-type binding, stack collection or /meta door, so no document could carry either. For external data: `object.external` (`ObjectExternalBindingSchema`, ADR-0015/0062) names a datasource by reference and connection credentials live in the datasource config — never inline in object metadata; `data/external-catalog.zod.ts` is that federated path's catalog surface and is untouched. For message queues: the LIVE surface is `kernel/events/integrations.zod.ts`'s `EventMessageQueueConfig` (`EventBusConfig.messageQueue`), which deliberately carries NO credential field — broker connection and SASL credentials are runtime deployment configuration, not authorable metadata. Either capability returns via the ENFORCE route of ADR-0049 through a new ADR — the executor / broker admin service first, the vocabulary second)", - "migrationId": "external-lookup-message-queue-families-retired", - "toMajor": 17, - "rationale": "Both families are the verdict of the 2026-08-12 census of spec schemas that permit inline credentials (fork (b): no `sys_metadata` door reaches them; accepted 2026-08-12): security-shaped declared surface with inline-credential sinks and ZERO consumers. `ExternalDataSourceSchema.authentication.config` is a record of unknown whose own docblock example wrote `\"clientSecret\": \"...\"` inline, and `MessageQueueConfigSchema.sasl.password` was a required inline broker credential — the class of the `sys_metadata` cleartext-sink finding (cleartext-at-rest credential sinks), except that unlike its two measured surfaces (driver config and connector `authentication`) nothing ever persisted these: no metadata-type binding (kernel/metadata-type-schemas.ts imports neither module), no stack collection, no object/field embedding (`object.external` binds `ObjectExternalBindingSchema` — remoteName/remoteSchema/writable/columnMap, no authentication), and zero imports outside packages/spec repo-wide, with the corpus-reach control (`DatasourceSchema` under identical exclusions) returning hits in the same run. The consumed MQ near-namesake `kernel/EventMessageQueueConfig` deliberately has no credential key, so the consumed shape had no credential and the credential-bearing shape had no consumer. A dead schema minus one field is still a dead schema, so the whole declarations go, not just the credential faces (the lesson of the plugin sandboxing config that was never wired to anything: an exported schema with no consumer reads as a capability to whoever finds it — here it read as an invitation to author secrets in cleartext). With no carrier key there is nothing to tombstone and no source or `sys_metadata` row for a D2 conversion to rewrite: route 3, the shape of the earlier removals of the dynamic plugin-loading family, the `ui/` interaction configs, the widget / i18n shapes and the sweep of five declared-but-inert surfaces — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. ⚠️ The `data/ExternalFieldMapping:transform` tombstone of the field-mapping transform retirement (one of that retirement's three spellings; the whole transform union left because no runtime ever executed any of its five members) is SUBSUMED by the def retirement, the WidgetManifest.performance way: it goes with the shape that carried it. The base `shared/FieldMapping` tombstone and the `integration/ConnectorFieldMapping` spelling are untouched and still reject `transform` with that retirement's prescription. ⚠️ The reopen trigger of the maintainer's 2026-08-12 ruling on the cleartext sink — it closed each artefact's contract (Option A) and parked the class-level `sys_metadata` write-boundary guard (Option B) until \"a third measured artefact-type surface\" — is NOT met by this census: that guard stays parked; this is the ADR-0049 leg of the fork the triage pre-agreed." + "surface": "page `object-form` components — `properties.customFields` (which used to accept any value)", + "replacement": "a list of closed inline form fields `{ name, label?, type?, required?, options?, … }` — the members the form draws, in camelCase, each option `{ label, value, description?, visibleWhen? }` with `value` a string, a number or a boolean. Write a `visibleOn` (or a legacy `condition`) as `visibleWhen`, move a member's `defaultValue` into the block's `initialValues`, drop `id`, and leave the `grid` widget's snake_case keys (`min_rows`, `allow_add`, …) out until the widget reads a camelCase spelling.", + "migrationId": "ui-object-form-custom-fields-typed", + "toMajor": 18, + "rationale": "The form merges `customFields` over the fields it generates from the object's metadata — a member naming a declared field replaces that field's whole definition, any other is added — and draws each member as it was written, handing it to the field widget as its metadata. The page-component row declared it `z.unknown()`, so `42`, a member with no `name`, or a misspelled member passed the component-props gate, and the form drew the field without it. The row now takes a closed runtime form field of the members the form draws, keyed by `name`, each typed to its read — by reference where this package already declares the member (the object field's metadata members, the evaluated predicates). An option is the runtime option the form's option controls draw — `label`, `value`, `description`, `visibleWhen` — and its `value` is any string, number or boolean, kept as written: an inline field binds no object column, so a stored field's lowercase identifier rule does not apply to it. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and the authored census, with every inline option list evaluated, found no working member to respell. Deployed metadata NOT MEASURED." }, { - "surface": "PUT /api/v1/meta/field/{object}.{name} (runtime-authored standalone `field` items)", - "replacement": "Author the field inside its object and write the whole object — PUT /api/v1/meta/object/{object} with the new field in `fields` — or declare it in the object source (`**/*.object.ts`) and redeploy", - "migrationId": "field-runtime-create-withdrawn", - "toMajor": 17, - "rationale": "The `field` registry entry declared `allowRuntimeCreate: true` and the platform never built a read path for it. Measured end-to-end through the real HttpDispatcher -> ObjectStackProtocolImplementation -> SysMetadataRepository: `PUT /api/v1/meta/field/showcase_task.zz_probe` answered 200 with {\"success\":true,\"state\":\"active\",\"message\":\"Saved field …\"}, the row persisted, and `GET /api/v1/meta/object/showcase_task` then listed fields = [title, status] with zz_probe ABSENT — forever. The row is even self-readable by name (`GET /meta/field/showcase_task.zz_probe` -> 200, `_diagnostics.valid: true`), which makes it well-formed and universally inert rather than malformed. The seam is that `field` is the ONE declared type with no standalone existence: fields are authored inside the object (`ObjectSchema.fields`), a `field` write mints a SEPARATE row keyed ('field','.'), and nothing composes fragment rows into their parent — `applyRegistryWriteThrough` routes only `type === 'object'`, and `filePatterns` (`**/*.field.ts`) match nothing in any app. A declared capability the platform cannot honour is ADR-0049 false compliance, and the maintainer ruled REMOVE on 2026-08-12 rather than build the read path, which is a feature spanning at least three packages (a composition step that does not exist, ~20 `gate.fields` call sites, physical schema/migrations, and cold boot via `loadMetaFromDb`); if ever wanted it is a separate card — implementation first, declaration second. ⚠️ This is NOT the `api` withdrawal's rationale reused (`api-runtime-create-withdrawn`): that ruling rested on \"zero business pull\", and \"add a field\" is the opposite — a core Studio/CRM operation. The justification here is that the operation REMAINS AVAILABLE on the route that actually composes: `object` keeps `allowRuntimeCreate: true`, so what is withdrawn is a second, broken SPELLING of adding a field, not the ability to add one. There is NO D2 conversion, for the reason this list exists: nothing in an authored source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an authorable one, and no authored source changes — an `**/*.object.ts` file valid before this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, so it is one semantic TODO for operators and Studio callers rather than a stack conversion — the same disposition `api` and `BatchOptions.validateOnly` take. ADR-0049 / ADR-0087." + "surface": "page `object-form` and `object-master-detail-form` components — `properties.fields` (whose entries used to accept any value)", + "replacement": "a list of bare field names, in the order the form draws them. Write a `{ name: 'email' }` entry as `'email'` — the form only ever drew its name — and move a `label` or `required` override onto a `sections[].fields` entry (`type` is always the object field's); write a `{ field: 'email' }` entry as `'email'`, or move it into a section's `fields`, the vocabulary it belongs to.", + "migrationId": "ui-object-form-fields-names-typed", + "toMajor": 18, + "rationale": "The form reads its top-level `fields` as the names of the fields to draw, in order, selecting from the object's fields and from `customFields`; the master-detail form hands its own to the parent form verbatim. objectui declares the member `string[]`, but the page-component rows declared it `z.array(z.unknown())` while the form drew a `{ name }` entry by that name — the shape objectui's page-builder guide taught, with a `label`, `type` and `required` the form silently dropped. objectui has since retired that entry from every authoring face — the guide and its fixtures name the fields — keeping only a STORED one readable; so both rows now take field names, and refuse an object entry with what to write instead: a `{ name }` entry is its bare name, and a `{ field }` entry — the `sections[].fields` vocabulary, which the form skips at the top level with a console warning — is its bare name or belongs in a section. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, the form already draws a stored `{ name }` entry by its name, and an override written beside it has no rewrite that keeps it — moving it onto a section is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED." }, { - "surface": "data.filter $regex / $options — in a STORED filter (dashboard widget filter and globalFilters, report runtimeFilter, page and component filter, solution-blueprint filter), and equally in the where clause of a query request", - "replacement": "$icontains for the case-insensitive substring match this was almost always used for, or $contains for a case-sensitive one — a pattern that genuinely needs a regular expression has no filter-level replacement", - "migrationId": "filter-regex-options-retired", - "toMajor": 17, - "rationale": "Like `driver-aggregate-undeclared-key-aliases-removed` and `driver-sql-distinct-bare-filter-typed`, this entry records a LENIENCY being withdrawn rather than a declared surface: `$regex` was never in `FILTER_OPERATORS` and never a key on `StringOperatorSchema`. That is measured, not assumed — `git log -S'$regex'` over `packages/spec/src` returns only doc comments describing how `$contains` LOWERS to MongoDB (`Contains substring - SQL: LIKE %?% | MongoDB: $regex`), plus the retirement's own contract-half change, which added the name solely as `RETIRED_FILTER_OPERATORS` prescription data. ⚠️ But it differs from those two in the one way that decides the disposition, so a reader should not have to infer it: those were driver CALL ARGUMENTS, code and never stack metadata, whereas a filter IS stored metadata. `FilterConditionSchema` is an OPEN RECORD (`z.record(z.string(), z.unknown())`) because a filter key is a field name, so a stored `{ name: { $regex: 'acme.*' } }` parses GREEN and always will — a `retiredKey()` tombstone cannot exist on an open map, which is exactly why the ledger has to carry this. What such a stack used to get was four different answers from four backends: `driver-sql` and Turso's remote transport compiled it to a LIKE-escaped SUBSTRING (so `a.b` matched only the literal `a.b` and the regex was silently never a regex), `driver-memory` and objectql's `having` ran it as a real `RegExp` (so the same filter also matched `axb`, and an INVALID pattern was caught and answered `false` — zero rows, in silence), and `driver-mongodb` refused it with a bare `Error` carrying no `code` and no `status`. It is now refused everywhere with INVALID_FILTER / 400 naming the replacement. There is deliberately NO D2 conversion and this sits in `semantic` rather than among the mechanical transforms: rewriting `$regex` to `$icontains` is NOT lossless in either direction — a regex metacharacter becomes a literal — so an auto-applied rewrite would silently change which rows a dashboard, report or permission filter selects, a wrong number rather than a missing one. Choosing the substring the pattern MEANT is a judgment about the query, not a transform. ⚠️ This entry covers BOTH HALVES of the maintainer's 2026-08-06 ruling (option B: retire `$regex` loudly and add `$icontains`, rather than make five backends agree on one regex dialect), not just the driver one: the contract half (the `$icontains` declaration, the `$contains` family pinned case-sensitive, and the `RETIRED_FILTER_OPERATORS` prescriptions) landed before the gate that makes a breaking changeset state its ADR-0087 disposition existed, and so was never asked for a ledger entry; the driver half is where the refusal became executable. One surface, one entry, registered from the half that made it observable. ADR-0049 / ADR-0087." + "surface": "page `object-form` components — `properties.contentLayout`, `.submitBehavior`, `.navigateOnSuccess` and `.mobile` (which used to accept any value)", + "replacement": "the shape the form reads: `contentLayout` `'simple'` or `'tabbed'`; `submitBehavior` the form view's own block — `{ kind: 'thank-you', title?, message? }`, `{ kind: 'redirect', url, delayMs? }` with a relative `url`, `{ kind: 'continue' }` or `{ kind: 'next-record' }`; `navigateOnSuccess` a relative path string; `mobile` `{ stickyActions?, stepper?, stepperMinFields?, stepperFieldsPerStep?, fullscreenLongText? }`, with `stepper` `true`, `false` or `'auto'` and the two counts positive integers. Write a `submitBehavior` `kind` as one of the four; move a `redirect` destination to a relative path; write `heading` as `title`.", + "migrationId": "ui-object-form-members-typed", + "toMajor": 18, + "rationale": "The form reads these members with one shape, and the page-component row declared them `z.unknown()`, so any value passed the component-props gate and the form answered an off-shape one with a silent default: a `submitBehavior` `kind` it does not know fell through to the thank-you panel; a misspelled `contentLayout` such as `'tabs'` stacked the sections; a `navigateOnSuccess` that is not a string threw after the record was written, so the submit reported a failure; and a `mobile` member it does not read, or a `stepper` outside `true` / `false` / `'auto'`, was ignored. The row now takes the form view's own `submitBehavior` by reference — the block the renderers already judge a redirect `url` through — so one value is judged the same way on the form view and the block, and the measured shape for the other three. The form's `fields` and `sections` and the master-detail form's two stay open, because the form draws a `{ name }` field entry and an inline runtime field inside a section, which the typed shapes would refuse; and `customFields` stays open until the spec declares the runtime form field its entries are. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and an off-shape value has no rewrite that both keeps what the form shows today and honours what the author wrote — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED." }, { - "surface": "flow.errorHandling.maxRetries (under strategy: 'retry')", - "replacement": "an explicit count >= 1 (e.g. maxRetries: 3), or strategy: 'fail'", - "migrationId": "flow-retry-max-retries-required", - "toMajor": 17, - "rationale": "maxRetries had two defaults — FlowSchema `.default(0)` and the engine's `maxRetries ?? 3` — so an unstated count retried 0 times through the schema and 3 times through a hand-built definition. With the engine's copy removed the unstated count is unambiguously 0, and retrying zero times is exactly `strategy: 'fail'`, so the schema now refuses the combination instead of it silently doing nothing. There is no lossless rewrite: 0 preserves the behaviour a parsed flow got but contradicts what its author wrote, and any positive count is a NEW decision about re-running the whole flow with its side effects. That choice is the author's." + "surface": "page `object-form` and `object-master-detail-form` components — `properties.sections` (whose entries used to accept any value)", + "replacement": "closed sections `{ name?, label?, description?, collapsible?, collapsed?, visibleWhen?, columns?, pane?, fields }` (or `{ group, columns?, pane? }`), each `fields` entry a field name, the form view's `{ field, … }` entry or an inline form field `{ name, type, … }`. Write a section or field `visibleOn` as `visibleWhen`, a string `columns: '2'` as the number `2`, and a section `label` (or a field entry's `label` / `placeholder` / `helpText`) as a plain string.", + "migrationId": "ui-object-form-sections-typed", + "toMajor": 18, + "rationale": "The form reads a section's heading, collapse pair, `visibleWhen`, `columns`, `pane`, `group` and `fields` — the key set of the form view's section — and draws three kinds of field entry: a name, the form view's `{ field }` entry overriding that object field, and an inline runtime form field drawn as it stands. The page-component rows declared each section `z.unknown()`, so a misspelled key passed the component-props gate and the form drew the section without it; a form view's deprecated `visibleOn` and string `columns`, which a form view folds at parse, reached the form raw — a page block's `properties` is never parsed on the way — and were dropped. Both rows now take one section shape of their own, the stored form view unchanged: the form view's section keys plus the three entry arms, canonical spellings only, a label a plain string because the form draws it as it stands, and the form view's group-reference rule. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and the authored census, with every inline option list evaluated, found no working section to respell. Deployed metadata NOT MEASURED." }, { - "surface": "data.hookContext.session.roles", - "replacement": "(removed — gate on `session.userId` / `session.isSystem`; for PRIVILEGE ask the security service, which reads `permissions` / `positions` / posture off the execution context, ADR-0095 D3)", - "migrationId": "hook-context-session-roles-retired", - "toMajor": 17, - "rationale": "Declared on the runtime hook context, read by exactly two consumers, produced by nobody. The two readers were the approvals record lock and the delegation write guard, each opening with `session.roles?.includes('admin')`; ObjectQL's `buildSession()` builds the session field by field and has never written `roles`, and nothing else feeds a HookContext in objectstack, cloud or objectui (cloud's hook consumers read `hookContext?.session?.userId`; objectui's `roles` are the `/auth/me` user payload, a different surface; an ACTION body's `ctx.session` is a different untyped object that does carry `roles`, tracked apart and unaffected). Both branches were therefore dead on every real engine path — an authorization decision in shape only, and a second admin dialect competing with the one ADR-0090 D3 / ADR-0095 D3 sanction. An earlier fix removed both readers, returning the record lock and the delegation guard to the one permission vocabulary; this removes the declaration, per ADR-0049 enforce-or-remove. This is a RUNTIME context, not stored metadata: the engine builds a HookContext per operation and nothing persists one, so no `sys_metadata` row, example or template can carry the key and there is no source for the D2 chain to rewrite — the `openApi31` / `activationEvents` shape, one semantic TODO rather than a stack conversion. The key IS tombstoned (`HookContextSchema` is deliberately not `.strict()` — a plain delete would strip it silently, as a removed field key was measured to be, ADR-0104), so a consumer that parses a context it was handed still meets the prescription. ADR-0049." + "surface": "page `object-gantt` components — `properties.markers` (whose entries used to accept any value)", + "replacement": "a list of `{ date, label?, color? }`: `date` an ISO date or date-time string (required), `label` the text drawn against the line, `color` any CSS colour. Write a marker `title`, `text` or `name` as `label`, and a `colour` as `color`; give every marker a string `date`.", + "migrationId": "ui-object-gantt-markers-typed", + "toMajor": 18, + "rationale": "The gantt reads each marker with one shape — `date` places the line, and a date that does not parse or falls outside the drawn range draws none; `label` is drawn against it; `color` paints it, the theme's primary colour when absent — and the page-component row declared the entries `z.unknown()`, because that contract was objectui's alone. So a marker with no `date`, a numeric `date` or a misspelled member passed the component-props gate, and the chart drew no line, or drew it with no label and in the default colour. The spec now declares objectui's own authoring declaration of a marker, `{ date, label?, color? }` with `date` a string (authored metadata is JSON, which cannot carry a `Date`), closed as every element shape on that map is. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and a misspelled member has no rewrite that says which member the author meant — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED." }, { - "surface": "engine.registerHook(event, handler, { object: '' | [] | [''] }), and a scope whose `excludeObjects` cancels its `object` entirely", - "replacement": "name the object(s) — `object: 'account'` / `object: ['account', 'contact']` — or, for a global hook, `object: '*'` or no `object` key at all; for a cancelled scope, widen `object` or drop the overlapping names from `excludeObjects`", - "migrationId": "hook-register-empty-object-target-refused", - "toMajor": 17, - "rationale": "An earlier breaking fix established that an empty hook target is not \"no target\" and closed the shape at the two METADATA doors — `HookSchema.object`'s refine and `hook-binder.ts`'s `normalizeObjects`. `engine.registerHook`, the CODE door, goes through neither, so all three spellings still registered, each producing a defect the author did not write: `''` is FALSY, so the allow face was skipped entirely and the entry became a GLOBAL hook (that fix's headline failure mode — blank intent taking the broadest possible blast radius); `[]` and `['']` are truthy but admit no object name, so the entry could never fire. The later `excludeObjects` face (a hook global except for the objects it names) then brought a fourth shape reached by arithmetic rather than by one bad name: an `object` list every member of which is also excluded admits nothing, so that entry can never fire either. All four are ADR-0078 silently-inert declarations, and all four are now refused at REGISTRATION.\n\nNo mechanical rewrite exists, in either direction. The refused values carry no recoverable intent — `object: ''` could have meant `'*'` (what it actually did) or a specific object name the author forgot to fill in, and those are opposite registrations; choosing between them is a judgment the chain cannot make. Nor could the MATCHING read be changed instead: teaching the matcher that `''` is an unmatchable name would silently convert a hook firing on every object into one firing on none — the same class of defect pointing the other way, which is why the `excludeObjects` change declined to do it in passing.\n\nThis is a RUNTIME registration API, not stored metadata, so — like `hook-context-session-roles-retired` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and the ledger entry is the notification channel. One metadata surface reaches it INDIRECTLY and is the reason this is not purely a code-side note: a `record-change` flow's start node forwards `config.objectName` verbatim into `registerHook` (`RecordChangeTrigger.start`), so a flow authored with a blank `objectName` used to bind a trigger to EVERY object in the tenant. It now fails to bind instead, loudly — the automation engine's per-flow bind guard warns and the `kernel:bootstrapped` binding audit re-reports it — which is the correct end state, but it is an observable change for that flow. ADR-0078." + "surface": "page `object-grid` components — `properties.columns` (whose entries used to accept any value)", + "replacement": "the list view's own `columns`: all field-name strings, or all column entries `{ field, label?, width?, align?, hidden?, sortable?, resizable?, wrap?, type?, pinned?, summary?, prefix?, link?, action? }`. Respell a column keyed `accessorKey` / `header` or `name` as `field` / `label`; write a list as all strings or all entries, never a mix; delete a column key the entry does not declare (`editable`, `options`, `reference`, `currency`, `precision`, …) — inline editing is the grid's own `editable`, and option labels, relational metadata and number formats are the object field's.", + "migrationId": "ui-object-grid-columns-typed", + "toMajor": 18, + "rationale": "The grid reads `columns` with one shape — all field-name strings or all column entries, decided by the first entry, drawing only an entry with a string `field` and reading the column entry's own members off it — and the page-component row declared it `z.array(z.unknown())`, so any entry passed the component-props gate and the grid answered an off-shape one in silence: a column keyed `accessorKey` / `header` or `name`, or one with no `field`, drew no column, a mixed list lost every entry the first one did not match, and a key the grid never reads off a column (`editable`, `options`, `reference`) was ignored. The member was held while the grid's group headers drew a column's `options` ahead of the field's; the renderer has since retired that read and takes the labels from the object field only, so the row takes the list view's own `columns` by reference — the column entry a list view already refuses an undeclared key on. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and a refused column has no rewrite that both keeps what the grid draws today and honours what the author wrote — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED." }, { - "surface": "observability.SEMCONV.httpRequestErrorsTotal (the published metric name http_request_errors_total{method,route}, and its emission from the runtime dispatcher's per-route wrapper)", - "replacement": "the 5xx rate is `http_requests_total{status=~\"5..\"}` — the TRANSPORT emits that family through the `IHttpServer.afterResponse` seam, so it covers every inbound surface; unhandled-exception rate specifically, which is the one thing the retired counter uniquely reported, is the `errorReporter` (Sentry / Datadog / your adapter), which still fires on every 5xx throw", - "migrationId": "http-request-errors-total-retired", - "toMajor": 17, - "rationale": "ADR-0049 enforce-or-remove, on a DECLARED-not-enforced metric name. `SEMCONV` published `http_request_errors_total` as part of a stable namespace declared \"so hosts can wire alerts/dashboards against it\", but the only emitter was `@objectstack/runtime`'s `instrumentRouteHandler`, applied only by the dispatcher's own route Proxy — so the series never saw auth's `getRawApp()` mount, the REST data API via `RouteManager`, or any other inbound surface. Its two siblings in the same family were moved to the transport seam (the request counter, then the latency histogram, both through the response-observing hook the transport was given) and this one could not follow: `HttpResponseObservation` carries `{method, routePattern, status, elapsedMs}` and NO throw signal of any kind, so every transport-side shape would have counted a DIFFERENT population rather than the same one more widely. The divergence was measured in both directions — the dispatcher answers its own errors through `errorResponseBase`, which sets a status and does not re-throw, so the old counter MISSED those, while its `catch` incremented unconditionally, so a thrown 4xx WAS counted as an error. And `http_requests_total` already carries a `status` label, so a status-class error counter would be fully derivable from data the transport already publishes. Maintainer ruling 2026-08-20 (option C of four presented, over B \"move it to the transport as a status class\" and D \"keep it dispatcher-scoped and rename it\"): RETIRE. A metric NAME is a RESPONSE surface, not authorable metadata — no stack, example or template carries it, so there is no source for a D2 conversion to rewrite and no schema to tombstone; a host names the series in its own dashboard or alert file, outside this repo. That is exactly why this entry exists: for an operator whose Grafana keys on the string, the ledger is the only notification channel there is. Same disposition, and the same reason, as `runtime-httpserver-wrapper-retired` and `enhanced-api-error-field-errors-renamed`. ADR-0049 / ADR-0087." + "surface": "page `object-grid` components — `properties.exportOptions` (which used to accept any value)", + "replacement": "the export options object a list view's `exportOptions` declares: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`, with `formats` drawn from `csv`, `xlsx` and `json`, `maxRecords` a non-negative integer, and `includeHeaders` / `streaming` booleans. Where a bare format array was written, write `{ formats: [...] }` to offer the formats you listed — the grid will now offer exactly those — or `{}` to keep the csv/json default the grid has been offering. Delete `pdf` from `formats`, and any key the object does not declare; delete an `exportOptions: null` (it never enabled the menu).", + "migrationId": "ui-object-grid-export-options-closed", + "toMajor": 18, + "rationale": "The grid reads one export options block — `exportOptions.formats`, `.maxRecords`, `.includeHeaders`, `.fileNamePrefix` and `.streaming` — the block a list view declares, but the page-component row declared the key `z.unknown()`, so any value passed the component-props gate. The trap was the list view's legacy spelling: a bare format array is legal on a list view, which lifts it to `{ formats }` at parse, and was accepted on the grid, which lifts nothing — the export menu appeared, offering the csv/json default, and the author's list was dropped without a report. The row now takes the list view's export options object itself rather than its union, so a legacy spelling does not spread to a surface that never read it: a bare array is refused with the object form named, a format outside the enum is refused at its index (`pdf` with its retirement text), and a key the object does not declare is named. It is read where every page component's props are: the component-props gate reports these as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape; a bare array has no rewrite that both keeps what the grid shows today and honours what the author wrote, which is the judgment this entry leaves to the upgrader; and the authored census found nothing to respell. Population measured at the change, on origin/main f148852752: zero `object-grid` blocks authoring `exportOptions` in the examples, the package fixtures, the documentation and the published skills, against ten authored `object-grid` blocks through the same matcher (nine in TypeScript, one in a YAML documentation example) and four list-view `exportOptions` authorings as the key's control. Deployed metadata NOT MEASURED." }, { - "surface": "system.serverEvent / system.serverEventType / system.serverCapabilities / system.serverStatus (the lifecycle-event, capability-report and status vocabulary of system/http-server.zod.ts — 4 defs, 8 exported names)", - "replacement": "(removed — there is no replacement key, because there was never a key. Server lifecycle is the transport plugin's own start/stop seam; per-request and per-server observability is `system/metrics.zod.ts` and `system/logging.zod.ts` (plus `OS_SERVER_TIMING` for timings), and liveness is the `/health` endpoint. What a transport plugin can DO it states by implementing the kernel plugin contract — the seams it registers are the capability statement, and a self-described capability record can only disagree with them. Server-level configuration that IS authorable lives on `defineStack({ server })` / `StackServerConfigSchema`, which is unaffected)", - "migrationId": "http-server-runtime-vocabulary-retired", - "toMajor": 17, - "rationale": "The second and final ADR-0049 pass over `system/http-server.zod.ts`. The first removed the CONFIG half (`HttpServerConfigSchema`, nine keys, zero readers, zero authoring entry); this removes the RUNTIME half — a 7-member lifecycle event union with a timestamped envelope, an eight-boolean capability report, and a five-state status record with connection and request counters. Nothing ever emitted, consumed or parsed any of them. This card was HELD for four days rather than queued, on a specific and legitimate doubt: a response/capability vocabulary can be a REFERENCE surface for host implementers, so \"zero consumers in this repo\" is weaker evidence for one of those than for an authorable key (the CSS-variable rebuttal). The hold was lifted by measuring the reference reader itself rather than by re-running the same grep: `plugin-hono-server`, the one in-tree host implementation, neither implements nor reports any of the three — it names no capability record, no status shape and no event union, and what it registers is routes and middleware through the kernel plugin contract. A declaration-site grep put every declaration in this one file, a quoted-name sweep across objectstack and objectui found no reader outside it, and the control passed in the SAME run: `MiddlewareConfig`, declared twelve lines away, resolves to `packages/runtime/src/middleware.ts`. So the sweep could see a reader in this file when there was one. With no carrier key there is nothing to tombstone, and with no author there is no source or `sys_metadata` row for a D2 conversion to rewrite: RETIRED_DEFS_BY_MAJOR plus this entry are the declaration — route 3, the same shape as the config half's removal in this very file and the earlier removals of the dynamic plugin-loading family, the `ui/` interaction configs and the widget / i18n shapes. If host-implementer conformance becomes a real requirement it returns through the ENFORCE route: an adapter contract with a checker behind it, vocabulary second. ADR-0049." + "surface": "page `object-grid` components — `properties.fields`, `.selection`, `.selectable`, `.rowActions`, `.bulkActions` and `.batchActions`; page `object-kanban` components — `properties.columns`; page `object-calendar` components — `properties.calendar` (which used to accept any value)", + "replacement": "the shape each block reads, the list view's own where it has one: `object-grid` `fields` field-name strings; `selection` `{ type }` with `none` / `single` / `multiple`; `selectable` `true`, `false`, `'single'` or `'multiple'`; `rowActions`, `bulkActions` and `batchActions` action-name strings. `object-kanban` `columns` all lanes `{ id, title, cards?, limit?, className?, collapsed? }` or all bare value strings (never mixed), a lane `id` a string. `object-calendar` `calendar` `{ startDateField, endDateField?, titleField?, colorField?, allDayField? }`. Move an object entry of `fields` to `columns`; move a `{ name }` entry of `bulkActions` to `bulkActionDefs` or write the bare name; style a lane with `className` instead of `color`; rename `dateField` / `endField` to `startDateField` / `endDateField`.", + "migrationId": "ui-object-grid-kanban-calendar-list-members-typed", + "toMajor": 18, + "rationale": "Each renderer reads these members with one shape, and the page-component rows declared them `z.unknown()`, so any value passed the component-props gate and the block answered an off-shape one with a silent default: an object entry of `fields` named no field; a `{ name }` entry of `bulkActions` was skipped; a kanban lane list mixing objects and strings drew a blank lane and swept its records into the trailing lane; and a calendar block without `startDateField` placed no event. The rows now take the list view's own `selection`, `rowActions`, `bulkActions` (for `batchActions` too, the spelling the grid reads first) and `calendar` members by reference, and the measured shape for the grid's `fields` and `selectable` and the kanban lane, so one value is judged the same way on every door that carries it. The grid's `columns` is not narrowed: its group-header labels read an authored column's `options`, which the list view's column entry does not declare, so it stays open until that read is ruled. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and an off-shape value has no rewrite that both keeps what the block shows today and honours what the author wrote — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED." }, { - "surface": "api.ImportRequest runAutomations — the declared default of the key on BOTH import bodies, POST /api/v1/data/:object/import (ImportRequest) and its async twin POST /api/v1/data/:object/import/jobs (CreateImportJobRequest, which IS the same schema object). It was declared default(false) and described as \"off by default for bulk\"; it is now default(true), which is what the server has always done", - "replacement": "an explicit runAutomations: false on any import request that is meant to load rows without firing triggers/hooks. That spelling is unchanged and has always been the only one the server read — what changes is that omitting the key now DECLARES what it already DID. Callers who want automations on need write nothing", - "migrationId": "import-run-automations-declared-default-corrected", - "toMajor": 17, - "rationale": "A DECLARATION corrected to match a runtime that did not move — the inverse of a behaviour flip, and registered here for the reason protocol 12's `rest-requireauth-default-flip` and this major's `action-descriptor-resume-authority-default-flip` are: whether a given import was meant to fire triggers is a judgment no transform can make, so the prescription is a TODO rather than a rewrite. The server decides in import-prepare.ts with `body?.runAutomations !== false`, i.e. an omitted flag runs automations, and has since the flag was first honoured — automations always ran on import historically (the engine ignored the flag entirely before then), so opt-out was made the explicit act, matching platform convention. The schema said the opposite in both machine-readable and human-readable form, and both SHIPPED: `.default(false)` in `@objectstack/spec`'s JSON Schema, and the describe prose in the published reference tables for both defs. ⚠️ Nothing in this repo reconciled the two and NO deployed caller changes behaviour: no request path parses an import body through this schema — the route reads the raw body, and the sole reference to `CreateImportJobRequestSchema` is the declarative `ImportJobApiContracts` catalog entry, a declaration and not a parse. That is exactly why this needed a ruling rather than a docs edit: the divergence was unobservable in-tree and observable only to a consumer OUTSIDE it. A client or SDK that validated its request through the published schema materialised `runAutomations: false` from the declared default and sent it explicitly, and the server honoured it — so the same request body produced opposite behaviour depending on whether the caller validated before sending, with the validating caller silently losing its triggers. Nothing rejected it, nothing warned, and the reference page told an author the wrong thing in the other direction. There is deliberately NO schema tombstone and no D2 conversion: no key is removed, and an HTTP request body is neither authored nor persisted — the same disposition `notification-list-cursor-retired` takes for the sibling default on this major, and `batch-options-validate-only-retired` before it. The declared move itself is recorded mechanically, per key, in DEFAULT_CHANGES_BY_MAJOR[17] — the per-key default fingerprint added once a flipped default was found invisible to every gate — whose `from`/`to` fingerprints are re-derived on every build. Maintainer ruling 2026-08-09, disposition A: the spec follows the runtime. ADR-0049 / ADR-0078." + "surface": "`object-grid` page-component page sizes (`ComponentPropsMap['object-grid']` — `pagination.pageSize`, each `pagination.pageSizeOptions[]` entry, and the flat `pageSize` shorthand) — zero, negative and non-integer values (`pagination: { pageSize: 0 }`, `pageSize: 25.5`)", + "replacement": "a positive integer, or no declaration at all. A page size of `0` has no defined meaning on this surface and never had one: delete the key to take the renderer's own default, or write the page size that was meant (`pageSize: 0` authored to mean \"no paging\" is `showPagination: false` with no `pagination` bag, since the bag's PRESENCE is what enables paging)", + "migrationId": "ui-object-grid-page-size-positive-integer-refused", + "toMajor": 18, + "rationale": "This door still carried the shape it was given when the `object-*` blocks first got `ComponentPropsMap` rows measured from their read points — `pagination: z.unknown()` and `pageSize: z.number()` — after the view arm converged on `z.number().int().positive()`. So the SAME authored member carried two accept sets and renderers read the looser one: `PaginationConfigSchema` (`view.zod.ts`) refuses `pageSize: 0` and pins that refusal by name, and every other `pageSize` the package declares is bounded with its own throwing pin (`kernel/metadata-plugin.zod.ts`, `marketplace/marketplace.zod.ts`) — the component arm was the only one that accepted `0`. The value is LIVE: an objectui grid measurement found that an authored `pagination.pageSize: 0` reached `ObjectGrid`, went out on the wire as `$top: 0` and rendered ZERO ROWS, with no grouping needed to trigger it, and it reached the renderer through this arm. objectui's grid plugin repaired the consumer half — it now refuses a non-positive page size at all three read points (one resolver, fail-soft, one loud diagnostic); this is the declaration half, and it is not a prerequisite for that repair. ⚠️ The `pagination` bag itself stays OPEN (`z.looseObject`): only the two members whose value is a page size are bounded, and sibling keys parse and pass through exactly as before. `PaginationConfigSchema` on the view arm is a closed shape and is unchanged by this entry." }, { - "surface": "job.retryPolicy.maxRetries (> 10) / job.retryPolicy.backoffMultiplier (< 1)", - "replacement": "maxRetries <= 10, and backoffMultiplier >= 1", - "migrationId": "job-retry-policy-constraints-tightened", - "toMajor": 17, - "rationale": "The RetryPolicy converged onto one declaration from its automation and system copies keeps the automation side's bounds, which the job side never had: `maxRetries` is capped at 10 and `backoffMultiplier` floored at 1. Neither has a lossless rewrite. Clamping `maxRetries: 20` to 10 would halve a retry budget its author chose, and a `backoffMultiplier` below 1 describes a delay that SHRINKS on each attempt — retrying a failing dependency ever faster, which is the opposite of backoff and was never a shape the engine meant to offer. Both now fail at parse time with the bound named, rather than being silently reinterpreted. Choosing the replacement count (or accepting the cap) is the author's call." + "surface": "page `object-grid` components — `properties.rowHeight`, `.rowColor`, `.navigation`, `.conditionalFormatting`, `.bulkActionDefs`, `.aggregations` and `.operations` (which used to accept any value)", + "replacement": "the shape the grid reads, the list view's own where it has one: `rowHeight` one of `compact` / `short` / `medium` / `tall` / `extra_tall`; `rowColor` `{ field, colors }`; `navigation` `{ mode?, size?, openNewTab?, preventNavigation? }`; `conditionalFormatting` `[{ condition, style }]` with a CEL `condition` and a CSS `style` map; `bulkActionDefs` the list view's bulk-action defs; `aggregations` `[{ field, type }]` with `type` one of `count`, `sum`, `avg`, `min`, `max`, `count_distinct`; `operations` `{ create?, update?, delete?, export? }` booleans. Rewrite an objectui-native formatting rule `{ field, operator, value, backgroundColor }` as `{ condition: \"record.FIELD == VALUE\", style: { backgroundColor } }`; delete `operations.read` and `operations.import`, which nothing reads.", + "migrationId": "ui-object-grid-row-members-typed", + "toMajor": 18, + "rationale": "The grid reads each of these members with one shape, and the page-component row declared them `z.unknown()`, so any value passed the component-props gate and the grid answered an off-shape one with a silent default: an off-preset `rowHeight` such as `42` rendered as `compact`, a `rowColor` of the wrong shape coloured no row, a `navigation` written as a bare mode string opened the record page whatever it named, an aggregation with an unknown function drew a zero nothing computed or no number at all, and an `operations` toggle nothing reads toggled nothing. The row now takes the list view's own schemas for the five members a list view declares, and the measured shape for `aggregations` and `operations`, so one value is judged the same way on both doors. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and an off-shape value has no rewrite that both keeps what the grid shows today and honours what the author wrote — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED." }, { - "surface": "api.listNotifications cursor — the key on BOTH halves of GET /api/v1/notifications (ListNotificationsRequestSchema and ListNotificationsResponseSchema) and the cursor argument of the client SDK call client.notifications.list(). The same entry covers the limit default: the request schema no longer declares default(20)", - "replacement": "a larger `limit` — the route answers the newest N notifications and has no page 2. There is no replacement for `cursor`, deliberately: nothing ever minted one, so no caller holds a value to carry over. Callers that looped on it were re-reading the first window and should read one window sized to what they display (the Console bell polls exactly this way). For the removed `limit` default, send the number you want explicitly if you were relying on 20 — omitting it takes the server window, which is 50 on the platform inbox and clamped into 1..200, and has been since before the declaration existed", - "migrationId": "notification-list-cursor-retired", - "toMajor": 17, - "rationale": "One capability, both halves, never half-deleted (maintainer ruling 2026-08-07, Option A, ruled jointly with the repair that made `unreadCount` really count the whole inbox). `cursor` was declared on the request and on the response and honoured on neither: the dispatcher domain reads `read` / `type` / `limit` and nothing else, and no emit site has ever written the response key. It was worse than inert because it had a shipped PRODUCER — the SDK appended it to the query string — so a caller paginating by the published contract looped on page 1 forever, with no error and no 400. Measured over a real boot with 60 unread before the removal: page2 === page1, both parsing green against the response schema, which is why no conformance gate could see it. This is `data.query.cursor` (`query-cursor-retired`) one layer up, with the same verdict for the same reason, down to deleting the SDK producer alongside the key. A first-class inbox cursor, if one is ever designed, will be a response-minted opaque token — a different API — so keeping this one preserved a wrong design rather than a roadmap. The `limit` default goes with it because the FICTION WAS THE MECHANISM, not the number: no request path parses a query string through this schema (the fix for request bodies never checked against their declared schemas wired the catalog's requestSchema to the real entry for BODIES only), so `.default(20)` never stamped anything onto anything, and the server has always applied its own 50. Re-spelling 20 as 50 — the other arm the ruling allowed — would have kept a declaration that does not execute and merely made it coincide with the implementation until someone moved the clamp; `.optional()` plus prose is true about both the schema and the server. No constraint (`.int()` / `.max(200)`) is declared either, because the service CLAMPS an out-of-range limit rather than refusing it, and declaring a rejection the wire does not perform is the same defect mirrored. Route 2, and the split is worth stating exactly because the two halves of the bookkeeping go different ways. There IS a tombstone: both schemas are non-strict, so a bare deletion would have made Zod SILENTLY STRIP whatever a caller kept sending — a clean parse and a parameter that never takes effect, which is this issue's own defect re-created one layer down (the silent strip measured when a field key pruned from a non-strict schema still parsed and simply vanished, ADR-0104). So `cursor` is `retiredKey()` on both halves, typed `never` for tsc and raising the prescription at any parse, and both keys are registered in RETIRED_KEYS_BY_MAJOR[17]. There is NO D2 conversion: a conversion rewrites an authored source or a stored `sys_metadata` row, and these two shapes are HTTP-only — nobody authors a `ListNotificationsRequest` and nothing persists one. Request AND response shapes: two semantic TODOs for API callers, no stack conversion — the same disposition `BatchOptions.validateOnly` (a declared dry-run that wrote for real) and the `AnalyticsQueryRequest` envelope keys already take in this major. The `limit` default is declared separately and mechanically, in DEFAULT_CHANGES_BY_MAJOR[17] (the table that closed the blind spot where a default or constraint change on an authorable key was recorded by no gate), whose `from`/`to` fingerprints are re-derived on every build. ADR-0049 / ADR-0078." + "surface": "page `object-kanban` components — `properties.conditionalFormatting` (which used to accept any value)", + "replacement": "the list view's own rules, `[{ condition, style }]`: a non-blank CEL `condition` over the card's `record.*` and a CSS `style` map of string values. Rewrite a native rule `{ field, operator, value, backgroundColor }` as `{ condition: \"record.FIELD == VALUE\", style: { backgroundColor } }`, an `expression` as `condition`, and move a colour written beside `condition` into `style`.", + "migrationId": "ui-object-kanban-conditional-formatting-typed", + "toMajor": 18, + "rationale": "The board reads `conditionalFormatting` as an ordered list of `{ condition, style }` rules, through the evaluator the grid's rows use, and paints a card with the `style` of the first rule whose condition holds; objectui declares exactly the list view's rule as the member's only dialect. The page-component row declared it `z.unknown()`, so `42`, a bare string or a rule with no `style` passed the component-props gate and the board painted no card for it. The row now takes the list view's own member, by reference, as `object-grid` does, so one rule is judged the same way on every door. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and the authored census found no working rule to respell. Deployed metadata NOT MEASURED." }, { - "surface": "protocol.deletePackage({ packageId }) with no `organizationId` (and its transport, DELETE /api/v1/packages/:id)", - "replacement": "explicit `allTenants: true` for a cross-tenant uninstall, or an `organizationId` to scope it", - "migrationId": "package-uninstall-explicit-all-tenants", - "toMajor": 17, - "rationale": "An uninstall that named no organization matched EVERY organization's rows — measured at 5 of 5 deleted, including a foreign org's, while uninstall's orphaned-row defect was being repaired. That width was never chosen; it fell out of a missing argument, and the two transports of the same route disagreed because of it. In protocol 17 the call is REFUSED instead: neither `organizationId` nor `allTenants: true` answers 400 `TENANT_SCOPE_REQUIRED` and deletes nothing, as does supplying both (they are contradictory, not redundant). Whether a given caller meant \"this tenant\" or \"every tenant\" is an intent no transform can recover: `resolveActiveOrganizationId` is catch-wrapped, so an accidental org-less call and a deliberate environment-wide one are byte-identical at the call site — which is the whole reason the parameter had to become explicit rather than conventional. Nothing in authored metadata spells this: it is a runtime call-site contract, so it is one semantic TODO for operators and API callers rather than a stack conversion — the same disposition `rest-requireauth-default-flip` (protocol 12) takes for its own default flip." + "surface": "page `object-map`, `object-gantt` and `object-tree` components — `properties.navigation` (which used to accept any value)", + "replacement": "the list view's navigation block `{ mode?, size?, openNewTab?, preventNavigation? }`, `mode` one of `page`, `drawer`, `modal`, `split`, `popover`, `new_window`, `none` — the block `object-grid`, `object-kanban`, `object-calendar` and `object-timeline` already take. Rewrite a bare mode string such as `navigation: 'drawer'` as `navigation: { mode: 'drawer' }`.", + "migrationId": "ui-object-map-gantt-tree-navigation-typed", + "toMajor": 18, + "rationale": "Each of the three renderers hands `navigation` to the shared navigation hook, which reads `navigation.mode`, falls back to `page` when it finds none, and types its mode union as the list view's `NavigationConfigSchema`. The rows declared the member `z.unknown()`, so any value passed the component-props gate and an off-shape one was answered with a silent default: `navigation: 42` and a bare mode string such as `'drawer'` both opened the record page, whatever they named. The three rows now take the list view's schema by reference, so one value is judged the same way on every door that carries it. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and an off-shape value has no rewrite that both keeps what the block shows today (the record page) and honours what the author wrote — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED." }, { - "surface": "kernel.dynamicLoadRequest.activationEvents / studio.studioPluginManifest.activationEvents", - "replacement": "(removed — delete the key. Every plugin activates immediately on load/registration, which is the only behaviour that has ever existed; `activate()` still runs at registration time. Lazy activation, if built, returns via the enforce route of ADR-0049 through a new ADR, with a vocabulary its executor actually honours)", - "migrationId": "plugin-activation-events-retired", - "toMajor": 17, - "rationale": "Both `activationEvents` keys — and the `ActivationEventSchema` trigger vocabulary they embedded (`onCommand` / `onRoute` / … / `onView`, once the kernel and studio copies had converged on the kernel's structured `{ type, pattern }` shape) — promised lazy plugin activation (\"plugins remain dormant until an activation event fires\") that no runtime in objectstack, cloud, cloud-v1 or objectui ever implemented: nothing anywhere read the key, every plugin activates immediately, and cloud-v1's own ROADMAP recorded lazy activation as unimplemented (planned v0.4.0). That is the ADR-0049 false-compliance shape in the semantically-lying direction: an author writing `activationEvents: [{ type: 'onMetadataType', pattern: 'flow' }]` expected deferral and got eager activation with a clean parse. Neither parent shape is stored metadata — `StudioPluginManifest` is TS configuration parsed by `defineStudioPlugin` (a root schema, never part of a stack tree) and `DynamicLoadRequest` is a runtime request shape with no caller — so no `sys_metadata` row can carry the key and there is no source for the D2 chain to rewrite; this entry is the D3 record. The kernel key is tombstoned via `retiredKey()` (its schema is not `.strict()`; a plain delete would strip an authored value silently), the studio key is rejected by the strict manifest parse with a guidance prescription (as are its former VS Code-flavoured aliases `activation` / `events` / `onActivate`), and the orphaned `ActivationEventSchema` / `ActivationEvent` exports are removed from `./kernel` and `./studio` with the keys (the lesson of the unwired plugin sandboxing / integrity / approval config removed before this: an exported schema with no consumer is read as a capability). Both keys took ADR-0049's REMOVE answer, not ENFORCE, while protocol 17 was still unreleased. SUPERSEDED ON THE KERNEL SIDE by the maintainer's REMOVE ruling on the rest of the plugin-runtime family (same unreleased major): the whole `DynamicLoadRequest` shape — and the rest of the plugin-runtime family with it — was removed, which took this key's `retiredKey()` tombstone with it. That is strictly stronger than the tombstone, not weaker: there is no longer a `DynamicLoadRequest` to author the key INTO, so the prescription an author needs is no longer \"delete this key\" but \"this request shape does not exist\" (see `plugin-runtime-family-retired`). The studio half of this entry is unaffected and still enforced by the strict manifest parse." + "surface": "page `object-master-detail-form` components — `properties.details[]` (each detail entry, which used to accept any value) and `properties.details[].columns[]` (its inline grid columns), including `scale` on a column that declares no `type` and whose `name` is a `currency` field of the entry's `childObject`", + "replacement": "each entry is `{ childObject, relationshipField?, columns?, formFields?, inlineMode?, amountField?, totalField?, title?, minRows?, maxRows?, addLabel? }` — the keys the renderer reads — with `inlineMode` one of `grid` / `form`. Each column is the strict, name-keyed inline grid column a relationship field's `inlineColumns` takes — `{ name, label?, type?, … }`, where `{ name }` alone hydrates the rest from the child object's field. Write `childObject` on every entry; write `name` where a column said `field` (or `fieldName`, `key`) or was a bare field-name string; delete `scale` from a column that renders as a currency column, whether it declares `type: 'currency'` or takes it from a `currency` child field — nothing replaces it, the currency's ISO 4217 minor unit decides; delete any key neither shape declares.", + "migrationId": "ui-object-master-detail-form-details-closed", + "toMajor": 18, + "rationale": "The block draws one inline grid per detail entry, hydrating an authored column list with the same rule and into the same grid as the other two carriers of the inline grid column, but nothing judged its entries: a key the renderer does not read was ignored in silence, and a column carrying a key the grid does not read, or `scale` on a currency column — refused on the other carriers under the maintainer's rulings of 2026-09-23 (option B, `scale` retired from the currency type) and 2026-09-24 (option 乙 — a currency's ISO 4217 minor unit decides its display) — went through `objectstack validate` green. The entry is now a strict shape and its `columns` references the column schema, so every rule that schema holds applies here too, with its own prescription. The entry half is read where every page component's props are: the component-props gate reports a failing entry or column as an advisory `component-props-unknown-key` / `component-props-invalid` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. The identity-only half is `defineStack`'s cross-reference check, which already judged the other two carriers: it now reaches the block wherever a page carries it and refuses an identity-only column over a `currency` child field that carries `scale`, with the column schema's own message; reach: the child object must be declared in the same stack, and a column the column schema refuses on its own is left to the component-props gate. No conversion is registered: nothing on the load path refuses the shape, and the authored census found nothing to respell. Population measured at the change, on origin/main ebdb6f2aca: one authored block in the examples (the showcase project workspace, one entry `{ title, childObject, addLabel }`, no columns), one documentation example whose three columns were bare field-name strings (rewritten as `{ name }` columns in the same change), and zero `field`-keyed detail columns, against one authored `inlineColumns` block as the control. Deployed metadata NOT MEASURED." }, { - "surface": "manifest.loading (the whole block: strategy / preload / codeSplitting / dynamicImport / initialization / dependencyResolution / hotReload / caching / sandboxing / monitoring)", - "replacement": "nothing to re-declare — delete the key. Plugins are composed at boot: `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder` in `packages/core/src/plugin-order.ts`). For the isolation `loading.sandboxing` appeared to configure, note that the plugin trust tier (`manifest.runtime`, ADR-0025 §3.6) does not supply it either: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the manifest permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation", - "migrationId": "plugin-manifest-loading-retired", - "toMajor": 17, - "rationale": "ADR-0049 enforce-or-remove; the maintainer ruled REMOVE on 2026-08-04, on condition that a bare-name sweep of cloud and objectui came back clean first. The block declared a complete plugin loading policy and NOTHING read it. A bare-name scan of all three repos — objectstack, cloud (measured 2026-08-09) and objectui (measured at pickup), each with a control probe proving the scan saw the tree — put every hit inside `packages/spec` itself: this module's own declaration, its own unit tests, the `Manifest.loading` embed and the generated artifacts. `manifest.loading.*` had zero readers in `packages/core`, `packages/runtime` and `packages/metadata`. So the key parsed, entered the manifest, and changed nothing — an exported schema with no consumer, read as a capability, at the scale of a whole block. What made it outrank ordinary inert-key cleanup is `sandboxing`: it declared process / vm / iframe / web-worker isolation, IPC transports and an `allowedServices` ACL, so an AI author (ADR-0033) reading that vocabulary concluded the platform isolates plugins, wrote the config, and received a clean parse and zero isolation. An inert security control is worse than an absent one because it is believed. Hot reload was additionally a TWO-SOURCE defect: the docs pointed at this dead `PluginHotReloadSchema` while the only implementation body, `HotReloadManager` (`packages/core/src/hot-reload.ts`), reads a different vocabulary — `HotReloadConfigSchema` in `plugin-lifecycle-advanced.zod.ts`. Ruling §2 converges on the surviving side: that schema is KEPT as the starting point for a future enforce decision (it has an implementation body but no runtime composes it yet), and enforcing it is deliberately a separate decision, not this retirement. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections. A package manifest is neither — `PLURAL_TO_SINGULAR` has no `packages` / `plugins` entry, so a manifest is not a stack collection member and a stored manifest row passes that seam through unchanged. A conversion would be a transform with no seam that ever runs." + "surface": "page `object-metric` components — `properties.aggregate` and `.trend` (which used to accept any value)", + "replacement": "the shape the tile reads: `aggregate` `{ field?, function, groupBy? }`, with `function` one of the engine's `count`, `sum`, `avg`, `min`, `max` or `count_distinct`, a `field` for every function but `count`, and `groupBy` the chart aggregate's own union — a field name or a `{ field, dateGranularity?, alias? }` date-bucket node — here optional; `trend` `{ value, label?, direction? }`, with `value` a number, `label` a string or an inline locale map and `direction` `up`, `down` or `neutral`. Write a string `aggregate` as an object (`'count'` → `{ function: 'count' }`); move `dateGranularity` inside `groupBy`; write a bare trend direction as `{ value, direction }`.", + "migrationId": "ui-object-metric-aggregate-trend-typed", + "toMajor": 18, + "rationale": "The tile reads these members with one shape, and the page-component row declared them `z.unknown()`, so any value passed the component-props gate and the tile answered an off-shape one in silence: a string `aggregate` or a function the engine does not have asked the server for a measure it does not have, so the tile showed an error or, on the client-side fallback, a sum it was not asked for; `groupby` for `groupBy` drew one ungrouped number; and a `trend` with no `value` painted a lone `%`, with a misspelled member or direction simply not drawn. The row now takes the query AST's own aggregation functions — the six the tile forwards to the engine — and the chart aggregate's `groupBy` union by reference, and the badge's measured shape for `trend`. The aggregate is not the chart's whole: the chart requires `groupBy` and five functions, while a metric paints one number over every row and draws a `count_distinct` wherever the analytics service answers it. `drillDown` and `compareTo` stay open: the chart's drill-down declares a `filter` the tile never reads and refuses a `report` it draws, and the dashboard widget's comparison declares a `dimension` this path never reads, so each waits on a ruling between the reference and the read. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and an off-shape value has no rewrite that both keeps what the tile shows today and honours what the author wrote — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED." }, { - "surface": "kernel.dynamicLoadRequest / kernel.dynamicUnloadRequest / kernel.dynamicPluginResult / kernel.pluginSource / kernel.dynamicPluginOperation", - "replacement": "(removed — there is no replacement shape, because there is no operation to describe. Plugins are composed at boot: `defineStack` registers them and the kernel runs register → init → start; the set is fixed until the process restarts. Delete the import and the value. Runtime plugin loading, if it is ever built, returns via the enforce route of ADR-0049 through a new ADR — loader first, vocabulary second)", - "migrationId": "plugin-runtime-family-retired", - "toMajor": 17, - "rationale": "The five schemas declared the \"Dynamic Loading\" capability — runtime load / unload / reload of plugins without a kernel restart, with sandboxing, integrity hashes, drain strategies and dependent-cascade policy — and NOTHING implemented it. A bare-name scan of objectstack, cloud and objectui found zero references outside this package's own declaration, its unit tests and the generated artifacts: no runtime ever received a `DynamicLoadRequest`, performed a load/unload, or produced a `DynamicPluginResult`. That is the ADR-0049 false-compliance shape at its most inviting to an AI author (ADR-0033), who reads `DynamicLoadRequestSchema` in the published IDE bundle as proof the platform hot-loads plugins and constructs a request that parses clean and is received by nobody (an exported schema with no consumer is read as a capability). The earlier removal of this module's discovery/sandbox config island — plugin sandboxing, integrity and approval settings that nothing read — left these five in place explicitly: \"operation contracts, not security promises; the enforce-or-remove call on them is a design decision rather than a correction\" — but that suspension lived only in a changeset paragraph with no issue carrying it. The maintainer's ruling of 2026-08-03 is that decision, answered REMOVE: hot loading is a real future capability, but nothing is being built and nothing pulls it, and when it is built its vocabulary enters the schema with the implementation. `experimental` was considered and rejected: it is only `.describe()` prose and cannot stop an import, the weakest of the three ADR-0049 channels. None of the five is stored metadata — they are root request/result payload shapes embedded in no parent schema and parsed against no metadata document — so no `sys_metadata` row can carry one and there is no source for the D2 chain to rewrite; this entry is the D3 record. The removal also subsumes the kernel half of `plugin-activation-events-retired`: that tombstone goes with the shape that carried it. ADR-0049." + "surface": "page `object-metric` components — `properties.compareTo` (which used to accept any value)", + "replacement": "the shape the tile reads: `{ kind }`, with `kind` the dashboard widget comparison's own vocabulary, `previousPeriod` or `previousYear`. Write a bare kind string as an object (`'previousYear'` → `{ kind: 'previousYear' }`), and delete a `dimension`: the tile shifts the date macros in its own `filter`, so state the window there.", + "migrationId": "ui-object-metric-compare-to-typed", + "toMajor": 18, + "rationale": "The tile reads `compareTo` with one shape — `kind` alone, dispatching on `previousYear` and treating every other value as `previousPeriod` — and the page-component row declared it `z.unknown()`, so any value passed the component-props gate and the tile answered an off-shape one in silence: a bare `'previousYear'` or a kind outside the two compared against the previous period, and a `dimension` was carried and never read, because this inline tile shifts the date macros in its own `filter` while only a dashboard widget's dataset path hands `dimension` to the analytics executor. The row now takes `{ kind }`, with `kind` the dashboard widget comparison's own member by reference, and refuses `dimension` by name with that prescription rather than accepting a key the tile ignores. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and a `dimension` has no rewrite that keeps the window the author meant — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED." }, { - "surface": "sys_position.permissions — the \"JSON-serialized array of permission strings\" textarea column left the platform position table declared by plugin-security (packages/plugins/plugin-security/src/objects/sys-position.object.ts), together with the clone_position copy entry that carried it between rows", - "replacement": "nothing on this table — delete the key from any authored `sys_position` seed row (stack `data` entries) or data-door write that still carries it. There are no direct position-level permission strings anywhere on the platform: capability reaches a position ONLY through permission-set bindings (`sys_position_permission_set` rows, created in Setup or by an app's kernel:ready binder) and is resolved from the position `name` at request time. A value that was recording intent as documentation belongs in `description`, which remains declared", - "migrationId": "position-permissions-column-retired", - "toMajor": 17, - "rationale": "Maintainer ruling 2026-08-20 on the finding that nothing writes or reads this column, ADR-0049 enforce-or-remove: REMOVE. The object-scoped census (all sys_position-naming files, with same-object positive controls resolving `active` / `delegatable` / `is_default` / `name` to real readers) measured the column at zero on both sides: the only row writers — the builtin and declared position bootstrappers — set label / description / managed_by / active / is_default, and position→grant resolution consults `sys_position_permission_set` rows plus the position `name`, never this column. Its only in-repo reference was the clone_position action copying it between rows — a copy of a value nothing writes. objectui was searched under the same discipline (evidenceScope closure): no console surface names the column — the position pickers and Setup views read name / label / id only, so a designer preview consumer does not exist either. That left a declared free-text grant catalogue on a security object that no runtime enforced: an author — human or AI — who filled it believed they granted permission strings directly on the position, and nothing refused or honoured the value. This is a platform-object COLUMN retirement, not a spec-key retirement, so the bookkeeping follows the ups-delegated-from-column-retired shape: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — PositionSchema never declared `permissions`, and the surface ratchets are expected byte-identical), no liveness-ledger row is added (the ledger walks PositionSchema's shape, which never carried the key — a row would be an orphan), and the disposition is a SEMANTIC entry rather than a D2 conversion: no conversion in the chain rewrites seed rows today and the measured author base is zero, while the loud channel already exists at runtime — the engine schema preflight refuses an undeclared field with 400 INVALID_FIELD before the driver or any hook runs — so this entry carries the prescription and the refusal carries the enforcement. The live-authoring half is the PositionSchema strict-parse guidance for `permissions`, which names the binding table in the rejection. ⚠️ Existing physical columns are deliberately untouched: schema sync is additive (ADR-0045), so a deployed database keeps the column; the platform stops declaring, projecting or accepting it. Zero producers means no rows are expected to carry a value; no backfill or destructive DDL is required or wanted. If position-level direct grants ever become a real need, the column is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce or do not declare." + "surface": "page `object-metric` components — `properties.drillDown.report` (which used to accept any value)", + "replacement": "a report definition, the same shape as `reports[]` (`ReportSchema`): `{ name, label, dataset, values, … }`, or a `joined` report whose every block binds a `dataset`. Write a bare report name, a `{ name }` reference or the retired `objectName` / `columns` form as the dataset-bound report itself.", + "migrationId": "ui-object-metric-drill-down-report-typed", + "toMajor": 18, + "rationale": "The metric tile hands `drillDown.report` to the shared drill drawer, which draws it as a report — with the metric's filter joined into the report's own `runtimeFilter` — when it is dataset-bound (a non-empty `dataset`, or a `joined` report with a block that binds one), and lists the records for any other value. The page-component row declared it `z.unknown()`, so a report with no `dataset`, a misspelled report key, a bare report name or a `{ name }` reference passed the component-props gate, and the drawer quietly listed the records instead. The row now takes `ReportSchema` by reference — the declaration objectui already names for the member — and, since a joined report refuses a block that binds no `dataset`, every report it admits is one the drawer draws. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and the authored census found no drawn report to respell. Deployed metadata NOT MEASURED." }, { - "surface": "data.query.aggregations[].function ('array_agg' / 'string_agg')", - "replacement": "an ordinary `fields` query, shaped in the caller — or a stored field that materialises the roll-up. For a deduplicated COUNT the live spelling is unchanged: `count_distinct` stays declared", - "migrationId": "query-array-string-agg-retired", - "toMajor": 17, - "rationale": "The stored half of this retirement is a conversion (`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. `QueryAST` is never stored in stack metadata — it is the client SDK builder's output and the `POST /data/:object/query` body — so there is no source for the chain to rewrite and callers move their own queries. Both values were declared-but-unlowered on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller following the schema against a SQL datasource got a refusal, not an array. They did run on `driver-mongodb` and on the engine's in-memory fallback, which is what makes this the one narrowing in the batch that removes reachable behaviour: an aggregation that worked on one backend and failed on another is exactly the unpredictability the ruling ended, and the maintainer's 2026-08-05 investment freeze on driver-memory and driver-mongodb had both of those backends frozen at the time (that freeze was lifted on 2026-08-11). `count_distinct` was deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049's enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049." + "surface": "page `object-metric` components — `properties.drillDown` (which used to accept any value)", + "replacement": "the shape the tile reads: `{ enabled?, title?, target?, columns?, maxRows?, report? }`, the first five the chart drill-down's own members — `enabled` a boolean, `title` a string, `target` `drawer`, `dialog` or `navigate`, `columns` field names, `maxRows` a positive whole number — and `report` still open. Delete a drill `filter` and scope the metric with its own `filter`, one level up; delete a `mode`, since a metric always lists the records behind its number.", + "migrationId": "ui-object-metric-drill-down-typed", + "toMajor": 18, + "rationale": "The tile reads `drillDown` with one shape — `enabled`, `title`, `target`, `columns`, `maxRows` and `report`, scoping the drilled list by the metric's own `filter` — and the page-component row declared it `z.unknown()`, so any value passed the component-props gate and the tile answered an off-shape one in silence: a drill `filter` or a `mode` was carried and never read, a misspelled member was simply not applied, and a non-numeric page size reached the drilled list. The row now takes the five list members the chart drill-down declares, by reference, and refuses `filter` and `mode` by name: a metric tile has no click event for a drill filter to resolve against, and no row for `mode` to open as a record. The chart's shape is not taken whole, because it declares `filter`. The drill `report` stays open: the tile draws a dataset-bound report through the shared drawer, but no spec drill shape declares a `report` member yet. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and a drill `filter` has no rewrite that keeps the scope the author meant — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED." }, { - "surface": "data.query.cursor", - "replacement": "a `where` predicate on the sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` (the documented manual-keyset pattern)", - "migrationId": "query-cursor-retired", - "toMajor": 17, - "rationale": "The `cursor` key promised keyset pagination and no driver implemented it: the cursor was accepted and ignored, so every page came back identical — a caller looping \"until hasMore is false\" never terminates. Worse than inert, it had a shipped public producer (`QueryBuilder.cursor()`, removed with the key). The caller-built `Record` shape also leaks sort/storage detail and squats on the reserved REST parameter set; a first-class cursor, if ever designed, will be a response-minted opaque token — a different API, so keeping this one preserved a wrong design rather than a roadmap. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078." + "surface": "page `object-timeline` components — `properties.items` (whose entries used to accept any value)", + "replacement": "the entry kind the block's `variant` selects: on `vertical` (the default) or `horizontal`, a feed entry `{ time?, title, description?, variant?, icon?, content?, className? }`; on `gantt`, a gantt row `{ label, items? }` whose bars are `{ title?, startDate?, endDate?, variant? }`, each date a string or epoch milliseconds. Write a feed entry's `date` as `time` and its `color` as `variant` (`default`, `success`, `warning`, `danger`, `info`); move a gantt row to `variant: 'gantt'`, or a feed entry off it.", + "migrationId": "ui-object-timeline-items-typed", + "toMajor": 18, + "rationale": "The timeline rail draws `items` as authored, ahead of every record source, and each branch of its renderer reads only its own kind of entry: the feed branches read `time`, `title`, `description`, `variant`, `icon`, `content` and `className`; the gantt branch reads a row's `label` and its bars' `title`, `startDate`, `endDate` and `variant`. The page-component row declared each entry `z.unknown()`, so a misspelled key, a feed entry with no `title`, or a gantt row on a feed timeline passed the component-props gate, and the rail drew an empty, unlabelled entry. The row now takes objectui's two ruled kinds, closed, and pairs each entry with the kind its `variant` selects; a feed entry's `content` (child components) is held unjudged until a writer appears. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and the authored census found no drawn entry to respell. Deployed metadata NOT MEASURED." }, { - "surface": "data.query.distinct", - "replacement": "`groupBy` for unique combinations; the `count_distinct` aggregation for deduplicated counts; the SQL/memory drivers' `distinct(object, field)` door for one column's values", - "migrationId": "query-distinct-retired", - "toMajor": 17, - "rationale": "The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND worse pagination metadata, and a side effect that \"confirmed\" the flag was doing something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with the key). The count suppression is deleted in the same change — `total` is truthful for those queries again. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078." + "surface": "page `object-timeline` components — `properties.mapping` (which used to accept any value)", + "replacement": "the binding record the rail reads: `{ title?, date?, description?, variant? }`, each a field name. Write `titleField`, `dateField` / `startDateField`, `descriptionField` and `variantField` inside `mapping` as `title`, `date`, `description` and `variant`; write a bare field name as the member it binds (`mapping: { title: 'subject' }`).", + "migrationId": "ui-object-timeline-mapping-typed", + "toMajor": 18, + "rationale": "The timeline rail reads `mapping` as four field names — `title` and `date` between the `timeline` block's own member and the flat fallback, `description` ahead of `descriptionField`, and `variant`, the field whose value picks each entry's marker colour and the one binding with no other spelling — and the page-component row declared it `z.unknown()`, because that contract was objectui's alone. So a bare field name, a non-string binding or a misspelled member passed the component-props gate, and the rail bound nothing for it and drew the default field. The spec now declares objectui's own declaration of the binding record, four optional field names, closed as every element shape on that map is. It is read where every page component's props are: the component-props gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and the authored census found nothing to respell. Deployed metadata NOT MEASURED." }, { - "surface": "data.query.fields", - "replacement": "expand (`expand: { owner_id: { object: 'user', fields: ['name'] } }`), whose nested query selects the related record's own columns — keeping the foreign key in your own projection (`fields: ['title', 'owner_id']`), because the relation is carried by that column and projecting it away leaves expansion nothing to resolve. A dotted `fields` path is NOT a replacement: no driver ever resolved one, and the ingress refuses it (`400 INVALID_FIELD` — refused since a dotted projection was found silently widening the response to every field). Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes) — the same remedy the sort axis's refusal hint was corrected to prescribe, because a formula or rollup field materialises no column to sort or select by", - "migrationId": "query-field-node-object-form-retired", - "toMajor": 17, - "rationale": "The `FieldNode` union declared a nested-select object form `{ field, fields, alias }` that was inert end to end: no producer emitted it, and no consumer read `.fields` or `.alias` — objectql's formula projection and known-field filters, driver-sql's `select()` and driver-memory's projection all treat the list as `string[]`, driver-mongodb keyed its projection with the entry itself, and the REST ingress stringified it. Nested selection is `expand`, which the engine resolves via batch `$in` queries. This is a REQUEST surface — `QueryAST` is never stored in stack metadata (no view, dataset or report authors one), so there is no source for the chain to rewrite: the schema narrows to `z.string()` and callers move their own select lists. ADR-0049 / ADR-0078." + "surface": "`kind:'react'` page source — `` and `` (the react-tier overlay aliases published as deprecated when the react tier converged on the metadata-tier vocabulary)", + "replacement": "`` — ListViewSchema's own `data` data source and `type` view kind, the same two keys a metadata list view authors. `objectName=\"x\"` → `data={{ provider: 'object', object: 'x' }}`; `viewType=\"kanban\"` → `type=\"kanban\"`. A `` with no `data` at all is refused too: on a react page no host stamps the object, so the data source is the required binding there.", + "migrationId": "ui-react-list-view-binding-aliases-retired", + "toMajor": 18, + "rationale": "A react page's source is a JSX string, not a keyed document: `objectstack migrate meta` rewrites stored metadata by key and cannot rewrite props inside authored source, so the move is by hand. The contract deprecated both aliases in favour of the metadata-tier spelling (maintainer ruling 2026-08-23: the react tier converges on the metadata-tier vocabulary, deprecating first) while objectui's ListView still read only `objectName`, so the canonical spelling validated green and rendered an empty list. The consumer fold has landed (objectui `normalizeListViewSchema`, console pin a472b071: `data.provider === 'object'` → `objectName`, and the author's `type` read for the view kind), and the maintainer ruled the aliases retired with no deprecation window (2026-09-07). Writing either alias is now a publish-time `react-prop-retired` error carrying this prescription — never a silent pass on a key the renderer happens to still read." }, { - "surface": "data.query.joins", - "replacement": "expand (`expand: { owner_id: { object: 'user', fields: ['name'] } }`), whose nested query selects the related record's own columns — keeping the foreign key in your own projection (`fields: ['title', 'owner_id']`), because the relation is carried by that column and projecting it away leaves expansion nothing to resolve. A dotted `fields` path is NOT a replacement: no driver ever resolved one, and the ingress refuses it (`400 INVALID_FIELD` — refused since a dotted projection was found silently widening the response to every field). Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes) — the same remedy the sort axis's refusal hint was corrected to prescribe, because a formula or rollup field materialises no column to sort or select by", - "migrationId": "query-joins-retired", - "toMajor": 17, - "rationale": "The `joins` array was declared-but-inert: no engine or driver read `query.joins` anywhere on the query path, so a query carrying it behaved exactly as if the key were absent — while the name squatted on the reserved REST parameter set. Related-record retrieval already has a live spelling (`expand`, resolved by the engine via batch `$in` queries), so the removal deletes the second, broken spelling rather than the capability, and the orphaned `JoinNode`/`JoinType`/`JoinStrategy` cluster goes with the key. A REQUEST surface — `QueryAST` is never stored in stack metadata — so there is no source for the chain to rewrite; callers move their own queries. ADR-0049 / ADR-0078." + "surface": "page `record:alert` / `record:quick_actions` / `record:history` / `record:discussion` components — `properties` (undeclared keys, notably a typo'd `severty`, quick_actions' inline `actions`, history's host-channel `entries` / `loading`, and any key at all on `record:discussion`)", + "replacement": "the declared shapes the renderers read. `record:alert`: `severity?`, `title?` / `body?` (string or inline locale map), `visible?` (boolean | CEL string | `{ dialect, source }`), `icon?`, `action?` `{ actionName, label?, variant? }`, `dismissible?`, `dismissKey?`. `record:quick_actions`: `actionNames?`, `requiredPermissions?`, `location?` (the spec's own action-location vocabulary), `align?`, `inline?`, `variant?` / `size?` (the Button primitive's vocabulary). `record:history`: `limit?`, `emptyText?` / `unknownUserText?` (literal strings). `record:discussion`: `record:chatter`'s own row — one schema for the pair. Every rejection carries the surface, the offending key and a prescription (`actions` → `actionNames`; `entries` / `loading` → omit, the block self-fetches `sys_activity`; `aria` on quick_actions → not declared until the renderer reads the contract spelling; `visibleWhen` / `visibility` on the alert → `visible`; a locale map as history text → a literal string)", + "migrationId": "ui-record-blocks-unknown-keys-refused", + "toMajor": 18, + "rationale": "These were the four `record:*` components the component-props unknown-key gate (an authorable surface refuses a key it does not declare; for a component's `properties`, by parsing them against the type's `ComponentPropsMap` row) could not reach after the rail was given its strict row: each had a registered objectui renderer (and, bar `record:discussion`, a `PageComponentType` entry and a console palette slot) but no `ComponentPropsMap` row, so the props gate's dispatch skipped them as unregistered and every authored key rode through. A typo'd `severty` on the platform's own banner surface parsed, typechecked, validated, built and shipped as a silent no-op while sibling components in the same file drew loud diagnostics. The rows declare the shapes the renderers actually read (measured from read points at the objectui pin, not from the registrations' declared-input lists — quick_actions' registration claims an empty-bar fallback the renderer does not implement, and omits the `aria.label` read that exists but under a spelling the shared ARIA shape refuses), so an undeclared key is now a publish-time refusal instead of a silent no-op." }, { - "surface": "data.query.windowFunctions", - "replacement": "`aggregations` + `groupBy` for request-level analytics; `SqlDriver.findWithWindowFunctions(object, query)` for embedders on a SQL datasource", - "migrationId": "query-window-functions-retired", - "toMajor": 17, - "rationale": "The `windowFunctions` array was declared-but-inert on the query path: `find()` never applied a window function, so every OVER clause a caller declared was silently dropped. The capability only ever ran behind `SqlDriver.findWithWindowFunctions()`, a driver-level door that is not on the `IDataDriver` contract and whose flat input shape (`{ function, alias, partitionBy?, orderBy? }`) the spec vocabulary never matched — `WindowFunctionNodeSchema` declared `field`/`over`/`frame` members the door never read, so that cluster is removed with the key rather than left as a false affordance. A REQUEST surface, never stored; no source to rewrite. ADR-0049 / ADR-0078." + "surface": "page `record:line_items` components — `properties` (which used to accept any key) and `properties.columns[]` (its inline grid columns)", + "replacement": "the declared shape the renderer reads: `{ childObject?, relationshipField, columns, parentObject?, parentId?, recordId?, amountField?, totalField?, title?, readonly?, minRows?, maxRows?, filter?, sort?, limit? }`, with `filter` the ViewFilterRule array, `sort` the SortItem array and `limit` a positive integer; `childObject` may come from the component-level `dataSource` binding instead. `columns` is required and holds at least one column, each the strict, name-keyed inline grid column a relationship field's `inlineColumns` takes — `{ name, label?, type?, options?, … }`. Write `name` where a column said `field` (or `fieldName`, `key`); declare `label`, `type` and `options` on the column, because this block draws a column exactly as declared and hydrates nothing from the child object's field; delete `scale` from a column declaring `type: 'currency'`; delete `addLabel`, `formFields` and `inlineMode`, which belong to an `object-master-detail-form` detail entry and are not read here, `sortField`, which no block takes (the detail entry derives the line-position field from the child object), and any other key the shape does not declare.", + "migrationId": "ui-record-line-items-props-closed", + "toMajor": 18, + "rationale": "The block draws one inline grid of the record's child rows, through the same objectui grid as the other three carriers of the inline grid column, but it had no `ComponentPropsMap` row: it was the one entry on the string-arm registration ledger, so the component-props gate skipped it as unregistered and every authored key rode through. The showcase project page keyed all five of its columns `field`, the spelling the grid retired, and published green; the grid binds a column by `name`, so every cell rendered empty. The row is measured from the renderer's read points at the objectui pin, not from the registration's declared-input list, and its `columns` references the column schema, so every rule that schema holds applies here too, with its own prescription. It is read where every page component's props are: the component-props gate reports a failing key or column as an advisory `component-props-unknown-key` / `component-props-invalid` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. `defineStack`'s identity-only column check does not reach this block: the panel hands its columns to the grid as authored, so there is no hydrated type to judge. No conversion is registered: nothing on the load path refuses the shape, and the one `field`-keyed producer was respelled in the same change. Population measured at the change, on origin/main 1ecb871beb: one authored block in the examples (the showcase project detail page, five `field`-keyed columns, respelled `name`), zero in the documentation, against eight authored `record:*` blocks of other types through the same matcher as the control. Deployed metadata NOT MEASURED." }, { - "surface": "ui.RecordDetailsProps.sections (the `record:details` page component)", - "replacement": "an OBJECT array — `sections: [{ label, columns, fields: [...] }]` — replacing the string-ID list; `label` gives the heading, `columns` its grid width, `name` makes the heading translatable, and the new sibling `hideFields` omits named fields from the body", - "migrationId": "record-details-sections-object-form", - "toMajor": 17, - "rationale": "`record:details` declared `sections` as a list of section IDs — `[\"overview\", \"financials\"]` — a shape nothing produced and nothing consumed, while every real page authored the object form. This is authorable metadata on the publish/parse path, so it is the D2 class exactly; it is registered as a SEMANTIC step rather than a mechanical conversion because a string ID carries no field list and the chain cannot invent one — only the author knows which fields the section named `overview` was meant to render. The measurement that made the type change safe is also what makes the prescription unambiguous: the ID-list form had zero read paths and zero producers. objectui's `RecordDetailsRenderer` maps every entry as an object (`s.name` / `s.label` / `s.title` / `s.fields`) with no string branch at all — a string entry spreads into a character map and renders nothing; `@object-ui/types`' `RecordDetailsComponentProps` mirror already declared `Array<{ name?, label?, fields, ... }>`; the Studio block designer can only author `{ label, columns, fields }`; `packages/lint` has modelled it as `nestedSections` all along; and every page in this repo — three showcase pages plus the `sys_user` platform page — authors the object form. So the break lands only on stored metadata written against a declaration nothing ever honoured, and it lands at publish time rather than rewriting data at rest. The same change DECLARED `hideFields`, which the `sys_user` platform page had been authoring undeclared. Registered late, by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger: the change that declared the object form predates the gate that makes a breaking changeset state its ledger disposition, so nothing asked it what it had done about the ledger, and the sibling key on the same def — `ui/RecordDetailsProps:layout`, retired in a neighbouring change — carries a tombstone while this face carried none. ADR-0087." + "surface": "page `record:reference_rail` components — `properties` and each `entries[]` item: undeclared keys (notably a per-entry `filter`, an entry `icon`, and an inline locale map as `title`)", + "replacement": "the declared shape the renderer reads: `entries[]` of `{ objectName, relationshipField, title?, limit?, displayField? }` plus a component-level `hideEmpty`. Every rejection carries the surface, the offending key and a prescription (`filter` → remove it, or use `record:related_list` whose `filter` is real; `icon` → remove it, no render path reads it; entry-level `hideEmpty` → move it up beside `entries`; `items` / `related` → `entries`; `object` → `objectName`; `label` → `title`; a `title` locale map → a literal string, or omit it to keep the localized object label)", + "migrationId": "ui-reference-rail-unknown-keys-refused", + "toMajor": 18, + "rationale": "The rail was the `record:*` component the component-props unknown-key gate (an authorable surface refuses a key it does not declare; for a component's `properties`, by parsing them against the type's `ComponentPropsMap` row) could not reach: it had a registered renderer and a `PageComponentType` entry but no `ComponentPropsMap` row, so the props gate's dispatch skipped it as unregistered and every authored key rode through. Measured on 17.0.0 GA end to end: a planted entry `filter` passed tsc, `objectstack validate` and `objectstack build`, shipped verbatim in the artifact, and the rendered rail kept counting and listing unfiltered rows — while the same build loudly reported `record:related_list` keys in the same file. The row declares the shape the renderer actually reads (measured from its read points, not its TS interface — the interface's `icon` is read by nothing and is refused, not declared), so an undeclared key is now a publish-time refusal instead of a silent no-op." }, { - "surface": "restServer.openApi31", - "replacement": "(removed — no replacement key exists. Delete the key; for a real outbound webhook use `Webhook` from `@objectstack/spec/automation`. Config-driven OpenAPI 3.1 webhooks/callbacks documentation returns, if ever, via the enforce route of ADR-0049 through a new ADR)", - "migrationId": "rest-server-openapi31-block-removed", - "toMajor": 17, - "rationale": "The `openApi31` block (`webhooks` / `callbacks` / `jsonSchemaDialect` / `pathItemReferences`, typed by `OpenApi31ExtensionsSchema` with `OpenApiWebhookEventSchema` and `CallbackSchema` under it) promised OpenAPI 3.1 document synthesis nothing delivered: the REST server's `normalizeConfig` forwards only `api`/`crud`/`metadata`/`batch`/`routes`, and the served /openapi.json is the pre-generated @objectstack/spec contract enriched with the live server URL and the registered objects — a webhook declared here never appeared in any served document (ADR-0049; the declared-but-unconsumed shape an earlier audit found in the connector webhook and event enums one layer up). There is no behaviour to preserve and nothing stored to rewrite: `RestServerConfig` is plugin TS configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), never a `sys_metadata` shape — the stack tree's `api` block declares only its four scoping/auth knobs. The three schemas are removed with the key (zero import-level consumers in objectstack / cloud / objectui); the key itself is tombstoned because the schema is not `.strict()` and a plain delete would strip it silently." + "surface": "reports[].blocks[].dataset on a report whose type is joined: a block that binds no dataset (the joined arm of the ReportSchema refinement)", + "replacement": "Bind the block to a dataset: set the block's `dataset` to the dataset whose measures (`values`) and dimensions (`rows`) it shows. A block with nothing to show can be deleted instead, as long as the report keeps at least one block.", + "migrationId": "ui-report-joined-block-dataset-required", + "toMajor": 18, + "rationale": "ADR-0021 single-form, enforced (ADR-0049 enforce-or-remove, the enforce arm). A `joined` report carries its data on `blocks`, each an independent query over that block's own `dataset`, and the container selects nothing: a container `dataset` is refused. `ReportSchema`'s refinement comment and the reports guide both said each block is dataset-bound, but the joined arm required only that `blocks` be non-empty, and a block's `dataset` is optional on its shape, so a block with no `dataset` parsed, passed `objectstack validate` and every save door, and drew nothing. Measured at this repo's `.objectui-sha` pin `ab187972159583b595facdcae3c73b50f6f312e9`: the joined renderer hands each block's `dataset` to its table, whose query hook goes idle on an empty name, so an unbound block draws an empty table and issues no query; a report whose blocks all lack one fails the dataset-report guard and falls through to the pre-9.0 presentation bridge, which issues no query either, and a dashboard drill-down that opens that report lists the records instead of drawing it. Studio's report inspector authors blocks through the spec form's `blocks` repeater, which adds a blank row and requires no column of it, so a block saved with only a name reached the store with no error. The joined arm now refuses each such block at `blocks[i].dataset`, naming the block, with the prescription to bind it to a dataset. `dataset` stays optional on the block shape itself: `blocks` is read only on a `joined` report, and a block on any other report type is ignored, as before. Ships at once, with no deprecation window: there is no window in which an unbound block draws anything, and there is no mechanical rewrite, because only the author knows which dataset the block was meant to show." }, { - "surface": "runtime.HttpServer (the exported delegating wrapper class)", - "replacement": "register an `IHttpServer` ADAPTER INSTANCE directly as the `http.server` service — `HonoHttpServer` or whatever adapter the host already builds — instead of wrapping one", - "migrationId": "runtime-httpserver-wrapper-retired", - "toMajor": 17, - "rationale": "`@objectstack/runtime` exported an `HttpServer` class that took an `IHttpServer` in its constructor, declared `implements IHttpServer`, and forwarded only the contract's REQUIRED members (`get` / `post` / `put` / `delete` / `patch` / `use` / `listen` / `close`). It forwarded none of the OPTIONAL ones — `getPort?()`, `getRawApp?()`, `setFallbackHandler?()`. `packages/spec/src/contracts/http-server.ts` instructs consumers to feature-detect exactly those members with `typeof server.X === \"function\"` and to degrade when absent, so wrapping a capable adapter made every probe answer false and the capability vanish with the adapter underneath providing it the whole time. The sharpest consequence is worth writing down before anyone reaches for a wrapper of the same shape: a host that wrapped `HonoHttpServer` and registered the wrapper as `http.server` would answer 404 to every endpoint its metadata declared, because `setFallbackHandler` — the ONLY entry path for declarative `apis:` endpoints since publish stopped refusing them and 17 began executing them — was never forwarded. This is a TS/API contract surface: an HTTP server adapter is CODE, never stack metadata, so there is no authored source for the chain to rewrite and deliberately no schema tombstone — nothing ever ran an adapter through a `.parse()`. That is precisely why this entry must exist: for an untyped JS host the ledger is the only notification channel there is, and for a typed one tsc reports at the construction site. Same disposition, and the same reason, as `storage-service-list-retired` and `data-driver-find-stream-retired`. Registered by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger, not by the original change: the wrapper's removal landed before the gate that makes a breaking changeset state its ledger disposition existed, so nothing ever asked it what it had done about the ledger. ADR-0049 / ADR-0087." + "surface": "`report.blocks[].chart` (REMOVED from the joined report block shape) and `report.chart` on a report whose `type` is `joined` (REFUSED by `ReportSchema`'s refinement) — a chart anywhere on a joined report", + "replacement": "nothing on the joined report: a joined report draws each block as a table and has no chart channel at either level. Delete the `chart`. If the chart was wanted, give the slice it was meant to plot a report of its own — `type` `tabular`, `summary` or `matrix`, binding the same `dataset` the block bound, selecting the dimension and measure the chart names in its `rows` and `values` — carry the `chart` over to that report's top level, where `xAxis` names a dataset dimension and `yAxis` a measure exactly as before, and reach it from the app navigation beside the joined report.", + "migrationId": "ui-report-joined-chart-retired", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove. Nothing ever drew a chart on a joined report: the renderer's joined branch draws each block as a table and returns before its one read of the report's `chart`, and no renderer reads a block's `chart` at all — measured at this repo's `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52` (`DatasetReportRenderer.tsx`, joined branch at lines 1462-1524, the only chart read at 1557). So both coordinates parsed, passed the `validate-chart-bindings` lint (which resolved their axes as if they would plot), and showed tables only. The D2 conversion `report-joined-chart-removed` already REPAIRS THE DATA: it strips both from authored sources on a chain replay and from stored `sys_metadata` rows at rehydration, a lossless delete because neither value ever rendered. What it cannot repair is intent. Deleting the key leaves the report looking exactly as it always did — which is the problem when the author believed a chart was there: they were reading a chart that never existed, and only they know whether they wanted one. A walker cannot move it anywhere either: a joined report has no chart channel, and creating a new report, choosing its type and placing it in navigation are authoring decisions, not rewrites. The Studio report form offered a block chart input until this change, so a stored row carrying one is a real shape, not a hypothetical. Ships at once, no deprecation window: there is no window in which a key the renderer never reads does anything. `chart` on every non-joined report is unchanged — it is that report's live embedded chart." }, { - "surface": "@objectstack/spec: the exported type `SharingExecutionContext` (`contracts/sharing-service`), and its re-export from @objectstack/plugin-sharing — the six-field context shape (`userId` / `tenantId` / `positions` / `permissions` / `systemPermissions` / `isSystem`) that sharing, approval and report enforcement signatures used to name", - "replacement": "`ExecutionContext` from `@objectstack/spec` — the complete `resolveAuthzContext` envelope the sharing, approval and report contracts have declared since they converged onto it. Every one of the retired type's six fields exists on it under the same name and type, so a value that satisfied the old type already satisfies the envelope: only the annotation is rewritten, never the value", - "migrationId": "sharing-execution-context-retired", - "toMajor": 17, - "rationale": "ADR-0049 enforce-or-remove, completing the maintainer's ruling of 2026-08-07 on the share-link context (enforcement adjudicates on the WHOLE envelope, never a per-site subset). This type was the declared context parameter of 36 signatures across three contracts — `ISharingService` / `ISharingRuleService`, `IApprovalService`, `IReportService` — and it omitted four fields those gates need: `accessible_org_ids` (under the `group` tenancy posture this IS the Layer 0 wall, ADR-0105 D2), `org_user_ids`, `posture` (ADR-0095 D2) and `tabPermissions`. Its damage ran in the MIRROR direction of the share-link twin, which that same ruling moved onto the whole context: nothing trimmed the VALUES — the engine middleware always handed the whole context down — it was the declared TYPE that was narrow, so an implementation could not READ what it had been given without casting out of its own contract (`const posture = (context as any).posture` in plugin-approvals' privileged-override gate). One change converged the contracts, two more re-annotated the four implementations (sharing and audit, then approvals and reports), and this change removes the now-unreferenced declaration, the deletion that split had deferred. Why this needs a ledger entry despite nothing in-repo referencing it: it is the `export-field-meta-constraints-retired` / `hook-context-session-roles-retired` disposition — a PUBLISHED TypeScript surface with no spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry the prescription, and the ledger is the only channel that reaches an upgrader. Why D3 semantic and not a D2 conversion: nothing authored or stored changes shape. The name is only ever spelled inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach it, and no `sys_metadata` row carries it. ADR-0049 / ADR-0087." + "surface": "report selection keys on a `joined` container — a top-level `dataset`, or a NON-EMPTY top-level `rows` / `columns` / `values` list, on a report whose `type` is `joined` (`ReportSchema`'s refinement)", + "replacement": "the same key on the `blocks[]` entries that need it — each block binds its own `dataset` and selects its own `rows` / `columns` / `values` — or DELETE it. Deleting changes nothing that renders: the container value was never read. The refusal lands at the key's own path and says both, the way the container `order` refusal beside it always has, and that `order` refusal is unchanged.", + "migrationId": "ui-report-joined-container-selection-refused", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove, the enforce arm: the four keys stay declared (they are the selection of every non-joined report), and the one report type that never reads them now refuses them. A `joined` report selects nothing itself, and the refinement already said so for `order` alone — it refused a container `order` with a pointer onto `blocks[]` while the four selection keys beside it parsed green. Measured at this repo's `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52`: `DatasetReportRenderer`'s joined branch (`DatasetReportRenderer.tsx:1462`) reads `blocks`, plus the container `runtimeFilter` and `drilldown` resolved above it, and returns before the top-level reads of `columns` / `dataset` / `rows` / `values` begin (line 1529 onward) — so each was accepted by the metadata layer and dropped by the renderer without a word. The alias tables made it reachable: `fields` / `measures` / `metrics` route to `values`, `groupings` / `groupBy` / `dimensions` to `rows`, and `objectName` / `object` / `dataSet` / `source` to `dataset`, on a joined report as on any other. Studio's report inspector hides the top-level binding for a joined report (`ReportDefaultInspector.tsx:328`) but its type picker patches only `type`, so a report bound first and switched to `joined` second carries the keys invisibly. An empty list is NOT refused: it selects nothing, which is what a joined container selects — the container `order` refusal's own threshold. Ships at once, no deprecation window: there is no window in which a key the renderer never reads does anything." }, { - "surface": "security.sharingRule.sharedWith.type `group` / `guest`, and owner-type rules (`type: owner` + `ownedBy`)", - "replacement": "`group` → `team` (the enforced runtime vocabulary); `guest` → delete the rule and expose the records through a public form or a share link; `type: owner` → rewrite as a `type: criteria` rule. `business_unit` is newly authorable for the single-unit case", - "migrationId": "sharing-rule-recipient-reconcile", - "toMajor": 17, - "rationale": "The authoring `ShareRecipientType` enum had drifted behind both the ADR-0090 D3 rename and the enforced runtime, in both directions at once. It still offered the pre-rename `group`, which the seed path silently SKIPPED, while omitting two recipients the runtime and bootstrap already enforced (`team` via `sys_team` / `sys_team_member`, and `business_unit`). It also offered `guest`, which had no runtime recipient mapping at all. Each of those is a rule that validated and then materialised nothing — the ADR-0078 shape, and on a SECURITY surface, where the failure is silent under-sharing: the author sees a valid rule and believes a set of people can reach the records, and no error ever contradicts them. Owner-type rules go for a different and sharper reason: they depend on live team / position membership, which the static materialiser cannot track, so they could not be made to work by fixing a name. They return as an enforced form only if membership-reactive re-materialisation is designed. This is a semantic entry rather than a mechanical conversion because only one of the three rewrites is a rename: `group` → `team` is mechanical, but `guest` and `type: owner` have no target — the author has to decide who was actually meant to reach those records and say so in a form the runtime enforces, and a transform that guessed would be inventing an access grant. After this change every authorable recipient and rule type on the SharingRule surface is enforced; the `queue` recipient stays runtime-reserved and deliberately non-authorable (there is no `sys_queue` yet). Note the two neighbouring conversions cover DIFFERENT faces of this schema and not this one: `sharing-recipient-role-to-position` is the ADR-0090 role → position rename and `sharing-rule-access-level-full-to-edit` is the access-level vocabulary. The change came out of the metadata property liveness audit, which found security properties parsed but never enforced, and was registered late, by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger. ADR-0078 / ADR-0090 D3 / ADR-0087." + "surface": "ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, or present and undefined, on equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before or after (an alias spelling of any of them included), on every carrier of ViewFilterRuleSchema", + "replacement": "the value the rule compares against — value: \"open\" on equals, value: \"2026-01-01\" on after. A rule that meant \"the field has no value\" becomes one of the four operators that take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction from their name and still parse with or without a value. A rule that was an unfinished row is deleted. The list operators (in / not_in) and the range operator (between) refused an absent value before this change and still do, in their own words", + "migrationId": "view-filter-rule-absent-value-refused", + "toMajor": 18, + "rationale": "The value key's own published description has declared, since the value was first shaped by its operator, that every operator outside the list, range and unary sets takes a scalar, and that only the unary operators ignore the key; the refinement implementing the coupling returned early on an absent value for every operator, so a rule with no value parsed green on all thirteen scalar operators. The query path refuses the same rule: both lowerings of a stored rule — the console's and the REST lookup-picker route's — emit it as the two-element [field, operator] node, which the filter-AST lowering reads as an undefined comparand and refuses with INVALID_FILTER / 400, measured for all thirteen operators. Nothing between storage and the query drops the rule, so one such rule failed every query that read its view, the view's other rules included. The first-party producer does not write the shape: the console filter builder drops a row whose operator takes a value and whose value is missing before it saves, and the drill-down save-as-view path checks each rule against this schema before persisting it (read at the pinned objectui commit). Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: there is no value to infer, and writing a value, switching to a unary operator and deleting the rule are three different predicates only the author can choose between. The read path does not re-validate stored rows (the reading the sibling entry view-filter-rule-scalar-operator-array-refused records), so a stored view keeps loading — and keeps failing its queries, as it did before this change; what changes is that RE-SAVING it is refused at the value path, naming the operator and the field. ADR-0049 / ADR-0087 / ADR-0112." }, { - "surface": "data.query.orderBy[].direction (SortNode)", - "replacement": "`order` — `orderBy: [{ field: \"updated_at\", order: \"desc\" }]`. One word, same values (`asc` / `desc`)", - "migrationId": "sort-node-direction-rejected", - "toMajor": 17, - "rationale": "`SortNodeSchema` was a plain `z.object`, so zod's default `.strip` applied and a sort node spelling its direction `direction` lost the key silently. Measured on `main` before the change: `SortNodeSchema.parse({ field: \"updated_at\", direction: \"desc\" })` returned `{ field: \"updated_at\", order: \"asc\" }` — the key discarded and `order` falling back to its `asc` default, so the sort ran in the OPPOSITE direction and the request succeeded. Paired with `limit`, which is how a caller asks for \"the latest N\", that is not a reordered page but a DIFFERENT SET OF ROWS, returned under an ordinary 200 with nothing in the response to distinguish it from the answer that was asked for. `direction` is not a typo: it is the live vocabulary of a neighbouring contract, `IReportService.orderBy`, and `plugin-auth/objectql-adapter.ts` already translated between the two by hand — a translation known to be necessary and enforced nowhere, the ADR-0049 shape. Both doors closed in one change: `SortNodeSchema` is now a `strictObject` carrying `aliases: { direction: \"order\" }`, and `normalizeSortNodes` in `metadata-protocol` refuses `{ field, direction }` with `400 INVALID_SORT`. The alias is deliberate rather than left to the edit-distance fallback, because no edit distance bridges `direction` → `order` and a bare \"unrecognized key\" would leave the caller exactly where the silent strip did. This is registered as a semantic entry rather than a mechanical conversion for one reason worth stating: the rewrite itself is trivially mechanical, but a stored `direction: \"asc\"` is ambiguous evidence — the author may have written it meaning ascending and been silently GIVEN ascending, so the visible behaviour never contradicted them, and only they can say whether the sort they have been reading was the sort they asked for. Registered by the stock reconciliation of the v17 train's breaking changesets: the in-code alias tombstone shipped with the change that closed both doors (maintainer ruling 2026-08-03), but the ledger half never did, and a retirement needs both — the tombstone is the proof the removal was declared, the ledger entry is what `spec-changes.json`, the upgrade guide and `os migrate meta` project to consumers. ADR-0049 / ADR-0087." + "surface": "ui.ViewFilterRule operator — the TypeScript INPUT type of a view filter rule, on every carrier of ViewFilterRuleSchema (ListView.filter, a view tab filter, Page.filterBy, the related-list, record-picker and object-* block filter doors)", + "replacement": "the canonical operator id, a member of ViewFilterOperator (VIEW_FILTER_OPERATORS). A typed rule written operator: \"eq\" becomes operator: \"equals\"; every legacy spelling maps to exactly one canonical id, and VIEW_FILTER_OPERATOR_ALIASES is that map (ne and neq to not_equals, gt to greater_than, gte to greater_than_or_equal, nin and notIn to not_in, isNull to is_null, and the rest). A value that is not yet known to be an operator — read from storage, a URL or user input — is typed unknown and handed to ViewFilterRuleSchema.safeParse, or folded with normalizeFilterOperator first; the schema stays the judge", + "migrationId": "view-filter-rule-operator-input-canonical", + "toMajor": 18, + "rationale": "The operator key is a z.preprocess over the alias fold, and zod types a preprocess's INPUT from its function's parameter. That parameter was unknown, so ViewFilterRule (a z.input) typed operator as unknown: a rule with operator: 42, or any string at all, compiled on every carrier and was refused only when the door parsed it. The typed input is now the canonical ViewFilterOperator, the vocabulary the alias table's own contract says new producers emit. The RUNTIME does not move: the door still folds every spelling it folded before to canonical and still refuses a non-string with the enum's own issue at operator, so a stored sys_metadata row, a YAML or JSON body, and a plain-JS producer that carries an alias keep parsing exactly as before, and os validate answers as before. Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: what narrows is only what TypeScript source may write. The exported normalizeFilterOperator keeps its unknown parameter on purpose — it exists to fold untyped stored metadata, and its callers pass raw strings by design. ADR-0087 / ADR-0122." }, { - "surface": "type alias: the 102 XInput names of @objectstack/spec (ConnectorInput, AppInput, PageInput, ActionInput, ServiceObjectInput, ExecutionContextInput, TaskInput, … — 52 files across api/ automation/ data/ identity/ integration/ kernel/ security/ system/ ui/)", - "replacement": "the BARE name. ADR-0122 phase 2 moved the author state onto `X`, which makes `XInput` a character-for-character synonym of it — the permanent synonym D3 forbids. Drop the `Input` suffix: `ConnectorInput` -> `Connector`. Symmetrically, a consumer that held a PARSE RESULT under the bare name moves to `XParsed`, which phase 1 (16.x) already declared for every schema whose two shapes differ, so the target name has existed for a release. NINE `*Input` names are NOT retired and need no edit: `ExpressionInput`, `CronExpressionInput`, `TemplateExpressionInput` and `PredicateInput` are the bare aliases of their own `…InputSchema`, and `FormFieldInput`, `QueryInput`, `FieldInput`, `ObjectStackDefinitionInput` and `NavigationItemInput` are composed (recursive or `Partial`-shaped) types no bare alias denotes.", - "migrationId": "spec-type-alias-input-suffix-retired", - "toMajor": 17, - "rationale": "This entry exists for the reason `data-driver-find-stream-retired`, `storage-service-list-retired` and `actor-user-roles-to-positions` exist, and it is the same disposition: the surface is a TYPESCRIPT NAME, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — an `XInput` alias never had a carrier key, never emitted a def, and no `.parse()` ever saw it. Measured and verified rather than assumed: `json-schema/`, `json-schema.manifest/` and `authorable-surface/` are BYTE-IDENTICAL across this change, because those generators enumerate runtime `z.ZodType` exports and never read a type alias. So nothing left the published metadata surface and RETIRED_DEFS_BY_MAJOR is deliberately untouched — an entry there would falsely claim the metadata contract shrank. The enforced channel is tsc: the name is gone, so every consumer gets TS2724/TS2305 naming the import. That is loud but MUTE about the replacement — a compile error says `ConnectorInput` does not exist, not that `Connector` now means what it meant. The generated upgrade guide is the only channel that carries the second half, which is precisely the gap ADR-0087 registration exists to close — the `ctx.user` `roles` alias was removed with no ledger entry, and that entry had to land separately. ⚠️ Deliberately NOT registered alongside it: the 1384 bare aliases the same change FLIPPED from `z.infer` to `z.input`. Those names all still exist and still resolve; what moved is which of a schema's two shapes they denote, and only where the two differ (663 of 1384 — the rest are isomorphic and the flip is a no-op there, pinned as such). A consumer holding an authored literal is made MORE correct by it, silently; one holding a parse result gets a tsc error at the first defaulted key it reads. Registering that as a rename would misdescribe it — no name was retired — and the changeset carries its own FROM -> TO for it. ADR-0122 D8/D9 (its phase 2, which moved every bare name to the author state)." + "surface": "ui.ViewFilterRule value on a SCALAR operator — an ARRAY where the operator takes one value (equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before, after), on every carrier of ViewFilterRuleSchema", + "replacement": "one scalar — a string, number, boolean or null. A rule written value: [\"won\"] on equals becomes value: \"won\"; a rule that really did mean membership of a list becomes operator: \"in\" with the array unchanged. The list operators (in / not_in) and the range operator (between) are untouched and still take their arrays. The unary operators (is_empty / is_not_empty / is_null / is_not_null) are untouched too: they take their direction from the operator NAME and their value position is discarded, so whatever sits there still parses, array included. An omitted value is still an omitted value", + "migrationId": "view-filter-rule-scalar-operator-array-refused", + "toMajor": 18, + "rationale": "Closing the protocol half of the maintainer's ruling C-prime of 2026-09-20 on objectui's render-time filter converter — the protocol is the only refusal set, so a document it accepts never throws at render time — verbatim, untranslated: 「the differences are the protocol's to close」. The value key's own published description has declared this rule since the value was first shaped by its operator — 「every other operator takes a scalar」 — and the refinement that implements the coupling returned early for every operator that is neither a list operator nor between, so the entire scalar class was declared and, from then until this change, not judged. ⚠️ This REVERSES a reading recorded in the sibling entry view-filter-rule-value-shaped-by-operator, which listed a scalar operator carrying an array as deliberately accepted because it 「lowers to a deep-equality comparand」. The backends a lowered view rule reaches at this release do not agree, so each is named rather than generalised. The SQL family REFUSES: the lowered node reaches driver-sql's bare field-value loop, which asserts the comparand against its own SCALAR_COMPARAND_OPERATORS set; an array is none of the six accepted comparand types the platform declares in ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE, so the comparand is refused with the withheld INVALID_FILTER / 400 envelope — and with it the driver-turso and driver-sqlite-wasm drivers built on driver-sql, and turso's remote transport. driver-memory REFUSES the same shape in the same envelope (its assertFilterConditionShape throws on an array in the implicit-equality position). driver-mongodb ANSWERS: its translateFilter passes the array through unchanged and the engine's shared comparand doors (normalizeFilterComparandTypes, assertListComparandShapes) both pass the shape, so the server applies MongoDB's equality rule for an array operand — a row matches when its stored array equals the value or holds the value as one of its elements, and a row storing the scalar does not match. That MongoDB reading is taken at the driver's compile face, at those engine doors and through mingo 7.2.4, which applies that rule; a live mongod instance was NOT measured. Method: driver-sql on SQLite, driver-memory, driver-mongodb's translateFilter and mingo were each run on the lowered node beside a scalar and an $in control; MySQL and a live Turso server were NOT measured. Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: a SemanticMigration converts nothing by its own type, and the stored-row pass replays D2 conversions only. Coercing at load would be the platform guessing intent — an array of two on equals has no honest single value, and picking the first is a different predicate. The read path does not re-validate stored rows, so a stored view keeps loading; what changes is that RE-SAVING it is refused at the value path. ADR-0049 / ADR-0087 / ADR-0112." }, { - "surface": "contracts.IStorageService.list", - "replacement": "track the keys you wrote (sys_file / file-reference records, queryable through ObjectQL with real pagination) instead of enumerating the bucket — and where no such record exists, the cursor-shaped `list(prefix, { cursor, limit })` this entry reserved, restored since, once cloud proved to be the first-party caller this repository could not see", - "migrationId": "storage-service-list-retired", - "toMajor": 17, - "rationale": "`list(prefix)` was an OPTIONAL contract method documented as \"List files in a directory/prefix\", and the two shipped adapters answered the same call with two different semantics — both of them silently incomplete. `LocalStorageAdapter.list` was a single-level `readdir`, so a nested key `a/b/c` was invisible under `list('a')` (only `a/b` came back), and a subdirectory that `stat` succeeded on was pushed into the result as a file, yielding a `StorageFileInfo` whose `size` is a directory inode and which cannot be downloaded at all. `S3StorageAdapter.list` was RECURSIVE (`ListObjectsV2` matches the whole key) and read neither `IsTruncated` nor `ContinuationToken`, so past 1000 objects the \"all files\" a caller received was the first page, with no signal. One contract method, two dialects, both quietly incomplete — and the first feature that genuinely needed to enumerate a prefix (backup, orphan sweep, migration audit) would have got two different answers on two deployments without an error on either. The email plugin's large-attachment storage work was nearly that feature: it planned to drive attachment reclamation off `list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one level down, and switched to queue-driven deferred work instead. Nothing consumed it afterwards: the only in-repo call site was the `SwappableStorageService` pass-through (which itself rejects when the active adapter has no `list`), and REST, CLI and the storage routes never called it. Remove was chosen over align-and-tighten (maintainer ruling, 2026-08-05, on the finding that measured the two dialects): aligning would grow a conformance surface nobody walks, while a prefix listing that cannot paginate is the wrong signature to inherit — when a real caller needs enumeration it returns cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases (nested keys, directory entries, >1000 objects) proving both backends agree. This is a TS/API contract surface — a storage adapter is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription there would reach no one. The enforced channel is tsc, and it reports at the call site. Same disposition, and the same reason, as `data-driver-find-stream-retired`. ADR-0049 / ADR-0087." + "surface": "A top-level `options` bag on a `view` item RECORD (`{ name, object, viewKind, config }`) saved through the metadata write door (`PUT /api/v1/meta/view/:name`, the Studio / MCP save): `options.kanban`, `options.calendar`, `options.timeline` and every other key in it, on either `viewKind`.", + "replacement": "The same per-kind blocks under the record's `config` — `options.kanban` becomes `config.kanban`, `options.timeline` becomes `config.timeline` — where each is judged by its kind's own block schema, and a key that block does not declare is re-spelled or deleted as its refusal says. A key `config` already sets wins; the bag's copy is deleted. The flattened list overlay (no `config`) keeps its legacy `options` bag, judged key by key, as before.", + "migrationId": "view-item-options-bag-refused", + "toMajor": 18, + "rationale": "The record member re-opens its top level with a strip so the console's round-trip keys survive, and that strip dropped a top-level `options` bag from the parse without looking inside it, while the save stored the request body. The console reads a record's body from `config` on the object page but spreads the whole stored record on the interface page, so the same saved view rendered two ways. Now that the save stores the parsed body, the bag would instead vanish silently on the next save. The maintainer's ruling of 2026-09-30 refuses it by name with the prescription to write `config.KIND`; declaring it would have kept a second spelling of one block on a second member. No console write puts the bag on a record. Not convertible: which of two spellings of one block the author meant, where both are set, is the author's call." }, { - "surface": "ai.tool.requiresConfirmation", - "replacement": "put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — the flag the platform confirmation CONTRACT is written against, and that contract is ENFORCED. An AI-facing call on an action declaring the flag must carry the confirmation member `confirm: true` on the request and is REFUSED without it with `ACTION_CONFIRMATION_REQUIRED` (428), the refusal naming the action and the exact member to set. A gate, not a queue: nothing is parked, and a refused call did not run — no record was read and none was written. ⚠ Two bounds: the enforced set is the doors that enforce the author's `ai.exposed` opt-in, today the action door reached from the MCP `run_action` tool, while REST `/actions` is not `ai.exposed`-gated and sits outside the gate; and `confirm: true` is an unverifiable caller claim, so the gate makes forgetting loud without proving a human approved", - "migrationId": "tool-requires-confirmation-retired", - "toMajor": 17, - "rationale": "`ToolSchema.requiresConfirmation` accepted `true` and no execution path ever read it: not the LLM tool set (a tool reaches the model as name / description / parameters only), not `ToolRegistry.execute`, not `POST /ai/tools/:name/execute`, and not the MCP bridge, which derives `destructiveHint` from a hardcoded name list. Setting it on a destructive tool produced NO PAUSE. For an ordinary dead property that is untidy; for a SAFETY property it is false compliance, the case ADR-0049 exists for — an author gates a destructive tool, sees the flag accepted, and ships believing a human is in the loop. It is made worse by the near-miss: `action.ai.requiresConfirmation` carries the same name and DOES work, so the mistake reads as correct in review. This is registered as a semantic entry rather than a mechanical conversion because the rewrite is not a rename at all — the replacement lives on a different metadata object at a different layer, and deciding which action should carry the gate (or whether the operation should be an action at all) is a judgement the chain cannot make. Deleting the key mechanically would be the worst possible transform here: it would leave the metadata parsing green while silently completing the removal of a safety gate the author believed was in place. `ToolSchema` was made `.strict()` in the same change, which is load-bearing rather than tidying — removing a key from a non-strict schema swaps one silent no-op for another, so the retired key now REJECTS and the parse error carries the prescription, that being the one channel every consumer bumping `@objectstack/spec` is guaranteed to hit. Registered by the stock reconciliation of the v17 train's breaking changesets: the `retiredKey()` tombstone shipped with the change that executed ADR-0033's deletion of this unenforced key, and still stands in `ai/tool.zod.ts`, but the ledger half never did. A retirement needs both — the tombstone is the proof the removal was declared, this entry is what `spec-changes.json`, the upgrade guide and `os migrate meta` project to consumers. ADR-0033 §2 / ADR-0049 / ADR-0087." + "surface": "view.owner / view.hidden on the view item record — the per-user owner and the switcher-hidden flag", + "replacement": "(removed — no per-user view scope and no switcher filter exists.) A view is listed to everyone who can read its object. A view that must not be listed is deleted; per-user view scoping is a parked direction, not a shipped mechanism.", + "migrationId": "view-item-owner-hidden-retired", + "toMajor": 18, + "rationale": "The D2 conversion `view-item-owner-hidden-removed` deletes both keys from every view item RECORD — in `views` (stack sources and stored rows) and in the assembled-manifest view item channel (package export, environment artifacts) — and the delete is lossless: both switcher read paths filter on the view kind and object and sort on `order`, so `hidden: true` hid nothing and no scope ever read `owner`. The judgment is about exposure. A view an author marked as one user's, or hid from the switcher, has always been listed to every user who can read the object — its name, its columns, its filters and its sort. Whether anything in such a view was meant to stay private, and whether it should now be deleted rather than kept, is the author's call. A flattened view overlay's own `owner` and `hidden` are a separate family on a different door, with their own D2 conversion `view-overlay-owner-hidden-removed` and their own D3 entry `view-overlay-owner-hidden-retired`." }, { - "surface": "ui.touchInteraction / ui.gestureConfig / ui.dndConfig / ui.keyboardNavigationConfig / ui.componentAnimation / ui.motionConfig / ui.pageTransition / ui.offlineConfig (the whole export surface of ui/touch.zod.ts, ui/dnd.zod.ts, ui/keyboard.zod.ts, ui/animation.zod.ts and ui/offline.zod.ts — 32 defs, 64 exported names)", - "replacement": "(removed — there is no replacement key, because there was never a key. Touch targets, drag-and-drop, focus management, keyboard shortcuts and motion are RENDERER BUILT-IN behaviour: the component library decides them, not a per-page metadata author. Offline is a platform capability, and its vocabulary belongs on the sync engine that owns the queue, the conflict policy and the cache — none of which exists yet. Delete the import and the value. Whichever of these earns real product pull returns WITH its own vocabulary and its executor — the way inbound rate limiting came back, as a new key carrying only what its executor consumes — not by un-retiring a declaration)", - "migrationId": "ui-interaction-config-family-retired", - "toMajor": 17, - "rationale": "Five `@objectstack/spec/ui` modules declared a full interaction-configuration vocabulary — 22 `z.object` sites across touch/gesture, drag-and-drop, focus/keyboard, animation/motion and offline/sync — and NOTHING in the protocol carried them. This is the ADR-0049 false-compliance shape in its most inviting form for an AI author (ADR-0033), and worse than the ordinary declared-but-unread defect: `authorable-surface.json` listed 109 keys under these defs and `content/docs/references/ui/{touch,dnd,keyboard,animation,offline}.mdx` rendered them as authoring tables, so the published documentation advertised a vocabulary with no carrier key anywhere. An author following `dnd.mdx` and writing a `dnd:` block onto a page component was rejected by `PageComponentSchema` for an unrecognized key — the docs and the schema disagreeing about the platform (Prime Directive #10). Three independent measurements, each with its controls passing in the same run: (1) no module under `packages/spec/src` imported any of the five except the `ui/index.ts` barrel, so no schema declared a carrier key; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` (25 roots, 4742 nodes) reached none of the 21 named object shapes, while `PageSchema`, `WebhookSchema` and `StateMachineSchema` all resolved `direct` and a synthetic carrier flipped all 21 — so unreachability was a fact about the graph, not a broken walker; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these modules' own unit tests. objectui holds TYPE re-exports and parity ratchets, never validators, and says so (its types package deliberately dropped the spec/ui zod-validator re-exports and keeps type-only ones). The 2026-08-04 ruling retired the family — touch, drag-and-drop, keyboard and motion are renderer built-in behaviour and offline belongs to a sync engine, none of it per-page metadata — and weighed wiring a carrier key (option B) and rejected it: that is a feature with a renderer behind it, not ledger clean-up. It also weighed tightening the shapes to `strictObject` and rejected that explicitly — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave \"a precisely validated dead slot, the more convincing lie\" (the lesson of the datasource capability flags: `readOnly` was precisely validated and read by nothing, while a shipped example called a datasource a read replica and wrote through it). Because there was no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, the same route 3 as `plugin-runtime-family-retired` (the kernel plugin-runtime family) and the `HttpServerConfig` retirement (seven keys no runtime read and no authoring door reached, retired with their container). ⚠️ Not to be confused with the theme-token retirement (theme-driven typography is not a near-term capability, so nine token groups nothing consumed were retired), which retired the THEME `animation` block — a different file, different defs, and that one did have a carrier key and therefore a tombstone. ADR-0049." + "surface": "A flattened `view` overlay saved through the metadata write door (`PUT /api/v1/meta/view/:name`, the Studio / MCP save) whose `viewKind` names one family while the body was judged by the other: a column-less `viewKind: \"list\"` body, which only the form overlay member used to accept (its list keys `sort`, `searchableFields`, `timeline`, `sharing` and the rest stripped unread), and a `viewKind: \"form\"` body carrying list `columns`, which only the list overlay member used to accept.", + "replacement": "Each overlay is judged by the member its `viewKind` names. A column-less list overlay is a patch on the view it shadows and carries list keys the list view schema accepts: a `sort` array of `{ field, order }` (the bare string clause was retired in 17.5.0), no `timeline.metaFields` (the timeline block has no such key), an array `searchableFields`, and the list `sharing` block (`{ type, lockedBy }`), not the form public-link block. A column-less list overlay names no `type`; one that does is a full inline config and lists its `columns`. A form overlay's `columns` is its body-column count (an integer); a field list means the body is a list view (`viewKind: \"list\"`) or belongs in `sections: [{ fields }]`.", + "migrationId": "view-overlay-judged-by-viewkind-arm", + "toMajor": 18, + "rationale": "Both overlay members shared one `viewKind: list | form` enum. The list member required `columns`, so it refused the column-less list patch the console writes on every toolbar save (the ruled patch-only storage shape, maintainer ruling: 「`persistViewPatch` 只存 patch,不存 merged base」); the union then tried the form member, which requires no list key and strips every one, and accepted it — so a retired `sort` string or a `timeline.metaFields` the list schema refuses by name was saved with `success: true` and stored as sent. Measured on `origin/main` @ `4df101c3` and again at `ce70876e` (after the `options`-bag door landed) through the real save. Ruled route C-prime: each member admits one `viewKind`, the list member judges a column-less patch (`columns` optional there only; the authoring list view keeps it required), and a column-less body that names a `type` stays refused at `columns`. Not convertible: whether a refused value was a typo or a stale capability is the author's call." }, { - "surface": "ui.notificationAction / ui.embedConfig", - "replacement": "(removed — there is no replacement shape, because there was never a key to write either into. Delete the import and the value. Notification presentation is still described by the surviving `NotificationType` / `NotificationSeverity` / `NotificationPosition` vocabulary; public access to a form is granted by the LIVE `FormView.sharing` block (`SharingConfig`), which is untouched. Notification action buttons as metadata, and iframe embedding, return via the enforce route of ADR-0049 through a new ADR — carrier key and renderer first, vocabulary second)", - "migrationId": "ui-notification-action-embed-config-retired", - "toMajor": 17, - "rationale": "Both shapes were published `@objectstack/spec/ui` vocabulary with NO AUTHORING DOOR. The v17 unknown-key strictness sweep, which measured each ui/ file for an authoring door before closing any shape, measured them three ways on 2026-08-03 and this retirement re-ran all three against `origin/main` before removing anything, each with a positive control that passed in the same run: (1) CARRIER — no schema in `packages/spec/src` declared a key of either type (`ui/notification.zod`'s only non-test importer was the barrel; `ui/sharing.zod`'s were the barrel and `ui/view.zod.ts`, which names its SIBLING `SharingConfigSchema`), measured by resolving specifiers rather than substring-matching, because the repo holds two `sharing.zod` modules and a substring test miscredits `stack.zod.ts` to the UI one; (2) REACHABILITY — a BFS from the 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema`, over `build-schemas.ts`'s own walk including its derived-clone bridge, never reached either, while `Page` / `Action` / `DashboardWidget` / `Webhook` and `SharingConfig` itself all resolved `root-graph` in the same run and an injected synthetic carrier flipped both; (3) PARSE — zero `.parse()` in objectstack, cloud or objectui outside their own unit tests. So nobody could author one and nothing ever validated one: the shape of the unwired plugin sandboxing config removed before it, an exported schema with no consumer read as a capability, and the ADR-0033 trap where an AI author takes `EmbedConfigSchema` in the published bundle as proof the platform serves iframes. Neither is stored metadata and neither has a carrier, so no `sys_metadata` row can hold one and there is no source for the D2 chain to rewrite; this entry is the D3 record. The sweep deliberately did NOT close them with `.strict()` — strictness is a property of a PARSE, and closing a shape nothing parses buys only \"a precisely-validated dead slot, the more convincing lie\" (the lesson of the datasource capability flags, whose `readOnly` was precisely validated and read by nothing) — and left the disposition to ADR-0049's enforce-or-remove, which came back REMOVE on 2026-08-04: a dead surface with no authoring door retires implementation-first, as three same-shape rulings that week had already decided. Each was orphaned by an earlier retirement one level up: `NotificationAction` lost its wrappers when the dual-source cleanup removed the `./ui` copies of `NotificationSchema` / `NotificationConfigSchema` (the same names declared differently on other entry points) — that retirement's published \"zero consumers\" evidence was later falsified for objectui, which re-exported both names, and is corrected on `ui/notification.zod`'s tombstone; the removal itself stands — and `EmbedConfig` lost its key at 17.0.0 when the 2026-06 liveness audit retired `App.embed` (no iframe route ever read it) — that key still stands as a `retiredKey()` tombstone in `app.zod.ts`, so an author who wrote the KEY already meets a prescription; this removes the value shape that outlived it. ⚠️ The retirement is per SCHEMA, not per file: `ui/sharing.zod` KEEPS `SharingConfigSchema`, a live door carried by `FormViewSchema.sharing` and read by `rest-server.ts` to mount the anonymous form routes, and `ui/notification.zod` keeps its three presentation enums. objectui consumed `NotificationActionSchema.shape.variant` as a VOCABULARY (never a parse) to pin its own hand-written `NotificationActionButton` interface — which is exactly why \"has a consumer\" never meant \"has an authoring door\" here; that pin is adapted objectui-side when it refreshes this dependency. ADR-0049." + "surface": "The legacy `options` bag on a flattened `view` overlay saved through the metadata write door (`PUT /api/v1/meta/view/:name`, the Studio / MCP save): `options.kanban`, `options.calendar`, `options.gantt`, `options.gallery`, `options.timeline`, `options.chart`, `options.map` and `options.tree` on a list overlay, any other key in the bag, and the bag on a form overlay.", + "replacement": "Each `options.KIND` block carrying only keys the top-level `KIND` block declares, with values that block accepts — or, preferred, the same keys moved to the top-level `KIND` block, which wins per key where both set one. A key the block does not declare is deleted or re-spelled to the declared key the refusal names (`options.kanban.groupField` becomes `groupByField`, `options.calendar.dateField` becomes `startDateField`); `options.timeline.metaFields` has no declared successor and is deleted. Any other key in the bag is deleted, and a form overlay carries no bag at all.", + "migrationId": "view-overlay-options-bag-judged", + "toMajor": 18, + "rationale": "The list overlay member re-opens its top level with a strip so the console's round-trip keys survive, and that strip dropped the `options` bag from the parse without looking inside it. The save stores the request body, not the parse output, and objectui's interface page forwards a stored view's `options` into the list renderer, which merges `options.KIND` under the top-level block — so a key the strict block refuses by name (`timeline.metaFields`) was saved and rendered when spelled `options.timeline.metaFields`. Measured on `origin/main` @ `8d1f7ab` through the real save. Ruled direction A (the maintainer's ruling of 2026-09-24): judge each `options.KIND` with the kind's strict schema and refuse an out-of-contract key by name, as the direct spelling is; refusing the bag whole was ruled out because the legacy `options.map` path is live and pinned. Judged key by key, because the renderer reads the bag as a per-key underlay of the top-level block: a bag that carries only the keys the top-level block leaves to it is legal and stays accepted. Not convertible: whether a refused key was a typo of a declared one or a retired capability is the author's call." }, { - "surface": "ui.widgetManifest / ui.widgetLifecycle / ui.widgetEvent / ui.widgetProperty / ui.widgetSource / ui.i18nObject / ui.pluralRule / ui.numberFormat / ui.dateFormat / ui.localeConfig (the widget-registration vocabulary of ui/widget.zod.ts, and the five doorless shapes of ui/i18n.zod.ts — 10 defs, 26 exported names)", - "replacement": "(removed — there is no replacement key, because there was never a key. A custom field widget is still named the same way it always was: `field.widget` is a plain string naming a component the RENDERER has registered, and objectui's registry has always carried its own runtime manifest for that (`RuntimeWidgetManifest` / `RuntimeWidgetSource` in `@object-ui/types`, renamed off the spec's names under objectui's rule that a symbol named like a spec export must import it or take a name of its own), which models different keys and never derived from these. For localisation: write the default-language string on `label` / `description` — the framework generates the translation key at registration time from the naming convention — and put translations in translation files, which is the LIVE `system/translation.zod.ts` surface. Widget registration and locale formatting as authorable protocol metadata return via the ENFORCE route of ADR-0049 through a new ADR — the registry / loader / formatter first, the vocabulary second)", - "migrationId": "ui-widget-i18n-family-retired", - "toMajor": 17, - "rationale": "`ui/widget.zod.ts` published a complete widget-registration vocabulary — a manifest with lifecycle hooks, custom events, configurable properties and an npm/remote/inline implementation-source union — and `ui/i18n.zod.ts` published a structured-label, plural-rule and locale-formatting vocabulary. NOTHING in the protocol carried either. Three independent measurements, re-run on `origin/main` immediately before the removal with their controls passing in the SAME run: (1) no module under `packages/spec/src` imported `widget.zod` at all, and the only imports of `i18n.zod` anywhere name `I18nLabelSchema` / `AriaPropsSchema` (both KEPT), so no schema declared a carrier key — `field.widget` is a `z.string()` naming a registered component and has never referenced `WidgetManifest`; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` reached none of them, while `PageSchema` / `ObjectListViewSchema` resolved `direct` in the same run and a synthetic carrier flipped every one of them; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these files' own unit tests. `NumberFormat` / `DateFormat` DID have a carrier key (`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier was itself doorless, so the subtree was `no door` rather than `no gate` and goes whole — leaving the two leaves behind would strand exported schemas with no consumer, which an author reads as a capability. `I18nObjectSchema` was additionally superseded by its own file-neighbour: `I18nLabelSchema`'s documentation already says translation keys are generated at registration time and translations live in translation files, and the live translation surface is `system/translation.zod.ts`, which uses none of these shapes. The 2026-08-06 ruling weighed giving them a carrier (option B) and rejected it: that is a feature with a registry and a renderer behind it, not ledger clean-up. Tightening them to `strictObject` was rejected earlier and explicitly, by the batch of the v17 unknown-key strictness sweep that measured this file as having no authoring door — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave \"a precisely validated dead slot, the more convincing lie\" (the lesson of the datasource capability flags, whose `readOnly` was precisely validated and read by nothing). With no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, route 3, the same shape as `ui-interaction-config-family-retired`, `plugin-runtime-family-retired` and the `HttpServerConfig` retirement. ⚠️ `WidgetManifest.performance`'s own `retiredKey()` tombstone (left by the close-out sweep that removed the inert `performance` keys no renderer applied) is SUBSUMED here, the way the kernel `activationEvents` tombstone went with the removed plugin-runtime family: it goes with the shape that carried it, which is strictly stronger than the tombstone, because there is no longer a manifest to author the key INTO. ⚠️ One of the nine widget sites is deliberately NOT retired. `FieldWidgetPropsSchema` survives: it is a REACT PROPS CONTRACT rather than authorable metadata (it never appeared in `authorable-surface/` or `json-schema.manifest/` — its `onChange` is a `z.function()`), so \"zero parse\" is its design and not its defect, and it acquired a live cross-repo compile-time consumer one day before that sweep batch measured: an objectui fix of 2026-08-03, made to follow the spec, renamed `@object-ui/fields`' validation slot onto the spec's `error` with no alias, the form renderer began producing it, and `packages/fields/src/__tests__/spec-symbol-batch7.test.ts` pins the shape against `import type { FieldWidgetProps } from '@objectstack/spec/ui'` as an intentional tripwire. Re-verified on objectui `origin/main` 2026-08-07. ADR-0049." + "surface": "view.owner / view.hidden on a flattened view overlay — the lean PUT /api/v1/meta/view/:name body with no config that the console saves for a view it personalizes", + "replacement": "(removed — no per-user view scope and no switcher filter exists.) A view is listed to everyone who can read its object. A view that must not be listed is deleted, or no longer shipped from source; per-user view scoping is a parked direction (ADR-0017), not a shipped mechanism.", + "migrationId": "view-overlay-owner-hidden-retired", + "toMajor": 18, + "rationale": "The D2 conversion `view-overlay-owner-hidden-removed` deletes both keys from every flattened overlay (a view body with no `config` and no container slot) — in `views` (stack sources, and every stored row, replayed on each read before it is served or badged) and in the assembled-manifest view item channel — and the delete is lossless: both switcher read paths filter on the view kind and object and sort on `order`, so an overlay saved with `hidden: true` hid nothing and no scope ever read `owner`. The judgment is about exposure, the same one the view item record's retirement leaves. A view someone hid or marked as one user's through its overlay has always been listed to every user who can read the object. The delete is lossless but does not always close the row: an overlay row that held nothing but its identity and these keys is left identity-only, a body the view door refuses, so that row needs its author (see the acceptance criteria). Measured writers in this repository and its sibling UI: zero (no source, example or skill, and objectui at its pinned commit and at main writes neither key on an overlay; the HotCRM app writes neither). NOT MEASURED: clients outside this repository, and production stored rows — the write door accepted and stored both until this release, and no deployment store is reachable from here." }, { - "surface": "sys_user_permission_set.delegated_from — the ADR-0091 D3 provenance column left the platform grant table declared by plugin-security (packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts). The sibling declaration on sys_user_position is untouched", - "replacement": "nothing on this table — delete the key from any authored `sys_user_permission_set` seed row (stack `data` entries) or data-door write that still carries it. Delegation semantics live on `sys_user_position`, where `delegated_from` remains declared AND runtime-enforced: the delegated-admin gate is what makes a position insert a delegation, and the explain engine attributes \"via delegation from X, until Y\". A permission-set grant that needs a provenance note keeps `reason` (free text), which remains declared on both grant tables", - "migrationId": "ups-delegated-from-column-retired", - "toMajor": 17, - "rationale": "Maintainer ruling 2026-08-18 on the finding that the delegation gate never reads this column on this object, ADR-0049 enforce-or-remove: REMOVE. The runtime delegation gate is structurally scoped to sys_user_position (`isDelegationWrite` returns false for every other object, so `assertSelfDelegation` is unreachable for this table), and the explain engine reads delegation provenance from sys_user_position rows only. On sys_user_permission_set the column was therefore declared and data-door-writable while NO runtime consumer read it — its only enforcement was an authoring-time lint (the D3 \"delegation row needs a reason\" rule), which a row written through the generic data door never meets. That is declared-but-unenforced in its pure form, on a security object: an author who stamped delegated_from on a permission-set grant believed they constrained delegation, and nothing refused or honoured it. Producers measured at zero — the only object literals naming both the table and the column were lint test fixtures. This is a platform-object COLUMN retirement, not a spec-key retirement, so the bookkeeping follows the audit-log-action-enum-retired shape: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — the surface ratchets are expected byte-identical), and the disposition is a SEMANTIC entry rather than a D2 conversion. A conversion over stack `data` seed records would be mechanically expressible, but no conversion in the chain rewrites seed rows today and the measured author base is zero; the loud channel already exists at runtime — the engine schema preflight refuses an undeclared field with 400 INVALID_FIELD before the driver or any hook runs — so this entry carries the prescription and the refusal carries the enforcement. ⚠️ Existing physical columns are deliberately untouched: schema sync is additive (ADR-0045), so a deployed database keeps the column; the platform stops declaring, projecting or accepting it. Zero producers means no rows are expected to carry a value; no backfill or destructive DDL is required or wanted. If delegation at permission-set granularity ever becomes a real need, the column is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce or do not declare." + "surface": "ui.PaginationConfig.pageSize — an OMITTED page size on a view", + "replacement": "nothing, to take the platform display page size of 50. To keep the old 25 rows per page on a view, write it: `pagination: { pageSize: 25 }`", + "migrationId": "view-pagination-page-size-default-50", + "toMajor": 18, + "rationale": "A RULED behaviour change on a default, so there is nothing to rewrite and nothing to refuse: the maintainer's ruling of 2026-09-24 set the platform display page size to 50, declared once in the protocol, and the declared default of `PaginationConfigSchema.pageSize` moved from 25 to 50. A `pagination` block that omits `pageSize` now parses to 50 — 50 rows per page on a paged view, and a fetch ceiling of 50 on a view with no pager (kanban, gallery, timeline). A view with no `pagination` block at all parses with none on either side; its page size reaches it through the renderer, which is ruled to read the spec default rather than keep its own number (an earlier ruling on the grid's page size, which the page-size ruling restated). Not losslessly convertible because the question is intent, not text: a mechanical pass that wrote `pageSize: 25` into every silent view would preserve the old number and defeat the ruling, and one that wrote 50 would add nothing the default does not already do. Only the deployment knows which silent views were relying on 25. The accept set is unchanged — a positive integer — and every authored `pageSize` parses exactly as before." }, { - "surface": "ui.ViewFilterRule value — the third key of a view filter rule, on every carrier of ViewFilterRuleSchema: ListView.filter, a list view tab filter, Page.filterBy, a related-list component filter and a lookup picker filter. It accepted any declared scalar or array for EVERY operator; the accepted shape is now decided by the rule operator — in / not_in require an array, between requires exactly two bounds, and every other operator is unchanged", - "replacement": "an ARRAY for in / not_in (a single value becomes a one-element list: value: \"won\" becomes value: [\"won\"]), and a two-element [min, max] array for between. The empty list [] stays legal for in / not_in and keeps its meaning. Nothing else moves: a scalar operator carrying an array, a string operator carrying a number, and a unary operator carrying an ignored value all still parse", - "migrationId": "view-filter-rule-value-shaped-by-operator", - "toMajor": 17, - "rationale": "A publish-time gate catching up to a query-time one, not a new rule. An earlier fix closed the RUNTIME half: `assertListComparandShapes` (@objectstack/objectql, filter-comparand-shape.ts) refuses a lowered `{ stage: { $nin: \"won\" } }` with a named 400 INVALID_FILTER, and before that it was a 500. The authoring surface stayed silent, so the failure was two-stage: the view published cleanly and only broke when someone opened it. That file names this very schema as the reachable authoring source of the defect. The tightening MIRRORS that gate exactly — three constraints, one for one — and deliberately goes no further, because an earlier fix already settled the opposite error (the ordering operators' comparand widened to the strings the platform itself produces): a schema stricter than the runtime \"in ways the runtime deliberately allows\" was the WRONG side and was widened to match. So `in: []` is still accepted (a declared predicate both drivers implement), `equals: [\"a\",\"b\"]` is still accepted (it lowers to a deep-equality comparand), and `is_empty: \"\"` is still accepted (the null predicates take their direction from the operator NAME — convertComparison ignores the value position, and the ObjectUI client deliberately sends a truthy placeholder there). ⚠️ Metadata AT REST is deliberately NOT rewritten, and there is no D2 conversion. A D2 entry replays a shape the platform once WROTE and renamed; this shape was never written by any first-party producer (every in / not_in rule in this repo, in objectui and in the cloud repo already carries an array — measured) and has never EXECUTED, since it 400s on first render today. Coercing it at load would be the platform guessing intent rather than replaying a rename, and it cannot guess honestly: value: \"\" would become the predicate [\"\"] (a real filter on the empty string) rather than the \"not filled in yet\" a console row means, and between: 5 has no defensible second bound at all. The read path does not re-validate stored rows (applyConversionsToStoredItem never validates, by its own contract), so no stored view becomes unreadable; what changes is that RE-SAVING such a view is refused at the write gate naming `value`, instead of storing a filter that 400s. ADR-0049 / ADR-0078 / ADR-0112." + "surface": "`VISIBILITY_STRICT_OPTIONS` (const) on `@objectstack/spec/shared` — the shared `strictObject` options of the visibility-carrying view/page shapes (ADR-0089 D3a)", + "replacement": "(removed from the public surface — no replacement export. It was an internal option bag for this package's own schemas; the visibility contract it configures is unchanged and still published through the schemas that use it — `FormFieldSchema`, `FormSectionSchema` and the page component — together with `normalizeVisibleWhen` and `VISIBILITY_ALIAS_KEYS`, which stay exported.)", + "migrationId": "visibility-strict-options-unexported", + "toMajor": 18, + "rationale": "ADR-0049 enforce-or-remove applied to an export. The const was barrel-exported while its type, `StrictObjectOptions`, is deliberately unpublished, so no consumer could annotate it, spread it into a typed option bag or name it in a parameter — a published value with no usable contract and zero measured pull outside this package. Publishing the type instead was weighed and not adopted: no consumer ever asked for it, and it would turn the strict-object template's internals into public API." }, { - "surface": "api.listViews / api.getView / api.createView / api.updateView / api.deleteView (the ViewProtocol interface and its ten Request/Response schemas in api/protocol.zod.ts — 10 defs, 25 exported names)", - "replacement": "the two view surfaces that are actually routed. For a view's STORED definition, the generic metadata methods with `type: 'view'` — `getMetaItem` / `getMetaItems` / `saveMetaItem` / `deleteMetaItem`, served at `/api/v1/meta/view/:name`. For the RESOLVED render-time view, `getUiView` (`GetUiViewRequest` / `GetUiViewResponse`), served at `/api/v1/ui/view/:object/:type`. Neither is addressed by a `viewId`, which is the one thing the retired surface offered and the one thing nothing implemented", - "migrationId": "view-management-protocol-retired", - "toMajor": 17, - "rationale": "A complete viewId-addressed CRUD surface — list (with a list/form filter), read, create, patch, delete — with none of the three things a protocol method needs. Measured on origin/main immediately before the removal: no implementation (`packages/metadata-protocol/src/protocol.ts` declares no `listViews` / `getView` / `createView` / `updateView` / `deleteView`; its only view resolver is `getUiView`), no route (`packages/rest/src/rest-server.ts` never mentions `viewId`, so nothing viewId-addressed is reachable over HTTP at all), and no caller (the only `ViewProtocol` mention outside its own file was the services checklist, which already recorded the five as declared-and-unrouted). The look-alike hits a bare-name grep turns up are all different contracts: `metadata-manager.ts`'s `getView(name: string)` is another class, and objectui's `getView(objectName, viewId)` resolves through `client.meta.getItem('view', …)`, i.e. the metadata route. What makes this worth a removal rather than a note is that the cost is already measured. A declared surface that is name-identical and semantics-adjacent to a real one is an attractive nuisance in every grep, and it mis-directed a decision once: The issue asking what `GET /ui/view/:object/:type` answers AND its 2026-08-07 maintainer ruling both read `GetViewResponseSchema` (zero implementations) as the contract of `GET /ui/view/:object/:type`, whose declared response is `GetUiViewResponseSchema` — one word apart, 250 lines up. That ruling's reasoning happened to survive the mix-up (\"nobody can consume `{object, view}` successfully today\" was true, though not for the stated reason), which is the luck this removal stops relying on. Route 3: none of the ten was a key on an authorable shape, nothing parsed them, so there is no tombstone and no D2 conversion — RETIRED_DEFS_BY_MAJOR plus this entry are the declaration. If reading and writing ONE view by id becomes a real requirement it returns implementation-first. ADR-0049, ADR-0087, maintainer ruling 2026-08-07." + "surface": "The `waitEventConfig` block of every `type: 'wait'` flow node, and the `boundaryConfig` block of every `type: 'boundary_event'` node — the BLOCK, not a key inside it. `eventType` has been required INSIDE each block since protocol 17, so the contract already refused `waitEventConfig: {}`; what it also accepted was the block missing entirely, which is the state a freshly created node is in. Two documents, two verdicts, and the accepted one was the silent one. Also narrowed one level down: under `eventType: 'timer'`, `timerDuration` is now required and may not be blank. ⚠️ That second narrowing sits on the BLOCK and is NOT gated on `type: 'wait'`, so it reaches any node type that carries a `waitEventConfig` at all — a `start` node spelled `waitEventConfig: { eventType: 'timer' }` parsed before and is refused now. Inert in practice, because no executor but the wait one reads the block, but a stack that spells it elsewhere must be edited too, so scan for the KEY and not only for the node type.", + "replacement": "Declare what resumes the node, on the node: `waitEventConfig: { eventType: 'timer', timerDuration: 'PT1H' }` for a delay — QUOTE a bare number, the key is a string and a numeric string is read as milliseconds, so '60000' is the same 60s wait as 'PT1M' — or `{ eventType: 'signal' | 'webhook' | 'manual' | 'condition', signalName: '' }` when an external producer resumes the run. For `boundary_event`, `boundaryConfig: { attachedToNodeId: '', eventType: 'error' | 'timer' | 'signal' | 'cancel' }`. ⛔ There is deliberately NO default for either `eventType`: a required key has no \"unset behaves as\", and an indefinite park — if one is ever wanted — is its own declared `eventType`, never the absence of configuration. ⚠️ `boundary_event` has no executor in the runtime at all (a flow reaching one fails with NO_EXECUTOR), so a stored boundary node is an authoring-surface repair: the native construct for error handling is a `try_catch` region (ADR-0031).", + "migrationId": "wait-node-event-config-required", + "toMajor": 18, + "rationale": "Maintainer ruling of 2026-09-13, the clause of the reply that covers this item, verbatim and untranslated: 「其他同意」 — carrying the presented option: the protocol is the source of truth; a designer never invents a default the protocol does not apply; a default the protocol should have is declared by the protocol; a required key has no \"unset behaves as\". ⛔ NOT losslessly convertible, and the reason is that the missing value is an INTENT no artifact records: a block-less wait node does not say whether its author meant a delay (and for how long) or a named signal (and which one), and a transform that picked one would be inventing the very default this ruling forbids. What the old runtime picked was 'timer' with no duration, which is not a wait at all: measured through a real `engine.execute()` run, such a node answered `{ success: true, suspend: true }`, scheduled no wake-up job THOUGH A JOB SERVICE WAS ANSWERING, persisted no `waitUntil` for a later boot's re-arm pass, and emitted not one log line at any level — the run parked forever and reported success. So the conversion layer (D2) cannot hide this break and the tombstone channel cannot carry it either (nothing was renamed or retired; a key that was optional became required), which leaves D3: a structured TODO naming each node that must be edited. The alternative considered and NOT taken was to warn and keep parsing — a warning on the authoring path an AI agent drives is read by nobody, and the agent reports \"done\" over a flow that hangs." }, { - "surface": "CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / discovery routes.workflow / RestApiRouteCategory workflow", - "replacement": "the live mechanisms the slot only ever pointed at: `state_machine` validation rules for record state machines, approval flow nodes on the approvals runtime (ADR-0019) for approvals, lifecycle hooks + `record_change` flows (service-automation) for record-triggered automation", - "migrationId": "workflow-service-slot-retired", - "toMajor": 17, - "rationale": "The workflow slot was declared end to end and implemented nowhere: no code in either repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches were plugin-dev's retired stub probe and the generic discovery walk), no implementation of any WorkflowProtocol method ever existed, and no host ever mounted `/api/v1/workflow` (DEFAULT_DISPATCHER_ROUTES, before it was retired as a stale list, named it among routes that never existed). Every part of it was ADR-0078's silently-inert declaration: a CoreServiceName nothing filled, a contract nothing implemented, a protocol nothing served, a discovery route field no builder could truthfully populate. These are TS/API surfaces and a discovery RESPONSE field — never stored in stack metadata, so there is no source for the chain to rewrite; consumers of the deleted types move their imports themselves. ADR-0049 / ADR-0078." + "surface": "the four WebSocket configuration durations whose name carried no unit: WebSocketConfig.reconnectInterval, WebSocketConfig.pingInterval, WebSocketConfig.timeout and WebSocketServerConfig.heartbeatInterval (api/websocket.zod.ts)", + "replacement": "reconnectIntervalMs, pingIntervalMs, timeoutMs and heartbeatIntervalMs — rename each key; every value is unchanged, and so is every default (1000, 30000, 5000, 30000)", + "migrationId": "websocket-durations-unit-in-key", + "toMajor": 18, + "rationale": "Maintainer ruling B (2026-09-02, extended on 2026-09-05 to runtime-emitted durations): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. What makes this shape worth one entry rather than four is the neighbour: on both configs a bare duration sits directly beside a bare COUNT — maxReconnectAttempts on the client, reconnectAttempts on the server — so `reconnectInterval: 5` and `maxReconnectAttempts: 5` read as the same kind of number and are not. Suffixing the durations separates the two families at the authoring site; the counts keep their names, because a count has no unit to carry. All four are retiredKey() tombstones (neither shape is strict, so a bare deletion would strip in silence). Why a semantic entry and not a D2 conversion: a WebSocketConfig is a client CONNECTION argument and a WebSocketServerConfig is a server CONSTRUCTION argument — neither is a stack collection member and neither is ever stored as a sys_metadata row, so the conversion chain has no seam that would see one. The same disposition the epoch-instant renames on this file took (epoch-instant-keys-renamed), and what ruling B prescribes for a key that is not authorable metadata. ADR-0087." } ], "removed": [] diff --git a/packages/spec/src/kernel/manifest-unknown-keys.test.ts b/packages/spec/src/kernel/manifest-unknown-keys.test.ts index 50915520baa..fba52f44303 100644 --- a/packages/spec/src/kernel/manifest-unknown-keys.test.ts +++ b/packages/spec/src/kernel/manifest-unknown-keys.test.ts @@ -38,6 +38,7 @@ import { describe, it, expect } from 'vitest'; import { ManifestSchema, PluginEnginesSchema } from './manifest.zod'; +import { PROTOCOL_MAJOR } from './protocol-version'; import { ArtifactPackageSchema, ObjectStackDefinitionSchema } from '../stack.zod'; import { formatZodError } from '../shared/error-map.zod'; @@ -104,6 +105,10 @@ describe("unknown keys inside `manifest:` are refused at parse (the silent-drop const issue = unrecognized(result)!; expect(issue.keys).toEqual(['specVersion']); expect(issue.message).toContain('engines.protocol'); + // The range it prescribes is the one the handshake admits: the current + // protocol major, never the previous one the handshake refuses. + expect(issue.message).toContain(`protocol: '^${PROTOCOL_MAJOR}'`); + expect(issue.message).not.toContain(`protocol: '^${PROTOCOL_MAJOR - 1}'`); expect(issue.message).not.toContain('Did you mean'); }); }); diff --git a/packages/spec/src/kernel/manifest.zod.ts b/packages/spec/src/kernel/manifest.zod.ts index 619cb5d85c6..ca3e57ad59a 100644 --- a/packages/spec/src/kernel/manifest.zod.ts +++ b/packages/spec/src/kernel/manifest.zod.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { CORE_PLUGIN_TYPES } from './plugin.zod'; +import { PROTOCOL_MAJOR } from './protocol-version'; import { SEMVER_2_0_0_VERSION_PATTERN } from './version-grammar'; import { retiredKey } from '../shared/retired-key'; import { closedObject, strictObject, strictObjectError } from '../shared/strict-object'; @@ -407,9 +408,12 @@ export const ManifestSchema = strictObject({ + 'exiting 0. The declared keys are enumerated by `ManifestSchema` (@objectstack/spec, ' + 'kernel/manifest.zod.ts) and in the package-manifest reference docs.', guidance: { + // The prescribed range is spelled from PROTOCOL_MAJOR, the value the load-time + // handshake compares and `os init` stamps, so it moves with the protocol instead + // of naming a major the handshake already refuses. specVersion: '`specVersion` is not a package-manifest key. The protocol axis the runtime checks at ' - + 'load is `engines.protocol` — declare `engines: { protocol: \'^17\' }` (the range the ' + + `load is \`engines.protocol\` — declare \`engines: { protocol: '^${PROTOCOL_MAJOR}' }\` (the range the ` + 'scaffold stamps); `specVersion` keeps its meaning only on the marketplace TEMPLATE ' + 'manifest (`cloud/template-manifest.zod.ts`), which is a different surface.', }, diff --git a/packages/spec/src/kernel/protocol-version.test.ts b/packages/spec/src/kernel/protocol-version.test.ts index bdd301131db..88da508c71f 100644 --- a/packages/spec/src/kernel/protocol-version.test.ts +++ b/packages/spec/src/kernel/protocol-version.test.ts @@ -23,6 +23,24 @@ describe('PROTOCOL_VERSION', () => { const pkgPath = fileURLToPath(new URL('../../package.json', import.meta.url)); const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version: string }; const pkgMajor = Number.parseInt(pkg.version.split('.')[0]!, 10); + if (PROTOCOL_MAJOR === pkgMajor) return; + + // ONE exception (ruling record 6049734955 on #22085, Q1 → B; its route, + // ruling record 6056808625, letter E): in Changesets pre mode with a pending + // `major` for this package, the protocol major may already equal the major + // that release is about to publish, so the protocol move lands in an + // ordinary pull request with full CI instead of inside the version pass. + // Its evidence is the repository's `.changeset/` directory, which a `local` + // test may not read (check:cross-package-test-inputs), and this file stays + // `local` because release.yml's post-version check runs it by that project. + // So this case recognises the exception's SHAPE only (a stable package + // version exactly one major behind) and the evidence (pre mode AND a pending + // `major` for @objectstack/spec) is judged by the repository gate + // check-changeset-no-major.mjs, which the Check Changeset job runs on every + // pull request and which refuses the shape without that evidence. Any other + // drift is red here. + const preModeShape = PROTOCOL_MAJOR === pkgMajor + 1 && !pkg.version.includes('-'); + if (preModeShape) return; expect(PROTOCOL_MAJOR).toBe(pkgMajor); }); }); diff --git a/packages/spec/src/kernel/protocol-version.ts b/packages/spec/src/kernel/protocol-version.ts index e35b7174865..e6efaeca460 100644 --- a/packages/spec/src/kernel/protocol-version.ts +++ b/packages/spec/src/kernel/protocol-version.ts @@ -13,9 +13,13 @@ * the moment the runtime crosses into `N+1`. * * Kept in lockstep with the package's own major; `protocol-version.test.ts` - * asserts it against `package.json` so the two cannot drift. + * asserts it against `package.json` so the two cannot drift. One exception, in + * Changesets pre mode with a pending `major` for this package: the constant may + * already name the major that release is about to publish, so the protocol move + * lands in a reviewed pull request with full CI rather than in the version pass + * (ruling record 6049734955, Q1 → B). */ -export const PROTOCOL_VERSION = '17.0.0'; +export const PROTOCOL_VERSION = '18.0.0'; /** The protocol major as an integer — the value the handshake compares. */ export const PROTOCOL_MAJOR: number = Number.parseInt(PROTOCOL_VERSION.split('.')[0]!, 10); diff --git a/scripts/check-changeset-no-major.mjs b/scripts/check-changeset-no-major.mjs index 60c2eb34134..f7ccd4701bc 100644 --- a/scripts/check-changeset-no-major.mjs +++ b/scripts/check-changeset-no-major.mjs @@ -14,9 +14,20 @@ * package, so a PR-scoped predicate is the whole of what it entails. Read * the block headed "The LEVEL axis" for what it cross-checks, where the * declaration comes from, and the residual it records. - * - * The run exits with the WORSE of the two verdicts and prints both, because - * they are independent facts about one changeset set. + * 3. THE PROTOCOL LOCKSTEP EVIDENCE (ruling records 6049734955 and + * 6056808625) — the one question here about the TREE rather than the + * diff. `PROTOCOL_VERSION` moves to the next major in an ordinary pull + * request, not in the version pass, so between that pull request and the + * line's first prerelease the constant is one major ahead of + * `@objectstack/spec`'s stable version. `protocol-version.test.ts` + * recognises that SHAPE and hands the judgement here, because the + * evidence lives in `.changeset/` and a `local` spec test may not read + * outside its package. The shape is admitted only in pre mode with a + * pending `major` for `@objectstack/spec`; otherwise it is refused, naming + * the remedy. Read the block headed "The protocol lockstep evidence". + * + * The run exits with the WORST of the three verdicts and prints all of them, + * because they are independent facts about one tree. * * Run: node scripts/check-changeset-no-major.mjs --base [--head ] * node scripts/check-changeset-no-major.mjs # base defaults to origin/main @@ -2023,6 +2034,181 @@ export function readPre(root) { } } +// ── The protocol lockstep evidence (ruling records 6049734955, 6056808625) ── +// +// `PROTOCOL_VERSION` is held equal to `@objectstack/spec`'s package major by +// `packages/spec/src/kernel/protocol-version.test.ts`. The ruling moves the +// protocol major in an ordinary pull request with full CI rather than in the +// version pass, and gives that lockstep exactly one exception: in Changesets pre +// mode with a pending `major`, the protocol major may equal the major about to +// be published. Between that pull request and the line's first version pass the +// tree therefore carries a stable package version (`17.7.0`) beside a protocol +// one major ahead (`18.0.0`). +// +// The lockstep test recognises that SHAPE and nothing more: it runs in the spec +// package's `local` vitest project (release.yml's post-version check runs it by +// that project), and a `local` test may not read the repository's `.changeset/` +// (check:cross-package-test-inputs). The EVIDENCE is judged here, beside the two +// readings this gate already makes of the same directory: `pre.json` (the RC +// exemption) and the pending majors (`--list`). It reads the TREE, not the diff, +// on purpose: the condition is a property of the state a pull request leaves +// behind, and a state that breaks it was introduced by the pull request that +// removed the marker, exited pre mode or moved the constant, each of which is +// then refused here. +// +// ⛔ Both inputs are read from SOURCE text, never from a built `dist`: the gate +// runs before `pnpm install` and needs no build. The constant's declaration is +// the line `sync-protocol-version.mjs` rewrites at version time, read with the +// same literal shape. + +/** The repository paths this question reads, relative to the root. */ +export const PROTOCOL_VERSION_SOURCE = 'packages/spec/src/kernel/protocol-version.ts'; +export const SPEC_MANIFEST = 'packages/spec/package.json'; +export const SPEC_PACKAGE = '@objectstack/spec'; + +/** + * The protocol major declared in `protocol-version.ts`'s source text, or `null` + * when the declaration is not there. + * + * @param {string | null} text + * @returns {number | null} + */ +export function protocolMajorIn(text) { + const m = /^export const PROTOCOL_VERSION = (['"])(\d+)\.\d+\.\d+\1;/m.exec(String(text ?? '')); + if (!m) return null; + const major = Number.parseInt(m[2], 10); + return Number.isInteger(major) && major >= 1 ? major : null; +} + +/** + * Everything the lockstep question reads off one tree. Each field is `null` + * (or empty) when it cannot be read, and `judgeProtocolLockstep` turns an + * unreadable REQUIRED input into a failure, never a pass (#4690). + * + * @param {string} root + * @returns {{ protocolMajor: number | null, specVersion: string | null, + * pre: { mode?: string, tag?: string } | null, pendingSpecMajors: string[] | null }} + */ +export function readProtocolLockstepInputs(root) { + let protocolMajor = null; + try { + protocolMajor = protocolMajorIn(readFileSync(join(root, PROTOCOL_VERSION_SOURCE), 'utf8')); + } catch { + protocolMajor = null; + } + let specVersion = null; + try { + const version = JSON.parse(readFileSync(join(root, SPEC_MANIFEST), 'utf8'))?.version; + specVersion = typeof version === 'string' && version.length > 0 ? version : null; + } catch { + specVersion = null; + } + const changesets = readChangesets(root); + const pendingSpecMajors = changesets + ? [...changesets.keys()].sort().filter((name) => majorPackagesIn(changesets.get(name)).includes(SPEC_PACKAGE)) + : null; + return { protocolMajor, specVersion, pre: readPre(root), pendingSpecMajors }; +} + +/** + * Decide the lockstep question. Pure, like `judge`. + * + * unreadable a required input could not be read -> exit 1 (#4690) + * not-applicable no shape: the constant is not exactly one major ahead + * of a STABLE package version (equal majors, a + * prerelease package, any other drift — the last is the + * lockstep test's own red) -> exit 0 + * evidenced the shape, in pre mode, with a pending `major` for + * `@objectstack/spec` -> exit 0 + * refused the shape without that evidence -> exit 1 + * + * `pendingSpecMajors` is only required once the shape is present: a tree that + * is not in the window has nothing for it to prove. + * + * @param {{ protocolMajor: number | null, specVersion: string | null, + * pre: { mode?: string, tag?: string } | null, pendingSpecMajors: string[] | null }} input + * @returns {{ verdict: string, protocolMajor: number | null, specVersion: string | null, + * missing: string[], evidence: string[], tag: string | null }} + */ +export function judgeProtocolLockstep({ protocolMajor, specVersion, pre, pendingSpecMajors }) { + const base = { protocolMajor, specVersion, missing: [], evidence: [], tag: null }; + const unreadable = []; + if (protocolMajor === null) unreadable.push(`the PROTOCOL_VERSION declaration in ${PROTOCOL_VERSION_SOURCE}`); + const m = /^(\d+)\.(\d+)\.(\d+)(-.+)?$/.exec(String(specVersion ?? '')); + if (!m) unreadable.push(`the version in ${SPEC_MANIFEST}`); + if (unreadable.length) return { ...base, verdict: 'unreadable', missing: unreadable }; + + const specMajor = Number.parseInt(m[1], 10); + const stable = m[4] === undefined; + if (!(stable && protocolMajor === specMajor + 1)) return { ...base, verdict: 'not-applicable' }; + + if (pendingSpecMajors === null) { + return { ...base, verdict: 'unreadable', missing: ['the .changeset directory'] }; + } + const missing = []; + if (pre?.mode !== 'pre') missing.push('pre-mode'); + if (pendingSpecMajors.length === 0) missing.push('pending-major'); + if (missing.length) return { ...base, verdict: 'refused', missing }; + return { ...base, verdict: 'evidenced', evidence: pendingSpecMajors, tag: pre.tag ?? 'unknown' }; +} + +/** + * Render the lockstep verdict. Pure, like `render`: the self-test asserts the + * MESSAGE, and a refusal carries ONE `::error::` annotation (#18263). + * + * @param {ReturnType< typeof judgeProtocolLockstep >} result + * @returns {{ exitCode: number, stdout: string[], stderr: string[] }} + */ +export function renderProtocolLockstep(result) { + const stdout = []; + const stderr = []; + const where = `PROTOCOL_VERSION major ${result?.protocolMajor ?? '?'}, ${SPEC_PACKAGE}@${result?.specVersion ?? '?'}`; + switch (result?.verdict) { + case 'unreadable': { + const what = result.missing.join(' and '); + stderr.push( + `⛔ check-changeset-no-major: the protocol lockstep evidence could not be judged — ${what} could not be read. ` + + 'Missing input is a failure, never a pass (#4690).', + ); + stdout.push( + errorAnnotation({ + title: 'Check Changeset: the protocol lockstep inputs could not be read', + message: `${what} could not be read, so the protocol lockstep exception was not judged. A gate that cannot read its input has verified nothing.`, + }), + ); + return { exitCode: 1, stdout, stderr }; + } + case 'not-applicable': + stdout.push(`✓ Protocol lockstep: ${where} — not one major ahead of a stable version, so there is no pre-mode exception to judge.`); + return { exitCode: 0, stdout, stderr }; + case 'evidenced': + stdout.push( + `✓ Protocol lockstep: ${where} — the one exception, evidenced: pre mode (tag \`${result.tag}\`) and a pending ` + + `\`major\` for ${SPEC_PACKAGE} (${result.evidence.map((f) => `.changeset/${f}`).join(', ')}).`, + ); + return { exitCode: 0, stdout, stderr }; + case 'refused': { + const specMajor = Number.parseInt(String(result.specVersion).split('.')[0], 10); + const lacks = []; + if (result.missing.includes('pre-mode')) lacks.push('Changesets is not in pre mode (`.changeset/pre.json` with `"mode": "pre"`)'); + if (result.missing.includes('pending-major')) lacks.push(`no pending changeset declares \`"${SPEC_PACKAGE}": major\``); + const remedy = + `Either put the constant back in lockstep — \`PROTOCOL_VERSION = '${specMajor}.0.0'\` in ${PROTOCOL_VERSION_SOURCE} — ` + + `or open the next major line first: \`changeset pre enter \` and a changeset declaring \`"${SPEC_PACKAGE}": major\`, ` + + `so the version pass publishes ${specMajor + 1}.0.0-.0 and the protocol major matches it.`; + const message = + `${where}: the protocol major is one ahead of the package, which the lockstep admits only in pre mode with a ` + + `pending major for ${SPEC_PACKAGE}, and here ${lacks.join(', and ')}. ${remedy}`; + stderr.push(`⛔ check-changeset-no-major: protocol lockstep refused. ${message}`); + stdout.push(errorAnnotation({ title: 'Check Changeset: PROTOCOL_VERSION is ahead of the package without pre mode and a pending major', message })); + return { exitCode: 1, stdout, stderr }; + } + default: + stderr.push(`⛔ check-changeset-no-major: unknown protocol lockstep verdict ${JSON.stringify(result?.verdict)}.`); + return { exitCode: 1, stdout, stderr }; + } +} + /** * `--list`: the whole pending `.changeset` directory, majors called out. * @@ -2135,7 +2321,15 @@ function main(argv) { for (const line of level.stdout) console.log(line); for (const line of level.stderr) console.error(line); - process.exit(Math.max(exitCode, level.exitCode)); + // ── The protocol lockstep evidence (ruling records 6049734955, 6056808625) ─ + // + // The third independent fact, read off the TREE (see its block above), and + // folded into the exit code the same way: the worst verdict wins. + const lockstep = renderProtocolLockstep(judgeProtocolLockstep(readProtocolLockstepInputs(REPO_ROOT))); + for (const line of lockstep.stdout) console.log(line); + for (const line of lockstep.stderr) console.error(line); + + process.exit(Math.max(exitCode, level.exitCode, lockstep.exitCode)); } // ── Self-test ──────────────────────────────────────────────────────────────── @@ -2182,13 +2376,15 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'THE ROOT: a packed `bin` target is a published surface the axis can refuse (#16692)': 37, '#18263: the refusal says its reason, and says it where the API can read it': 35, '#19008: the level headline is the parsed declaration, not a literal': 30, + 'The protocol lockstep evidence, in BOTH directions (ruling 6056808625)': 39, }); // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. Lowered 20 → 19 when the // carrier-axis battery left with the gate label it read (ruling record -// 5770886272 on #19061): the ordinary direction, one battery, one row. -const SELF_TEST_BATTERY_FLOOR = 19; +// 5770886272 on #19061): the ordinary direction, one battery, one row. Raised +// 19 → 20 with the protocol lockstep battery (ruling record 6056808625). +const SELF_TEST_BATTERY_FLOOR = 20; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -4208,6 +4404,107 @@ function selfTest() { ); } + // ── The protocol lockstep evidence (ruling records 6049734955, 6056808625) ─ + battery('The protocol lockstep evidence, in BOTH directions (ruling 6056808625)'); + { + const MARKER = ['22080-v18-line-opens.md']; + const PRE = { mode: 'pre', tag: 'next' }; + const shape = (over) => ({ protocolMajor: 18, specVersion: '17.7.0', pre: PRE, pendingSpecMajors: MARKER, ...over }); + + // The admitting direction: the shape WITH both halves of the evidence. + const ok = judgeProtocolLockstep(shape({})); + assert(ok.verdict === 'evidenced', `lockstep: shape + pre mode + pending spec major is evidenced — got ${ok.verdict}`); + const okOut = renderProtocolLockstep(ok); + assert(okOut.exitCode === 0, 'lockstep: an evidenced shape exits 0'); + assert(okOut.stdout.join('\n').includes('.changeset/22080-v18-line-opens.md'), 'lockstep: the evidenced line names the changeset that carries the major'); + + // The refusing direction, one missing half at a time, then both. + const noPre = judgeProtocolLockstep(shape({ pre: null })); + assert(noPre.verdict === 'refused' && noPre.missing.join() === 'pre-mode', `lockstep: the shape without pre.json is refused for pre mode — got ${noPre.verdict} ${noPre.missing}`); + const exited = judgeProtocolLockstep(shape({ pre: { mode: 'exit', tag: 'next' } })); + assert(exited.verdict === 'refused' && exited.missing.join() === 'pre-mode', 'lockstep: `pre exit` is not pre mode — refused'); + const noMajor = judgeProtocolLockstep(shape({ pendingSpecMajors: [] })); + assert(noMajor.verdict === 'refused' && noMajor.missing.join() === 'pending-major', `lockstep: pre mode without a pending spec major is refused — got ${noMajor.verdict} ${noMajor.missing}`); + const neither = judgeProtocolLockstep(shape({ pre: null, pendingSpecMajors: [] })); + assert(neither.verdict === 'refused' && neither.missing.join() === 'pre-mode,pending-major', 'lockstep: with neither half, both are named'); + + // The refusal says its reason and its remedy, once, where the API reads it. + const refusedOut = renderProtocolLockstep(noPre); + const refusedText = [...refusedOut.stdout, ...refusedOut.stderr].join('\n'); + assert(refusedOut.exitCode === 1, 'lockstep: a refusal exits 1'); + assert(refusedOut.stdout.filter((l) => l.startsWith('::error ')).length === 1, 'lockstep: a refusal carries exactly ONE ::error annotation (#18263)'); + assert(refusedText.includes("PROTOCOL_VERSION = '17.0.0'"), 'lockstep: the remedy names the in-lockstep constant for THIS package major'); + assert(refusedText.includes('changeset pre enter'), 'lockstep: the remedy names entering pre mode'); + assert(refusedText.includes('"@objectstack/spec": major'), 'lockstep: the remedy names the pending major it needs'); + assert(renderProtocolLockstep(noMajor).stderr.join('\n').includes('no pending changeset declares'), 'lockstep: a missing major is named as such, not as a pre-mode problem'); + + // No shape: nothing to judge, whatever the evidence says. + assert(judgeProtocolLockstep({ protocolMajor: 17, specVersion: '17.7.0', pre: null, pendingSpecMajors: [] }).verdict === 'not-applicable', 'lockstep: equal majors outside pre mode are not-applicable (the ordinary PR)'); + assert(judgeProtocolLockstep({ protocolMajor: 18, specVersion: '18.0.0-next.0', pre: PRE, pendingSpecMajors: [] }).verdict === 'not-applicable', 'lockstep: after the line opens (a prerelease of the protocol major) there is no shape'); + assert(judgeProtocolLockstep({ protocolMajor: 19, specVersion: '17.7.0', pre: PRE, pendingSpecMajors: MARKER }).verdict === 'not-applicable', 'lockstep: two majors ahead is NOT the exception — it is the lockstep test\'s own red'); + assert(judgeProtocolLockstep({ protocolMajor: 18, specVersion: '17.8.0-next.2', pre: PRE, pendingSpecMajors: MARKER }).verdict === 'not-applicable', 'lockstep: a prerelease of the OLD major is not the stable shape'); + assert(judgeProtocolLockstep({ protocolMajor: 17, specVersion: '17.7.0', pre: null, pendingSpecMajors: null }).verdict === 'not-applicable', 'lockstep: an unreadable .changeset is not demanded when there is no shape'); + assert(renderProtocolLockstep(judgeProtocolLockstep({ protocolMajor: 17, specVersion: '17.7.0', pre: null, pendingSpecMajors: [] })).exitCode === 0, 'lockstep: not-applicable exits 0'); + + // Missing input is a failure, never a pass (#4690). + assert(judgeProtocolLockstep(shape({ protocolMajor: null })).verdict === 'unreadable', 'lockstep: an unreadable constant is a failure'); + assert(judgeProtocolLockstep(shape({ specVersion: null })).verdict === 'unreadable', 'lockstep: an unreadable package version is a failure'); + assert(judgeProtocolLockstep(shape({ pendingSpecMajors: null })).verdict === 'unreadable', 'lockstep: the shape with an unreadable .changeset is a failure'); + assert(renderProtocolLockstep(judgeProtocolLockstep(shape({ protocolMajor: null }))).exitCode === 1, 'lockstep: unreadable exits 1'); + + // The source reader: the declaration sync-protocol-version.mjs rewrites, read as TEXT (no build). + assert(protocolMajorIn("export const PROTOCOL_VERSION = '18.0.0';\n") === 18, 'lockstep reader: the single-quoted declaration'); + assert(protocolMajorIn('export const PROTOCOL_VERSION = "18.0.0";\n') === 18, 'lockstep reader: a double-quoted declaration'); + assert(protocolMajorIn("// export const PROTOCOL_VERSION = '19.0.0';\n") === null, 'lockstep reader: a commented-out declaration declares nothing'); + assert(protocolMajorIn('export const PROTOCOL_MAJOR = 18;\n') === null, 'lockstep reader: a file without the declaration reads null'); + + // The live tree reads: every input this question needs is readable here. + const live = readProtocolLockstepInputs(REPO_ROOT); + assert(Number.isInteger(live.protocolMajor), `lockstep: the live ${PROTOCOL_VERSION_SOURCE} yields an integer major — got ${live.protocolMajor}`); + assert(typeof live.specVersion === 'string', `lockstep: the live ${SPEC_MANIFEST} yields a version`); + assert(Array.isArray(live.pendingSpecMajors), 'lockstep: the live .changeset directory reads'); + assert(judgeProtocolLockstep(live).verdict !== 'unreadable', 'lockstep: the live tree is judgeable'); + + // The readers on a real temp tree, the shape and each half of the evidence. + const tmp = mkdtempSync(join(tmpdir(), 'cnm-lockstep-')); + try { + const write = (rel, text) => { + mkdirSync(dirname(join(tmp, rel)), { recursive: true }); + writeFileSync(join(tmp, rel), text); + }; + write(PROTOCOL_VERSION_SOURCE, "/** doc */\nexport const PROTOCOL_VERSION = '18.0.0';\n"); + write(SPEC_MANIFEST, JSON.stringify({ name: SPEC_PACKAGE, version: '17.7.0' })); + write('.changeset/pre.json', JSON.stringify(PRE)); + write('.changeset/22080-v18-line-opens.md', MAJOR); + write('.changeset/other.md', MINOR); + write('.changeset/pre/consumed.md', MAJOR); + const read = readProtocolLockstepInputs(tmp); + assert(read.pendingSpecMajors.join() === '22080-v18-line-opens.md', `lockstep readers: only a TOP-LEVEL pending changeset counts — got ${JSON.stringify(read.pendingSpecMajors)}`); + assert(judgeProtocolLockstep(read).verdict === 'evidenced', 'lockstep readers: the window tree is evidenced'); + rmSync(join(tmp, '.changeset/22080-v18-line-opens.md')); + assert(judgeProtocolLockstep(readProtocolLockstepInputs(tmp)).verdict === 'refused', 'lockstep readers: a major consumed into .changeset/pre/ is not pending — refused'); + write('.changeset/22080-v18-line-opens.md', MAJOR); + rmSync(join(tmp, '.changeset/pre.json')); + assert(judgeProtocolLockstep(readProtocolLockstepInputs(tmp)).verdict === 'refused', 'lockstep readers: no pre.json — refused'); + write(SPEC_MANIFEST, JSON.stringify({ name: SPEC_PACKAGE, version: '18.0.0-next.0' })); + assert(judgeProtocolLockstep(readProtocolLockstepInputs(tmp)).verdict === 'not-applicable', 'lockstep readers: after the version pass there is no shape'); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + + // The wiring: main() judges it and folds it into the exit code, and the + // spec test's hand-off names this file, so neither half points at nothing. + const selfText = readFileSync(fileURLToPath(import.meta.url), 'utf8'); + assert( + selfText.includes('renderProtocolLockstep(judgeProtocolLockstep(readProtocolLockstepInputs(REPO_ROOT)))'), + 'lockstep wiring: main() judges the live tree', + ); + assert(/process\.exit\(Math\.max\(exitCode, level\.exitCode, lockstep\.exitCode\)\)/.test(selfText), 'lockstep wiring: the verdict reaches the exit code'); + const handOffPath = join(REPO_ROOT, 'packages/spec/src/kernel/protocol-version.test.ts'); + const handOff = existsSync(handOffPath) ? readFileSync(handOffPath, 'utf8') : ''; + assert(handOff.includes('check-changeset-no-major.mjs'), 'lockstep wiring: the lockstep test hands the shape to THIS gate by name'); + } + // ── The floor: every declared battery RAN, and ran its cases (#13489) ─── // // Evaluated after every battery has had its chance and BEFORE the verdict, so @@ -4260,7 +4557,7 @@ function selfTest() { } console.log( `✓ check-changeset-no-major --self-test: ${checked} assertions ` + - '(frontmatter dialects measured against @changesets/parse + the pre/exit exemption switch in both directions + the #7005 diff scoping over real temp git repos + the #4690 pins + the LEVEL axis on #16044\'s two real heads + the wiring).', + '(frontmatter dialects measured against @changesets/parse + the pre/exit exemption switch in both directions + the #7005 diff scoping over real temp git repos + the #4690 pins + the LEVEL axis on #16044\'s two real heads + the protocol lockstep evidence in both directions + the wiring).', ); return SELF_TEST_VERDICT; diff --git a/scripts/check-future-spec-major.mjs b/scripts/check-future-spec-major.mjs index 9fe210b8312..f4c2c336bc4 100644 --- a/scripts/check-future-spec-major.mjs +++ b/scripts/check-future-spec-major.mjs @@ -347,6 +347,29 @@ const QUOTATION_EXEMPTIONS = Object.freeze([ 'The same recorded evidence sentence, in the registry that carries the ' + 'semantic entry above. A count, not a version.', }, + { + file: 'packages/spec/spec-changes.json', + major: 4997, + kind: 'measurement', + covers: 2, + witness: /controls objectstack 12966 and @objectstack\/spec 4997 on the same corpus/, + why: + 'The generated copies of that same semantic entry\'s evidence sentence: ' + + '`gen:spec-changes` projects step 18\'s semantic entries into the ' + + 'per-major 17 → 18 record and into the aggregate record, once each, ' + + 'from protocol 18 on. A count, not a version.', + }, + { + file: 'docs/protocol-upgrade-guide.md', + major: 4997, + kind: 'measurement', + covers: 1, + witness: /controls objectstack 12966 and @objectstack\/spec 4997 on the same corpus/, + why: + 'The generated copy of that same semantic entry\'s evidence sentence: ' + + '`gen:upgrade-guide` renders step 18\'s semantic entries into the ' + + 'Protocol 17 → 18 section from protocol 18 on. A count, not a version.', + }, { file: 'docs/adr/0021-analytics-dataset-semantic-layer.md', major: 6526,