Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/20201-d3-entry-per-family-major-18.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 3 additions & 2 deletions packages/spec/scripts/build-migration-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,8 +273,9 @@ const closeMarker = (kind: Kind, major: number) => ` // </os-generated ${kind
* self-test drives it without touching the tree.
*
* A region present in the file with no entries on disk is emitted EMPTY rather
* than removed — a major whose semantic residue is genuinely nil (protocol 14)
* is a real state, and the marker is where its first entry will land.
* than removed — an empty region is a real state (a freshly opened step, or
* protocol 14's, whose step carried no D3 entry before every retirement family
* was required to carry one), and the marker is where its first entry will land.
*/
export function renderRegistry(source: string, byKind: Record<Kind, Entry[]>): { text: string; errors: string[] } {
const errors: string[] = [];
Expand Down
29 changes: 20 additions & 9 deletions packages/spec/src/conversions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3459,7 +3459,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
Expand All @@ -3468,6 +3468,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
Expand Down Expand Up @@ -9305,10 +9311,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
Expand Down Expand Up @@ -9794,12 +9803,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -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.',
};
Original file line number Diff line number Diff line change
@@ -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.',
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

// 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
// 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.',
};
Original file line number Diff line number Diff line change
@@ -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.',
};
Loading
Loading