From 40c3fac134c691d1b32de13677b19e7474e6759e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 13:35:16 +0000 Subject: [PATCH 1/6] fix(spec): correct the prose that justified a missing D3 entry by losslessness (#20201) Ruling B (ADR-0087 D3): every retirement family carries one D3 entry, even when a lossless D2 conversion repairs its data. Corrects the step-17 docblock's stale 'semantic list is deliberately empty' fact, step 18's tenancy.organizationField rationale sentence, and the single-entry sites in conversions/registry.ts, the cacheTtl retired key, the metadata-endpoints entry comment and the migration-registry generator docblock. Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- .../spec/scripts/build-migration-registry.ts | 5 ++- packages/spec/src/conversions/registry.ts | 29 +++++++++----- .../18.api__ApiEndpoint__cacheTtl.ts | 16 ++++---- ...a-endpoints-switch-radius-repartitioned.ts | 5 ++- packages/spec/src/migrations/registry.ts | 39 +++++++++++-------- 5 files changed, 58 insertions(+), 36 deletions(-) diff --git a/packages/spec/scripts/build-migration-registry.ts b/packages/spec/scripts/build-migration-registry.ts index 5c2dfcecab7..4e45f5f2cab 100644 --- a/packages/spec/scripts/build-migration-registry.ts +++ b/packages/spec/scripts/build-migration-registry.ts @@ -273,8 +273,9 @@ const closeMarker = (kind: Kind, major: number) => ` // ): { text: string; errors: string[] } { const errors: string[] = []; diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index cee304a07e5..03119bd1fae 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -3457,7 +3457,7 @@ const datasourceConfigDriverKeyAliases: MetadataConversion = { * rows. So the stored value converges here rather than each reader learning to * accept both. * - * ## Why D2 and not D3 + * ## Why the data repair is D2 * * There is a concrete stored value with a lossless, behaviour-preserving * rewrite, which is the D2 test exactly. `mongo` and `mongodb` resolve to the @@ -3466,6 +3466,12 @@ const datasourceConfigDriverKeyAliases: MetadataConversion = { * {@link datasourceConfigDriverKeyAliases}, whose scope guard exists because * rewriting a sqlite `path:` WOULD have moved a database. * + * Losslessness decides only that the data repair is D2. It does not decide + * whether the family ALSO owes a D3 entry — every retirement family does + * (`SemanticMigration`, `migrations/types.ts`). This one converts into + * protocol 17, whose step shipped before that rule and was not back-filled, + * so it has none. + * * ## Why it stays on the LIVE load path * * Unlike the key-alias conversion above, `mongo` is not a spelling the authoring @@ -9303,10 +9309,13 @@ const jobTimeoutToTimeoutMs: MetadataConversion = { * `apis[].cacheTtl` → `apis[].cacheTtlSeconds` (protocol 18, #15677 for #14478) * — the `api` half of the same rename `hookTimeoutToTimeoutMs` and * `jobTimeoutToTimeoutMs` document, and the ONE key of that card's twelve that - * gets a conversion rather than a semantic entry: `apis:` is a stack collection + * also gets a conversion: `apis:` is a stack collection * (`apis: z.array(ApiEndpointSchema)`) and `api` is a registered metadata kind * stored as a row, so the chain has a seam that sees it. The other eleven are - * wire payloads and construction arguments the chain never touches. + * wire payloads and construction arguments the chain never touches, so their + * D3 entries are their only channel. This key's family carries a D3 entry too, + * `api-endpoint-cache-ttl-unit-in-key`: the rename keeps the value, and only + * the author can say whether the value was ever in seconds. * * Same posture as its two siblings: retired from the load path, tombstoned at * the schema, replayable here. The fixture keeps `rateLimit` out of the @@ -9792,12 +9801,14 @@ const viewPageMountRemoved: MetadataConversion = { * upstream and failed downstream, and the author was told off by the wrong * layer. * - * The rewrite is lossless and wholly mechanical, which is why this is a D2 - * conversion rather than a semantic TODO: `'created_at desc'` carries exactly - * the tuple `{ field: 'created_at', order: 'desc' }`; a bare field name meant - * ASCENDING, so it is written out as `order: 'asc'` rather than omitted - * (`order` is required on the entry); and the comma-separated multi-key form - * the wire normalizer splits on becomes one entry per key, in the same order. + * The rewrite is lossless and wholly mechanical, which is why the data repair + * is a D2 conversion (the family's D3 entry, + * `list-view-sort-string-clause-retired`, carries the clauses this rewrite + * leaves alone): `'created_at desc'` carries exactly the tuple + * `{ field: 'created_at', order: 'desc' }`; a bare field name meant ASCENDING, + * so it is written out as `order: 'asc'` rather than omitted (`order` is + * required on the entry); and the comma-separated multi-key form the wire + * normalizer splits on becomes one entry per key, in the same order. * * ⚠️ A string that does NOT parse as that grammar is left ALONE and emits * nothing — the `'-field'` OData-ish dialect above all. That dialect belongs to diff --git a/packages/spec/src/migrations/entries/retired-keys/18.api__ApiEndpoint__cacheTtl.ts b/packages/spec/src/migrations/entries/retired-keys/18.api__ApiEndpoint__cacheTtl.ts index 61b8d350f4b..04e63f71847 100644 --- a/packages/spec/src/migrations/entries/retired-keys/18.api__ApiEndpoint__cacheTtl.ts +++ b/packages/spec/src/migrations/entries/retired-keys/18.api__ApiEndpoint__cacheTtl.ts @@ -8,11 +8,13 @@ // `cacheTtlSeconds`; the value is unchanged and the key stays GET-only. // Tombstoned with `retiredKey()` — the shape is not `.strict()`, so a bare // deletion would strip the old key in silence, and the unknown-key error could -// not carry the rename. This is the ONE key of this card's twelve that gets a -// D2 CONVERSION rather than a semantic entry: `apis:` is a stack collection -// (`stack.zod.ts` — `apis: z.array(ApiEndpointSchema)`) and an `api` is a -// registered metadata kind stored as a row, so the conversion chain has a seam -// that sees it. `api-endpoint-cache-ttl-to-cache-ttl-seconds` rewrites it, -// retired from the load path (no alias window). Registered under 18 for the -// launch-window reason its neighbours state. +// not carry the rename. This is the ONE key of this card's twelve that also +// gets a D2 CONVERSION: `apis:` is a stack collection (`stack.zod.ts` — +// `apis: z.array(ApiEndpointSchema)`) and an `api` is a registered metadata +// kind stored as a row, so the conversion chain has a seam that sees it. +// `api-endpoint-cache-ttl-to-cache-ttl-seconds` rewrites it, retired from the +// load path (no alias window); the family's D3 entry is +// `api-endpoint-cache-ttl-unit-in-key`, because a rename that keeps the value +// cannot say whether the value was ever in seconds. Registered under 18 for +// the launch-window reason its neighbours state. export const entry = 'api/ApiEndpoint:cacheTtl'; diff --git a/packages/spec/src/migrations/entries/semantic/18.metadata-endpoints-switch-radius-repartitioned.ts b/packages/spec/src/migrations/entries/semantic/18.metadata-endpoints-switch-radius-repartitioned.ts index 0a6d412c47a..cde36112fb2 100644 --- a/packages/spec/src/migrations/entries/semantic/18.metadata-endpoints-switch-radius-repartitioned.ts +++ b/packages/spec/src/migrations/entries/semantic/18.metadata-endpoints-switch-radius-repartitioned.ts @@ -8,8 +8,9 @@ // What an embedder is owed is a PRESCRIPTION, because the mounted route table their // existing config produces has moved in both directions, and the compiler cannot // tell them: every key is optional and boolean, so the old spelling still compiles -// and still parses. That is exactly the residue D2 cannot express, which is why this -// is a semantic entry rather than a conversion. +// and still parses. That residue is also why this family has no D2 conversion at +// all; its D3 entry is owed either way, because every retirement family carries +// one whether or not a conversion also repairs its data. import type { SemanticMigration } from '../../types.js'; export const entry: SemanticMigration = { diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index b45e2f28368..2f9b5d85887 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -75,12 +75,15 @@ export const MIGRATION_SUPPORT_FLOOR = 16; /** * Protocol 17 step. * - * Mechanical, and mechanical only: the three deprecated aliases that a schema - * transform used to fold into a canonical key and drop from the parsed output - * (`action.execute`, `field.conditionalRequired`, `agent.knowledge.topics`) are - * removed from the spec. Each is a pure key rename with an unchanged value, so - * the whole break replays losslessly — there is no semantic residue and the - * `semantic` list is deliberately empty. + * Mechanical: the three deprecated aliases that a schema transform used to + * fold into a canonical key and drop from the parsed output (`action.execute`, + * `field.conditionalRequired`, `agent.knowledge.topics`) are removed from the + * spec. Each is a pure key rename with an unchanged value, so each replays + * losslessly as a D2 conversion. None of the three carries a D3 entry: this + * step shipped before every retirement family was required to carry one + * (`SemanticMigration`, `./types.ts`), and it was not back-filled when that + * rule landed. The step's `semantic` list is NOT empty — its later retirements + * carry their D3 entries in the generated region below. * * The three conversions are `retiredFromLoadPath` from the day they land: 17 * gives the aliases no acceptance window at all, and each schema tombstones its @@ -5250,9 +5253,11 @@ const step18: MigrationStep = { + '`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 there is no semantic residue — an application ' - + 'whose tenant column genuinely is not `organization_id` declares `tenancy.tenantField`, ' - + 'which both walls the object and stamps its platform rows. ' + + '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 ' @@ -15408,13 +15413,15 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // `cacheTtlSeconds`; the value is unchanged and the key stays GET-only. // Tombstoned with `retiredKey()` — the shape is not `.strict()`, so a bare // deletion would strip the old key in silence, and the unknown-key error could - // not carry the rename. This is the ONE key of this card's twelve that gets a - // D2 CONVERSION rather than a semantic entry: `apis:` is a stack collection - // (`stack.zod.ts` — `apis: z.array(ApiEndpointSchema)`) and an `api` is a - // registered metadata kind stored as a row, so the conversion chain has a seam - // that sees it. `api-endpoint-cache-ttl-to-cache-ttl-seconds` rewrites it, - // retired from the load path (no alias window). Registered under 18 for the - // launch-window reason its neighbours state. + // not carry the rename. This is the ONE key of this card's twelve that also + // gets a D2 CONVERSION: `apis:` is a stack collection (`stack.zod.ts` — + // `apis: z.array(ApiEndpointSchema)`) and an `api` is a registered metadata + // kind stored as a row, so the conversion chain has a seam that sees it. + // `api-endpoint-cache-ttl-to-cache-ttl-seconds` rewrites it, retired from the + // load path (no alias window); the family's D3 entry is + // `api-endpoint-cache-ttl-unit-in-key`, because a rename that keeps the value + // cannot say whether the value was ever in seconds. Registered under 18 for + // the launch-window reason its neighbours state. 'api/ApiEndpoint:cacheTtl', // #14691 — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, // executing the #14369 liveness census (15 `dead` rows across the `crud` / From 92af158fb2d7433cd00cbddda09c9cabb2eceee9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 13:41:47 +0000 Subject: [PATCH 2/6] fix(spec): add D3 entries for the first seventeen major-18 families missing one WIP toward #20201's census: one D3 entry per D2-backed retirement family that had none (ruling B, ADR-0087 D3). Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- .../18.api-endpoint-cache-ttl-unit-in-key.ts | 31 + .../18.connector-error-mapping-retired.ts | 39 ++ ...nector-resilience-durations-unit-in-key.ts | 38 ++ .../18.cube-metric-filters-retired.ts | 34 + ....dashboard-refresh-interval-unit-in-key.ts | 34 + ...8.element-input-target-variable-retired.ts | 37 ++ ...-inline-and-related-list-columns-closed.ts | 37 ++ .../18.field-reference-to-spelling-retired.ts | 32 + .../18.form-view-option-default-retired.ts | 31 + .../semantic/18.hook-timeout-unit-in-key.ts | 29 + .../semantic/18.job-timeout-unit-in-key.ts | 30 + .../18.mapping-lookup-params-retired.ts | 35 ++ ...sistence-auto-save-interval-unit-in-key.ts | 34 + .../18.object-grid-default-sort-retired.ts | 30 + .../18.object-kanban-quick-add-retired.ts | 31 + .../18.page-component-responsive-retired.ts | 36 ++ ...8.permission-restore-purge-bits-retired.ts | 39 ++ ...18.record-highlights-field-icon-retired.ts | 29 + ...nslation-component-submit-label-retired.ts | 30 + .../18.turso-config-timeout-unit-in-key.ts | 31 + packages/spec/src/migrations/registry.ts | 587 ++++++++++++++++++ 21 files changed, 1254 insertions(+) create mode 100644 packages/spec/src/migrations/entries/semantic/18.api-endpoint-cache-ttl-unit-in-key.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.connector-error-mapping-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.connector-resilience-durations-unit-in-key.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.cube-metric-filters-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.dashboard-refresh-interval-unit-in-key.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.element-input-target-variable-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.field-inline-and-related-list-columns-closed.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.field-reference-to-spelling-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.form-view-option-default-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.hook-timeout-unit-in-key.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.job-timeout-unit-in-key.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.mapping-lookup-params-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.memory-persistence-auto-save-interval-unit-in-key.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.object-grid-default-sort-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.object-kanban-quick-add-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.page-component-responsive-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.permission-restore-purge-bits-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.record-highlights-field-icon-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.translation-component-submit-label-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.turso-config-timeout-unit-in-key.ts diff --git a/packages/spec/src/migrations/entries/semantic/18.api-endpoint-cache-ttl-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.api-endpoint-cache-ttl-unit-in-key.ts new file mode 100644 index 00000000000..ce577a58cac --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.api-endpoint-cache-ttl-unit-in-key.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #15677 (stack card 2/6 of #14478, maintainer ruling B: a duration key +// carries its unit in its NAME) — the D3 entry of the +// `api-endpoint-cache-ttl-to-cache-ttl-seconds` family (ruling B on #17152: +// one D3 entry per retirement family, even when D2 is lossless). The card's +// other eleven keys have no conversion and carry their own D3 entries; this +// one has both. +export const entry: SemanticMigration = { + id: 'api-endpoint-cache-ttl-unit-in-key', + 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.', + reason: '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.', + acceptanceCriteria: '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.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.connector-error-mapping-retired.ts b/packages/spec/src/migrations/entries/semantic/18.connector-error-mapping-retired.ts new file mode 100644 index 00000000000..a6684ba02b2 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.connector-error-mapping-retired.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #14676 (ADR-0049 enforce-or-remove) — the D3 entry of the +// `connector-error-mapping-removed` family (ruling B on #17152: one D3 entry +// per retirement family, even when D2 is lossless). The family is the key on +// both carriers (`integration/Connector:errorMapping`, +// `integration/DeclarativeConnectorEntry:errorMapping`) and the shape that +// leaves with it — `integration/ErrorMappingConfig`, +// `integration/ErrorMappingRule` and `integration/ConnectorErrorCategory` in +// RETIRED_DEFS_BY_MAJOR[18]. +export const entry: SemanticMigration = { + id: 'connector-error-mapping-retired', + 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.', + reason: '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.', + acceptanceCriteria: '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.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.connector-resilience-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.connector-resilience-durations-unit-in-key.ts new file mode 100644 index 00000000000..8dcf1c9b66e --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.connector-resilience-durations-unit-in-key.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #15680 (stack card of #14478, maintainer ruling B: a duration key carries its +// unit in its NAME) — the D3 entry of the +// `connector-health-and-trigger-durations-unit-in-key` family (ruling B on +// #17152: one D3 entry per retirement family, even when D2 is lossless). The +// two keys share one authored document and one conversion, so they share one +// entry. Both renamed keys are still unread (the liveness ledger records each +// as dead, `liveness/connector.json`): the rename is an honesty fix to the +// declaration, and the entry says so rather than implying a live engine. +export const entry: SemanticMigration = { + id: 'connector-resilience-durations-unit-in-key', + surface: 'connector.health.circuitBreaker.monitoringWindow and connector.triggers[].interval — ' + + 'the two connector durations whose name carried no unit', + replacement: '`monitoringWindowMs` (milliseconds) and `intervalSeconds` (seconds) — rename each ' + + 'key; both values are unchanged.', + reason: 'The D2 conversion `connector-health-and-trigger-durations-unit-in-key` renames both keys ' + + 'in `connectors[]` and on stored connector rows, keeping each value, with a separate notice ' + + 'per key so an operator sees which of its own keys moved; the rename is lossless because ' + + 'each key always meant the unit its new name states. Two judgments remain. First, the units ' + + 'were easy to get wrong in opposite directions: `monitoringWindow` (milliseconds) sat one ' + + 'key below `resetTimeoutMs`, and the bare token `interval` means MILLISECONDS elsewhere in ' + + 'this same spec while a trigger interval meant SECONDS — so a trigger written ' + + '`interval: 60000` for one minute asked for once every sixteen hours or so, and the rename ' + + 'keeps 60000. Second, neither key drives an engine today: no polling loop reads a trigger ' + + 'interval, and no circuit breaker exists for connectors, so nothing reads the monitoring ' + + 'window. An author who relied ' + + 'on either for behaviour has not been getting it, before or after this rename.', + acceptanceCriteria: 'No connector carries `health.circuitBreaker.monitoringWindow` or ' + + '`triggers[].interval`; the parse refuses both with the rename. Every `monitoringWindowMs` ' + + 'value is the window the author intends in milliseconds and every `intervalSeconds` value ' + + 'the cadence the author intends in seconds — a trigger meant to poll every minute reads ' + + '`intervalSeconds: 60`. No part of the deployment\'s design depends on a connector polling ' + + 'on that interval or tripping on that window: where it did, the author has moved that need ' + + 'to a mechanism that runs.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.cube-metric-filters-retired.ts b/packages/spec/src/migrations/entries/semantic/18.cube-metric-filters-retired.ts new file mode 100644 index 00000000000..a7884c09534 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.cube-metric-filters-retired.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #10414 (ADR-0049 enforce-or-remove) — the D3 entry of the +// `metric-filters-removed` family (ruling B on #17152: one D3 entry per +// retirement family, even when D2 is lossless). The unknown-keys entry +// `analytics-authorable-unknown-keys-refused` names the conversion only in +// passing; this is the family's own entry. The strip preserves observed +// behaviour exactly — which is the problem: the observed behaviour was an +// unfiltered number under a filtered name. +export const entry: SemanticMigration = { + id: 'cube-metric-filters-retired', + surface: 'analyticsCubes[].measures..filters — the per-metric raw-SQL filter list', + replacement: 'One of three filters that ARE applied: a `where` condition at query time, the ' + + 'condition folded into the metric\'s own `sql` expression (a conditional aggregate), or an ' + + 'ADR-0021 dataset measure with a structured `filter`.', + reason: '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 three live mechanisms expresses the condition ' + + 'they meant — a query-time `where` changes every query, a conditional aggregate changes the ' + + 'metric, a dataset measure moves it to the governed layer — and whether numbers already ' + + 'published from the unfiltered metric need to be revisited.', + acceptanceCriteria: '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 three 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.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.dashboard-refresh-interval-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.dashboard-refresh-interval-unit-in-key.ts new file mode 100644 index 00000000000..7a7383a9391 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.dashboard-refresh-interval-unit-in-key.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #15680 (stack card of #14478, maintainer ruling B: a duration key carries its +// unit in its NAME) — the D3 entry of the +// `dashboard-refresh-interval-to-refresh-interval-seconds` family (ruling B on +// #17152: one D3 entry per retirement family, even when D2 is lossless). The +// one key of that stack whose reader lives in another repository, released on +// its own schedule — so besides the unit check, the author owes a look at the +// running console. +export const entry: SemanticMigration = { + id: 'dashboard-refresh-interval-unit-in-key', + 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.', + reason: '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.', + acceptanceCriteria: '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.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.element-input-target-variable-retired.ts b/packages/spec/src/migrations/entries/semantic/18.element-input-target-variable-retired.ts new file mode 100644 index 00000000000..c7a0b8321f8 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.element-input-target-variable-retired.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #9198 (ADR-0049 enforce-or-remove) — the D3 entry of the +// `element-input-target-variable-removed` family. Every retirement family +// carries one D3 entry even when a lossless D2 conversion repairs its data +// (ruling B on #17152, on the maintainer's #15954 authority). The strip is +// lossless because the key never bound anything; what it cannot do is create +// the binding the author meant to declare, because only the author knows which +// page variable an input was supposed to feed. +export const entry: SemanticMigration = { + id: 'element-input-target-variable-retired', + 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.', + reason: '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.', + acceptanceCriteria: '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.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.field-inline-and-related-list-columns-closed.ts b/packages/spec/src/migrations/entries/semantic/18.field-inline-and-related-list-columns-closed.ts new file mode 100644 index 00000000000..8f9e0486366 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.field-inline-and-related-list-columns-closed.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #9227 — the D3 entry of the `field-column-lists-canonicalized` family (ruling +// B on #17152: one D3 entry per retirement family, even when D2 repairs the +// data). The conversion respells and folds every entry it can resolve; it +// deliberately leaves the two shapes it cannot resolve for the parse to refuse, +// and the related-list fold drops decoration keys by design. Both halves are +// the author's to finish. +export const entry: SemanticMigration = { + id: 'field-inline-and-related-list-columns-closed', + 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.', + reason: '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.', + acceptanceCriteria: '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.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.field-reference-to-spelling-retired.ts b/packages/spec/src/migrations/entries/semantic/18.field-reference-to-spelling-retired.ts new file mode 100644 index 00000000000..f6cc6d84fb2 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.field-reference-to-spelling-retired.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #13700 (ui#6837 half 1) — the D3 entry of the `field-reference-to-alias` +// family (ruling B on #17152: one D3 entry per retirement family, even when D2 +// is lossless). The rename is lossless for every row it can decide; it leaves +// the one row it cannot decide, and it cannot reach code. +export const entry: SemanticMigration = { + id: 'field-reference-to-spelling-retired', + 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.', + reason: '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.', + acceptanceCriteria: '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.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.form-view-option-default-retired.ts b/packages/spec/src/migrations/entries/semantic/18.form-view-option-default-retired.ts new file mode 100644 index 00000000000..77e2cb71c77 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.form-view-option-default-retired.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #12868 (maintainer-ruled narrowing on the objectui#6263 analysis) — the D3 +// entry of the `form-view-option-default-removed` family (ruling B on #17152: +// one D3 entry per retirement family, even when D2 is lossless). The strip +// changes no form; the prescribed replacement changes MORE than one form, and +// that difference is the author's call. +export const entry: SemanticMigration = { + id: 'form-view-option-default-retired', + 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`.', + reason: '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.', + acceptanceCriteria: '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.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.hook-timeout-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.hook-timeout-unit-in-key.ts new file mode 100644 index 00000000000..d086802d179 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.hook-timeout-unit-in-key.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #14478 (maintainer ruling B: a duration key carries its unit in its NAME) — +// the D3 entry of the `hook-timeout-to-timeout-ms` family (ruling B on #17152: +// one D3 entry per retirement family, even when D2 is lossless). The rename +// keeps the number, which is exactly the part the ruling was about: whether +// that number was ever in milliseconds is the author's to say. +export const entry: SemanticMigration = { + id: 'hook-timeout-unit-in-key', + 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.', + reason: '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.', + acceptanceCriteria: '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.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.job-timeout-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.job-timeout-unit-in-key.ts new file mode 100644 index 00000000000..f074f2cc192 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.job-timeout-unit-in-key.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #14478 (maintainer ruling B: a duration key carries its unit in its NAME) — +// the D3 entry of the `job-timeout-to-timeout-ms` family (ruling B on #17152: +// one D3 entry per retirement family, even when D2 is lossless). The job half +// of the hook rename, with its own audience: whoever schedules background work. +export const entry: SemanticMigration = { + id: 'job-timeout-unit-in-key', + 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.', + reason: '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.', + acceptanceCriteria: '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.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.mapping-lookup-params-retired.ts b/packages/spec/src/migrations/entries/semantic/18.mapping-lookup-params-retired.ts new file mode 100644 index 00000000000..1f51a1eeea5 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.mapping-lookup-params-retired.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #10329 (ADR-0049 enforce-or-remove) — the D3 entry of the +// `mapping-lookup-params-removed` family (ruling B on #17152: one D3 entry per +// retirement family, even when D2 is lossless). The strip preserves observed +// import behaviour exactly; the judgment it leaves is whether the author's +// import was ever doing what the four keys said. +export const entry: SemanticMigration = { + id: 'mapping-lookup-params-retired', + 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.', + reason: '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.', + acceptanceCriteria: '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.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.memory-persistence-auto-save-interval-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.memory-persistence-auto-save-interval-unit-in-key.ts new file mode 100644 index 00000000000..a699cccf98f --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.memory-persistence-auto-save-interval-unit-in-key.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #15680 (stack card of #14478, maintainer ruling B: a duration key carries its +// unit in its NAME) — the D3 entry of the +// `memory-persistence-auto-save-interval-to-ms` family (ruling B on #17152: one +// D3 entry per retirement family, even when D2 is lossless). Registered keys: +// `data/FilePersistenceConfig:autoSaveInterval` and +// `data/AutoPersistenceConfig:autoSaveInterval` — both arms of the persistence +// union, because both forward the same value to the same file adapter. +export const entry: SemanticMigration = { + id: 'memory-persistence-auto-save-interval-unit-in-key', + 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.', + reason: '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.', + acceptanceCriteria: '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.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.object-grid-default-sort-retired.ts b/packages/spec/src/migrations/entries/semantic/18.object-grid-default-sort-retired.ts new file mode 100644 index 00000000000..6acc9703e1f --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.object-grid-default-sort-retired.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #11805 (ADR-0049 enforce-or-remove) — the D3 entry of the +// `object-grid-default-sort-removed` family (ruling B on #17152: one D3 entry +// per retirement family, even when D2 is lossless). The conversion follows +// the renderer's own precedence exactly, so it preserves what the grid did — +// including the case where what the grid did was not what the author wrote. +export const entry: SemanticMigration = { + id: 'object-grid-default-sort-retired', + 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.', + reason: '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.', + acceptanceCriteria: '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.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.object-kanban-quick-add-retired.ts b/packages/spec/src/migrations/entries/semantic/18.object-kanban-quick-add-retired.ts new file mode 100644 index 00000000000..77e6fc48ec2 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.object-kanban-quick-add-retired.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #17260 (ADR-0049 enforce-or-remove) — the D3 entry of the +// `object-kanban-quick-add-removed` family (ruling B on #17152: one D3 entry +// per retirement family, even when D2 is lossless). The strip changes nothing a +// user sees; the decision it leaves is whether the board needed the control +// the author asked for. +export const entry: SemanticMigration = { + id: 'object-kanban-quick-add-retired', + surface: 'page.component.object-kanban.quickAdd — the per-column quick-add switch on the ' + + 'metadata-driven board', + replacement: '(removed from the metadata board.) The quick-add control exists on the `kanban-ui` ' + + 'block, where a React host supplies the `onQuickAdd` function the control calls. On a ' + + 'metadata board, records are created through the object\'s ordinary create action.', + reason: '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, or needs a React host rendering the ' + + '`kanban-ui` block with a real `onQuickAdd`, is a product decision about that board — not ' + + 'something a key delete can make.', + acceptanceCriteria: '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 either accepted creating records through the object\'s create ' + + 'action, or moved that board to a host that renders the `kanban-ui` block with `onQuickAdd` ' + + 'supplied — where clicking a column\'s add control creates a record in that column.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.page-component-responsive-retired.ts b/packages/spec/src/migrations/entries/semantic/18.page-component-responsive-retired.ts new file mode 100644 index 00000000000..6d8b71c3435 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.page-component-responsive-retired.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #11027 (ADR-0049) — the D3 entry of the `page-component-responsive-removed` +// family (ruling B on #17152: one D3 entry per retirement family, even when D2 +// is lossless). The family is the key plus the shape that leaves with it — +// `ui/ResponsiveConfig`, `ui/BreakpointColumnMap`, `ui/BreakpointName` and +// `ui/BreakpointOrderMap` in RETIRED_DEFS_BY_MAJOR[18]. The strip changes no +// pixel; translating an intended layout into CSS that IS applied is a design +// decision the conversion cannot make. +export const entry: SemanticMigration = { + id: 'page-component-responsive-retired', + 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.', + reason: '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.', + acceptanceCriteria: '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.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.permission-restore-purge-bits-retired.ts b/packages/spec/src/migrations/entries/semantic/18.permission-restore-purge-bits-retired.ts new file mode 100644 index 00000000000..8d986c0a7e1 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.permission-restore-purge-bits-retired.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #12497 (ADR-0049, maintainer ruling accepting #1883's recommendation B) — the +// D3 entry of the `permission-allow-restore-purge-removed` family (ruling B on +// #17152: one D3 entry per retirement family, even when D2 is lossless). The +// family is the four registered keys: `allowRestore` / `allowPurge` on +// `security/ObjectPermission` and on `security/EffectiveObjectPermission`. +// The strip changes no access decision; what it leaves is a governance +// question, because the bits sat on the two most destructive verbs. +export const entry: SemanticMigration = { + id: 'permission-restore-purge-bits-retired', + 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.', + reason: '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.', + acceptanceCriteria: '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.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.record-highlights-field-icon-retired.ts b/packages/spec/src/migrations/entries/semantic/18.record-highlights-field-icon-retired.ts new file mode 100644 index 00000000000..c834c6adfed --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.record-highlights-field-icon-retired.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #10054 (ADR-0049 enforce-or-remove) — the D3 entry of the +// `record-highlights-field-icon-removed` family (ruling B on #17152: one D3 +// entry per retirement family, even when D2 is lossless). The strip changes no +// pixel; what it leaves is the meaning the author put in an icon nobody drew. +export const entry: SemanticMigration = { + id: 'record-highlights-field-icon-retired', + 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.', + reason: '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.', + acceptanceCriteria: '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.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.translation-component-submit-label-retired.ts b/packages/spec/src/migrations/entries/semantic/18.translation-component-submit-label-retired.ts new file mode 100644 index 00000000000..b6b7ec81d2c --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.translation-component-submit-label-retired.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #10926 — the D3 entry of the `translation-component-submit-label-removed` +// family (ruling B on #17152: one D3 entry per retirement family, even when D2 +// is lossless). The strip deletes a string nothing has read since its carrier +// retired; where the translator's work should go instead is not something the +// conversion can decide. +export const entry: SemanticMigration = { + id: 'translation-component-submit-label-retired', + 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.', + reason: '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.', + acceptanceCriteria: '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.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.turso-config-timeout-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.turso-config-timeout-unit-in-key.ts new file mode 100644 index 00000000000..23466f25ab0 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.turso-config-timeout-unit-in-key.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #15680 / #15682 (stack card of #14478, maintainer ruling B: a duration key +// carries its unit in its NAME) — the D3 entry of the +// `turso-config-timeout-to-timeout-ms` family (ruling B on #17152: one D3 entry +// per retirement family, even when D2 is lossless). One conversion covers the +// authored surface of both declarations — the spec's turso contract and the +// driver package's own mirror schema, renamed in the same change. +export const entry: SemanticMigration = { + id: 'turso-config-timeout-unit-in-key', + 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.', + reason: '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.', + acceptanceCriteria: '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.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 2f9b5d85887..bb7d6b20939 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5873,6 +5873,33 @@ const step18: MigrationStep = { + '`@objectstack/spec/api-assembled`. No metadata document, stored row or JSON Schema reference ' + 'needs editing: the schemas and their published ids did not change.', }, + // #15677 (stack card 2/6 of #14478, maintainer ruling B: a duration key + // carries its unit in its NAME) — the D3 entry of the + // `api-endpoint-cache-ttl-to-cache-ttl-seconds` family (ruling B on #17152: + // one D3 entry per retirement family, even when D2 is lossless). The card's + // other eleven keys have no conversion and carry their own D3 entries; this + // one has both. + { + id: 'api-endpoint-cache-ttl-unit-in-key', + 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.', + reason: '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.', + acceptanceCriteria: '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.', + }, { id: 'api-error-retry-after-unit-in-key', surface: 'EnhancedApiError.retryAfter (api/errors.zod.ts) — the ADR-0112 error envelope on the wire', @@ -6895,6 +6922,41 @@ const step18: MigrationStep = { + 'fail tsc on upgrade; the fix is choosing a shipped driver, never ' + 'widening a local mirror of the enum.', }, + // #14676 (ADR-0049 enforce-or-remove) — the D3 entry of the + // `connector-error-mapping-removed` family (ruling B on #17152: one D3 entry + // per retirement family, even when D2 is lossless). The family is the key on + // both carriers (`integration/Connector:errorMapping`, + // `integration/DeclarativeConnectorEntry:errorMapping`) and the shape that + // leaves with it — `integration/ErrorMappingConfig`, + // `integration/ErrorMappingRule` and `integration/ConnectorErrorCategory` in + // RETIRED_DEFS_BY_MAJOR[18]. + { + id: 'connector-error-mapping-retired', + 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.', + reason: '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.', + acceptanceCriteria: '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.', + }, { id: 'connector-provider-context-connection-timeout-ms-retired', surface: 'ConnectorProviderContext.connectionTimeoutMs, the declared connect deadline handed ' @@ -6937,6 +6999,40 @@ const step18: MigrationStep = { + 'deliberately do NOT move, and a sweep that removed either has over-applied this entry: ' + 'both resolve to real reads at the fetch site.', }, + // #15680 (stack card of #14478, maintainer ruling B: a duration key carries its + // unit in its NAME) — the D3 entry of the + // `connector-health-and-trigger-durations-unit-in-key` family (ruling B on + // #17152: one D3 entry per retirement family, even when D2 is lossless). The + // two keys share one authored document and one conversion, so they share one + // entry. Both renamed keys are still unread (the liveness ledger records each + // as dead, `liveness/connector.json`): the rename is an honesty fix to the + // declaration, and the entry says so rather than implying a live engine. + { + id: 'connector-resilience-durations-unit-in-key', + surface: 'connector.health.circuitBreaker.monitoringWindow and connector.triggers[].interval — ' + + 'the two connector durations whose name carried no unit', + replacement: '`monitoringWindowMs` (milliseconds) and `intervalSeconds` (seconds) — rename each ' + + 'key; both values are unchanged.', + reason: 'The D2 conversion `connector-health-and-trigger-durations-unit-in-key` renames both keys ' + + 'in `connectors[]` and on stored connector rows, keeping each value, with a separate notice ' + + 'per key so an operator sees which of its own keys moved; the rename is lossless because ' + + 'each key always meant the unit its new name states. Two judgments remain. First, the units ' + + 'were easy to get wrong in opposite directions: `monitoringWindow` (milliseconds) sat one ' + + 'key below `resetTimeoutMs`, and the bare token `interval` means MILLISECONDS elsewhere in ' + + 'this same spec while a trigger interval meant SECONDS — so a trigger written ' + + '`interval: 60000` for one minute asked for once every sixteen hours or so, and the rename ' + + 'keeps 60000. Second, neither key drives an engine today: no polling loop reads a trigger ' + + 'interval, and no circuit breaker exists for connectors, so nothing reads the monitoring ' + + 'window. An author who relied ' + + 'on either for behaviour has not been getting it, before or after this rename.', + acceptanceCriteria: 'No connector carries `health.circuitBreaker.monitoringWindow` or ' + + '`triggers[].interval`; the parse refuses both with the rename. Every `monitoringWindowMs` ' + + 'value is the window the author intends in milliseconds and every `intervalSeconds` value ' + + 'the cadence the author intends in seconds — a trigger meant to poll every minute reads ' + + '`intervalSeconds: 60`. No part of the deployment\'s design depends on a connector polling ' + + 'on that interval or tripping on that window: where it did, the author has moved that need ' + + 'to a mechanism that runs.', + }, { id: 'cube-join-sql-and-relationship-retired', // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code @@ -6982,6 +7078,36 @@ const step18: MigrationStep = { + '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.', }, + // #10414 (ADR-0049 enforce-or-remove) — the D3 entry of the + // `metric-filters-removed` family (ruling B on #17152: one D3 entry per + // retirement family, even when D2 is lossless). The unknown-keys entry + // `analytics-authorable-unknown-keys-refused` names the conversion only in + // passing; this is the family's own entry. The strip preserves observed + // behaviour exactly — which is the problem: the observed behaviour was an + // unfiltered number under a filtered name. + { + id: 'cube-metric-filters-retired', + surface: 'analyticsCubes[].measures..filters — the per-metric raw-SQL filter list', + replacement: 'One of three filters that ARE applied: a `where` condition at query time, the ' + + 'condition folded into the metric\'s own `sql` expression (a conditional aggregate), or an ' + + 'ADR-0021 dataset measure with a structured `filter`.', + reason: '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 three live mechanisms expresses the condition ' + + 'they meant — a query-time `where` changes every query, a conditional aggregate changes the ' + + 'metric, a dataset measure moves it to the governed layer — and whether numbers already ' + + 'published from the unfiltered metric need to be revisited.', + acceptanceCriteria: '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 three 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.', + }, { id: 'dashboard-header-modal-target-page-only', surface: @@ -7015,6 +7141,36 @@ const step18: MigrationStep = { + '`.` target instead. Clicking each converted button opens the intended ' + 'page or form rather than a refusal dialog.', }, + // #15680 (stack card of #14478, maintainer ruling B: a duration key carries its + // unit in its NAME) — the D3 entry of the + // `dashboard-refresh-interval-to-refresh-interval-seconds` family (ruling B on + // #17152: one D3 entry per retirement family, even when D2 is lossless). The + // one key of that stack whose reader lives in another repository, released on + // its own schedule — so besides the unit check, the author owes a look at the + // running console. + { + id: 'dashboard-refresh-interval-unit-in-key', + 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.', + reason: '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.', + acceptanceCriteria: '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.', + }, // The judgement half of `dashboard-widget-chart-config-structure-removed`. The // D2 conversion strips the four keys mechanically; what they CARRIED cannot be // moved by a walker, because the intent lands one level up in a selection the @@ -8148,6 +8304,39 @@ const step18: MigrationStep = { + '`schemaValid: true` in `--json`, and the run closes with the schema-valid line ' + 'rather than the manual-changes warning', }, + // #9198 (ADR-0049 enforce-or-remove) — the D3 entry of the + // `element-input-target-variable-removed` family. Every retirement family + // carries one D3 entry even when a lossless D2 conversion repairs its data + // (ruling B on #17152, on the maintainer's #15954 authority). The strip is + // lossless because the key never bound anything; what it cannot do is create + // the binding the author meant to declare, because only the author knows which + // page variable an input was supposed to feed. + { + id: 'element-input-target-variable-retired', + 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.', + reason: '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.', + acceptanceCriteria: '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.', + }, { id: 'element-number-filter-rule-array', surface: @@ -8754,6 +8943,39 @@ const step18: MigrationStep = { + '`max_scale`; `number` / `percent` / `rating` / `slider` fields keep their `scale` and ' + 'still refuse over-scale writes.', }, + // #9227 — the D3 entry of the `field-column-lists-canonicalized` family (ruling + // B on #17152: one D3 entry per retirement family, even when D2 repairs the + // data). The conversion respells and folds every entry it can resolve; it + // deliberately leaves the two shapes it cannot resolve for the parse to refuse, + // and the related-list fold drops decoration keys by design. Both halves are + // the author's to finish. + { + id: 'field-inline-and-related-list-columns-closed', + 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.', + reason: '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.', + acceptanceCriteria: '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.', + }, { id: 'field-master-detail-set-null-refused', surface: "object field `deleteBehavior: 'set_null'` authored on a `master_detail` field", @@ -8991,6 +9213,34 @@ const step18: MigrationStep = { + '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.', }, + // #13700 (ui#6837 half 1) — the D3 entry of the `field-reference-to-alias` + // family (ruling B on #17152: one D3 entry per retirement family, even when D2 + // is lossless). The rename is lossless for every row it can decide; it leaves + // the one row it cannot decide, and it cannot reach code. + { + id: 'field-reference-to-spelling-retired', + 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.', + reason: '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.', + acceptanceCriteria: '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.', + }, { id: 'field-scale-precision-integer-refused', surface: 'object field `scale` / `precision` declarations (`Field.number` and friends) — ' @@ -9841,6 +10091,33 @@ const step18: MigrationStep = { + '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`).', }, + // #12868 (maintainer-ruled narrowing on the objectui#6263 analysis) — the D3 + // entry of the `form-view-option-default-removed` family (ruling B on #17152: + // one D3 entry per retirement family, even when D2 is lossless). The strip + // changes no form; the prescribed replacement changes MORE than one form, and + // that difference is the author's call. + { + id: 'form-view-option-default-retired', + 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`.', + reason: '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.', + acceptanceCriteria: '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.', + }, { id: 'hook-register-undispatched-lifecycle-event-refused', surface: @@ -9897,6 +10174,31 @@ const step18: MigrationStep = { + 'throw, and any list `total` or `groupBy` that was expected to be scoped is scoped by a ' + 'middleware rather than by a hook.', }, + // #14478 (maintainer ruling B: a duration key carries its unit in its NAME) — + // the D3 entry of the `hook-timeout-to-timeout-ms` family (ruling B on #17152: + // one D3 entry per retirement family, even when D2 is lossless). The rename + // keeps the number, which is exactly the part the ruling was about: whether + // that number was ever in milliseconds is the author's to say. + { + id: 'hook-timeout-unit-in-key', + 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.', + reason: '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.', + acceptanceCriteria: '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.', + }, { id: 'hot-reload-inert-state-strategies-retired', surface: @@ -10198,6 +10500,32 @@ const step18: MigrationStep = { + 'and must be verified as such: nothing ever parsed or read these shapes, so removing ' + 'them removes no behaviour.', }, + // #14478 (maintainer ruling B: a duration key carries its unit in its NAME) — + // the D3 entry of the `job-timeout-to-timeout-ms` family (ruling B on #17152: + // one D3 entry per retirement family, even when D2 is lossless). The job half + // of the hook rename, with its own audience: whoever schedules background work. + { + id: 'job-timeout-unit-in-key', + 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.', + reason: '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.', + acceptanceCriteria: '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.', + }, { id: 'kernel-compatibility-matrix-estimated-migration-time-unit-in-key', surface: 'CompatibilityMatrixEntry.estimatedMigrationTime, the migration effort estimate whose ' @@ -10836,6 +11164,67 @@ const step18: MigrationStep = { + 'Prove the widening separately and cheaply: a prerelease version that used to be ' + 'refused at build time now builds.', }, + // #10329 (ADR-0049 enforce-or-remove) — the D3 entry of the + // `mapping-lookup-params-removed` family (ruling B on #17152: one D3 entry per + // retirement family, even when D2 is lossless). The strip preserves observed + // import behaviour exactly; the judgment it leaves is whether the author's + // import was ever doing what the four keys said. + { + id: 'mapping-lookup-params-retired', + 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.', + reason: '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.', + acceptanceCriteria: '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.', + }, + // #15680 (stack card of #14478, maintainer ruling B: a duration key carries its + // unit in its NAME) — the D3 entry of the + // `memory-persistence-auto-save-interval-to-ms` family (ruling B on #17152: one + // D3 entry per retirement family, even when D2 is lossless). Registered keys: + // `data/FilePersistenceConfig:autoSaveInterval` and + // `data/AutoPersistenceConfig:autoSaveInterval` — both arms of the persistence + // union, because both forward the same value to the same file adapter. + { + id: 'memory-persistence-auto-save-interval-unit-in-key', + 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.', + reason: '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.', + acceptanceCriteria: '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.', + }, { id: 'memory-persistence-placeholder-refused', surface: 'memory driver config `persistence.path` (file persistence and the `auto` ' + @@ -11343,6 +11732,32 @@ const step18: MigrationStep = { + 'the whole migration; beside filter: [] the grid reads defaultFilters, so move those ' + 'rules onto filter rather than deleting them.', }, + // #11805 (ADR-0049 enforce-or-remove) — the D3 entry of the + // `object-grid-default-sort-removed` family (ruling B on #17152: one D3 entry + // per retirement family, even when D2 is lossless). The conversion follows + // the renderer's own precedence exactly, so it preserves what the grid did — + // including the case where what the grid did was not what the author wrote. + { + id: 'object-grid-default-sort-retired', + 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.', + reason: '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.', + acceptanceCriteria: '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.', + }, { id: 'object-index-unknown-keys-refused', surface: 'object `indexes[]` entries (`IndexSchema`) — undeclared keys', @@ -11368,6 +11783,33 @@ const step18: MigrationStep = { + 'drift window carrying `indexes[].where` is rejected with the database-layer prescription ' + 'rather than saved with the key silently dropped.', }, + // #17260 (ADR-0049 enforce-or-remove) — the D3 entry of the + // `object-kanban-quick-add-removed` family (ruling B on #17152: one D3 entry + // per retirement family, even when D2 is lossless). The strip changes nothing a + // user sees; the decision it leaves is whether the board needed the control + // the author asked for. + { + id: 'object-kanban-quick-add-retired', + surface: 'page.component.object-kanban.quickAdd — the per-column quick-add switch on the ' + + 'metadata-driven board', + replacement: '(removed from the metadata board.) The quick-add control exists on the `kanban-ui` ' + + 'block, where a React host supplies the `onQuickAdd` function the control calls. On a ' + + 'metadata board, records are created through the object\'s ordinary create action.', + reason: '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, or needs a React host rendering the ' + + '`kanban-ui` block with a real `onQuickAdd`, is a product decision about that board — not ' + + 'something a key delete can make.', + acceptanceCriteria: '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 either accepted creating records through the object\'s create ' + + 'action, or moved that board to a host that renders the `kanban-ui` block with `onQuickAdd` ' + + 'supplied — where clicking a column\'s add control creates a record in that column.', + }, // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code // span already, and a nested backtick would close it. { @@ -11726,6 +12168,73 @@ const step18: MigrationStep = { + '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.', }, + // #11027 (ADR-0049) — the D3 entry of the `page-component-responsive-removed` + // family (ruling B on #17152: one D3 entry per retirement family, even when D2 + // is lossless). The family is the key plus the shape that leaves with it — + // `ui/ResponsiveConfig`, `ui/BreakpointColumnMap`, `ui/BreakpointName` and + // `ui/BreakpointOrderMap` in RETIRED_DEFS_BY_MAJOR[18]. The strip changes no + // pixel; translating an intended layout into CSS that IS applied is a design + // decision the conversion cannot make. + { + id: 'page-component-responsive-retired', + 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.', + reason: '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.', + acceptanceCriteria: '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.', + }, + // #12497 (ADR-0049, maintainer ruling accepting #1883's recommendation B) — the + // D3 entry of the `permission-allow-restore-purge-removed` family (ruling B on + // #17152: one D3 entry per retirement family, even when D2 is lossless). The + // family is the four registered keys: `allowRestore` / `allowPurge` on + // `security/ObjectPermission` and on `security/EffectiveObjectPermission`. + // The strip changes no access decision; what it leaves is a governance + // question, because the bits sat on the two most destructive verbs. + { + id: 'permission-restore-purge-bits-retired', + 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.', + reason: '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.', + acceptanceCriteria: '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.', + }, { id: 'platform-timezone-columns-iana-domain-refused', surface: @@ -12311,6 +12820,31 @@ const step18: MigrationStep = { + '`collapsible: true` explicitly — an unset key now defers to the renderer, which does ' + 'not collapse.', }, + // #10054 (ADR-0049 enforce-or-remove) — the D3 entry of the + // `record-highlights-field-icon-removed` family (ruling B on #17152: one D3 + // entry per retirement family, even when D2 is lossless). The strip changes no + // pixel; what it leaves is the meaning the author put in an icon nobody drew. + { + id: 'record-highlights-field-icon-retired', + 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.', + reason: '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.', + acceptanceCriteria: '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.', + }, { id: 'rest-api-endpoint-handler-status-retired', // No backticks in `surface` — build-upgrade-guide.ts renders it inside a @@ -13983,6 +14517,32 @@ const step18: MigrationStep = { + 'and must be verified as such: nothing ever parsed or read these shapes, so removing ' + 'them removes no behaviour.', }, + // #10926 — the D3 entry of the `translation-component-submit-label-removed` + // family (ruling B on #17152: one D3 entry per retirement family, even when D2 + // is lossless). The strip deletes a string nothing has read since its carrier + // retired; where the translator's work should go instead is not something the + // conversion can decide. + { + id: 'translation-component-submit-label-retired', + 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.', + reason: '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.', + acceptanceCriteria: '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.', + }, // The judgment half of `translation-per-app-settings-removed`. The D2 // conversion deletes the group mechanically; what it cannot say in a // `to: '(removed)'` notice is WHERE those strings were rendering and what @@ -14085,6 +14645,33 @@ const step18: MigrationStep = { + '(`@objectstack/service-settings`’s `settingsBuiltinTranslations`) — ⛔ do not re-add ' + 'app-side copy at either door, which is refused.', }, + // #15680 / #15682 (stack card of #14478, maintainer ruling B: a duration key + // carries its unit in its NAME) — the D3 entry of the + // `turso-config-timeout-to-timeout-ms` family (ruling B on #17152: one D3 entry + // per retirement family, even when D2 is lossless). One conversion covers the + // authored surface of both declarations — the spec's turso contract and the + // driver package's own mirror schema, renamed in the same change. + { + id: 'turso-config-timeout-unit-in-key', + 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.', + reason: '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.', + acceptanceCriteria: '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.', + }, // The AUTHORING half of the turso driver's constructor refusals. The driver // refuses these configurations when it is built; this entry records that the // datasource contract now refuses them where they are written, together with From c8656ad356e219fb6449ba4eac0c5666799c12d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 13:43:52 +0000 Subject: [PATCH 3/6] fix(spec): the remaining D3 entries and the census pin for major 18 Completes one D3 entry per D2-backed major-18 retirement family (25 new entries), and pins in the registry's own test that from protocol 18 on every graduated conversion is named by a D3 entry of its step. Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- .../semantic/18.chart-config-aria-retired.ts | 32 ++++ .../18.list-view-page-mount-retired.ts | 33 ++++ ...18.list-view-sort-string-clause-retired.ts | 31 ++++ ...ject-tenancy-organization-field-retired.ts | 34 +++++ .../18.view-item-owner-hidden-retired.ts | 34 +++++ .../spec/src/migrations/migrations.test.ts | 83 ++++++++++ packages/spec/src/migrations/registry.ts | 144 ++++++++++++++++++ 7 files changed, 391 insertions(+) create mode 100644 packages/spec/src/migrations/entries/semantic/18.chart-config-aria-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.list-view-page-mount-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.list-view-sort-string-clause-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.object-tenancy-organization-field-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.view-item-owner-hidden-retired.ts diff --git a/packages/spec/src/migrations/entries/semantic/18.chart-config-aria-retired.ts b/packages/spec/src/migrations/entries/semantic/18.chart-config-aria-retired.ts new file mode 100644 index 00000000000..3092f381e07 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.chart-config-aria-retired.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// Maintainer decision batch #118 item 2 (ADR-0049 enforce-or-remove) — the D3 +// entry of the `chart-config-aria-removed` family (ruling B on #17152: one D3 +// entry per retirement family, even when D2 is lossless). Registered keys: +// `ui/ChartConfig:aria` and `ui/ReportChart:aria`, over three authored sites. +// The strip changes nothing a screen reader hears; the accessible name the +// author wrote was never announced, and moving it is the author's edit. +export const entry: SemanticMigration = { + id: 'chart-config-aria-retired', + 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.', + reason: '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.', + acceptanceCriteria: '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.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.list-view-page-mount-retired.ts b/packages/spec/src/migrations/entries/semantic/18.list-view-page-mount-retired.ts new file mode 100644 index 00000000000..ba3e2c6d053 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.list-view-page-mount-retired.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #17063 (maintainer ruling, decision batch #107 item 1) — the D3 entry of the +// `view-page-mount-removed` family (ruling B on #17152: one D3 entry per +// retirement family, even when D2 is lossless). Registered keys: +// `ui/ListView:pageName` and `ui/ObjectListView:pageName`, plus the `'page'` +// value of the list-view `type` enum. The strip lands every view on what it +// already drew — an empty grid — which is exactly why it cannot be the end of +// the migration. +export const entry: SemanticMigration = { + id: 'list-view-page-mount-retired', + 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.', + reason: '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.', + acceptanceCriteria: '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.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.list-view-sort-string-clause-retired.ts b/packages/spec/src/migrations/entries/semantic/18.list-view-sort-string-clause-retired.ts new file mode 100644 index 00000000000..6f654598f57 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.list-view-sort-string-clause-retired.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #17053 (objectui#8221 decision batch #77, option B: one sort orthography +// platform-wide) — the D3 entry of the `list-view-sort-string-clause-to-array` +// family (ruling B on #17152: one D3 entry per retirement family, even when D2 +// is lossless). The rewrite is lossless for every clause in the grammar it +// knows; the clauses it cannot lower, and the collection it cannot reach, are +// the author's. +export const entry: SemanticMigration = { + id: 'list-view-sort-string-clause-retired', + 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.', + reason: '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.', + acceptanceCriteria: '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.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.object-tenancy-organization-field-retired.ts b/packages/spec/src/migrations/entries/semantic/18.object-tenancy-organization-field-retired.ts new file mode 100644 index 00000000000..2cf3007d9ff --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.object-tenancy-organization-field-retired.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #19054 (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18) — the D3 +// entry of the `object-tenancy-organization-field-removed` family (ruling B on +// #17152: one D3 entry per retirement family, even when D2 is lossless). This +// is the family the pre-ruling rationale said had "no semantic residue". The +// delete changes no stamp; what it leaves is an author who asked for a +// divergence and should know they never got it. +export const entry: SemanticMigration = { + id: 'object-tenancy-organization-field-retired', + 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.', + reason: '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.', + acceptanceCriteria: '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.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.view-item-owner-hidden-retired.ts b/packages/spec/src/migrations/entries/semantic/18.view-item-owner-hidden-retired.ts new file mode 100644 index 00000000000..3b39d0cf2a3 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.view-item-owner-hidden-retired.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #20085 (ADR-0049 enforce-or-remove; triage direction 「retire both keys」) — +// the D3 entry of the `view-item-owner-hidden-removed` family (ruling B on +// #17152: one D3 entry per retirement family, even when D2 is lossless). +// Registered keys: `owner` / `hidden` on `ui/ViewItem` and on the wire member +// `ui/ViewItemWire`. The delete changes no render and no listing; what it +// leaves is a visibility belief — views marked private or hidden were listed +// to everyone who could read the object. +export const entry: SemanticMigration = { + id: 'view-item-owner-hidden-retired', + 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.', + reason: '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 keeps its own ' + + '`owner` and `hidden`: those live on a different door that this retirement does not touch.', + acceptanceCriteria: '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.', +}; diff --git a/packages/spec/src/migrations/migrations.test.ts b/packages/spec/src/migrations/migrations.test.ts index 9e40c941102..139d0b23c16 100644 --- a/packages/spec/src/migrations/migrations.test.ts +++ b/packages/spec/src/migrations/migrations.test.ts @@ -1,5 +1,9 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + import { describe, expect, it } from 'vitest'; import { ALL_CONVERSIONS, CONVERSIONS_BY_MAJOR } from '../conversions/registry.js'; @@ -23,6 +27,41 @@ import { const CONVERSION_IDS = new Set(ALL_CONVERSIONS.map((c) => c.id)); +/** + * Ruling B (ADR-0087 D3; the contract is `SemanticMigration` in `./types.ts`): + * every retirement family carries ONE D3 entry, even when a lossless D2 + * conversion also repairs its data — D2 carries the mechanical repair only. + * Protocol 17 shipped before the rule and was not back-filled, so the pin + * below binds from protocol 18 on. + */ +const D3_PER_FAMILY_FROM_MAJOR = 18; + +const SEMANTIC_ENTRIES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), 'entries/semantic'); + +/** + * The full text — leading comment AND literal — of every `semantic/` entry + * file registered under `major`, keyed by filename. The files, not the parsed + * objects: an entry may name its family's conversion in the comment the + * generator carries into `registry.ts` rather than in a field, and + * `check:migration-registry` already proves the files and the generated + * region are the same set. + */ +function semanticEntrySources(major: number): Map { + const out = new Map(); + for (const name of readdirSync(SEMANTIC_ENTRIES_DIR).sort()) { + if (name.startsWith(`${major}.`) && name.endsWith('.ts')) { + out.set(name, readFileSync(resolve(SEMANTIC_ENTRIES_DIR, name), 'utf8')); + } + } + return out; +} + +/** The entry files of `major` that name conversion `id` as a whole id, not as a prefix of a longer one. */ +function entriesNaming(sources: Map, id: string): string[] { + const whole = new RegExp(`(? whole.test(text)).map(([name]) => name); +} + describe('migration chain (ADR-0087 D3)', () => { describe('registry integrity', () => { it('every step references only real conversion ids', () => { @@ -52,6 +91,50 @@ describe('migration chain (ADR-0087 D3)', () => { } }); + // The census pin for ruling B (#20201). A graduated conversion is one + // retirement family's data repair, so from `D3_PER_FAMILY_FROM_MAJOR` on, + // a step whose D3 list names none of a conversion is a family shipped with + // its repair and without its judgment — the exact shape the pre-ruling + // "lossless, so no semantic residue" reasoning produced. What this CANNOT + // see, stated so a green run is not over-read: (1) whether the entry that + // names a conversion is that family's OWN — a passing mention satisfies it, + // so ownership was judged by reading, in the census; (2) a family retired + // with no conversion at all — no machine-readable link joins a retired key + // or def to its D3 entry, so those were paired by reading too. + it('from protocol 18 on, every graduated D2 conversion is named by a D3 entry of its own step (ruling B)', () => { + const checked = MIGRATION_MAJORS.filter((m) => m >= D3_PER_FAMILY_FROM_MAJOR); + // Anti-vacuity: the major this rule was first measured on is in range. + expect(checked).toContain(D3_PER_FAMILY_FROM_MAJOR); + const unnamed: string[] = []; + let pairs = 0; + for (const m of checked) { + const sources = semanticEntrySources(m); + for (const id of MIGRATIONS_BY_MAJOR[m]!.conversionIds) { + if (entriesNaming(sources, id).length === 0) unnamed.push(`protocol ${m}: ${id}`); + else pairs++; + } + } + expect(unnamed).toEqual([]); + expect(pairs).toBeGreaterThan(0); + }); + + it('the census pin sees a family that has its entry, and not a prefix of a longer id', () => { + // Control for the pin above: a pair that predates the census, so the + // matcher is proven to find a family's entry by reading the files at all. + const sources18 = semanticEntrySources(18); + expect(entriesNaming(sources18, 'cube-join-sql-and-relationship-removed')).toContain( + '18.cube-join-sql-and-relationship-retired.ts', + ); + // Whole-id match: `record-chatter-position-vocabulary` is a prefix of its + // D3 entry's own id, so only the entry's real citation of the conversion + // may count, never its id line. + expect(entriesNaming(sources18, 'record-chatter-position-vocabulary')).toContain( + '18.record-chatter-position-vocabulary-converged.ts', + ); + const idLineOnly = new Map([['x.ts', "id: 'record-chatter-position-vocabulary-converged'"]]); + expect(entriesNaming(idLineOnly, 'record-chatter-position-vocabulary')).toEqual([]); + }); + it('the support floor is at or below the earliest step', () => { expect(MIGRATION_SUPPORT_FLOOR).toBeLessThanOrEqual(MIGRATION_MAJORS[0]!); }); diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index bb7d6b20939..7e8a36be6c3 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6558,6 +6558,34 @@ const step18: MigrationStep = { + 'deliberately UNCHANGED and must be verified as such: nothing ever parsed or read these ' + 'shapes, so removing them removes no behaviour.', }, + // Maintainer decision batch #118 item 2 (ADR-0049 enforce-or-remove) — the D3 + // entry of the `chart-config-aria-removed` family (ruling B on #17152: one D3 + // entry per retirement family, even when D2 is lossless). Registered keys: + // `ui/ChartConfig:aria` and `ui/ReportChart:aria`, over three authored sites. + // The strip changes nothing a screen reader hears; the accessible name the + // author wrote was never announced, and moving it is the author's edit. + { + id: 'chart-config-aria-retired', + 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.', + reason: '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.', + acceptanceCriteria: '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.', + }, { id: 'cli-command-contribution-retired', surface: @@ -11020,6 +11048,62 @@ const step18: MigrationStep = { + 'that carries only live keys (`{ mode: \'drawer\', size: \'lg\' }`) parses byte-for-byte ' + 'as it did before.', }, + // #17063 (maintainer ruling, decision batch #107 item 1) — the D3 entry of the + // `view-page-mount-removed` family (ruling B on #17152: one D3 entry per + // retirement family, even when D2 is lossless). Registered keys: + // `ui/ListView:pageName` and `ui/ObjectListView:pageName`, plus the `'page'` + // value of the list-view `type` enum. The strip lands every view on what it + // already drew — an empty grid — which is exactly why it cannot be the end of + // the migration. + { + id: 'list-view-page-mount-retired', + 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.', + reason: '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.', + acceptanceCriteria: '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.', + }, + // #17053 (objectui#8221 decision batch #77, option B: one sort orthography + // platform-wide) — the D3 entry of the `list-view-sort-string-clause-to-array` + // family (ruling B on #17152: one D3 entry per retirement family, even when D2 + // is lossless). The rewrite is lossless for every clause in the grammar it + // knows; the clauses it cannot lower, and the collection it cannot reach, are + // the author's. + { + id: 'list-view-sort-string-clause-retired', + 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.', + reason: '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.', + acceptanceCriteria: '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.', + }, { id: 'logging-durations-unit-in-key', surface: 'HttpDestinationConfig `batch.flushInterval` / `retry.initialDelay` / `timeout` and ' @@ -11810,6 +11894,36 @@ const step18: MigrationStep = { + 'action, or moved that board to a host that renders the `kanban-ui` block with `onQuickAdd` ' + 'supplied — where clicking a column\'s add control creates a record in that column.', }, + // #19054 (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18) — the D3 + // entry of the `object-tenancy-organization-field-removed` family (ruling B on + // #17152: one D3 entry per retirement family, even when D2 is lossless). This + // is the family the pre-ruling rationale said had "no semantic residue". The + // delete changes no stamp; what it leaves is an author who asked for a + // divergence and should know they never got it. + { + id: 'object-tenancy-organization-field-retired', + 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.', + reason: '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.', + acceptanceCriteria: '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.', + }, // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code // span already, and a nested backtick would close it. { @@ -15424,6 +15538,36 @@ const step18: MigrationStep = { + '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.', }, + // #20085 (ADR-0049 enforce-or-remove; triage direction 「retire both keys」) — + // the D3 entry of the `view-item-owner-hidden-removed` family (ruling B on + // #17152: one D3 entry per retirement family, even when D2 is lossless). + // Registered keys: `owner` / `hidden` on `ui/ViewItem` and on the wire member + // `ui/ViewItemWire`. The delete changes no render and no listing; what it + // leaves is a visibility belief — views marked private or hidden were listed + // to everyone who could read the object. + { + id: 'view-item-owner-hidden-retired', + 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.', + reason: '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 keeps its own ' + + '`owner` and `hidden`: those live on a different door that this retirement does not touch.', + acceptanceCriteria: '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.', + }, // The door half of ruling A on objectui#10380 (#20051). A write-time narrowing // of the flattened view overlays: the stored rows it newly refuses are read and // served exactly as before and fail only on their next save, which is why this From d4c7a41c8d70d2d457d78f3ad4a6cf7600985434 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 13:48:19 +0000 Subject: [PATCH 4/6] chore(changeset): patch @objectstack/spec for the major-18 D3 census Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- .../20201-d3-entry-per-family-major-18.md | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .changeset/20201-d3-entry-per-family-major-18.md diff --git a/.changeset/20201-d3-entry-per-family-major-18.md b/.changeset/20201-d3-entry-per-family-major-18.md new file mode 100644 index 00000000000..16426ecdefd --- /dev/null +++ b/.changeset/20201-d3-entry-per-family-major-18.md @@ -0,0 +1,30 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): every protocol-18 retirement family now carries its D3 entry, including the 25 whose data repair is a lossless D2 conversion (#20201) + +Clause-②: no + +ADR-0087 D3 requires one semantic (D3) entry per retirement family, even when a +lossless D2 conversion already repairs the data: D2 carries the mechanical repair +only, and the D3 entry says what the consumer still has to decide. Twenty-five +protocol-18 families shipped a D2 conversion and no D3 entry, some of them +justified by "lossless, so no semantic residue". `MIGRATIONS_BY_MAJOR[18].semantic` +gains one entry per family, so `os migrate meta` lists each as a TODO on the +17 → 18 hop, with its reason and acceptance criteria. Among them: + +- the seven duration renames (`hook.timeout`, `job.timeout`, `apis[].cacheTtl`, + `dashboard.refreshInterval`, the connector health / trigger durations, the memory + driver's `autoSaveInterval` and the turso `timeout`). The rename keeps the value, + so only the author can say whether it was ever written in the unit the new key + names. +- `object.tenancy.organizationField`, `view.owner` / `view.hidden`, + `permission.objects.*.allowRestore` / `allowPurge` and the list-view `page` mount. + Each delete is lossless, and each leaves a belief the author held that the + platform never honoured. + +No accept set moves and no conversion changes. The registry's own test now fails +when a protocol-18-or-later step graduates a conversion that no D3 entry of that +step names. The prose that justified the missing entries is corrected, and the +protocol-17 docblock no longer calls that step's `semantic` list empty. From 3197fce2947c5aa3ea39b4675202ae366d9b0700 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 15:35:46 +0000 Subject: [PATCH 5/6] chore(spec): regenerate the migration registry after merging main Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- packages/spec/src/migrations/registry.ts | 79 ++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 7e8a36be6c3..bfdb0f70b48 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -10528,6 +10528,43 @@ const step18: MigrationStep = { + 'and must be verified as such: nothing ever parsed or read these shapes, so removing ' + 'them removes no behaviour.', }, + { + id: 'inline-grid-column-currency-scale-refused', + 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, and on a column that declares no `type`, is untouched', + 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.', + reason: + 'Maintainer ruling 5791803339 (batch #215 item 1, letter B) retired `scale` from the ' + + '`currency` field type, and ruling 5805782503 (batch #218 item 2, letter 乙 — 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: only a DECLARED column `type` is judged — a column that declares none takes ' + + 'its type from the child field when the console hydrates it, which the column schema cannot ' + + 'see. 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.', + acceptanceCriteria: + '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, and columns declaring no `type`, ' + + 'keep their `scale`; a column\'s `prefix` is still accepted on a currency column.', + }, // #14478 (maintainer ruling B: a duration key carries its unit in its NAME) — // the D3 entry of the `job-timeout-to-timeout-ms` family (ruling B on #17152: // one D3 entry per retirement family, even when D2 is lossless). The job half @@ -15568,6 +15605,48 @@ const step18: MigrationStep = { + '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.', }, + // #20186, route C-prime (seat answers 5855433719 / 5855548706). A write-time + // re-routing of the flattened view overlays: each member judges only the + // `viewKind` it names. The stored rows it newly refuses are read and served + // exactly as before and fail only on their next save, which is why this is a + // semantic entry and not a conversion — which value a refused key meant is a + // fact only its author holds. + { + id: 'view-overlay-judged-by-viewkind-arm', + 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 }]`.', + reason: + '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.', + acceptanceCriteria: + '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`.', + }, // The door half of ruling A on objectui#10380 (#20051). A write-time narrowing // of the flattened view overlays: the stored rows it newly refuses are read and // served exactly as before and fail only on their next save, which is why this From 5c60be7ee1e5464030825a0b7e875a3dd59a87f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 18:16:22 +0000 Subject: [PATCH 6/6] fix(spec): anchor the four dead tracker citations to their landing commits; the census pin says what to add Four new D3 entries carried issue numbers that no longer resolve on the board (deleted, not transferred). Each now names the commit in this repository's history that retired its family, and says what that commit decided. The pin's assertion message names the unnamed conversions and the remedy; its logic and scope are unchanged. Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- .../18.connector-error-mapping-retired.ts | 10 +++-- .../18.form-view-option-default-retired.ts | 12 +++--- .../18.mapping-lookup-params-retired.ts | 8 ++-- ...nslation-component-submit-label-retired.ts | 8 ++-- .../spec/src/migrations/migrations.test.ts | 9 ++++- packages/spec/src/migrations/registry.ts | 38 +++++++++++-------- 6 files changed, 54 insertions(+), 31 deletions(-) diff --git a/packages/spec/src/migrations/entries/semantic/18.connector-error-mapping-retired.ts b/packages/spec/src/migrations/entries/semantic/18.connector-error-mapping-retired.ts index a6684ba02b2..5495c01ae9f 100644 --- a/packages/spec/src/migrations/entries/semantic/18.connector-error-mapping-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.connector-error-mapping-retired.ts @@ -2,10 +2,12 @@ import type { SemanticMigration } from '../../types.js'; -// #14676 (ADR-0049 enforce-or-remove) — the D3 entry of the -// `connector-error-mapping-removed` family (ruling B on #17152: one D3 entry -// per retirement family, even when D2 is lossless). The family is the key on -// both carriers (`integration/Connector:errorMapping`, +// ADR-0049 enforce-or-remove — the D3 entry of the +// `connector-error-mapping-removed` family, which landed in commit 13c48c2a5: +// eleven inert authorable keys, one of them spelled like the live +// `userMessage` channel. One D3 entry per retirement family, even when D2 is +// lossless (ruling B on #17152). The family is the key on both carriers +// (`integration/Connector:errorMapping`, // `integration/DeclarativeConnectorEntry:errorMapping`) and the shape that // leaves with it — `integration/ErrorMappingConfig`, // `integration/ErrorMappingRule` and `integration/ConnectorErrorCategory` in diff --git a/packages/spec/src/migrations/entries/semantic/18.form-view-option-default-retired.ts b/packages/spec/src/migrations/entries/semantic/18.form-view-option-default-retired.ts index 77e2cb71c77..60d81fa3025 100644 --- a/packages/spec/src/migrations/entries/semantic/18.form-view-option-default-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.form-view-option-default-retired.ts @@ -2,11 +2,13 @@ import type { SemanticMigration } from '../../types.js'; -// #12868 (maintainer-ruled narrowing on the objectui#6263 analysis) — the D3 -// entry of the `form-view-option-default-removed` family (ruling B on #17152: -// one D3 entry per retirement family, even when D2 is lossless). The strip -// changes no form; the prescribed replacement changes MORE than one form, and -// that difference is the author's call. +// The D3 entry of the `form-view-option-default-removed` family, which landed +// in commit c459da6bc: a maintainer-ruled narrowing on the objectui#6263 +// analysis that took per-option `default` out of the form-view vocabulary and +// left the object-field face enforcing it. One D3 entry per retirement family, +// even when D2 is lossless (ruling B on #17152). The strip changes no form; +// the prescribed replacement changes MORE than one form, and that difference +// is the author's call. export const entry: SemanticMigration = { id: 'form-view-option-default-retired', surface: 'view.form.sections[].fields[].options[].default — the per-option pre-selection on a ' diff --git a/packages/spec/src/migrations/entries/semantic/18.mapping-lookup-params-retired.ts b/packages/spec/src/migrations/entries/semantic/18.mapping-lookup-params-retired.ts index 1f51a1eeea5..6d6a1d19332 100644 --- a/packages/spec/src/migrations/entries/semantic/18.mapping-lookup-params-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.mapping-lookup-params-retired.ts @@ -2,9 +2,11 @@ import type { SemanticMigration } from '../../types.js'; -// #10329 (ADR-0049 enforce-or-remove) — the D3 entry of the -// `mapping-lookup-params-removed` family (ruling B on #17152: one D3 entry per -// retirement family, even when D2 is lossless). The strip preserves observed +// ADR-0049 enforce-or-remove — the D3 entry of the +// `mapping-lookup-params-removed` family, which landed in commit 15d58dbf1: +// the import path never read the four lookup steering params, so they were +// retired and the conversion strips them. One D3 entry per retirement family, +// even when D2 is lossless (ruling B on #17152). The strip preserves observed // import behaviour exactly; the judgment it leaves is whether the author's // import was ever doing what the four keys said. export const entry: SemanticMigration = { diff --git a/packages/spec/src/migrations/entries/semantic/18.translation-component-submit-label-retired.ts b/packages/spec/src/migrations/entries/semantic/18.translation-component-submit-label-retired.ts index b6b7ec81d2c..36046b8f722 100644 --- a/packages/spec/src/migrations/entries/semantic/18.translation-component-submit-label-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.translation-component-submit-label-retired.ts @@ -2,9 +2,11 @@ import type { SemanticMigration } from '../../types.js'; -// #10926 — the D3 entry of the `translation-component-submit-label-removed` -// family (ruling B on #17152: one D3 entry per retirement family, even when D2 -// is lossless). The strip deletes a string nothing has read since its carrier +// The D3 entry of the `translation-component-submit-label-removed` family, +// which landed in commit d173125fb: the component-copy key left with its only +// declarer, `element:form`, rather than being re-anchored on `object-form`. +// One D3 entry per retirement family, even when D2 is lossless (ruling B on +// #17152). The strip deletes a string nothing has read since its carrier // retired; where the translator's work should go instead is not something the // conversion can decide. export const entry: SemanticMigration = { diff --git a/packages/spec/src/migrations/migrations.test.ts b/packages/spec/src/migrations/migrations.test.ts index 139d0b23c16..e54ae8bdc0d 100644 --- a/packages/spec/src/migrations/migrations.test.ts +++ b/packages/spec/src/migrations/migrations.test.ts @@ -114,7 +114,14 @@ describe('migration chain (ADR-0087 D3)', () => { else pairs++; } } - expect(unnamed).toEqual([]); + expect( + unnamed, + `graduated D2 conversion(s) named by no D3 entry of their own step: ${unnamed.join(', ')}. ` + + 'Remedy: add a D3 `semantic` entry of that step — a file under `entries/semantic/` ' + + 'prefixed with its protocol major, then `gen:migration-registry` — whose text names the ' + + 'conversion id as a whole word and says what judgment the consumer still owes after D2 ' + + 'repaired the data (one D3 entry per retirement family, even when D2 is lossless).', + ).toEqual([]); expect(pairs).toBeGreaterThan(0); }); diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index bfdb0f70b48..70ccf575a98 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6950,10 +6950,12 @@ const step18: MigrationStep = { + 'fail tsc on upgrade; the fix is choosing a shipped driver, never ' + 'widening a local mirror of the enum.', }, - // #14676 (ADR-0049 enforce-or-remove) — the D3 entry of the - // `connector-error-mapping-removed` family (ruling B on #17152: one D3 entry - // per retirement family, even when D2 is lossless). The family is the key on - // both carriers (`integration/Connector:errorMapping`, + // ADR-0049 enforce-or-remove — the D3 entry of the + // `connector-error-mapping-removed` family, which landed in commit 13c48c2a5: + // eleven inert authorable keys, one of them spelled like the live + // `userMessage` channel. One D3 entry per retirement family, even when D2 is + // lossless (ruling B on #17152). The family is the key on both carriers + // (`integration/Connector:errorMapping`, // `integration/DeclarativeConnectorEntry:errorMapping`) and the shape that // leaves with it — `integration/ErrorMappingConfig`, // `integration/ErrorMappingRule` and `integration/ConnectorErrorCategory` in @@ -10119,11 +10121,13 @@ const step18: MigrationStep = { + '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`).', }, - // #12868 (maintainer-ruled narrowing on the objectui#6263 analysis) — the D3 - // entry of the `form-view-option-default-removed` family (ruling B on #17152: - // one D3 entry per retirement family, even when D2 is lossless). The strip - // changes no form; the prescribed replacement changes MORE than one form, and - // that difference is the author's call. + // The D3 entry of the `form-view-option-default-removed` family, which landed + // in commit c459da6bc: a maintainer-ruled narrowing on the objectui#6263 + // analysis that took per-option `default` out of the form-view vocabulary and + // left the object-field face enforcing it. One D3 entry per retirement family, + // even when D2 is lossless (ruling B on #17152). The strip changes no form; + // the prescribed replacement changes MORE than one form, and that difference + // is the author's call. { id: 'form-view-option-default-retired', surface: 'view.form.sections[].fields[].options[].default — the per-option pre-selection on a ' @@ -11285,9 +11289,11 @@ const step18: MigrationStep = { + 'Prove the widening separately and cheaply: a prerelease version that used to be ' + 'refused at build time now builds.', }, - // #10329 (ADR-0049 enforce-or-remove) — the D3 entry of the - // `mapping-lookup-params-removed` family (ruling B on #17152: one D3 entry per - // retirement family, even when D2 is lossless). The strip preserves observed + // ADR-0049 enforce-or-remove — the D3 entry of the + // `mapping-lookup-params-removed` family, which landed in commit 15d58dbf1: + // the import path never read the four lookup steering params, so they were + // retired and the conversion strips them. One D3 entry per retirement family, + // even when D2 is lossless (ruling B on #17152). The strip preserves observed // import behaviour exactly; the judgment it leaves is whether the author's // import was ever doing what the four keys said. { @@ -14668,9 +14674,11 @@ const step18: MigrationStep = { + 'and must be verified as such: nothing ever parsed or read these shapes, so removing ' + 'them removes no behaviour.', }, - // #10926 — the D3 entry of the `translation-component-submit-label-removed` - // family (ruling B on #17152: one D3 entry per retirement family, even when D2 - // is lossless). The strip deletes a string nothing has read since its carrier + // The D3 entry of the `translation-component-submit-label-removed` family, + // which landed in commit d173125fb: the component-copy key left with its only + // declarer, `element:form`, rather than being re-anchored on `object-form`. + // One D3 entry per retirement family, even when D2 is lossless (ruling B on + // #17152). The strip deletes a string nothing has read since its carrier // retired; where the translator's work should go instead is not something the // conversion can decide. {