From ca8438732b5385ec46d56ac05ad3d0043824d540 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:37:07 +0000 Subject: [PATCH 1/3] fix(spec): the remaining migration-entry families state each lesson in words, not tracker numbers (stage 9) Each ADR-0087 semantic entry that still cited a tracker, pull-request, decision-batch or cross-repository number in its replacement / reason / acceptanceCriteria prose now says what the cited ruling, measurement or fix decided. Verbatim quotes that carried a card or batch number keep only their operative words. ADR ids and contributor-guide rule references stay; the address entry's dangling AGENTS.md rule number is stated as the rule. Text only: no entry id, surface, conversion or matcher changes. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- ...pproval-escalation-enabled-default-flip.ts | 3 ++- ...h-config-unadvertised-reserved-features.ts | 8 +++++--- .../17.batch-row-result-schema-shape.ts | 8 ++++++-- ...r-inline-authentication-publish-refused.ts | 8 +++++--- ...enhanced-api-error-field-errors-renamed.ts | 3 ++- ....job-retry-policy-constraints-tightened.ts | 3 ++- .../17.position-permissions-column-retired.ts | 3 ++- .../17.ups-delegated-from-column-retired.ts | 3 ++- .../17.workflow-service-slot-retired.ts | 5 +++-- ...ess-location-value-unknown-keys-refused.ts | 3 ++- .../18.admin-export-wildcard-removed.ts | 6 ++++-- ...admin-scope-business-unit-blank-refused.ts | 3 ++- ...ersation-analytics-duration-unit-in-key.ts | 6 ++++-- ...assembled-package-body-plugins-envelope.ts | 4 +++- ...18.audience-posture-default-invite-only.ts | 3 ++- ....autonumber-default-unique-organization.ts | 7 ++++--- .../18.branded-identifier-schemas-retired.ts | 2 +- .../18.cbp-master-detail-required-forced.ts | 5 +++-- ...l-predicate-one-value-comparand-refused.ts | 3 ++- ...change-management-duration-keys-retired.ts | 3 ++- .../18.change-management-family-retired.ts | 6 ++++-- ....cluster-driver-dangling-values-removed.ts | 2 +- ...e-request-response-interval-unit-in-key.ts | 7 ++++--- .../semantic/18.epoch-instant-keys-renamed.ts | 4 ++-- ...esignature-config-deadline-keys-retired.ts | 5 +++-- .../semantic/18.event-name-schema-retired.ts | 2 +- ...incident-response-deadline-keys-retired.ts | 3 ++- .../18.incident-response-family-retired.ts | 6 ++++-- .../18.logging-durations-unit-in-key.ts | 8 +++++--- ....memory-persistence-placeholder-refused.ts | 7 +++++-- ...18.observability-cel-predicates-retired.ts | 2 +- ...edule-flow-acting-organization-required.ts | 6 ++++-- ....screen-field-lookup-reference-required.ts | 2 +- .../18.send-template-input-org-retired.ts | 10 ++++++---- ...andard-error-code-batch-members-retired.ts | 7 ++++--- ...-code-concurrent-limit-exceeded-retired.ts | 4 ++-- ...tured-region-body-pause-and-end-refused.ts | 10 ++++++---- ...18.time-update-interval-sub-day-retired.ts | 3 ++- .../18.training-deadline-keys-retired.ts | 3 ++- .../semantic/18.training-family-retired.ts | 6 ++++-- ...nslation-per-app-settings-platform-only.ts | 5 +++-- ...turso-config-transport-mismatch-refused.ts | 19 ++++++++++++------- ...-bulk-action-param-unknown-keys-refused.ts | 2 +- .../18.wait-node-event-config-required.ts | 4 ++-- .../18.websocket-durations-unit-in-key.ts | 6 ++++-- 45 files changed, 143 insertions(+), 85 deletions(-) diff --git a/packages/spec/src/migrations/entries/semantic/17.approval-escalation-enabled-default-flip.ts b/packages/spec/src/migrations/entries/semantic/17.approval-escalation-enabled-default-flip.ts index 9c647f05210..80d278366a7 100644 --- a/packages/spec/src/migrations/entries/semantic/17.approval-escalation-enabled-default-flip.ts +++ b/packages/spec/src/migrations/entries/semantic/17.approval-escalation-enabled-default-flip.ts @@ -16,7 +16,8 @@ export const entry: SemanticMigration = { + 'the escalation sweep actually reads', reason: 'A DECLARED-DEFAULT CORRECTION plus the enforcement that makes the key real ' - + "(#12278, maintainer ruling 2026-08-27) — the same category as protocol 17's " + + "(maintainer ruling 2026-08-27, which moved the declared default to what the sweep had " + + "always done) — the same category as protocol 17's " + '`import-run-automations-declared-default-corrected`: the schema promised ' + '`enabled` defaults to `false` (SLA off) while the plugin-approvals sweep never ' + 'read the key at all — any escalation block with a positive `timeoutHours` ' diff --git a/packages/spec/src/migrations/entries/semantic/17.auth-config-unadvertised-reserved-features.ts b/packages/spec/src/migrations/entries/semantic/17.auth-config-unadvertised-reserved-features.ts index d414af77322..4f22291b7ae 100644 --- a/packages/spec/src/migrations/entries/semantic/17.auth-config-unadvertised-reserved-features.ts +++ b/packages/spec/src/migrations/entries/semantic/17.auth-config-unadvertised-reserved-features.ts @@ -11,14 +11,16 @@ export const entry: SemanticMigration = { + 'client: no login UI anywhere renders a passkey or magic-link affordance off them, so ' + 'the payload advertised two sign-in methods a user could never reach, and a deployer ' + 'setting `plugins.passkeys` / `plugins.magicLink` flipped a switch with no observable ' - + 'effect (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-11 on #7481 chose remove ' - + 'over keep-as-reserved). The two are not equally empty: nothing at all is wired behind ' + + 'effect (ADR-0049 enforce-or-remove; the maintainer ruling of 2026-08-11 chose remove ' + + 'over keep-as-reserved, so that a deployer cannot flip a flag that does nothing ' + + 'anywhere). The two are not equally empty: nothing at all is wired behind ' + '`passkeys`, whereas `magicLink`\'s better-auth endpoints are live and only their ' + 'advertisement was withdrawn. This is a RESPONSE surface — nobody authors or persists ' + 'an `AuthFeaturesConfig` — so there is no source for the chain to rewrite; the schema ' + 'tombstones both keys via retiredKey() and consumers drop their read. The withdrawal is ' + 'conditional: both return to the payload in the change that ships the login UI ' - + '(objectui#4179). ADR-0049, #7481.', + + '(flag-gated passkey and magic-link entry points, which objectui defers until the ' + + 'maintainer schedules them). ADR-0049.', acceptanceCriteria: 'No client reads `features.passkeys` or `features.magicLink` off `/api/v1/auth/config`; ' + 'a client that gated UI on either now treats the capability as absent rather than ' diff --git a/packages/spec/src/migrations/entries/semantic/17.batch-row-result-schema-shape.ts b/packages/spec/src/migrations/entries/semantic/17.batch-row-result-schema-shape.ts index 8435a72bb5e..42b38f48cfc 100644 --- a/packages/spec/src/migrations/entries/semantic/17.batch-row-result-schema-shape.ts +++ b/packages/spec/src/migrations/entries/semantic/17.batch-row-result-schema-shape.ts @@ -20,14 +20,18 @@ export const entry: SemanticMigration = { + 'validated and read `undefined` at runtime — the declared-but-not-delivered shape this ' + 'registry exists to close, on the response envelope (ADR-0119 D4 deferred the ' + 'reconciliation off a bug fix; this is that tracked change, shipped in the 17 major ' - + 'window). The ADR-0119/#4620 rollback marking is structured in the same move: the ' + + 'window). The ADR-0119 rollback marking, which the fix making `deleteManyData` and ' + + '`updateManyData` honour `atomic` carried to those two endpoints, is structured in the ' + + 'same move: the ' + '`ROLLED_BACK:` / `NOT_ATTEMPTED:` message-string prefixes become registered ' + '`ApiError.code` values (message keeps the human-readable cause and causal row index), ' + 'so "attempted and undone" vs "never ran" is machine-readable instead of a regex ' + 'convention. A RESPONSE surface — nothing stored in stack metadata carries a batch ' + 'row, so there is no source for the chain to rewrite; consumers of the legacy keys ' + 'move their reads themselves. Off-contract readers only: the legacy keys were never ' - + 'in the schema or the SDK types, so a typed consumer needs no change. #4793.', + + 'in the schema or the SDK types, so a typed consumer needs no change. Ruled 2026-08-03: ' + + 'the implementation moves to the schema\'s shape as a hard cut in the 17 major, with no ' + + 'dual-emit transition.', acceptanceCriteria: 'No consumer reads `row.error` or `row.record` on a batch result row; failures are read ' + 'from `row.errors` (message via `errors[0].message`, rollback state via ' diff --git a/packages/spec/src/migrations/entries/semantic/17.connector-inline-authentication-publish-refused.ts b/packages/spec/src/migrations/entries/semantic/17.connector-inline-authentication-publish-refused.ts index 3f6cff2a4c0..aad5980205b 100644 --- a/packages/spec/src/migrations/entries/semantic/17.connector-inline-authentication-publish-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/17.connector-inline-authentication-publish-refused.ts @@ -14,9 +14,11 @@ export const entry: SemanticMigration = { 'still carries resolved secrets inline.', reason: 'A published connector row lands whole in `sys_metadata`, so an inline `token` / `key` ' + - '/ `password` / `clientSecret` is cleartext at rest, readable through the data API ' + - '(#7990). No mechanical rewrite exists: whether the entry should become a `none` ' + - 'descriptor or a provider-bound instance with a `credentialRef` — and which secret ' + + '/ `password` / `clientSecret` is cleartext at rest, readable through the data API (the ' + + 'class a credential-persistence survey measured: any authored artefact whose schema ' + + 'permits an inline credential lands it there). No mechanical rewrite exists: whether ' + + 'the entry should become a `none` descriptor or a provider-bound instance with a ' + + '`credentialRef` — and which secret ' + 'store receives the credential — is a judgment about the connector, not a rename.', acceptanceCriteria: 'Every authored connector entry parses through `DeclarativeConnectorEntrySchema`; no ' + diff --git a/packages/spec/src/migrations/entries/semantic/17.enhanced-api-error-field-errors-renamed.ts b/packages/spec/src/migrations/entries/semantic/17.enhanced-api-error-field-errors-renamed.ts index f9f6539153d..4129b5ab5d6 100644 --- a/packages/spec/src/migrations/entries/semantic/17.enhanced-api-error-field-errors-renamed.ts +++ b/packages/spec/src/migrations/entries/semantic/17.enhanced-api-error-field-errors-renamed.ts @@ -13,7 +13,8 @@ export const entry: SemanticMigration = { 'was reading a field no server sent (ADR-0078\'s silently-inert declaration, on the ' + 'error envelope). This is a RESPONSE surface: no stack, example or template carries ' + 'the key, so there is no source for the chain to rewrite — the schema tombstones it ' + - 'via retiredKey() and consumers move their read themselves. ADR-0114 D4, #3977.', + 'via retiredKey() and consumers move their read themselves. ADR-0114 D4 (the ' + + 'field-level error code catalog).', acceptanceCriteria: 'No consumer reads `error.fieldErrors`; per-field validation detail is read from ' + '`error.fields`, and constructing an EnhancedApiError with `fieldErrors` fails to parse ' + diff --git a/packages/spec/src/migrations/entries/semantic/17.job-retry-policy-constraints-tightened.ts b/packages/spec/src/migrations/entries/semantic/17.job-retry-policy-constraints-tightened.ts index e3f1714d01d..baeb4b686ee 100644 --- a/packages/spec/src/migrations/entries/semantic/17.job-retry-policy-constraints-tightened.ts +++ b/packages/spec/src/migrations/entries/semantic/17.job-retry-policy-constraints-tightened.ts @@ -7,7 +7,8 @@ export const entry: SemanticMigration = { surface: 'job.retryPolicy.maxRetries (> 10) / job.retryPolicy.backoffMultiplier (< 1)', replacement: 'maxRetries <= 10, and backoffMultiplier >= 1', reason: - 'The converged RetryPolicy (#4661) keeps the automation side\'s bounds, which the job ' + 'The RetryPolicy converged onto one declaration from its automation and system copies ' + + 'keeps the automation side\'s bounds, which the job ' + 'side never had: `maxRetries` is capped at 10 and `backoffMultiplier` floored at 1. ' + 'Neither has a lossless rewrite. Clamping `maxRetries: 20` to 10 would halve a ' + 'retry budget its author chose, and a `backoffMultiplier` below 1 describes a delay ' diff --git a/packages/spec/src/migrations/entries/semantic/17.position-permissions-column-retired.ts b/packages/spec/src/migrations/entries/semantic/17.position-permissions-column-retired.ts index ed70add2e72..e3dc2759e58 100644 --- a/packages/spec/src/migrations/entries/semantic/17.position-permissions-column-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.position-permissions-column-retired.ts @@ -21,7 +21,8 @@ export const entry: SemanticMigration = { + 'A value that was recording intent as documentation belongs in `description`, ' + 'which remains declared', reason: - 'Maintainer ruling 2026-08-20 (#9885), ADR-0049 enforce-or-remove: REMOVE. The ' + 'Maintainer ruling 2026-08-20 on the finding that nothing writes or reads this column, ' + + 'ADR-0049 enforce-or-remove: REMOVE. The ' + 'object-scoped census (all sys_position-naming files, with same-object positive ' + 'controls resolving `active` / `delegatable` / `is_default` / `name` to real ' + 'readers) measured the column at zero on both sides: the only row writers — the ' diff --git a/packages/spec/src/migrations/entries/semantic/17.ups-delegated-from-column-retired.ts b/packages/spec/src/migrations/entries/semantic/17.ups-delegated-from-column-retired.ts index f6beff545ee..b617a51ba42 100644 --- a/packages/spec/src/migrations/entries/semantic/17.ups-delegated-from-column-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.ups-delegated-from-column-retired.ts @@ -20,7 +20,8 @@ export const entry: SemanticMigration = { + 'until Y". A permission-set grant that needs a provenance note keeps `reason` ' + '(free text), which remains declared on both grant tables', reason: - 'Maintainer ruling 2026-08-18 (#9730), ADR-0049 enforce-or-remove: REMOVE. The ' + 'Maintainer ruling 2026-08-18 on the finding that the delegation gate never reads this ' + + 'column on this object, ADR-0049 enforce-or-remove: REMOVE. The ' + 'runtime delegation gate is structurally scoped to sys_user_position ' + '(`isDelegationWrite` returns false for every other object, so ' + '`assertSelfDelegation` is unreachable for this table), and the explain engine ' diff --git a/packages/spec/src/migrations/entries/semantic/17.workflow-service-slot-retired.ts b/packages/spec/src/migrations/entries/semantic/17.workflow-service-slot-retired.ts index 00d2f9f327f..dda33ceed6e 100644 --- a/packages/spec/src/migrations/entries/semantic/17.workflow-service-slot-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.workflow-service-slot-retired.ts @@ -17,13 +17,14 @@ export const entry: SemanticMigration = { + 'repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches ' + 'were plugin-dev\'s retired stub probe and the generic discovery walk), no ' + 'implementation of any WorkflowProtocol method ever existed, and no host ever ' - + 'mounted `/api/v1/workflow` (the pre-#3586 DEFAULT_DISPATCHER_ROUTES listed it among ' + + 'mounted `/api/v1/workflow` (DEFAULT_DISPATCHER_ROUTES, before it was retired as a ' + + 'stale list, named it among ' + 'routes that never existed). Every part of it was ADR-0078\'s silently-inert ' + 'declaration: a CoreServiceName nothing filled, a contract nothing implemented, a ' + 'protocol nothing served, a discovery route field no builder could truthfully ' + 'populate. These are TS/API surfaces and a discovery RESPONSE field — never stored ' + 'in stack metadata, so there is no source for the chain to rewrite; consumers of the ' - + 'deleted types move their imports themselves. ADR-0049 / ADR-0078, #4451.', + + 'deleted types move their imports themselves. ADR-0049 / ADR-0078.', acceptanceCriteria: 'No import of IWorkflowService, WorkflowProtocol or the Get/WorkflowState/Config/' + 'Transition types resolves; no code calls getService(\'workflow\') or reads ' diff --git a/packages/spec/src/migrations/entries/semantic/18.address-location-value-unknown-keys-refused.ts b/packages/spec/src/migrations/entries/semantic/18.address-location-value-unknown-keys-refused.ts index aab53d301d0..573e8de2ce8 100644 --- a/packages/spec/src/migrations/entries/semantic/18.address-location-value-unknown-keys-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.address-location-value-unknown-keys-refused.ts @@ -13,7 +13,8 @@ export const entry: SemanticMigration = { + 'exactly `lat`, `lng`, `altitude`, `accuracy`. Every rejection carries the surface, the ' + 'offending key and a rename (`postal_code` / `zipCode` / `zip` / `postcode` → `postalCode`, ' + '`latitude` → `lat`, `longitude` → `lng`). A key that names no declared member is removed ' - + 'at the producer — never tolerated at a consumer (AGENTS.md #0.1)', + + 'at the producer — never tolerated at a consumer: an alias for an off-spec key in a ' + + 'consumer stays forbidden (contract-first — fix the metadata, not the runtime)', reason: 'Maintainer ruling 2026-09-01, option A: both value classes refuse undeclared keys. Both ' + 'value classes were all-optional ' diff --git a/packages/spec/src/migrations/entries/semantic/18.admin-export-wildcard-removed.ts b/packages/spec/src/migrations/entries/semantic/18.admin-export-wildcard-removed.ts index 4556dfc52e7..fce45fff666 100644 --- a/packages/spec/src/migrations/entries/semantic/18.admin-export-wildcard-removed.ts +++ b/packages/spec/src/migrations/entries/semantic/18.admin-export-wildcard-removed.ts @@ -27,7 +27,8 @@ export const entry: SemanticMigration = { + 'admin holds no app-authored set in which to write the per-object `false` that would ' + 'have won. So an application could declare an object exportable by nobody, ship, and be ' + 'silently wrong on an exfiltration boundary — declared ≠ enforced, on the axis where a ' - + 'silent gap costs the most. This is #5491 applied to export: that change removed ' + + 'silent gap costs the most. This is the 2026-08-07 ruling on the member baseline ' + + 'applied to export: that change removed ' + '`member_default`\'s CRUD wildcard because a wildcard in a set every principal resolves ' + 'is not a default but a floor nobody can get under; the export wildcard survived by ' + 'omission rather than by decision, one tier up. It cannot be mechanically converted, in ' @@ -39,7 +40,8 @@ export const entry: SemanticMigration = { + 'was never the defect (controls C1–C3 of the same run show it enforcing exactly), ' + 'specific-over-wildcard precedence is unchanged, `allowExport` on a `"*"` entry remains a ' + 'supported authoring shape in an app\'s OWN sets, and READ is untouched — an admin still ' - + 'sees every record they saw before. ADR-0087, maintainer ruling 2026-08-15, #8681.', + + 'sees every record they saw before. ADR-0087; maintainer ruling 2026-08-15, which ' + + 'removed `allowExport` from the wildcard entry of both shipped admin sets.', acceptanceCriteria: 'For every principal whose ADMIN export you rely on, the grant is now authored where you ' + 'control it: an app/environment permission set held by that principal names each object ' diff --git a/packages/spec/src/migrations/entries/semantic/18.admin-scope-business-unit-blank-refused.ts b/packages/spec/src/migrations/entries/semantic/18.admin-scope-business-unit-blank-refused.ts index eaef7f83927..ce440af8b7b 100644 --- a/packages/spec/src/migrations/entries/semantic/18.admin-scope-business-unit-blank-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.admin-scope-business-unit-blank-refused.ts @@ -22,7 +22,8 @@ export const entry: SemanticMigration = { + 'replacement that can be DERIVED from what was written: a blank names no unit, so the root the ' + 'author meant is not recoverable, and the platform must not pick one.', reason: - 'Maintainer ruling A on #19461 (decision batch #217 item 1, 2026-09-23 「217 同意」). ' + 'Maintainer ruling A, 2026-09-23: an empty or whitespace-only `businessUnit` is refused ' + + 'at parse, and stored scopes are not rewritten. ' + '`AdminScopeSchema` declared `businessUnit` as a bare string with no minimum, so ' + '`{ businessUnit: \'\' }` and `{ businessUnit: \' \' }` parsed green — measured against the ' + 'published spec 17.4.0 and re-measured on `main` before the change. This narrows a published ' diff --git a/packages/spec/src/migrations/entries/semantic/18.ai-conversation-analytics-duration-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.ai-conversation-analytics-duration-unit-in-key.ts index df74927cb76..be95976da0e 100644 --- a/packages/spec/src/migrations/entries/semantic/18.ai-conversation-analytics-duration-unit-in-key.ts +++ b/packages/spec/src/migrations/entries/semantic/18.ai-conversation-analytics-duration-unit-in-key.ts @@ -8,7 +8,9 @@ export const entry: SemanticMigration = { + 'unit (ai/conversation.zod.ts)', replacement: 'durationSeconds — rename the key; the value is unchanged', reason: - 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + 'Maintainer ruling B (2026-09-02, extended on 2026-09-05 to runtime-emitted durations): ' + + 'the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying ' + + 'value, never only in the describe prose, and no existing offender is grandfathered. ' + 'It stands alone because it is the only offender in ai/ and the only one on its file. ' + 'What makes the bare name worth a registry row rather than a quiet edit is the company ' + 'it kept: every other number on ConversationAnalytics is a COUNT — totalMessages, ' @@ -22,7 +24,7 @@ export const entry: SemanticMigration = { + 'runtime and handed to a consumer, never authored by hand and never stored as a ' + 'sys_metadata row, so the conversion chain has no seam that would ever see one — the ' + 'same disposition every runtime-emitted measurement in this stack has taken. ' - + '#15680, #14478, ADR-0087.', + + 'ADR-0087.', acceptanceCriteria: 'Every producer that BUILDS a ConversationAnalytics spells durationSeconds, and every ' + 'consumer that reads a session length reads durationSeconds. Authoring duration fails ' diff --git a/packages/spec/src/migrations/entries/semantic/18.assembled-package-body-plugins-envelope.ts b/packages/spec/src/migrations/entries/semantic/18.assembled-package-body-plugins-envelope.ts index 61af528ba62..9e8f73ea560 100644 --- a/packages/spec/src/migrations/entries/semantic/18.assembled-package-body-plugins-envelope.ts +++ b/packages/spec/src/migrations/entries/semantic/18.assembled-package-body-plugins-envelope.ts @@ -26,7 +26,9 @@ export const entry: SemanticMigration = { + 'rebuilt from source (`os build` / `composeStacks(…, { manifest: \'preserve\' })` no ' + 'longer folds them into a body), and a hand-written `packages[]` entry drops them.', reason: - 'A classification error, not a new special case (#15219; epic #14122 / #14512). ' + 'A classification error, not a new special case (maintainer ruling A, 2026-09-04: both ' + + 'keys are artifact envelope keys, top level only, never inside `packages[]` — decided ' + + 'while one artifact was being taught to carry several co-owning packages). ' + '`plugins` and `devPlugins` were the only members of the assembled-body key set whose ' + 'values are runtime ASSEMBLY instructions rather than serialisable metadata: `plugins` ' + 'holds what a host hands to `kernel.use()` — live plugin instances, manifests or package ' diff --git a/packages/spec/src/migrations/entries/semantic/18.audience-posture-default-invite-only.ts b/packages/spec/src/migrations/entries/semantic/18.audience-posture-default-invite-only.ts index 3d85ff7ecc9..affdbd8f55d 100644 --- a/packages/spec/src/migrations/entries/semantic/18.audience-posture-default-invite-only.ts +++ b/packages/spec/src/migrations/entries/semantic/18.audience-posture-default-invite-only.ts @@ -9,7 +9,8 @@ export const entry: SemanticMigration = { "explicit `auth: { audience: { posture: 'open' | 'email_domain', selfRegistrationPermissionSet: '' } }` " + '(deployments that intend open self-registration only)', reason: - 'The default audience posture flipped in #11739: an UNDECLARED `audience` now means ' + + 'The default audience posture flipped when one declared posture replaced the emergent ' + + 'self-registration default: an UNDECLARED `audience` now means ' + '`invite_only` — email/password self-registration (and social-provider JIT sign-up) is ' + 'refused with 403 SELF_REGISTRATION_CLOSED unless the address holds a pending invitation. ' + 'Previously the emergent default was open self-registration with no email verification. ' + diff --git a/packages/spec/src/migrations/entries/semantic/18.autonumber-default-unique-organization.ts b/packages/spec/src/migrations/entries/semantic/18.autonumber-default-unique-organization.ts index eac2da2b71f..78b95dffeaa 100644 --- a/packages/spec/src/migrations/entries/semantic/18.autonumber-default-unique-organization.ts +++ b/packages/spec/src/migrations/entries/semantic/18.autonumber-default-unique-organization.ts @@ -24,9 +24,10 @@ export const entry: SemanticMigration = { + 'conflicting key groups with row counts, and `os migrate plan` reports the blocked ' + '`create_index` with the same groups (ADR-0120 D4) — but which of the duplicate rows keeps ' + 'the number is a business decision no migration entry can make. Maintainer ruling ' - + '2026-08-31 (hotcrm#1301): an auto-number that may repeat is not an identifier, so unique ' - + 'is the platform default and opting out is the declaration, not the other way round ' - + '(#13894).', + + '2026-08-31, on a downstream CRM\'s measurement that eight of its nine auto-numbered ' + + 'business identifiers could be issued twice: an auto-number that may repeat is not an ' + + 'identifier, so unique is the platform default and opting out is the declaration, not ' + + 'the other way round.', acceptanceCriteria: 'Every `autonumber` field without an authored `unique` parses to `unique: \'organization\'` ' + '(`FieldSchema.parse({ type: \'autonumber\' }).unique === \'organization\'`, and through ' diff --git a/packages/spec/src/migrations/entries/semantic/18.branded-identifier-schemas-retired.ts b/packages/spec/src/migrations/entries/semantic/18.branded-identifier-schemas-retired.ts index 05640b74a39..c07be56e6eb 100644 --- a/packages/spec/src/migrations/entries/semantic/18.branded-identifier-schemas-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.branded-identifier-schemas-retired.ts @@ -20,7 +20,7 @@ export const entry: SemanticMigration = { + 'uses `SnakeCaseIdentifierSchema` or `SystemIdentifierSchema` from ' + '`@objectstack/spec/shared` directly — both stay published.)', reason: - 'Maintainer ruling 2026-09-01 on #13612 (director decision batch C, ' + 'Maintainer ruling 2026-09-01 (director decision batch C, ' + 'verbatim 「同意」: retire) — ADR-0049 enforce-or-remove. The brands ' + 'promised compile-time safety ("you cannot pass an ObjectName where a ' + 'FieldName is expected") that no consumer could obtain: no schema in ' diff --git a/packages/spec/src/migrations/entries/semantic/18.cbp-master-detail-required-forced.ts b/packages/spec/src/migrations/entries/semantic/18.cbp-master-detail-required-forced.ts index 3410f553647..6d6b7aaf447 100644 --- a/packages/spec/src/migrations/entries/semantic/18.cbp-master-detail-required-forced.ts +++ b/packages/spec/src/migrations/entries/semantic/18.cbp-master-detail-required-forced.ts @@ -12,13 +12,14 @@ export const entry: SemanticMigration = { + 'prescription. Metadata at rest is untouched: raw `.parse()`/`.safeParse()` still accept ' + 'the old shape, the security gate\'s derived enforcement stays, and the lint rule ' + '`relationship/master-detail-required` stays `warning` until its own v18 promotion ' - + '(#8772 Direction 1)', + + '(Direction 1 of the 2026-08-16 maintainer ruling whose Direction 2 this is)', reason: 'A `controlled_by_parent` detail derives ALL of its record access from the master that its ' + '`master_detail` reference names (ADR-0055). With the reference not `required`, an insert ' + 'may omit the master FK: the row lands with a null FK that the derived read filter ' + '`masterFK IN (accessible master ids)` can never match — unreadable by everyone — and ' - + 'every later by-id write answers `422 MISSING_REQUIRED_FIELD`. #8772 measured that only ' + + 'every later by-id write answers `422 MISSING_REQUIRED_FIELD`. The finding behind the ' + + 'ruling measured that only ' + 'the security gate closed this shape while the declaration surface still accepted it. ' + 'The maintainer ruling (2026-08-16, Direction 2) makes the unsafe shape impossible to ' + 'NEWLY declare at the builder; whether to keep `required: false` was never a real choice ' diff --git a/packages/spec/src/migrations/entries/semantic/18.cel-predicate-one-value-comparand-refused.ts b/packages/spec/src/migrations/entries/semantic/18.cel-predicate-one-value-comparand-refused.ts index aacd7277f4a..a9ab9d36a29 100644 --- a/packages/spec/src/migrations/entries/semantic/18.cel-predicate-one-value-comparand-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.cel-predicate-one-value-comparand-refused.ts @@ -34,7 +34,8 @@ export const entry: SemanticMigration = { + 'comparisons, flat in lists, and field-to-field comparisons between single-valued columns ' + 'lower and evaluate exactly as before', reason: - 'Ruling A on #19886 refused a list under != and in the equality slot; stage 2d closes the ' + 'Ruling A of 2026-09-24 refused a list under != and in the equality slot, holding both to ' + + 'the declared comparand — a literal or a `{ $field }` reference; stage 2d closes the ' + 'same fault one position over, measured through the real plugin-security on driver-sql and ' + 'driver-memory. !(record.status in [["closed", "archived"]]) lowered to a negated $in whose ' + 'only member was a list, which the strictly comparing write-check evaluator matched on no ' diff --git a/packages/spec/src/migrations/entries/semantic/18.change-management-duration-keys-retired.ts b/packages/spec/src/migrations/entries/semantic/18.change-management-duration-keys-retired.ts index f105431ee89..34fb1bf0c29 100644 --- a/packages/spec/src/migrations/entries/semantic/18.change-management-duration-keys-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.change-management-duration-keys-retired.ts @@ -14,7 +14,8 @@ export const entry: SemanticMigration = { + 'or rollback step, or compares an estimate with what happened, so there is no live ' + 'mechanism to declare a duration to', reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on #14477 (ruled A: retire per ' + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on the unread deadline keys ' + + '(ruled A: retire per ' + 'family). Three minute-shaped keys, at three nested sites, sat in the exported ' + 'change-management schemas and in the generated reference docs — an author could write ' + '`estimatedMinutes: 15` on a rollback step and reasonably expect it to feed a schedule — ' diff --git a/packages/spec/src/migrations/entries/semantic/18.change-management-family-retired.ts b/packages/spec/src/migrations/entries/semantic/18.change-management-family-retired.ts index fa409f515f0..fb7a18543cd 100644 --- a/packages/spec/src/migrations/entries/semantic/18.change-management-family-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.change-management-family-retired.ts @@ -24,7 +24,8 @@ export const entry: SemanticMigration = { + 'becomes a product capability it re-declares fresh, through the enforce route of ' + 'ADR-0049 — the engine first, the vocabulary second', reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 on #15513 (ruled A: retire the ' + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 on the families\' remaining keys ' + + 'and defs (ruled A: retire the ' + 'three compliance-shaped families whole via RETIRED_DEFS_BY_MAJOR, the ' + 'integration/ErrorMappingConfig precedent; not roadmapped). Six defs and roughly fifty ' + 'declared keys sat on the exported surface and in the generated reference docs, and were ' @@ -37,7 +38,8 @@ export const entry: SemanticMigration = { + 'read as gates the platform enforced, and neither ever did — the worst form of the ' + 'declared-but-unenforced shape, on a security-adjacent surface. Tagging the family ' + '`[EXPERIMENTAL — not enforced]` was the fallback the ruling did not take (a human-only ' - + 'signal). The #14477 duration-key tombstones (three nested sites, ' + + 'signal). The duration-key tombstones of the 2026-09-02 per-family ruling (three nested ' + + 'sites, ' + '`RETIRED_KEYS_BY_MAJOR[18]`, D3 `change-management-duration-keys-retired`) leave with ' + 'their defs\' source; their registry entries stay as history. Why D3 semantic and not a ' + 'D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` ' diff --git a/packages/spec/src/migrations/entries/semantic/18.cluster-driver-dangling-values-removed.ts b/packages/spec/src/migrations/entries/semantic/18.cluster-driver-dangling-values-removed.ts index a9ca6bacf74..62304abe75b 100644 --- a/packages/spec/src/migrations/entries/semantic/18.cluster-driver-dangling-values-removed.ts +++ b/packages/spec/src/migrations/entries/semantic/18.cluster-driver-dangling-values-removed.ts @@ -12,7 +12,7 @@ export const entry: SemanticMigration = { + 'a self-provided transport. A config naming `postgres` or `nats` never ' + 'worked: pick `redis`, or register the transport yourself under `custom`', reason: - 'Maintainer ruling on objectstack-ai/cloud#1626 (2026-08-24, option B ' + 'Maintainer ruling of 2026-08-24 on the cluster driver line-up (option B ' + 'adopted): single-node is the ObjectOS EE boundary, multi-node is Cloud ' + 'differentiation, and a DB-first postgres cluster driver is not built ' + 'absent concrete customer pull. The ruling\'s principle rider decides ' diff --git a/packages/spec/src/migrations/entries/semantic/18.device-request-response-interval-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.device-request-response-interval-unit-in-key.ts index 2a52a2778e3..8d61e968cb3 100644 --- a/packages/spec/src/migrations/entries/semantic/18.device-request-response-interval-unit-in-key.ts +++ b/packages/spec/src/migrations/entries/semantic/18.device-request-response-interval-unit-in-key.ts @@ -10,7 +10,9 @@ export const entry: SemanticMigration = { + 'in the device-flow response body', replacement: 'intervalSeconds — rename the key; the value (seconds, default 2) is unchanged', reason: - 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + 'Maintainer ruling B (2026-09-02, extended on 2026-09-05 to runtime-emitted durations): ' + + 'the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying ' + + 'value, never only in the describe prose, and no existing offender is grandfathered. ' + 'This key was ATTRIBUTED to RFC 8628 by the campaign card and reached this card only after ' + 'the attribution failed verification, so the evidence is recorded here rather than left in a ' + 'PR body. Ruling B exempts a key that mirrors a name fixed outside this repo, declared on the ' @@ -23,8 +25,7 @@ export const entry: SemanticMigration = { + 'marked key is exempted permanently and silently, while a wrongly renamed one is visible. ' + 'A SEMANTIC entry rather than a D2 conversion because the shape is RUNTIME-EMITTED — the ' + 'body of POST /api/v1/auth/device/request, never a stack collection member and never a ' - + 'sys_metadata row, so the conversion chain has no seam that would see one. #15677, #14478, ' - + 'ADR-0087.', + + 'sys_metadata row, so the conversion chain has no seam that would see one. ADR-0087.', acceptanceCriteria: 'No producer emits `interval` and no consumer reads it. The old spelling is a retiredKey() ' + 'tombstone, so authoring it fails tsc (the key types never) and fails the parse with the ' diff --git a/packages/spec/src/migrations/entries/semantic/18.epoch-instant-keys-renamed.ts b/packages/spec/src/migrations/entries/semantic/18.epoch-instant-keys-renamed.ts index 6548bd6e6ee..eacca8756c8 100644 --- a/packages/spec/src/migrations/entries/semantic/18.epoch-instant-keys-renamed.ts +++ b/packages/spec/src/migrations/entries/semantic/18.epoch-instant-keys-renamed.ts @@ -18,7 +18,7 @@ export const entry: SemanticMigration = { + 'milliseconds since the Unix epoch, still Date.now(). Only the key name ' + 'and the declared schema move', reason: - 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): a ' + 'Maintainer ruling B (2026-09-05, on the population the 2026-09-02 rule reaches): a ' + 'duration-shaped z.number() carries its unit in the key NAME, minus two ' + 'structural classes declared ON THE SCHEMA rather than in a gate ledger. ' + 'Epoch instants are the first class. They read to the rule exactly like ' @@ -43,7 +43,7 @@ export const entry: SemanticMigration = { + 'disposition kernel/KernelContext:previewMode already carries on one of ' + 'these very defs, and ruling B prescribes it explicitly: an ADR-0087 ' + 'conversion where the key is authorable, a semantic entry where it is ' - + 'runtime-emitted. #15676, #14478, ADR-0087.', + + 'runtime-emitted. ADR-0087.', acceptanceCriteria: 'No producer emits the old key and no consumer reads it. All four are ' + 'tombstoned with retiredKey(), so each fails tsc at the construction ' diff --git a/packages/spec/src/migrations/entries/semantic/18.esignature-config-deadline-keys-retired.ts b/packages/spec/src/migrations/entries/semantic/18.esignature-config-deadline-keys-retired.ts index 89c26fd8ef9..3706feee567 100644 --- a/packages/spec/src/migrations/entries/semantic/18.esignature-config-deadline-keys-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.esignature-config-deadline-keys-retired.ts @@ -13,9 +13,10 @@ export const entry: SemanticMigration = { + 'mechanism to declare an expiry window or a reminder interval to. `ESignatureConfig` ' + 'itself stays (`provider` / `enabled` / `signers`), unchanged', reason: - 'ADR-0049 enforce-or-remove; the 2026-09-02 ruling on #14477 held this pair on one ' + 'ADR-0049 enforce-or-remove; the 2026-09-02 ruling on the unread deadline keys held this ' + + 'pair on one ' + 'condition — "no roadmap ⇒ they retire with the other three families" — and the ' - + 'maintainer answered it on 2026-09-05 (decision batch #40, no roadmapped e-signature ' + + 'maintainer answered it on 2026-09-05 (no roadmapped e-signature ' + 'consumer), so the ruling\'s own branch resolves to retirement. Two day-shaped keys sat ' + 'on the published authorable surface (`authorable-surface/data.json`) and in the ' + 'generated reference docs — an author could write `expirationDays: 30` and reasonably ' diff --git a/packages/spec/src/migrations/entries/semantic/18.event-name-schema-retired.ts b/packages/spec/src/migrations/entries/semantic/18.event-name-schema-retired.ts index d6d60040ada..d79aa449e11 100644 --- a/packages/spec/src/migrations/entries/semantic/18.event-name-schema-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.event-name-schema-retired.ts @@ -21,7 +21,7 @@ export const entry: SemanticMigration = { + 'was validating platform event names, it parses through the enums ' + 'instead.)', reason: - 'Maintainer ruling 2026-09-01 on #13613 (director decision batch C, ' + 'Maintainer ruling 2026-09-01 (director decision batch C, ' + 'verbatim 「同意」: retire) — ADR-0049 enforce-or-remove. The schema ' + 'presented itself as the platform\'s event-name grammar while nothing ' + 'that runs consumed its three binding schemas, and the closed enums ' diff --git a/packages/spec/src/migrations/entries/semantic/18.incident-response-deadline-keys-retired.ts b/packages/spec/src/migrations/entries/semantic/18.incident-response-deadline-keys-retired.ts index 67131a9c9af..84388603989 100644 --- a/packages/spec/src/migrations/entries/semantic/18.incident-response-deadline-keys-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.incident-response-deadline-keys-retired.ts @@ -18,7 +18,8 @@ export const entry: SemanticMigration = { + 'declared on the object that stores the records and enforced by the LifecycleService — ' + 'not a number on this policy document', reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on #14477 (ruled A: retire per ' + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on the unread deadline keys ' + + '(ruled A: retire per ' + 'family). Six hour/minute/day-shaped keys sat on the published authorable surface and ' + 'in the generated reference docs — an author could write `triageDeadlineHours: 4` and ' + 'reasonably expect the platform to escalate after four hours — and read by NOTHING: ' diff --git a/packages/spec/src/migrations/entries/semantic/18.incident-response-family-retired.ts b/packages/spec/src/migrations/entries/semantic/18.incident-response-family-retired.ts index de70fa0efd8..9b7ab2fe9d9 100644 --- a/packages/spec/src/migrations/entries/semantic/18.incident-response-family-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.incident-response-family-retired.ts @@ -23,7 +23,8 @@ export const entry: SemanticMigration = { + 'capability it re-declares fresh, through the enforce route of ADR-0049 — the engine ' + 'first, the vocabulary second', reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 on #15513 (ruled A: retire the ' + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 on the families\' remaining keys ' + + 'and defs (ruled A: retire the ' + 'three compliance-shaped families whole via RETIRED_DEFS_BY_MAJOR, the ' + 'integration/ErrorMappingConfig precedent; not roadmapped). Eight defs and roughly ' + 'forty declared keys sat on the exported surface and in the generated reference docs, ' @@ -39,7 +40,8 @@ export const entry: SemanticMigration = { + 'promise the platform never kept, with no error and no feedback. Tagging the family ' + '`[EXPERIMENTAL — not enforced]` was the fallback the ruling did not take: it is a ' + 'human-only signal, and an AI generating from the schema still writes the key and ' - + 'believes it. The #14477 deadline-key tombstones (six sites, `RETIRED_KEYS_BY_MAJOR[18]`, ' + + 'believes it. The deadline-key tombstones of the 2026-09-02 per-family ruling (six sites, ' + + '`RETIRED_KEYS_BY_MAJOR[18]`, ' + 'D3 `incident-response-deadline-keys-retired`) leave with their defs\' source; their ' + 'registry entries stay as history. Why D3 semantic and not a D2 conversion: the chain ' + 'walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto ' diff --git a/packages/spec/src/migrations/entries/semantic/18.logging-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.logging-durations-unit-in-key.ts index 68fae0c955c..683e29a3770 100644 --- a/packages/spec/src/migrations/entries/semantic/18.logging-durations-unit-in-key.ts +++ b/packages/spec/src/migrations/entries/semantic/18.logging-durations-unit-in-key.ts @@ -10,15 +10,17 @@ export const entry: SemanticMigration = { + '`timeoutMs` (default 30000) on HttpDestinationConfig, and `buffer.flushIntervalMs` ' + '(default 1000) on LoggingConfig — rename the keys; every value (milliseconds) is unchanged', reason: - 'Director-seat ruling A on #15939, 2026-09-11, carrying the maintainer\'s 「同意」 (decision ' - + 'batch #115), executing the #14478 rule per file. All four keys named milliseconds in a ' + 'Maintainer ruling A, 2026-09-11: the gate that reads a duration key\'s JSDoc lands last, ' + + 'after its offenders are fixed file by file — so this entry executes, per file, the rule ' + + 'that a duration number key carries its unit in its name. All four keys named ' + + 'milliseconds in a ' + 'source JSDoc — "Flush interval in milliseconds", "Initial retry delay in milliseconds", ' + '"Timeout in milliseconds" — and the JSDoc above a key is not what ' + '`content/docs/references/**` renders; `.describe()` is, and none of the four carried one at ' + 'all. Measured by the `check:duration-unit-keys` census on this tree before the change, all ' + 'four read `[name: -] [prose: -]`: no unit in the key and no published prose to supply it, ' + 'so `content/docs/references/system/logging.mdx` printed a bare 5000 / 1000 / 30000 / 1000 ' - + 'and nothing on the page decided milliseconds from seconds. Under the #14478 gate, moving ' + + 'and nothing on the page decided milliseconds from seconds. Under that rule\'s gate, moving ' + 'the unit into the describe alone is itself a violation (unit in prose, none in the name), ' + 'so each key is renamed and given the describe it never had in the same stroke. ' + '⚠️ `flushInterval` was declared TWICE on this file, in two different defs and with two ' diff --git a/packages/spec/src/migrations/entries/semantic/18.memory-persistence-placeholder-refused.ts b/packages/spec/src/migrations/entries/semantic/18.memory-persistence-placeholder-refused.ts index 93a8dc69625..cdc2712df8a 100644 --- a/packages/spec/src/migrations/entries/semantic/18.memory-persistence-placeholder-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.memory-persistence-placeholder-refused.ts @@ -11,11 +11,14 @@ export const entry: SemanticMigration = { 'key unset and let the shared datasource factory scope the default per datasource, or ' + 'compute the config value in code before it enters `defineStack`', reason: - 'The #8336 defect one surface over: a `${…}` placeholder in memory persistence config ' + + 'The unresolved-placeholder defect one surface over from the datasource connection keys, ' + + 'where it is already refused: a `${…}` placeholder in memory persistence config ' + 'is resolved by NOTHING — the driver would create and write a literal `./${DATA_DIR}/…` ' + 'path, or write under the literal placeholder-bearing localStorage key, so the dump ' + 'lands in a wrongly-named location with no error naming the unresolved placeholder ' + - '(#8495; authored under the same false belief the #8336 ruling closes). These two keys ' + + '(authored under the same false belief the 2026-08-13 ruling closes: placeholder syntax ' + + 'in connection-material keys is refused at publish, because nothing resolves it). These ' + + 'two keys ' + 'are config-material like the connection keys, so the parent adjudication applies with ' + 'its reason intact; the memory driver\'s `initialData` stays deliberately UNJUDGED — it ' + 'carries arbitrary record values, where a literal `${…}` may be legitimate data. There ' + diff --git a/packages/spec/src/migrations/entries/semantic/18.observability-cel-predicates-retired.ts b/packages/spec/src/migrations/entries/semantic/18.observability-cel-predicates-retired.ts index 41277fd3190..e92f429e690 100644 --- a/packages/spec/src/migrations/entries/semantic/18.observability-cel-predicates-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.observability-cel-predicates-retired.ts @@ -35,7 +35,7 @@ export const entry: SemanticMigration = { + 'expression slots. So an author — very often an AI reading the generated reference page, ' + 'ADR-0033 — who wrote `successCriteria: \'p95 < 300ms\'` got a green parse and no signal, ' + 'indistinguishable from a predicate that ran and answered. ADR-0049 enforce-or-remove, ' - + 'ruled A by the maintainer on 2026-09-18 (director decision batch #160 item 3): by the ' + + 'ruled A by the maintainer on 2026-09-18: by the ' + 'standing criterion that a declared-but-unread capability is kept only when mainstream ' + 'platforms in the domain have it, application platforms do not carry SLI success criteria ' + 'or trace-sampling conditions as authorable application metadata — that lives in ' diff --git a/packages/spec/src/migrations/entries/semantic/18.schedule-flow-acting-organization-required.ts b/packages/spec/src/migrations/entries/semantic/18.schedule-flow-acting-organization-required.ts index f615c9bd9fc..76cf65fb344 100644 --- a/packages/spec/src/migrations/entries/semantic/18.schedule-flow-acting-organization-required.ts +++ b/packages/spec/src/migrations/entries/semantic/18.schedule-flow-acting-organization-required.ts @@ -36,7 +36,8 @@ export const entry: SemanticMigration = { + 'report, export and cleanup that filters by organization, while a refusal is visible ' + 'at boot and names its flow. Under the `single` posture with the switch on, declare ' + 'NOTHING: the run carries no organization and every tenant-scoped insert beneath it ' - + 'resolves the deployment\'s one organization through the #8844 guard. Under the `group` ' + + 'resolves the deployment\'s one organization through the guard that makes a ' + + 'system-context write resolve the install\'s organization. Under the `group` ' + 'posture with the switch on, declaring is OPTIONAL and both shapes are supported: a ' + 'declared flow behaves exactly as under `isolated` (the declaration bounds SELECTION and ' + 'identity alike), while an UNDECLARED flow arms, reads group-wide — which ADR-0105 D1 ' @@ -73,7 +74,8 @@ export const entry: SemanticMigration = { + 'for, so the gate is a deployment variable read at boot and the global default is OFF. ' + '2026-09-16, reopening the `group` half of that amendment and nothing else: ' + '「group 模式是本地部署的,运行 schedule 应该是可以的,但是你没有权限,可以单独开一个决策卡」 — ' - + 'ruled A′ on #18378. The 2026-09-08 ruling was made for the MULTI-TENANT shape, and ' + + 'ruled A′ the same day: under `group` with the switch on, a flow binds without a ' + + 'declaration. The 2026-09-08 ruling was made for the MULTI-TENANT shape, and ' + '`group` is not one: ADR-0105 D1 defines it as one legal group over one database with ' + 'group-wide visibility and cross-org workflow INHERENT to the shape, so a group-level ' + 'batch job is a capability of the posture rather than the cross-organization task the ' diff --git a/packages/spec/src/migrations/entries/semantic/18.screen-field-lookup-reference-required.ts b/packages/spec/src/migrations/entries/semantic/18.screen-field-lookup-reference-required.ts index dfb2dda74a2..a7ff3a3f9a4 100644 --- a/packages/spec/src/migrations/entries/semantic/18.screen-field-lookup-reference-required.ts +++ b/packages/spec/src/migrations/entries/semantic/18.screen-field-lookup-reference-required.ts @@ -30,7 +30,7 @@ export const entry: SemanticMigration = { + "direction: change `type` to `'text'`, which is what that field actually was, and " + 'keep the prose that asked for an id in `inlineHelpText`.', reason: - 'Maintainer ruling A′, 2026-09-13 (decision batch #130 item 1), verbatim, ' + 'Maintainer ruling A′, 2026-09-13, verbatim, ' + 'untranslated: 「同意」. ADR-0078 forbids metadata that parses, carries no marking and ' + 'does nothing — and its own worked example of that state is a `lookup` with no ' + '`reference`: the field renders a picker, the picker has no object to query, and ' diff --git a/packages/spec/src/migrations/entries/semantic/18.send-template-input-org-retired.ts b/packages/spec/src/migrations/entries/semantic/18.send-template-input-org-retired.ts index d71cf6da040..35d4c2f831e 100644 --- a/packages/spec/src/migrations/entries/semantic/18.send-template-input-org-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.send-template-input-org-retired.ts @@ -8,15 +8,17 @@ export const entry: SemanticMigration = { replacement: '(removed — never implemented; delete the key from the call. It is NOT replaced by ' + '`organizationId`: that member is the delivery row\'s tenant stamp ' - + '(`sys_email.organization_id` pass-through, #11741) and opts into no template overlay ' + + '(`sys_email.organization_id` pass-through, added so the email writer stamps a ' + + 'delivery row\'s organization at the source) and opts into no template overlay ' + 'resolution)', reason: - 'ADR-0049 enforce-or-remove (#11832). `SendTemplateInput.org` was declared as "Tenant id ' + 'ADR-0049 enforce-or-remove. `SendTemplateInput.org` was declared as "Tenant id ' + 'for org-overlay resolution (when supported)" and no implementation ever read it: ' + '`@objectstack/plugin-email` — the only IEmailService implementation — resolves templates ' + 'on `(name, locale)` only, so a caller passing `org` got no org-overlay resolution and no ' - + 'error; the "(when supported)" hedge was the declaration admitting the gap. After #11741 ' - + 'landed `organizationId` beside it, the input carried two org-shaped keys of which one did ' + + 'error; the "(when supported)" hedge was the declaration admitting the gap. After the ' + + 'delivery-row stamp landed `organizationId` beside it, the input carried two org-shaped ' + + 'keys of which one did ' + 'nothing — exactly the shape that invites an AI author to pick the wrong one. There is no ' + 'behaviour to preserve and nothing stored to rewrite: the key only ever appeared in a ' + 'call-time input bag (the `data.engine.update options.upsert` precedent), which is why ' diff --git a/packages/spec/src/migrations/entries/semantic/18.standard-error-code-batch-members-retired.ts b/packages/spec/src/migrations/entries/semantic/18.standard-error-code-batch-members-retired.ts index 9093508f616..60320a422b3 100644 --- a/packages/spec/src/migrations/entries/semantic/18.standard-error-code-batch-members-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.standard-error-code-batch-members-retired.ts @@ -17,7 +17,8 @@ export const entry: SemanticMigration = { + 'spellings outright: it never fired, because nothing ever emitted them', reason: 'ADR-0049 enforce-or-remove applied to the error vocabulary. No producer has ever ' - + 'emitted any of the three — measured on #9266: outside the enum declaration the ' + + 'emitted any of the three — measured when a sweep of the error catalogue found these ' + + 'three entries publishing no HTTP status: outside the enum declaration the ' + 'only occurrences in the whole repo were two spec tests using them as arbitrary ' + 'fixture strings, and `git log -S` shows they never had a producer since ADR-0112 ' + 'introduced the vocabulary. A catalog member no producer can speak teaches an AI ' @@ -27,8 +28,8 @@ export const entry: SemanticMigration = { + '`driver-sql-upsert-cross-row-identity-merge-refused`) this entry is the ' + 'notification channel. No mechanical rewrite exists: a dead branch has no ' + 'correct mechanical target — the per-row codes carry strictly more information ' - + 'than the envelope code the branch expected. Maintainer ruling 2026-08-18: ' - + '「9266 同意 A」. #9266, ADR-0112, ADR-0049.', + + 'than the envelope code the branch expected. Maintainer ruling 2026-08-18: option A, ' + + 'retire all three from `StandardErrorCode`. ADR-0112, ADR-0049.', acceptanceCriteria: 'No consumer branches on the three retired spellings; batch failure handling reads ' + 'the per-row `results[].errors[].code` (`ROLLED_BACK` / `NOT_ATTEMPTED`) instead ' diff --git a/packages/spec/src/migrations/entries/semantic/18.standard-error-code-concurrent-limit-exceeded-retired.ts b/packages/spec/src/migrations/entries/semantic/18.standard-error-code-concurrent-limit-exceeded-retired.ts index ad0056372cc..5769e91eef5 100644 --- a/packages/spec/src/migrations/entries/semantic/18.standard-error-code-concurrent-limit-exceeded-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.standard-error-code-concurrent-limit-exceeded-retired.ts @@ -18,8 +18,8 @@ export const entry: SemanticMigration = { + 'concurrency limit registers a code for it in its own error-code ledger rather than reusing ' + 'the retired spelling. `QUOTA_EXCEEDED`, its catalogue neighbour, is unchanged.', reason: - 'ADR-0049 enforce-or-remove applied to the ADR-0112 error catalogue. Ruling A on #17707 ' - + '(maintainer 「同意」, decision batch #126 item 2) retired both producerless 429 members; the ' + 'ADR-0049 enforce-or-remove applied to the ADR-0112 error catalogue. Ruling A of ' + + '2026-09-13 (maintainer 「同意」) retired both producerless 429 members; the ' + 'closure-review ruling of 2026-09-24 (letter 留·收窄, maintainer 「其他同意」) narrowed it to ' + 'this code alone after `QUOTA_EXCEEDED` was found emitted by a hosted AI agent route and read ' + 'by the console chatbot plugin. The ledger doctrine in error-code-ledger.zod.ts names a ' diff --git a/packages/spec/src/migrations/entries/semantic/18.structured-region-body-pause-and-end-refused.ts b/packages/spec/src/migrations/entries/semantic/18.structured-region-body-pause-and-end-refused.ts index ca15d6afb61..d7664125b62 100644 --- a/packages/spec/src/migrations/entries/semantic/18.structured-region-body-pause-and-end-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.structured-region-body-pause-and-end-refused.ts @@ -27,12 +27,14 @@ export const entry: SemanticMigration = { + 'pause inside a region. A `try_catch` whose only purpose was to contain the region\'s ' + 'refusal has nothing left to contain and is deleted with it.', reason: - 'Maintainer ruling, decision batch #145 item 5, verbatim and untranslated: 「同意,其他也同' - + '意」, carrying the presented option C; extended by batch #146 「146 同意」, which attached ' - + 'the `end` half (the absorbed #18112) and recorded that #3267 is ruled 禁 — structured ' + 'Maintainer ruling of 2026-09-17, verbatim and untranslated: 「同意,其他也同' + + '意」, carrying the presented option C (a durable pause inside a structured region is ' + + 'refused at authoring time); extended the same day by a second ruling, which attached ' + + 'the `end` half (an `end` node inside a region body is refused as well) and ruled 禁 ' + + 'on building durable pause into structured regions — structured ' + 'regions do not support durable pause and a region body cannot terminate the run, so this ' + 'is that limit\'s authoring-time enforcement rather than an interim. The POPULATION was ' - + 'then fixed by decision batch #153 item 1, letter D (maintainer 「其他同意」): 「inside ' + + 'then fixed by the 2026-09-18 ruling, letter D (maintainer 「其他同意」): 「inside ' + '`loop` / `parallel` branch / `try_catch` (try and catch) bodies at any depth, the node ' + 'types `screen`, `wait`, `approval`, `approval_revise` and `end` are refused by ' + '`FlowSchema.superRefine` … `map` and `subflow` are ⛔ not refused by type.」 A parse-time ' diff --git a/packages/spec/src/migrations/entries/semantic/18.time-update-interval-sub-day-retired.ts b/packages/spec/src/migrations/entries/semantic/18.time-update-interval-sub-day-retired.ts index a8af9c9a764..62ac5c2f9a0 100644 --- a/packages/spec/src/migrations/entries/semantic/18.time-update-interval-sub-day-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.time-update-interval-sub-day-retired.ts @@ -16,7 +16,8 @@ export const entry: SemanticMigration = { + 'mechanical replacement that preserves a sub-day bucket, because no backend ever produced ' + 'one', reason: - 'ADR-0049 enforce-or-remove (#17296, the card #17206\'s changeset promised). The rest of the ' + 'ADR-0049 enforce-or-remove — the spec-side narrowing promised by the fix that made ' + + 'driver-memory\'s analytics face bucket by its declared granularity. The rest of the ' + 'contract never carried these three: `DateGranularity` (`data/query.zod.ts`) — the ' + 'vocabulary a `groupBy` entry and every driver\'s bucket expression are typed by — declares ' + 'five, `@objectstack/core`\'s `BUCKET_GRANULARITIES` labels the same five, and ' diff --git a/packages/spec/src/migrations/entries/semantic/18.training-deadline-keys-retired.ts b/packages/spec/src/migrations/entries/semantic/18.training-deadline-keys-retired.ts index f6af95589be..975b0faa367 100644 --- a/packages/spec/src/migrations/entries/semantic/18.training-deadline-keys-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.training-deadline-keys-retired.ts @@ -13,7 +13,8 @@ export const entry: SemanticMigration = { + 're-assigns training on an interval, escalates an expired certification or sends a ' + 'reminder, so there is no live mechanism to declare a duration or deadline to', reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on #14477 (ruled A: retire per ' + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on the unread deadline keys ' + + '(ruled A: retire per ' + 'family). Five minute/day-shaped keys sat on the published authorable surface and in the ' + 'generated reference docs — an author could write `validityDays: 365` and reasonably ' + 'expect a certificate to expire — and read by NOTHING: the schemas are exported from ' diff --git a/packages/spec/src/migrations/entries/semantic/18.training-family-retired.ts b/packages/spec/src/migrations/entries/semantic/18.training-family-retired.ts index 00ff1d9026d..4ab163eedb2 100644 --- a/packages/spec/src/migrations/entries/semantic/18.training-family-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.training-family-retired.ts @@ -21,7 +21,8 @@ export const entry: SemanticMigration = { + 'it re-declares fresh, through the enforce route of ADR-0049 — the engine first, the ' + 'vocabulary second', reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 on #15513 (ruled A: retire the ' + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 on the families\' remaining keys ' + + 'and defs (ruled A: retire the ' + 'three compliance-shaped families whole via RETIRED_DEFS_BY_MAJOR, the ' + 'integration/ErrorMappingConfig precedent; not roadmapped). Five defs and roughly ' + 'twenty-five declared keys sat on the exported surface and in the generated reference ' @@ -34,7 +35,8 @@ export const entry: SemanticMigration = { + 'and `TrainingPlan.sendReminders` were boolean capability claims of exactly the shape ' + 'ADR-0049 names: an author could write them, parse clean, and get no behaviour and no ' + 'diagnostic. Tagging the family `[EXPERIMENTAL — not enforced]` was the fallback the ' - + 'ruling did not take (a human-only signal). The #14477 deadline-key tombstones (five ' + + 'ruling did not take (a human-only signal). The deadline-key tombstones of the 2026-09-02 ' + + 'per-family ruling (five ' + 'sites, `RETIRED_KEYS_BY_MAJOR[18]`, D3 `training-deadline-keys-retired`) leave with ' + 'their defs\' source; their registry entries stay as history. Why D3 semantic and not a ' + 'D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` ' diff --git a/packages/spec/src/migrations/entries/semantic/18.translation-per-app-settings-platform-only.ts b/packages/spec/src/migrations/entries/semantic/18.translation-per-app-settings-platform-only.ts index 7587802b2c0..a25d9690939 100644 --- a/packages/spec/src/migrations/entries/semantic/18.translation-per-app-settings-platform-only.ts +++ b/packages/spec/src/migrations/entries/semantic/18.translation-per-app-settings-platform-only.ts @@ -75,8 +75,9 @@ export const entry: SemanticMigration = { + 'application must not do. Dropping it takes each overridden key back to the platform bundle’s ' + 'string, and each key it had filled back to the manifest literal. A mechanical notice reading ' + '"(removed)" conveys neither. The two bundles are separate namespaces from this major on ' - + '(ruling batch #132 item 2 letter ②, 2026-09-13), and the item door follows the file door ' - + '(ruling batch #210 item 2 letter B, 2026-09-22: the file door and the item door are two ' + + '(ruling of 2026-09-13, letter ②: a platform bundle schema and a per-app bundle schema, ' + + '`settings` absent from the per-app one), and the item door follows the file door ' + + '(ruling of 2026-09-22, letter B: the file door and the item door are two ' + 'authoring surfaces for ONE app metadata type, so they accept one shape; an admin override of ' + 'platform copy, if ever wanted, is a platform-level feature, not app metadata). ADR-0049 ' + 'enforce-or-remove supplied the question, not the answer — `settings` stays a LIVE platform ' diff --git a/packages/spec/src/migrations/entries/semantic/18.turso-config-transport-mismatch-refused.ts b/packages/spec/src/migrations/entries/semantic/18.turso-config-transport-mismatch-refused.ts index fd08bebf04b..0b7d2ef6830 100644 --- a/packages/spec/src/migrations/entries/semantic/18.turso-config-transport-mismatch-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.turso-config-transport-mismatch-refused.ts @@ -33,21 +33,26 @@ export const entry: SemanticMigration = { + 'or https, or drops timeoutMs. Each refusal names the key it sits on (url, syncUrl or ' + 'timeoutMs) and prints the spellings above', reason: - '#19977. Each key parsed on its own, so the contract accepted configurations the turso driver ' - + 'refuses when it is built (VALIDATION_ERROR / 400 from the constructor, since the #19893 ' - + 'and #19976 changes) — a datasource published clean and then failed at boot or at test ' - + 'connection. One more it built and then ignored until #20200: syncUrl under a forced remote ' + 'Each key parsed on its own, so the contract accepted configurations the turso driver ' + + 'refuses when it is built (VALIDATION_ERROR / 400 from the constructor, since the fixes ' + + 'that stopped a remote url beside a syncUrl from writing to process memory and an ' + + 'unrecognised url scheme from falling through to an in-memory local engine) — a ' + + 'datasource published clean and then failed at boot or at test connection. One more it ' + + 'built and then ignored until the constructor was taught to refuse it as well: syncUrl ' + + 'under a forced remote ' + 'mode, where the remote client was created without it, no sync ever ran and the sync call ' + 'failed as not supported while the driver reported sync as enabled (measured on the built ' + 'driver). Authoring now refuses exactly the constructor\'s refused set — the same predicates, ' + 'a scheme matched in any letter case, the url read trimmed as both datasource loaders hand ' + 'it over — plus that key, refused at authoring first as the declared-but-not-enforced shape ' - + 'ADR-0049 does not ship, and by the constructor too since #20200. Nothing the constructor ' - + 'accepts is refused (at #19977 that key was the one exception; since #20200 there is none): ' + + 'ADR-0049 does not ship, and by the constructor too since that later fix. Nothing the ' + + 'constructor accepts is refused (when authoring first refused that key it was the one ' + + 'exception; since the constructor refuses it too there is none): ' + 'a forced remote mode keeps its url unjudged, as the constructor does. Stored datasource ' + 'rows are not re-parsed ' + 'when they load, so a stored row still reaches the constructor as written; the constructor ' - + 'refuses the first four shapes there already and, since #20200, also refuses syncUrl under ' + + 'refuses the first four shapes there already and, since that later fix, also refuses ' + + 'syncUrl under ' + 'a forced remote mode and sync with no syncUrl when the datasource boots. What changes here ' + 'is that creating, testing or editing ' + 'its config through the datasource admin service, defineStack or os validate is refused at ' diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-bulk-action-param-unknown-keys-refused.ts b/packages/spec/src/migrations/entries/semantic/18.ui-bulk-action-param-unknown-keys-refused.ts index b77b9c84470..5885f8c769f 100644 --- a/packages/spec/src/migrations/entries/semantic/18.ui-bulk-action-param-unknown-keys-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.ui-bulk-action-param-unknown-keys-refused.ts @@ -48,7 +48,7 @@ export const entry: SemanticMigration = { + 'that spread reddened 7 of 12 cases in the consuming repo, so retiring it was measured off ' + 'the table. (2) the widget-config family rode the same spread and really was honoured by ' + 'whichever widget read it — those keys are refused now rather than forwarded, which is the ' - + 'accepted cost of closing the shape (maintainer ruling, decision batch #146 item 4, letter ' + + 'accepted cost of closing the shape (maintainer ruling, letter ' + 'A, 2026-09-17: 「Breaking for authored metadata」, one-shot, no grace window and no dual ' + 'spelling). ⛔ Do not read their rejection as "the renderer ignores them", and ⛔ do not ' + 'answer it by declaring the key on the object\'s FIELD: the bulk surface has no ' diff --git a/packages/spec/src/migrations/entries/semantic/18.wait-node-event-config-required.ts b/packages/spec/src/migrations/entries/semantic/18.wait-node-event-config-required.ts index bbb443e229f..ecccbc6eb80 100644 --- a/packages/spec/src/migrations/entries/semantic/18.wait-node-event-config-required.ts +++ b/packages/spec/src/migrations/entries/semantic/18.wait-node-event-config-required.ts @@ -31,8 +31,8 @@ export const entry: SemanticMigration = { + 'stored boundary node is an authoring-surface repair: the native construct for error ' + 'handling is a `try_catch` region (ADR-0031).', reason: - 'Maintainer ruling, decision batch #127 item 5, verbatim and untranslated: ' - + '「16678 具体解释,计划用哪个字段判断经理。其他同意」 — carrying the presented option: the ' + 'Maintainer ruling of 2026-09-13, the clause of the reply that covers this item, verbatim ' + + 'and untranslated: 「其他同意」 — carrying the presented option: the ' + 'protocol is the source of truth; a designer never invents a default the protocol does ' + 'not apply; a default the protocol should have is declared by the protocol; a required ' + 'key has no "unset behaves as". ⛔ NOT losslessly convertible, and the reason is that the ' diff --git a/packages/spec/src/migrations/entries/semantic/18.websocket-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.websocket-durations-unit-in-key.ts index 5ea2bd38871..20df13a9244 100644 --- a/packages/spec/src/migrations/entries/semantic/18.websocket-durations-unit-in-key.ts +++ b/packages/spec/src/migrations/entries/semantic/18.websocket-durations-unit-in-key.ts @@ -10,7 +10,9 @@ export const entry: SemanticMigration = { replacement: 'reconnectIntervalMs, pingIntervalMs, timeoutMs and heartbeatIntervalMs — rename ' + 'each key; every value is unchanged, and so is every default (1000, 30000, 5000, 30000)', reason: - 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + 'Maintainer ruling B (2026-09-02, extended on 2026-09-05 to runtime-emitted durations): ' + + 'the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying ' + + 'value, never only in the describe prose, and no existing offender is grandfathered. ' + 'What makes this shape worth one entry rather than four is the neighbour: on both configs a ' + 'bare duration sits directly beside a bare COUNT — maxReconnectAttempts on the client, ' + 'reconnectAttempts on the server — so `reconnectInterval: 5` and `maxReconnectAttempts: 5` ' @@ -22,7 +24,7 @@ export const entry: SemanticMigration = { + '— neither is a stack collection member and neither is ever stored as a sys_metadata row, so ' + 'the conversion chain has no seam that would see one. The same disposition the ' + 'epoch-instant renames on this file took (epoch-instant-keys-renamed), and what ruling B ' - + 'prescribes for a key that is not authorable metadata. #15677, #14478, ADR-0087.', + + 'prescribes for a key that is not authorable metadata. ADR-0087.', acceptanceCriteria: 'Every WebSocketConfigSchema.parse(…) / WebSocketServerConfigSchema.parse(…) site and every ' + 'literal handed to a WebSocket client or server spells the suffixed keys; authoring any old ' From 4a1fe70e5091c9b0269f702bc269f6443bcd54c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:37:36 +0000 Subject: [PATCH 2/3] chore(spec): regenerate the migration registry, spec-changes.json and the upgrade guide Generator output only (gen:migration-registry, gen:spec-changes, gen:upgrade-guide) for the stage-9 entry prose. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- docs/protocol-upgrade-guide.md | 18 +- packages/spec/spec-changes.json | 36 ++-- packages/spec/src/migrations/registry.ts | 228 ++++++++++++++--------- 3 files changed, 170 insertions(+), 112 deletions(-) diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 48fdc46a2d1..2395e55431e 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -237,7 +237,7 @@ Finally it removes the 'pdf' member of `view.exportOptions` formats (#8010, main - Why not automatic: The authored `enable.apiMethods` enum is now exactly the six primitives. The eight legacy values — `upsert`, `aggregate`, `history`, `search`, `restore`, `purge`, `import`, `export` — are no longer authorable, because they are DERIVED effective operations resolved by the server's single derivation table, and an enum that lets an author name both a primitive and something derived from it has two spellings for one fact. The FROM → TO is a table rather than a rename: `upsert` → `create` + `update`; `import` → `create` + `update`; `export`, `aggregate` and `search` → `list`; `history` → `get`; and `restore` / `purge` map to NOTHING — they never derived, because `enable.trash` was retired with the other dead `enable.*` flags in the 11.0 ADR-0049 removal of dead author-facing properties, so the value is deleted outright. That last row is why this is a semantic entry and not a mechanical conversion, and the reason is a security one: the mapping WIDENS. An allowlist naming `history` was granting read of one record's audit trail; rewritten to `get` it grants ordinary record reads, and an allowlist naming `search` becomes a grant of full `list`. A transform that applied the table silently would broaden real API permissions without anyone reading the diff, so the rewrite is delegated to the author with the widening flagged. The reporter codemod exists for exactly that shape: `node scripts/codemod/apimethods-legacy-to-primitives.mjs` scans, reports the exact replacement per site, and FLAGS the allowlists the mapping would widen so the edit stays reviewable — it reports, it does not rewrite. Stored metadata keeps parsing (permanent tolerance, narrowing only), so nothing breaks at rest; what changes is what an author may newly write. Registered late, by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger: the enum shrink (phase 2 of the programme that made UI action buttons agree with the `apiMethods` allowlist) predates the gate that makes a breaking changeset state its ledger disposition. ADR-0087. - Done when: No authored `enable.apiMethods` array names a legacy value; `objectstack validate` passes. Run the reporter codemod first and read its widening flags before applying anything — ⚠️ the migration is only correct if each widened grant was INTENDED. For every object where `history` became `get` or `search` became `list`, confirm the broader operation is one the API should genuinely expose; where it is not, the answer is not a different value in this enum but a permission set that withholds the operation. Where the six primitives are all present, prefer deleting the key: that is equivalent to default-open and it tracks future primitives, whereas a hand-listed six silently stops granting anything added later. `restore` / `purge` are deleted with no replacement — if trash-like behaviour was being relied on, that capability left in the 11.0 dead-property removal and this entry is not where it returns. - **`approval-escalation-enabled-default-flip`** — `automation.ApprovalEscalation.enabled — an OMITTED value inside an approval node's escalation block` → nothing, for the common intent (escalate on timeout): an escalation block carrying timeoutHours is live by default. To declare an SLA OFF while keeping its configuration, write enabled: false explicitly — which is now the spelling the escalation sweep actually reads - - Why not automatic: A DECLARED-DEFAULT CORRECTION plus the enforcement that makes the key real (#12278, maintainer ruling 2026-08-27) — the same category as protocol 17's `import-run-automations-declared-default-corrected`: the schema promised `enabled` defaults to `false` (SLA off) while the plugin-approvals sweep never read the key at all — any escalation block with a positive `timeoutHours` escalated, and with `action: 'auto_approve'` that silently approved requests their author had declared off the clock. The flip moves the default to `true` and, in the same change, the sweep starts honouring an explicit `enabled: false`. The feature-level switch is whether an `escalation` block exists at all; within a block carrying `timeoutHours`, escalation is on unless explicitly turned off. Deployed metadata that OMITS `enabled` does not change behaviour: it escalated before (the sweep ignored the key) and escalates after (the parse materializes `true`). Stored request snapshots written before the flip carry a MATERIALIZED `enabled: false` (the approval-node executor parses config through the old schema before snapshotting), so the sweep keeps a read-side legacy window keyed on the snapshot's `created_at`: pre-flip snapshots keep escalating exactly as they do today, and the window retires itself as those pending requests drain. What DOES change is that an explicit `enabled: false` finally binds — a flow that authored it (e.g. the console toggle switched off after a timeout was set) stops escalating on requests opened after the upgrade, which is the declared intent being honoured. + - Why not automatic: A DECLARED-DEFAULT CORRECTION plus the enforcement that makes the key real (maintainer ruling 2026-08-27, which moved the declared default to what the sweep had always done) — the same category as protocol 17's `import-run-automations-declared-default-corrected`: the schema promised `enabled` defaults to `false` (SLA off) while the plugin-approvals sweep never read the key at all — any escalation block with a positive `timeoutHours` escalated, and with `action: 'auto_approve'` that silently approved requests their author had declared off the clock. The flip moves the default to `true` and, in the same change, the sweep starts honouring an explicit `enabled: false`. The feature-level switch is whether an `escalation` block exists at all; within a block carrying `timeoutHours`, escalation is on unless explicitly turned off. Deployed metadata that OMITS `enabled` does not change behaviour: it escalated before (the sweep ignored the key) and escalates after (the parse materializes `true`). Stored request snapshots written before the flip carry a MATERIALIZED `enabled: false` (the approval-node executor parses config through the old schema before snapshotting), so the sweep keeps a read-side legacy window keyed on the snapshot's `created_at`: pre-flip snapshots keep escalating exactly as they do today, and the window retires itself as those pending requests drain. What DOES change is that an explicit `enabled: false` finally binds — a flow that authored it (e.g. the console toggle switched off after a timeout was set) stops escalating on requests opened after the upgrade, which is the declared intent being honoured. - Done when: A flow whose approval node omits `enabled` inside `escalation` still escalates on timeout (no metadata edit needed). A flow that writes `enabled: false` stops escalating for newly opened requests — verify one such request stays pending past its `timeoutHours` with no `escalate` audit row and no auto-decision. Requests opened BEFORE the upgrade keep their pre-upgrade behaviour (they escalate) regardless of the stored `enabled` bit. Clients that parse metadata through the published JSON Schema now materialize `enabled: true` where they materialized `false`; a client that needs the SLA off must write it explicitly. - **`audit-log-action-enum-retired`** — `sys_audit_log.action — the values 'export' and 'permission_change' left the select enum declared by plugin-audit (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts). The same two values also left the shipped list-view filters on that object: 'permission_change' from the auth_events view and 'export' from the config_changes view` → nothing, for either value — both are removed rather than renamed, because neither named an event this platform records. For permission changes, read the ordinary `create` / `update` rows on the permission objects themselves: a grant or binding write is an ordinary record write and the generic audit writer already ledgers it, so a second semantically-duplicate row was never minted. For `export` there is no replacement and nothing is lost: no export feature ever wrote an audit row. A consumer filtering `sys_audit_log` on either value was reading an empty result set on every deployment, and still is — what changed is that the contract no longer promises otherwise - Why not automatic: Maintainer ruling 2026-08-12 on the audit log's writerless actions, the retirement half of a two-half verdict: the cheap writers get built (`login` / `logout` on the auth session hooks, `config_change` from the settings service) and the enum values with no feature behind them are retired. 原则记录:空 widget + 永远查不到东西的过滤器是可见产品缺陷;审计面宁窄勿谎. The defect was false compliance on a COMPLIANCE surface, which is the sharpest form of ADR-0049 declared-≠-enforced: an auditor reading the action enum believed the platform captured permission changes and data exports, and the shipped list views and dashboard widgets showed them a filter and a tile for exactly those events. Both were permanently empty. Measured by enumerating every `sys_audit_log` writer in the repo — there are exactly two: plugin-audit`s generic hook writer, whose `actionFor` maps afterInsert/Update/Delete to create/update/delete and nothing else, and plugin-auth`s admin user-import. Neither has ever emitted `export` or `permission_change`. This is an enum-VALUE retirement, so the bookkeeping differs from a key retirement in the two ways `hook-body-crypto-hash-removed`, `dataset-measure-array-string-agg-removed` and `action-global-nav-location-removed` already record: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) and the four surface ratchets are expected to be byte-identical (no def changed). It differs from all three in being a SEMANTIC entry rather than a D2 conversion, and the reason is that there is no source to rewrite: `sys_audit_log` is a platform-owned, append-only object whose every field is `readonly: true`. Nobody authors an audit row and nobody authors this enum — the values appear only in rows the runtime writes and in queries consumers send. A conversion rewrites authored metadata or a stored `sys_metadata` row; this surface is neither, so the disposition is the one `BatchOptions.validateOnly` and the notification cursor already take in this major. ⚠️ Historical ROWS are deliberately untouched. A deployment that somehow holds a row with either value keeps it, and keeps reading it back: the enum is not enforced on this object at all (`validateRecord` skips `readonly` fields, and every field here is readonly), so nothing rejects stored history and no backfill is required or wanted. Deleting audit history to satisfy a schema narrowing would be the one genuinely destructive reading of this change. ADR-0049 / ADR-0087. @@ -246,7 +246,7 @@ Finally it removes the 'pdf' member of `view.exportOptions` formats (#8010, main - Why not automatic: The same maintainer ruling as `audit-log-action-enum-retired`, carried to the one value that ruling's own survey did not name (triage 2026-08-13). 原则记录:空 widget + 永远查不到东西的过滤器是可见产品缺陷;审计面宁窄勿谎. `restore` is the least ambiguous member of the family: the record-level writer could not have produced it even by accident, because `actionFor()` in audit-writers.ts is typed `'create' | 'update' | 'delete' | null` and its caller early-returns on null. A tree-wide search finds no other producer. What made it a card rather than a tidy-up is that TWO shipped declarations asserted the opposite, so a declaration-reading audit scored the action as covered: the `writes_only` list view offered it as a filter value, and the module docblock of auth-event-audit.ts named it among the actions the writer emits. The comment is the ADR-0049 declared-≠-enforced shape in its purest form (the shape a credential-storage audit had to settle by re-measuring two "hashed at rest" comments) — a sentence next to a mechanism, contradicted by the type signature of that very mechanism, with nothing in CI able to tell. Both declarations are corrected in one change, and the invariant behind the comment (every declared action has a writer) now has a pin test under it rather than prose. Bookkeeping is identical to the sibling entry, for the same reasons: an enum-VALUE retirement puts nothing in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) and leaves the four surface ratchets byte-identical (no def changed), and it is a SEMANTIC entry rather than a D2 conversion because there is no source to rewrite — `sys_audit_log` is a platform-owned, append-only object whose every field is `readonly: true`, so nobody authors an audit row and nobody authors this enum. ⚠️ This is a statement about the WRITER, not a product stance against undelete. Soft delete/restore is parked, not rejected: the undelete / purge lifecycle and the recycle bin are both held open, not declined. If that capability lands, this value returns WITH its writer — the emission point, its tests, and the view that surfaces it — never as a bare enum row again. ⚠️ Historical ROWS are deliberately untouched, exactly as for the sibling entry: the enum is not enforced on this object at all (`validateRecord` skips `readonly` fields), so any stored row keeps parsing and reading back, and no backfill is required or wanted. Deleting audit history to satisfy a schema narrowing would be the one genuinely destructive reading of this change. ADR-0049 / ADR-0087. - Done when: No consumer filters `sys_audit_log` on `action = "restore"` expecting rows: it was empty on every deployment before this change and behaves identically after it. Concretely, check three places. (1) Saved queries, dashboards and reports over `sys_audit_log`: a filter naming `restore` should be deleted, not re-pointed — there is no action that carries the meaning, because the platform records no restore event. (2) Any code branching on the action string (a badge map, a label switch, an option list in an audit-log filter UI): the `restore` arm is unreachable and should go, and a `switch` with an exhaustiveness check over the enum type will now fail to compile if it stays — that compile error is the enforced channel for TypeScript consumers. An option in a FILTER dropdown is the user-visible half and matters most: it offers an operator a choice that returns nothing. (3) Custom objects or plugins inserting `sys_audit_log` rows with this value: the write will NOT be refused (readonly fields are not validated), so it silently becomes a row whose action the object no longer declares. Pick a declared value, or open an issue for the action you actually need. ⚠️ Do NOT migrate or delete existing rows: audit history is append-only and stays exactly as written. - **`auth-config-unadvertised-reserved-features`** — `api.authConfig.features.passkeys / api.authConfig.features.magicLink` → (removed — no replacement flag; the capabilities are not advertised) - - Why not automatic: Both flags were served by `GET /api/v1/auth/config` from introduction and read by no client: no login UI anywhere renders a passkey or magic-link affordance off them, so the payload advertised two sign-in methods a user could never reach, and a deployer setting `plugins.passkeys` / `plugins.magicLink` flipped a switch with no observable effect (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-11 on #7481 chose remove over keep-as-reserved). The two are not equally empty: nothing at all is wired behind `passkeys`, whereas `magicLink`'s better-auth endpoints are live and only their advertisement was withdrawn. This is a RESPONSE surface — nobody authors or persists an `AuthFeaturesConfig` — so there is no source for the chain to rewrite; the schema tombstones both keys via retiredKey() and consumers drop their read. The withdrawal is conditional: both return to the payload in the change that ships the login UI (objectui#4179). ADR-0049, #7481. + - Why not automatic: Both flags were served by `GET /api/v1/auth/config` from introduction and read by no client: no login UI anywhere renders a passkey or magic-link affordance off them, so the payload advertised two sign-in methods a user could never reach, and a deployer setting `plugins.passkeys` / `plugins.magicLink` flipped a switch with no observable effect (ADR-0049 enforce-or-remove; the maintainer ruling of 2026-08-11 chose remove over keep-as-reserved, so that a deployer cannot flip a flag that does nothing anywhere). The two are not equally empty: nothing at all is wired behind `passkeys`, whereas `magicLink`'s better-auth endpoints are live and only their advertisement was withdrawn. This is a RESPONSE surface — nobody authors or persists an `AuthFeaturesConfig` — so there is no source for the chain to rewrite; the schema tombstones both keys via retiredKey() and consumers drop their read. The withdrawal is conditional: both return to the payload in the change that ships the login UI (flag-gated passkey and magic-link entry points, which objectui defers until the maintainer schedules them). ADR-0049. - Done when: No client reads `features.passkeys` or `features.magicLink` off `/api/v1/auth/config`; a client that gated UI on either now treats the capability as absent rather than reading `undefined` as false by accident, and constructing an `AuthFeaturesConfig` with either key fails to parse with its own prescription instead of being silently stripped. Magic-link deployments keep working: `plugins.magicLink` still mounts `/api/v1/auth/magic-link/send` and `/magic-link/verify`, which a custom UI may call directly. - **`authoring-schemas-strict-unknown-keys`** — `the protocol-17 authoring schemas closed against undeclared keys by the unknown-key strictness wave — `automation/` (flow and its six nested blocks, control-flow, state-machine, webhook, time-relative trigger, flow function), `security/` (permission sets, RLS policies, sharing rules) and `identity/position`, `ui/` (responsive, theme, chart, `AriaProps`, fifteen `view` sub-blocks, `ViewItem`, `userFilters`) — plus the `view` write-path identity precondition one level above them` → declared keys only. Each rejection names the surface, echoes the offending key and — where the word is recognisable — gives the canonical spelling, a retired-key tombstone, or a prescription where a rename would be wrong. A `view` body must additionally carry at least one key some union member declares, discounting the identity keys the write path stamps itself (`VIEW_WRITE_PATH_IDENTITY_KEYS`) - Why not automatic: zod's default `.strip` discarded any key these schemas did not declare and let the parse SUCCEED, so the author — increasingly an AI — got a success envelope and shipped metadata that quietly ignored what they wrote. Closing them turns that into a loud parse error (ADR-0049 enforce-or-remove, ADR-0078 no-silently-inert). It is not losslessly convertible for the same reason the two precedent entries at majors 15 and 16 are not: an arbitrary unknown key has no mapping target, and auto-deleting it would be exactly the silent data loss ADR-0078 bans — so each occurrence needs the author to decide, fix the typo, move it to the layer that owns it, or delete dead metadata. The named renames the errors carry are a help, not a transform: a large share of this wave is PRESCRIPTIONS rather than renames precisely because renaming would be wrong (`inputSchema.optional` is the opposite polarity of `required`; `errorHandling.maxAttempts` counts the first attempt where `maxRetries` counts the ones after it; a `responsiveStyles` bucket written on `responsive` is a wrong-layer pointer, and the two breakpoint vocabularies sixteen lines apart cannot be bridged by edit distance; `aria.live` is real on exactly one renderer and `ariaLabelledBy` has nothing to rename to; `finally` on `try_catch` and `context` on a state machine have no key at all). The eleventh member is not an unknown-key close but the same defect one level up — the `view` union had an arm that both stripped and required nothing, so it matched every object and `saveMetaItem` persisted garbage as an ACTIVE view overlay that read back badged valid. This is ONE entry for the whole major by the maintainer's 2026-08-12 ruling that the wave is registered one entry per major, not one per batch, mirroring the registry's only two precedents of this shape; the eleven batches it folds are the changesets `unknown-key-strictness-tier-a`, `-step2`, `-automation-batch11`, `-ui-batch13`, `-ui-batch15`, `-ui-batch16`, `strict-automation-control-flow-state-machine`, `view-subblock-strictness-batch18`, `rare-jars-shave`, `user-filters-allow-add-tab-promote-and-close` and `view-union-identity-precondition`, each carrying its own FROM → TO table in `CHANGELOG.md`. One batch promoted a key before closing its block: `userFilters.allowAddTab` was already read by objectui, so the maintainer's 2026-08-04 ruling declared it in the spec rather than let a correct-looking refusal tell authors to delete a working capability. The entry was registered in the backfill of the v17 train's breaking changesets, which had never been compared against the ledger. ADR-0049 / ADR-0078 / ADR-0087. @@ -255,13 +255,13 @@ Finally it removes the 'pdf' member of `view.exportOptions` formats (#8010, main - Why not automatic: The `validateOnly` key promised a dry-run ("validate records without persisting") but no batch surface ever read it — updateManyData / deleteManyData / batchData persist regardless. There is no behaviour to preserve and nothing stored to rewrite (it only ever appeared in an HTTP request body). Callers must stop sending it. - Done when: No /batch, /updateMany or /deleteMany call sends `options.validateOnly`; a request that includes it answers 400 VALIDATION_FAILED with the retirement prescription. - **`batch-row-result-schema-shape`** — `api.batchOperationResult — the per-row `results` entries of BatchUpdateResponse (`POST /data/:object/batch`, `/updateMany`, `/deleteMany`)` → `errors: ApiError[]` (was `error: string` — read `row.errors?.[0]?.message`, branch on `row.errors?.[0]?.code`), `data` (was `record`), and `index` (new — the row's position in the request array) - - Why not automatic: The rows the three bulk-write endpoints emitted had drifted from the schema that declared them: `BatchOperationResultSchema`, the client SDK's exported `BatchOperationResult` type and the reference docs all said `errors: ApiError[]` / `data` / `index`, while the wire carried `error: string` / `record` and never sent `index` at all. A TypeScript consumer written against the published type compiled, validated and read `undefined` at runtime — the declared-but-not-delivered shape this registry exists to close, on the response envelope (ADR-0119 D4 deferred the reconciliation off a bug fix; this is that tracked change, shipped in the 17 major window). The ADR-0119/#4620 rollback marking is structured in the same move: the `ROLLED_BACK:` / `NOT_ATTEMPTED:` message-string prefixes become registered `ApiError.code` values (message keeps the human-readable cause and causal row index), so "attempted and undone" vs "never ran" is machine-readable instead of a regex convention. A RESPONSE surface — nothing stored in stack metadata carries a batch row, so there is no source for the chain to rewrite; consumers of the legacy keys move their reads themselves. Off-contract readers only: the legacy keys were never in the schema or the SDK types, so a typed consumer needs no change. #4793. + - Why not automatic: The rows the three bulk-write endpoints emitted had drifted from the schema that declared them: `BatchOperationResultSchema`, the client SDK's exported `BatchOperationResult` type and the reference docs all said `errors: ApiError[]` / `data` / `index`, while the wire carried `error: string` / `record` and never sent `index` at all. A TypeScript consumer written against the published type compiled, validated and read `undefined` at runtime — the declared-but-not-delivered shape this registry exists to close, on the response envelope (ADR-0119 D4 deferred the reconciliation off a bug fix; this is that tracked change, shipped in the 17 major window). The ADR-0119 rollback marking, which the fix making `deleteManyData` and `updateManyData` honour `atomic` carried to those two endpoints, is structured in the same move: the `ROLLED_BACK:` / `NOT_ATTEMPTED:` message-string prefixes become registered `ApiError.code` values (message keeps the human-readable cause and causal row index), so "attempted and undone" vs "never ran" is machine-readable instead of a regex convention. A RESPONSE surface — nothing stored in stack metadata carries a batch row, so there is no source for the chain to rewrite; consumers of the legacy keys move their reads themselves. Off-contract readers only: the legacy keys were never in the schema or the SDK types, so a typed consumer needs no change. Ruled 2026-08-03: the implementation moves to the schema's shape as a hard cut in the 17 major, with no dual-emit transition. - Done when: No consumer reads `row.error` or `row.record` on a batch result row; failures are read from `row.errors` (message via `errors[0].message`, rollback state via `errors[0].code` — ROLLED_BACK / NOT_ATTEMPTED), records from `row.data`, and rows correlate to the request via `row.index`. Every row the three endpoints emit parses under `BatchOperationResultSchema` with those keys present. - **`client-delete-result-success`** — `client.DeleteDataResult.deleted (the return of `client.data.delete()`)` → `success` — `r.deleted` → `r.success`. Same call, same wire body, declared name - Why not automatic: `DeleteDataResult` carried the comment `Spec: DeleteDataResponseSchema` above a declaration that contradicted it: the interface declared `deleted: boolean` while `DeleteDataResponseSchema` declares `{ object, id, success }`. `deleted` has never been declared by any schema and no server path has ever returned it on `/data/:object/:id`. Both delete surfaces — `client.data.delete()` and the project-scoped `client.project(id).data.delete()` — are pure `unwrapResponse` / `_unwrap` passthroughs, so the interface is a CLAIM about the wire, never a rewrite of it, and the claim was false in the one direction that matters: the compiler endorsed the wrong spelling. `if (r.deleted)` compiled, read `undefined` at runtime, and the branch was never taken; `if (r.success)` was rejected by the compiler and correct on the wire. So this rename REVEALS a defect rather than breaking working code — every reader of the old key was already reading `undefined`, on every deployment and not just some, because the protocol path has always answered `success`. It is registered as a semantic entry rather than a mechanical conversion for the reason the rewrite itself does not capture: the key is one token, but a call site that branched on `r.deleted` has been taking the FALSE branch unconditionally since it was written, and whatever that branch did — or skipped — is what actually has to be re-read. There is no authored source for the chain to rewrite either; this is a published TypeScript surface whose enforced channel is tsc at the call site, and for an untyped JS caller there is no constrained channel at all, which is why the ledger entry is the only notification that reaches them. ⛔ Do not write `r.success ?? r.deleted`: there is one producer shape, and a consumer accepting two spellings is what contract-first exists to prevent (the same rule already moved the runtime's ObjectQL fallback from answering `deleted: true` to the declared `success`, on the producer side). No deprecated `deleted?: boolean` transition key ships, for the same reason — a transition period is for keys that WORKED, and this one never did. Registered by the stock reconciliation of the v17 train's breaking changesets, which had never been compared against the ledger. ADR-0087. - Done when: No code reads `.deleted` off a `client.data.delete()` / `client.project(id).data.delete()` result; `tsc` names every site for a typed caller, and an untyped JS caller must be swept by hand because nothing will report it. Nothing about the request, the route, the status codes or the error shapes changes, and no server needs upgrading — the value you may now read is the one that was already arriving. ⚠️ The real work is behavioural: every `if (r.deleted)` has been false since it was written, so re-read what each of those branches was supposed to do. Post-delete cleanup, cache invalidation, audit writes and UI refreshes guarded that way have never run, and switching to `r.success` turns them ON for the first time — verify that is what you want rather than assuming it restores prior behaviour. Any test that passed while asserting on `deleted` was asserting on `undefined` and needs rewriting, not renaming. - **`connector-inline-authentication-publish-refused`** — `connector.authentication on AUTHORED entries (defineStack `connectors:`, `PUT /meta/connector/:name`) — previously refused only on provider-bound instances (ADR-0097 §3), now refused on catalog descriptors too` → a catalog descriptor drops `authentication` (or sets `{ type: "none" }`) and documents the auth scheme in `description`; a dispatchable instance declares `provider` and references its credential with `auth: { type, credentialRef }` (ADR-0097 §3). Runtime `registerConnector` calls are unaffected — the runtime shape still carries resolved secrets inline. - - Why not automatic: A published connector row lands whole in `sys_metadata`, so an inline `token` / `key` / `password` / `clientSecret` is cleartext at rest, readable through the data API (#7990). No mechanical rewrite exists: whether the entry should become a `none` descriptor or a provider-bound instance with a `credentialRef` — and which secret store receives the credential — is a judgment about the connector, not a rename. + - Why not automatic: A published connector row lands whole in `sys_metadata`, so an inline `token` / `key` / `password` / `clientSecret` is cleartext at rest, readable through the data API (the class a credential-persistence survey measured: any authored artefact whose schema permits an inline credential lands it there). No mechanical rewrite exists: whether the entry should become a `none` descriptor or a provider-bound instance with a `credentialRef` — and which secret store receives the credential — is a judgment about the connector, not a rename. - Done when: Every authored connector entry parses through `DeclarativeConnectorEntrySchema`; no authored entry carries a non-`none` `authentication`; formerly inline credentials are reachable through `credentialRef` resolution and the connector still materializes. - **`dashboard-widget-compareto-offset`** — `dashboard.widgets[].compareTo: { offset: '7d' | '1M' | … } (every duration except '1y')` → compareTo: { kind: 'previousPeriod' } plus an explicit window on the widget's own `filter` - Why not automatic: The widget declared three comparison arms; the analytics executor implements one shape, `{ kind, dimension? }`, with no `offset` concept in it at all. On the ADR-0021 dataset path — the spec's single author-facing analytics shape — `{ offset }` was forwarded verbatim into that contract and threw `compareTo requires a timeDimension "undefined"`, taking the widget down; the arm ever only ran on the legacy inline chart path (measured when all three declared arms were found dead on the dataset path: two silently dropped, this one throwing). The conversion rewrites `{ offset: '1y' }`, which IS `previousYear` by definition. Every other duration has NO faithful target: `previousPeriod` shifts by the length of whatever window the widget's filter resolves to, which equals `7d` only when that window happens to be seven days long. Rewriting mechanically would silently change which rows the comparison column counts — a wrong number rather than a missing one, which is strictly worse and exactly the class this convergence exists to end. Re-stating the intended window is a judgment about the presentation, not a transform. @@ -341,7 +341,7 @@ ONE AUTHOR-REACHABLE SURFACE reaches this indirectly and is why it is not purely - Why not automatic: The `upsert` flag promised insert-if-absent on `engine.update()` but no engine or driver path ever read it: the key was declared on both update-options schemas and allowlisted by the unknown-option gate, yet `ObjectQL.update()` never referenced it and it was not a driver pass-through key — `{ upsert: true }` was accepted and silently dropped and the update stayed a plain update (ADR-0049 declared-but-unenforced). There is no behaviour to preserve and nothing stored to rewrite (it only ever appeared in a call-time option bag). Any future first-class upsert must reconcile with the engine's not-found gate — a by-id update whose id names no row throws RECORD_NOT_FOUND rather than inserting — which is why the flag is removed rather than implemented here. - Done when: No caller passes `options.upsert` to `engine.update()`; a call that includes it is refused loudly (the engine gate and both schemas quote one removal prescription) instead of succeeding with the option silently ignored. - **`enhanced-api-error-field-errors-renamed`** — `api.enhancedApiError.fieldErrors` → fields - - Why not automatic: The wire has always carried `fields` — the validators, import coercion, validation-failure.ts, @objectstack/client and the console's field-error extractor all say `fields`, and nothing ever emitted `fieldErrors`, so a reader keying on it was reading a field no server sent (ADR-0078's silently-inert declaration, on the error envelope). This is a RESPONSE surface: no stack, example or template carries the key, so there is no source for the chain to rewrite — the schema tombstones it via retiredKey() and consumers move their read themselves. ADR-0114 D4, #3977. + - Why not automatic: The wire has always carried `fields` — the validators, import coercion, validation-failure.ts, @objectstack/client and the console's field-error extractor all say `fields`, and nothing ever emitted `fieldErrors`, so a reader keying on it was reading a field no server sent (ADR-0078's silently-inert declaration, on the error envelope). This is a RESPONSE surface: no stack, example or template carries the key, so there is no source for the chain to rewrite — the schema tombstones it via retiredKey() and consumers move their read themselves. ADR-0114 D4 (the field-level error code catalog). - Done when: No consumer reads `error.fieldErrors`; per-field validation detail is read from `error.fields`, and constructing an EnhancedApiError with `fieldErrors` fails to parse with the rename prescription instead of silently losing the array. - **`etl-pipeline-layer-retired`** — `automation.etlPipeline / automation.etlPipelineRun / automation.etlSource / automation.etlDestination / automation.etlTransformation (the whole L2 layer of automation/etl.zod.ts, its four enums and the `ETL` factory — 9 defs, 27 exported names)` → (removed — no protocol surface replaces it, deliberately. Layer by layer: connector-attached synchronisation is `ConnectorSchema.syncConfig` (`integration/connector.zod.ts`), which is PARSED AND VALIDATED but NOT EXECUTED — a declared shape, not a running sync. `AutomationEngine.registerConnector` runs `ConnectorSchema.parse` and stores the parsed definition; nothing reads `syncConfig` back off it, and the key has no reader outside `packages/spec` at all — the same measurement that retired `syncConfig.schedule` in 18 under ADR-0049, with the other cron-typed positions nothing reads. What the platform DOES execute on a connector is its `actions`: a flow's `connector_action` node resolves the registered handler and awaits it, so an author who needs data actually moved drives it from there. Per-field value transformation on import is `shared/mapping.zod.ts`, whose `transform` is applied row by row by the REST import path and recorded key by key in `packages/spec/liveness/mapping.json`; scheduling is `system/job.zod.ts`. What has NO replacement is multi-source, multi-stage movement with joins and aggregations — because it never had an implementation either. It returns through the ENFORCE route: the engine first, the vocabulary second) - Why not automatic: The reading the spec dual-source cleanup used to retire L1 `DataSyncConfig` (its automation copy deleted as dead), re-measured one layer up and identical: narrative-only. No engine ever parsed, scheduled or executed an `ETLPipeline`. Measured on origin/main immediately before the removal: the only non-spec references in this repo are two fumadocs-generated documentation sources (`apps/docs/.source/*.ts`), not executors; objectui has no reference at all; there is no `liveness/etl.json` or `pipeline.json`, so no ADR-0049 gate ever had a reading on it — while the same file family's EXECUTED half does have one (`liveness/mapping.json`), which is the contrast that makes the absence meaningful rather than an oversight. The `etl` string in this registry was the one untested link the finding named, and it is not a loader path: it was the id of the retry-vocabulary entry for `ETLPipeline.retry` (a third retry-policy vocabulary the retry convergence had not covered), absorbed here. The layer was ADR-0078's asymmetry in its purest form — an author could write a complete ten-stage pipeline, get no error, and get no execution. It was also advertised: `packages/spec/docs/SYNC_ARCHITECTURE.md` named `ETLPipeline` as the recommended destination for authors displaced by the L1 retirement and listed ten transformation types with copyable examples down to `script | Custom JavaScript/Python`. That document is rewritten in the same change; a retirement whose own doc still recommends the retired layer is self-contradictory, and forwarding L1's authors to a second layer with no executor was the defect compounding rather than closing. ⚠️ `etl-retry-converged-onto-retry-policy` is SUBSUMED here, the way the `activationEvents`, dynamic plugin-loading and widget / i18n retirements each let an earlier tombstone go with the shape that carried it: both land in the unreleased protocol 17, so composed, a rename of `retry.maxAttempts` on a shape that does not survive the major has no observable effect — and keeping both would tell an upgrader to rewrite a key on a schema the same upgrade deletes. The `maxAttempts` `retiredKey()` tombstone goes with the shape that carried it, which is strictly stronger than the tombstone: there is no longer a `retry` block to author the key into. Route 3 — no carrier key, no parse site, so no D2 conversion and no tombstone; RETIRED_DEFS_BY_MAJOR plus this entry are the declaration. ADR-0049, ADR-0078. @@ -384,7 +384,7 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-conte - Why not automatic: A DECLARATION corrected to match a runtime that did not move — the inverse of a behaviour flip, and registered here for the reason protocol 12's `rest-requireauth-default-flip` and this major's `action-descriptor-resume-authority-default-flip` are: whether a given import was meant to fire triggers is a judgment no transform can make, so the prescription is a TODO rather than a rewrite. The server decides in import-prepare.ts with `body?.runAutomations !== false`, i.e. an omitted flag runs automations, and has since the flag was first honoured — automations always ran on import historically (the engine ignored the flag entirely before then), so opt-out was made the explicit act, matching platform convention. The schema said the opposite in both machine-readable and human-readable form, and both SHIPPED: `.default(false)` in `@objectstack/spec`'s JSON Schema, and the describe prose in the published reference tables for both defs. ⚠️ Nothing in this repo reconciled the two and NO deployed caller changes behaviour: no request path parses an import body through this schema — the route reads the raw body, and the sole reference to `CreateImportJobRequestSchema` is the declarative `ImportJobApiContracts` catalog entry, a declaration and not a parse. That is exactly why this needed a ruling rather than a docs edit: the divergence was unobservable in-tree and observable only to a consumer OUTSIDE it. A client or SDK that validated its request through the published schema materialised `runAutomations: false` from the declared default and sent it explicitly, and the server honoured it — so the same request body produced opposite behaviour depending on whether the caller validated before sending, with the validating caller silently losing its triggers. Nothing rejected it, nothing warned, and the reference page told an author the wrong thing in the other direction. There is deliberately NO schema tombstone and no D2 conversion: no key is removed, and an HTTP request body is neither authored nor persisted — the same disposition `notification-list-cursor-retired` takes for the sibling default on this major, and `batch-options-validate-only-retired` before it. The declared move itself is recorded mechanically, per key, in DEFAULT_CHANGES_BY_MAJOR[17] — the per-key default fingerprint added once a flipped default was found invisible to every gate — whose `from`/`to` fingerprints are re-derived on every build. Maintainer ruling 2026-08-09, disposition A: the spec follows the runtime. ADR-0049 / ADR-0078. - Done when: Every import request of yours that must NOT fire triggers sends `runAutomations: false` explicitly, rather than omitting the key and trusting the old declared default. The check is worth doing precisely where it looks unnecessary: if you build the body by parsing it through `ImportRequestSchema` (or the published JSON Schema) and then send the PARSED object, your bulk loads were running with automations OFF and will now run with them ON — that is the only class whose behaviour changes, and it changes toward what an unvalidated caller always got. ⚠️ Behaviour on the wire is deliberately UNCHANGED and should be verified as such: a body that omits `runAutomations` fired triggers before this change and fires them after, and `runAutomations: false` turns them off before and after. Nothing starts being refused — the route never validated this body against the schema and does not begin to. `dryRun` is unaffected and still runs NO automations whatever the flag says: it asks the engine's validate-only write path for its verdict, and that path deliberately fires no hooks. - **`job-retry-policy-constraints-tightened`** — `job.retryPolicy.maxRetries (> 10) / job.retryPolicy.backoffMultiplier (< 1)` → maxRetries <= 10, and backoffMultiplier >= 1 - - Why not automatic: The converged RetryPolicy (#4661) keeps the automation side's bounds, which the job side never had: `maxRetries` is capped at 10 and `backoffMultiplier` floored at 1. Neither has a lossless rewrite. Clamping `maxRetries: 20` to 10 would halve a retry budget its author chose, and a `backoffMultiplier` below 1 describes a delay that SHRINKS on each attempt — retrying a failing dependency ever faster, which is the opposite of backoff and was never a shape the engine meant to offer. Both now fail at parse time with the bound named, rather than being silently reinterpreted. Choosing the replacement count (or accepting the cap) is the author's call. + - Why not automatic: The RetryPolicy converged onto one declaration from its automation and system copies keeps the automation side's bounds, which the job side never had: `maxRetries` is capped at 10 and `backoffMultiplier` floored at 1. Neither has a lossless rewrite. Clamping `maxRetries: 20` to 10 would halve a retry budget its author chose, and a `backoffMultiplier` below 1 describes a delay that SHRINKS on each attempt — retrying a failing dependency ever faster, which is the opposite of backoff and was never a shape the engine meant to offer. Both now fail at parse time with the bound named, rather than being silently reinterpreted. Choosing the replacement count (or accepting the cap) is the author's call. - Done when: Every job declaring `retryPolicy` parses: no `maxRetries` above 10 and no `backoffMultiplier` below 1 remain, and each adjusted value was re-chosen knowing a retry re-runs the handler with its writes and callouts. No job fails to register with the retry-policy bound prescription. - **`notification-list-cursor-retired`** — `api.listNotifications cursor — the key on BOTH halves of GET /api/v1/notifications (ListNotificationsRequestSchema and ListNotificationsResponseSchema) and the cursor argument of the client SDK call client.notifications.list(). The same entry covers the limit default: the request schema no longer declares default(20)` → a larger `limit` — the route answers the newest N notifications and has no page 2. There is no replacement for `cursor`, deliberately: nothing ever minted one, so no caller holds a value to carry over. Callers that looped on it were re-reading the first window and should read one window sized to what they display (the Console bell polls exactly this way). For the removed `limit` default, send the number you want explicitly if you were relying on 20 — omitting it takes the server window, which is 50 on the platform inbox and clamped into 1..200, and has been since before the declaration existed - Why not automatic: One capability, both halves, never half-deleted (maintainer ruling 2026-08-07, Option A, ruled jointly with the repair that made `unreadCount` really count the whole inbox). `cursor` was declared on the request and on the response and honoured on neither: the dispatcher domain reads `read` / `type` / `limit` and nothing else, and no emit site has ever written the response key. It was worse than inert because it had a shipped PRODUCER — the SDK appended it to the query string — so a caller paginating by the published contract looped on page 1 forever, with no error and no 400. Measured over a real boot with 60 unread before the removal: page2 === page1, both parsing green against the response schema, which is why no conformance gate could see it. This is `data.query.cursor` (`query-cursor-retired`) one layer up, with the same verdict for the same reason, down to deleting the SDK producer alongside the key. A first-class inbox cursor, if one is ever designed, will be a response-minted opaque token — a different API — so keeping this one preserved a wrong design rather than a roadmap. The `limit` default goes with it because the FICTION WAS THE MECHANISM, not the number: no request path parses a query string through this schema (the fix for request bodies never checked against their declared schemas wired the catalog's requestSchema to the real entry for BODIES only), so `.default(20)` never stamped anything onto anything, and the server has always applied its own 50. Re-spelling 20 as 50 — the other arm the ruling allowed — would have kept a declaration that does not execute and merely made it coincide with the implementation until someone moved the clamp; `.optional()` plus prose is true about both the schema and the server. No constraint (`.int()` / `.max(200)`) is declared either, because the service CLAMPS an out-of-range limit rather than refusing it, and declaring a rejection the wire does not perform is the same defect mirrored. Route 2, and the split is worth stating exactly because the two halves of the bookkeeping go different ways. There IS a tombstone: both schemas are non-strict, so a bare deletion would have made Zod SILENTLY STRIP whatever a caller kept sending — a clean parse and a parameter that never takes effect, which is this issue's own defect re-created one layer down (the silent strip measured when a field key pruned from a non-strict schema still parsed and simply vanished, ADR-0104). So `cursor` is `retiredKey()` on both halves, typed `never` for tsc and raising the prescription at any parse, and both keys are registered in RETIRED_KEYS_BY_MAJOR[17]. There is NO D2 conversion: a conversion rewrites an authored source or a stored `sys_metadata` row, and these two shapes are HTTP-only — nobody authors a `ListNotificationsRequest` and nothing persists one. Request AND response shapes: two semantic TODOs for API callers, no stack conversion — the same disposition `BatchOptions.validateOnly` (a declared dry-run that wrote for real) and the `AnalyticsQueryRequest` envelope keys already take in this major. The `limit` default is declared separately and mechanically, in DEFAULT_CHANGES_BY_MAJOR[17] (the table that closed the blind spot where a default or constraint change on an authorable key was recorded by no gate), whose `from`/`to` fingerprints are re-derived on every build. ADR-0049 / ADR-0078. @@ -402,7 +402,7 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-conte - Why not automatic: The five schemas declared the "Dynamic Loading" capability — runtime load / unload / reload of plugins without a kernel restart, with sandboxing, integrity hashes, drain strategies and dependent-cascade policy — and NOTHING implemented it. A bare-name scan of objectstack, cloud and objectui found zero references outside this package's own declaration, its unit tests and the generated artifacts: no runtime ever received a `DynamicLoadRequest`, performed a load/unload, or produced a `DynamicPluginResult`. That is the ADR-0049 false-compliance shape at its most inviting to an AI author (ADR-0033), who reads `DynamicLoadRequestSchema` in the published IDE bundle as proof the platform hot-loads plugins and constructs a request that parses clean and is received by nobody (an exported schema with no consumer is read as a capability). The earlier removal of this module's discovery/sandbox config island — plugin sandboxing, integrity and approval settings that nothing read — left these five in place explicitly: "operation contracts, not security promises; the enforce-or-remove call on them is a design decision rather than a correction" — but that suspension lived only in a changeset paragraph with no issue carrying it. The maintainer's ruling of 2026-08-03 is that decision, answered REMOVE: hot loading is a real future capability, but nothing is being built and nothing pulls it, and when it is built its vocabulary enters the schema with the implementation. `experimental` was considered and rejected: it is only `.describe()` prose and cannot stop an import, the weakest of the three ADR-0049 channels. None of the five is stored metadata — they are root request/result payload shapes embedded in no parent schema and parsed against no metadata document — so no `sys_metadata` row can carry one and there is no source for the D2 chain to rewrite; this entry is the D3 record. The removal also subsumes the kernel half of `plugin-activation-events-retired`: that tombstone goes with the shape that carried it. ADR-0049. - Done when: No code imports `DynamicLoadRequestSchema`, `DynamicUnloadRequestSchema`, `DynamicPluginResultSchema`, `PluginSourceSchema`, `DynamicPluginOperationSchema` or any of their type aliases (`DynamicLoadRequest`, `DynamicUnloadRequest`, `DynamicPluginResult`, `PluginSource`, `DynamicPluginOperation`, `DynamicLoadRequestInput`, `DynamicUnloadRequestInput`) from `@objectstack/spec` or `@objectstack/spec/kernel` — every one is TS2305 after upgrade, on every public entry (pinned by symbol identity in `plugin-runtime-retirement.test.ts`). Nothing regresses at runtime, because nothing called anything: a caller that believed it was hot-loading a plugin was already only building an object. Boot-time composition through `defineStack` is unchanged. - **`position-permissions-column-retired`** — `sys_position.permissions — the "JSON-serialized array of permission strings" textarea column left the platform position table declared by plugin-security (packages/plugins/plugin-security/src/objects/sys-position.object.ts), together with the clone_position copy entry that carried it between rows` → nothing on this table — delete the key from any authored `sys_position` seed row (stack `data` entries) or data-door write that still carries it. There are no direct position-level permission strings anywhere on the platform: capability reaches a position ONLY through permission-set bindings (`sys_position_permission_set` rows, created in Setup or by an app's kernel:ready binder) and is resolved from the position `name` at request time. A value that was recording intent as documentation belongs in `description`, which remains declared - - Why not automatic: Maintainer ruling 2026-08-20 (#9885), ADR-0049 enforce-or-remove: REMOVE. The object-scoped census (all sys_position-naming files, with same-object positive controls resolving `active` / `delegatable` / `is_default` / `name` to real readers) measured the column at zero on both sides: the only row writers — the builtin and declared position bootstrappers — set label / description / managed_by / active / is_default, and position→grant resolution consults `sys_position_permission_set` rows plus the position `name`, never this column. Its only in-repo reference was the clone_position action copying it between rows — a copy of a value nothing writes. objectui was searched under the same discipline (evidenceScope closure): no console surface names the column — the position pickers and Setup views read name / label / id only, so a designer preview consumer does not exist either. That left a declared free-text grant catalogue on a security object that no runtime enforced: an author — human or AI — who filled it believed they granted permission strings directly on the position, and nothing refused or honoured the value. This is a platform-object COLUMN retirement, not a spec-key retirement, so the bookkeeping follows the ups-delegated-from-column-retired shape: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — PositionSchema never declared `permissions`, and the surface ratchets are expected byte-identical), no liveness-ledger row is added (the ledger walks PositionSchema's shape, which never carried the key — a row would be an orphan), and the disposition is a SEMANTIC entry rather than a D2 conversion: no conversion in the chain rewrites seed rows today and the measured author base is zero, while the loud channel already exists at runtime — the engine schema preflight refuses an undeclared field with 400 INVALID_FIELD before the driver or any hook runs — so this entry carries the prescription and the refusal carries the enforcement. The live-authoring half is the PositionSchema strict-parse guidance for `permissions`, which names the binding table in the rejection. ⚠️ Existing physical columns are deliberately untouched: schema sync is additive (ADR-0045), so a deployed database keeps the column; the platform stops declaring, projecting or accepting it. Zero producers means no rows are expected to carry a value; no backfill or destructive DDL is required or wanted. If position-level direct grants ever become a real need, the column is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce or do not declare. + - Why not automatic: Maintainer ruling 2026-08-20 on the finding that nothing writes or reads this column, ADR-0049 enforce-or-remove: REMOVE. The object-scoped census (all sys_position-naming files, with same-object positive controls resolving `active` / `delegatable` / `is_default` / `name` to real readers) measured the column at zero on both sides: the only row writers — the builtin and declared position bootstrappers — set label / description / managed_by / active / is_default, and position→grant resolution consults `sys_position_permission_set` rows plus the position `name`, never this column. Its only in-repo reference was the clone_position action copying it between rows — a copy of a value nothing writes. objectui was searched under the same discipline (evidenceScope closure): no console surface names the column — the position pickers and Setup views read name / label / id only, so a designer preview consumer does not exist either. That left a declared free-text grant catalogue on a security object that no runtime enforced: an author — human or AI — who filled it believed they granted permission strings directly on the position, and nothing refused or honoured the value. This is a platform-object COLUMN retirement, not a spec-key retirement, so the bookkeeping follows the ups-delegated-from-column-retired shape: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — PositionSchema never declared `permissions`, and the surface ratchets are expected byte-identical), no liveness-ledger row is added (the ledger walks PositionSchema's shape, which never carried the key — a row would be an orphan), and the disposition is a SEMANTIC entry rather than a D2 conversion: no conversion in the chain rewrites seed rows today and the measured author base is zero, while the loud channel already exists at runtime — the engine schema preflight refuses an undeclared field with 400 INVALID_FIELD before the driver or any hook runs — so this entry carries the prescription and the refusal carries the enforcement. The live-authoring half is the PositionSchema strict-parse guidance for `permissions`, which names the binding table in the rejection. ⚠️ Existing physical columns are deliberately untouched: schema sync is additive (ADR-0045), so a deployed database keeps the column; the platform stops declaring, projecting or accepting it. Zero producers means no rows are expected to carry a value; no backfill or destructive DDL is required or wanted. If position-level direct grants ever become a real need, the column is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce or do not declare. - Done when: No authored stack seeds `permissions` on a sys_position record, and no client write to that table carries the key. Concretely: (1) grep your stack sources for permissions next to sys_position — delete the key from any seed row; prose that was documenting intent belongs in `description`. (2) Boot and load your stack: a missed seed row fails loudly at insert with 400 INVALID_FIELD naming the column — that refusal is the enforced channel, not a silent drop. (3) If you meant to grant capability, author it where it is enforced: bind permission sets to the position (`sys_position_permission_set` rows, created in Setup or by an app's kernel:ready binder) — the authz resolver then expands the bindings from the position name at request time. - **`query-array-string-agg-retired`** — `data.query.aggregations[].function ('array_agg' / 'string_agg')` → an ordinary `fields` query, shaped in the caller — or a stored field that materialises the roll-up. For a deduplicated COUNT the live spelling is unchanged: `count_distinct` stays declared - Why not automatic: The stored half of this retirement is a conversion (`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. `QueryAST` is never stored in stack metadata — it is the client SDK builder's output and the `POST /data/:object/query` body — so there is no source for the chain to rewrite and callers move their own queries. Both values were declared-but-unlowered on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller following the schema against a SQL datasource got a refusal, not an array. They did run on `driver-mongodb` and on the engine's in-memory fallback, which is what makes this the one narrowing in the batch that removes reachable behaviour: an aggregation that worked on one backend and failed on another is exactly the unpredictability the ruling ended, and the maintainer's 2026-08-05 investment freeze on driver-memory and driver-mongodb had both of those backends frozen at the time (that freeze was lifted on 2026-08-11). `count_distinct` was deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049's enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049. @@ -459,7 +459,7 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-conte - Why not automatic: `ui/widget.zod.ts` published a complete widget-registration vocabulary — a manifest with lifecycle hooks, custom events, configurable properties and an npm/remote/inline implementation-source union — and `ui/i18n.zod.ts` published a structured-label, plural-rule and locale-formatting vocabulary. NOTHING in the protocol carried either. Three independent measurements, re-run on `origin/main` immediately before the removal with their controls passing in the SAME run: (1) no module under `packages/spec/src` imported `widget.zod` at all, and the only imports of `i18n.zod` anywhere name `I18nLabelSchema` / `AriaPropsSchema` (both KEPT), so no schema declared a carrier key — `field.widget` is a `z.string()` naming a registered component and has never referenced `WidgetManifest`; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` reached none of them, while `PageSchema` / `ObjectListViewSchema` resolved `direct` in the same run and a synthetic carrier flipped every one of them; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these files' own unit tests. `NumberFormat` / `DateFormat` DID have a carrier key (`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier was itself doorless, so the subtree was `no door` rather than `no gate` and goes whole — leaving the two leaves behind would strand exported schemas with no consumer, which an author reads as a capability. `I18nObjectSchema` was additionally superseded by its own file-neighbour: `I18nLabelSchema`'s documentation already says translation keys are generated at registration time and translations live in translation files, and the live translation surface is `system/translation.zod.ts`, which uses none of these shapes. The 2026-08-06 ruling weighed giving them a carrier (option B) and rejected it: that is a feature with a registry and a renderer behind it, not ledger clean-up. Tightening them to `strictObject` was rejected earlier and explicitly, by the batch of the v17 unknown-key strictness sweep that measured this file as having no authoring door — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave "a precisely validated dead slot, the more convincing lie" (the lesson of the datasource capability flags, whose `readOnly` was precisely validated and read by nothing). With no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, route 3, the same shape as `ui-interaction-config-family-retired`, `plugin-runtime-family-retired` and the `HttpServerConfig` retirement. ⚠️ `WidgetManifest.performance`'s own `retiredKey()` tombstone (left by the close-out sweep that removed the inert `performance` keys no renderer applied) is SUBSUMED here, the way the kernel `activationEvents` tombstone went with the removed plugin-runtime family: it goes with the shape that carried it, which is strictly stronger than the tombstone, because there is no longer a manifest to author the key INTO. ⚠️ One of the nine widget sites is deliberately NOT retired. `FieldWidgetPropsSchema` survives: it is a REACT PROPS CONTRACT rather than authorable metadata (it never appeared in `authorable-surface/` or `json-schema.manifest/` — its `onChange` is a `z.function()`), so "zero parse" is its design and not its defect, and it acquired a live cross-repo compile-time consumer one day before that sweep batch measured: an objectui fix of 2026-08-03, made to follow the spec, renamed `@object-ui/fields`' validation slot onto the spec's `error` with no alias, the form renderer began producing it, and `packages/fields/src/__tests__/spec-symbol-batch7.test.ts` pins the shape against `import type { FieldWidgetProps } from '@objectstack/spec/ui'` as an intentional tripwire. Re-verified on objectui `origin/main` 2026-08-07. ADR-0049. - Done when: No code imports `WidgetManifest(Schema|Parsed)`, `WidgetLifecycle(Schema)`, `WidgetEvent(Schema|Parsed)`, `WidgetProperty(Schema|Parsed)`, `WidgetSource(Schema|Parsed)`, `I18nObject(Schema)`, `PluralRule(Schema)`, `NumberFormat(Schema|Parsed)`, `DateFormat(Schema)` or `LocaleConfig(Schema|Parsed)` from `@objectstack/spec` or `@objectstack/spec/ui` — every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in `ui/widget-i18n-retirement.test.ts`). No metadata document needs editing, because none could ever carry one of these shapes: a stack that parsed before parses byte-for-byte the same after, and a `field.widget: "my_picker"` string is untouched. `FieldWidgetProps` / `FieldWidgetPropsSchema` / `FieldWidgetPropsParsed`, `I18nLabel(Schema)` and `AriaProps(Schema)` all still resolve on `@objectstack/spec/ui` and are asserted to. ⚠️ objectui needs a companion PR in the same window: `packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts` asserts the spec STILL owns `WidgetManifest` / `WidgetSource` (it is the "a workaround should not outlive its reason" half of the rename tripwire objectui added when it stopped declaring symbols under names the spec owns, designed to go red exactly here), and `packages/types/src/widget.ts`'s "Renamed off the spec's `WidgetManifest` name" comments now point at names that no longer exist. Both are prescribed responses to this removal, not collateral damage. - **`ups-delegated-from-column-retired`** — `sys_user_permission_set.delegated_from — the ADR-0091 D3 provenance column left the platform grant table declared by plugin-security (packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts). The sibling declaration on sys_user_position is untouched` → nothing on this table — delete the key from any authored `sys_user_permission_set` seed row (stack `data` entries) or data-door write that still carries it. Delegation semantics live on `sys_user_position`, where `delegated_from` remains declared AND runtime-enforced: the delegated-admin gate is what makes a position insert a delegation, and the explain engine attributes "via delegation from X, until Y". A permission-set grant that needs a provenance note keeps `reason` (free text), which remains declared on both grant tables - - Why not automatic: Maintainer ruling 2026-08-18 (#9730), ADR-0049 enforce-or-remove: REMOVE. The runtime delegation gate is structurally scoped to sys_user_position (`isDelegationWrite` returns false for every other object, so `assertSelfDelegation` is unreachable for this table), and the explain engine reads delegation provenance from sys_user_position rows only. On sys_user_permission_set the column was therefore declared and data-door-writable while NO runtime consumer read it — its only enforcement was an authoring-time lint (the D3 "delegation row needs a reason" rule), which a row written through the generic data door never meets. That is declared-but-unenforced in its pure form, on a security object: an author who stamped delegated_from on a permission-set grant believed they constrained delegation, and nothing refused or honoured it. Producers measured at zero — the only object literals naming both the table and the column were lint test fixtures. This is a platform-object COLUMN retirement, not a spec-key retirement, so the bookkeeping follows the audit-log-action-enum-retired shape: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — the surface ratchets are expected byte-identical), and the disposition is a SEMANTIC entry rather than a D2 conversion. A conversion over stack `data` seed records would be mechanically expressible, but no conversion in the chain rewrites seed rows today and the measured author base is zero; the loud channel already exists at runtime — the engine schema preflight refuses an undeclared field with 400 INVALID_FIELD before the driver or any hook runs — so this entry carries the prescription and the refusal carries the enforcement. ⚠️ Existing physical columns are deliberately untouched: schema sync is additive (ADR-0045), so a deployed database keeps the column; the platform stops declaring, projecting or accepting it. Zero producers means no rows are expected to carry a value; no backfill or destructive DDL is required or wanted. If delegation at permission-set granularity ever becomes a real need, the column is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce or do not declare. + - Why not automatic: Maintainer ruling 2026-08-18 on the finding that the delegation gate never reads this column on this object, ADR-0049 enforce-or-remove: REMOVE. The runtime delegation gate is structurally scoped to sys_user_position (`isDelegationWrite` returns false for every other object, so `assertSelfDelegation` is unreachable for this table), and the explain engine reads delegation provenance from sys_user_position rows only. On sys_user_permission_set the column was therefore declared and data-door-writable while NO runtime consumer read it — its only enforcement was an authoring-time lint (the D3 "delegation row needs a reason" rule), which a row written through the generic data door never meets. That is declared-but-unenforced in its pure form, on a security object: an author who stamped delegated_from on a permission-set grant believed they constrained delegation, and nothing refused or honoured it. Producers measured at zero — the only object literals naming both the table and the column were lint test fixtures. This is a platform-object COLUMN retirement, not a spec-key retirement, so the bookkeeping follows the audit-log-action-enum-retired shape: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — the surface ratchets are expected byte-identical), and the disposition is a SEMANTIC entry rather than a D2 conversion. A conversion over stack `data` seed records would be mechanically expressible, but no conversion in the chain rewrites seed rows today and the measured author base is zero; the loud channel already exists at runtime — the engine schema preflight refuses an undeclared field with 400 INVALID_FIELD before the driver or any hook runs — so this entry carries the prescription and the refusal carries the enforcement. ⚠️ Existing physical columns are deliberately untouched: schema sync is additive (ADR-0045), so a deployed database keeps the column; the platform stops declaring, projecting or accepting it. Zero producers means no rows are expected to carry a value; no backfill or destructive DDL is required or wanted. If delegation at permission-set granularity ever becomes a real need, the column is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce or do not declare. - Done when: No authored stack seeds `delegated_from` on a sys_user_permission_set record, and no client write to that table carries the key. Concretely: (1) grep your stack sources for delegated_from next to sys_user_permission_set — delete the key from any seed row; a row that was recording genuine hand-over provenance should say it in `reason` instead, which the platform stores on both grant tables. (2) Boot and load your stack: a missed seed row fails loudly at insert with 400 INVALID_FIELD naming the column — that refusal is the enforced channel, not a silent drop. (3) If you meant actual delegation-of-duty, author it where it is enforced: a sys_user_position insert with delegated_from = the writer, a mandatory future valid_until within the ceiling, and a mandatory reason (ADR-0091 D3) — the delegated-admin gate then validates the whole shape at runtime. - **`view-filter-rule-value-shaped-by-operator`** — `ui.ViewFilterRule value — the third key of a view filter rule, on every carrier of ViewFilterRuleSchema: ListView.filter, a list view tab filter, Page.filterBy, a related-list component filter and a lookup picker filter. It accepted any declared scalar or array for EVERY operator; the accepted shape is now decided by the rule operator — in / not_in require an array, between requires exactly two bounds, and every other operator is unchanged` → an ARRAY for in / not_in (a single value becomes a one-element list: value: "won" becomes value: ["won"]), and a two-element [min, max] array for between. The empty list [] stays legal for in / not_in and keeps its meaning. Nothing else moves: a scalar operator carrying an array, a string operator carrying a number, and a unary operator carrying an ignored value all still parse - Why not automatic: A publish-time gate catching up to a query-time one, not a new rule. An earlier fix closed the RUNTIME half: `assertListComparandShapes` (@objectstack/objectql, filter-comparand-shape.ts) refuses a lowered `{ stage: { $nin: "won" } }` with a named 400 INVALID_FILTER, and before that it was a 500. The authoring surface stayed silent, so the failure was two-stage: the view published cleanly and only broke when someone opened it. That file names this very schema as the reachable authoring source of the defect. The tightening MIRRORS that gate exactly — three constraints, one for one — and deliberately goes no further, because an earlier fix already settled the opposite error (the ordering operators' comparand widened to the strings the platform itself produces): a schema stricter than the runtime "in ways the runtime deliberately allows" was the WRONG side and was widened to match. So `in: []` is still accepted (a declared predicate both drivers implement), `equals: ["a","b"]` is still accepted (it lowers to a deep-equality comparand), and `is_empty: ""` is still accepted (the null predicates take their direction from the operator NAME — convertComparison ignores the value position, and the ObjectUI client deliberately sends a truthy placeholder there). ⚠️ Metadata AT REST is deliberately NOT rewritten, and there is no D2 conversion. A D2 entry replays a shape the platform once WROTE and renamed; this shape was never written by any first-party producer (every in / not_in rule in this repo, in objectui and in the cloud repo already carries an array — measured) and has never EXECUTED, since it 400s on first render today. Coercing it at load would be the platform guessing intent rather than replaying a rename, and it cannot guess honestly: value: "" would become the predicate [""] (a real filter on the empty string) rather than the "not filled in yet" a console row means, and between: 5 has no defensible second bound at all. The read path does not re-validate stored rows (applyConversionsToStoredItem never validates, by its own contract), so no stored view becomes unreadable; what changes is that RE-SAVING such a view is refused at the write gate naming `value`, instead of storing a filter that 400s. ADR-0049 / ADR-0078 / ADR-0112. @@ -468,7 +468,7 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-conte - Why not automatic: A complete viewId-addressed CRUD surface — list (with a list/form filter), read, create, patch, delete — with none of the three things a protocol method needs. Measured on origin/main immediately before the removal: no implementation (`packages/metadata-protocol/src/protocol.ts` declares no `listViews` / `getView` / `createView` / `updateView` / `deleteView`; its only view resolver is `getUiView`), no route (`packages/rest/src/rest-server.ts` never mentions `viewId`, so nothing viewId-addressed is reachable over HTTP at all), and no caller (the only `ViewProtocol` mention outside its own file was the services checklist, which already recorded the five as declared-and-unrouted). The look-alike hits a bare-name grep turns up are all different contracts: `metadata-manager.ts`'s `getView(name: string)` is another class, and objectui's `getView(objectName, viewId)` resolves through `client.meta.getItem('view', …)`, i.e. the metadata route. What makes this worth a removal rather than a note is that the cost is already measured. A declared surface that is name-identical and semantics-adjacent to a real one is an attractive nuisance in every grep, and it mis-directed a decision once: The issue asking what `GET /ui/view/:object/:type` answers AND its 2026-08-07 maintainer ruling both read `GetViewResponseSchema` (zero implementations) as the contract of `GET /ui/view/:object/:type`, whose declared response is `GetUiViewResponseSchema` — one word apart, 250 lines up. That ruling's reasoning happened to survive the mix-up ("nobody can consume `{object, view}` successfully today" was true, though not for the stated reason), which is the luck this removal stops relying on. Route 3: none of the ten was a key on an authorable shape, nothing parsed them, so there is no tombstone and no D2 conversion — RETIRED_DEFS_BY_MAJOR plus this entry are the declaration. If reading and writing ONE view by id becomes a real requirement it returns implementation-first. ADR-0049, ADR-0087, maintainer ruling 2026-08-07. - Done when: No source imports `ListViewsRequest(Schema)`, `ListViewsResponse(Schema)`, `GetViewRequest(Schema)`, `GetViewResponse(Schema)`, `CreateViewRequest(Schema)`, `CreateViewResponse(Schema)`, `UpdateViewRequest(Schema)`, `UpdateViewResponse(Schema)`, `DeleteViewRequest(Schema)` or `DeleteViewResponse(Schema)` from `@objectstack/spec/api`, and no host declares a `ViewProtocol` member. Reading and writing views still works end to end through the surfaces that were always the live ones: `GET /api/v1/meta/view/:name` returns the stored definition and `GET /api/v1/ui/view/:object/:type` returns the resolved view, both unchanged by this removal. `GetUiViewRequestSchema` / `GetUiViewResponseSchema` still resolve — they are the shapes that ruling meant. - **`workflow-service-slot-retired`** — `CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / discovery routes.workflow / RestApiRouteCategory workflow` → the live mechanisms the slot only ever pointed at: `state_machine` validation rules for record state machines, approval flow nodes on the approvals runtime (ADR-0019) for approvals, lifecycle hooks + `record_change` flows (service-automation) for record-triggered automation - - Why not automatic: The workflow slot was declared end to end and implemented nowhere: no code in either repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches were plugin-dev's retired stub probe and the generic discovery walk), no implementation of any WorkflowProtocol method ever existed, and no host ever mounted `/api/v1/workflow` (the pre-#3586 DEFAULT_DISPATCHER_ROUTES listed it among routes that never existed). Every part of it was ADR-0078's silently-inert declaration: a CoreServiceName nothing filled, a contract nothing implemented, a protocol nothing served, a discovery route field no builder could truthfully populate. These are TS/API surfaces and a discovery RESPONSE field — never stored in stack metadata, so there is no source for the chain to rewrite; consumers of the deleted types move their imports themselves. ADR-0049 / ADR-0078, #4451. + - Why not automatic: The workflow slot was declared end to end and implemented nowhere: no code in either repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches were plugin-dev's retired stub probe and the generic discovery walk), no implementation of any WorkflowProtocol method ever existed, and no host ever mounted `/api/v1/workflow` (DEFAULT_DISPATCHER_ROUTES, before it was retired as a stale list, named it among routes that never existed). Every part of it was ADR-0078's silently-inert declaration: a CoreServiceName nothing filled, a contract nothing implemented, a protocol nothing served, a discovery route field no builder could truthfully populate. These are TS/API surfaces and a discovery RESPONSE field — never stored in stack metadata, so there is no source for the chain to rewrite; consumers of the deleted types move their imports themselves. ADR-0049 / ADR-0078. - Done when: No import of IWorkflowService, WorkflowProtocol or the Get/WorkflowState/Config/Transition types resolves; no code calls getService('workflow') or reads discovery `routes.workflow` / `services.workflow`; record state machines, approvals and record-triggered automation go through the replacement mechanisms. Discovery output on a default boot is unchanged (the slot was always reported unavailable; now it is simply absent). --- diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 5b1494fa671..daab13e924b 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -420,7 +420,7 @@ "replacement": "nothing, for the common intent (escalate on timeout): an escalation block carrying timeoutHours is live by default. To declare an SLA OFF while keeping its configuration, write enabled: false explicitly — which is now the spelling the escalation sweep actually reads", "migrationId": "approval-escalation-enabled-default-flip", "toMajor": 17, - "rationale": "A DECLARED-DEFAULT CORRECTION plus the enforcement that makes the key real (#12278, maintainer ruling 2026-08-27) — the same category as protocol 17's `import-run-automations-declared-default-corrected`: the schema promised `enabled` defaults to `false` (SLA off) while the plugin-approvals sweep never read the key at all — any escalation block with a positive `timeoutHours` escalated, and with `action: 'auto_approve'` that silently approved requests their author had declared off the clock. The flip moves the default to `true` and, in the same change, the sweep starts honouring an explicit `enabled: false`. The feature-level switch is whether an `escalation` block exists at all; within a block carrying `timeoutHours`, escalation is on unless explicitly turned off. Deployed metadata that OMITS `enabled` does not change behaviour: it escalated before (the sweep ignored the key) and escalates after (the parse materializes `true`). Stored request snapshots written before the flip carry a MATERIALIZED `enabled: false` (the approval-node executor parses config through the old schema before snapshotting), so the sweep keeps a read-side legacy window keyed on the snapshot's `created_at`: pre-flip snapshots keep escalating exactly as they do today, and the window retires itself as those pending requests drain. What DOES change is that an explicit `enabled: false` finally binds — a flow that authored it (e.g. the console toggle switched off after a timeout was set) stops escalating on requests opened after the upgrade, which is the declared intent being honoured." + "rationale": "A DECLARED-DEFAULT CORRECTION plus the enforcement that makes the key real (maintainer ruling 2026-08-27, which moved the declared default to what the sweep had always done) — the same category as protocol 17's `import-run-automations-declared-default-corrected`: the schema promised `enabled` defaults to `false` (SLA off) while the plugin-approvals sweep never read the key at all — any escalation block with a positive `timeoutHours` escalated, and with `action: 'auto_approve'` that silently approved requests their author had declared off the clock. The flip moves the default to `true` and, in the same change, the sweep starts honouring an explicit `enabled: false`. The feature-level switch is whether an `escalation` block exists at all; within a block carrying `timeoutHours`, escalation is on unless explicitly turned off. Deployed metadata that OMITS `enabled` does not change behaviour: it escalated before (the sweep ignored the key) and escalates after (the parse materializes `true`). Stored request snapshots written before the flip carry a MATERIALIZED `enabled: false` (the approval-node executor parses config through the old schema before snapshotting), so the sweep keeps a read-side legacy window keyed on the snapshot's `created_at`: pre-flip snapshots keep escalating exactly as they do today, and the window retires itself as those pending requests drain. What DOES change is that an explicit `enabled: false` finally binds — a flow that authored it (e.g. the console toggle switched off after a timeout was set) stops escalating on requests opened after the upgrade, which is the declared intent being honoured." }, { "surface": "sys_audit_log.action — the values 'export' and 'permission_change' left the select enum declared by plugin-audit (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts). The same two values also left the shipped list-view filters on that object: 'permission_change' from the auth_events view and 'export' from the config_changes view", @@ -441,7 +441,7 @@ "replacement": "(removed — no replacement flag; the capabilities are not advertised)", "migrationId": "auth-config-unadvertised-reserved-features", "toMajor": 17, - "rationale": "Both flags were served by `GET /api/v1/auth/config` from introduction and read by no client: no login UI anywhere renders a passkey or magic-link affordance off them, so the payload advertised two sign-in methods a user could never reach, and a deployer setting `plugins.passkeys` / `plugins.magicLink` flipped a switch with no observable effect (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-11 on #7481 chose remove over keep-as-reserved). The two are not equally empty: nothing at all is wired behind `passkeys`, whereas `magicLink`'s better-auth endpoints are live and only their advertisement was withdrawn. This is a RESPONSE surface — nobody authors or persists an `AuthFeaturesConfig` — so there is no source for the chain to rewrite; the schema tombstones both keys via retiredKey() and consumers drop their read. The withdrawal is conditional: both return to the payload in the change that ships the login UI (objectui#4179). ADR-0049, #7481." + "rationale": "Both flags were served by `GET /api/v1/auth/config` from introduction and read by no client: no login UI anywhere renders a passkey or magic-link affordance off them, so the payload advertised two sign-in methods a user could never reach, and a deployer setting `plugins.passkeys` / `plugins.magicLink` flipped a switch with no observable effect (ADR-0049 enforce-or-remove; the maintainer ruling of 2026-08-11 chose remove over keep-as-reserved, so that a deployer cannot flip a flag that does nothing anywhere). The two are not equally empty: nothing at all is wired behind `passkeys`, whereas `magicLink`'s better-auth endpoints are live and only their advertisement was withdrawn. This is a RESPONSE surface — nobody authors or persists an `AuthFeaturesConfig` — so there is no source for the chain to rewrite; the schema tombstones both keys via retiredKey() and consumers drop their read. The withdrawal is conditional: both return to the payload in the change that ships the login UI (flag-gated passkey and magic-link entry points, which objectui defers until the maintainer schedules them). ADR-0049." }, { "surface": "the protocol-17 authoring schemas closed against undeclared keys by the unknown-key strictness wave — `automation/` (flow and its six nested blocks, control-flow, state-machine, webhook, time-relative trigger, flow function), `security/` (permission sets, RLS policies, sharing rules) and `identity/position`, `ui/` (responsive, theme, chart, `AriaProps`, fifteen `view` sub-blocks, `ViewItem`, `userFilters`) — plus the `view` write-path identity precondition one level above them", @@ -462,7 +462,7 @@ "replacement": "`errors: ApiError[]` (was `error: string` — read `row.errors?.[0]?.message`, branch on `row.errors?.[0]?.code`), `data` (was `record`), and `index` (new — the row's position in the request array)", "migrationId": "batch-row-result-schema-shape", "toMajor": 17, - "rationale": "The rows the three bulk-write endpoints emitted had drifted from the schema that declared them: `BatchOperationResultSchema`, the client SDK's exported `BatchOperationResult` type and the reference docs all said `errors: ApiError[]` / `data` / `index`, while the wire carried `error: string` / `record` and never sent `index` at all. A TypeScript consumer written against the published type compiled, validated and read `undefined` at runtime — the declared-but-not-delivered shape this registry exists to close, on the response envelope (ADR-0119 D4 deferred the reconciliation off a bug fix; this is that tracked change, shipped in the 17 major window). The ADR-0119/#4620 rollback marking is structured in the same move: the `ROLLED_BACK:` / `NOT_ATTEMPTED:` message-string prefixes become registered `ApiError.code` values (message keeps the human-readable cause and causal row index), so \"attempted and undone\" vs \"never ran\" is machine-readable instead of a regex convention. A RESPONSE surface — nothing stored in stack metadata carries a batch row, so there is no source for the chain to rewrite; consumers of the legacy keys move their reads themselves. Off-contract readers only: the legacy keys were never in the schema or the SDK types, so a typed consumer needs no change. #4793." + "rationale": "The rows the three bulk-write endpoints emitted had drifted from the schema that declared them: `BatchOperationResultSchema`, the client SDK's exported `BatchOperationResult` type and the reference docs all said `errors: ApiError[]` / `data` / `index`, while the wire carried `error: string` / `record` and never sent `index` at all. A TypeScript consumer written against the published type compiled, validated and read `undefined` at runtime — the declared-but-not-delivered shape this registry exists to close, on the response envelope (ADR-0119 D4 deferred the reconciliation off a bug fix; this is that tracked change, shipped in the 17 major window). The ADR-0119 rollback marking, which the fix making `deleteManyData` and `updateManyData` honour `atomic` carried to those two endpoints, is structured in the same move: the `ROLLED_BACK:` / `NOT_ATTEMPTED:` message-string prefixes become registered `ApiError.code` values (message keeps the human-readable cause and causal row index), so \"attempted and undone\" vs \"never ran\" is machine-readable instead of a regex convention. A RESPONSE surface — nothing stored in stack metadata carries a batch row, so there is no source for the chain to rewrite; consumers of the legacy keys move their reads themselves. Off-contract readers only: the legacy keys were never in the schema or the SDK types, so a typed consumer needs no change. Ruled 2026-08-03: the implementation moves to the schema's shape as a hard cut in the 17 major, with no dual-emit transition." }, { "surface": "client.DeleteDataResult.deleted (the return of `client.data.delete()`)", @@ -476,7 +476,7 @@ "replacement": "a catalog descriptor drops `authentication` (or sets `{ type: \"none\" }`) and documents the auth scheme in `description`; a dispatchable instance declares `provider` and references its credential with `auth: { type, credentialRef }` (ADR-0097 §3). Runtime `registerConnector` calls are unaffected — the runtime shape still carries resolved secrets inline.", "migrationId": "connector-inline-authentication-publish-refused", "toMajor": 17, - "rationale": "A published connector row lands whole in `sys_metadata`, so an inline `token` / `key` / `password` / `clientSecret` is cleartext at rest, readable through the data API (#7990). No mechanical rewrite exists: whether the entry should become a `none` descriptor or a provider-bound instance with a `credentialRef` — and which secret store receives the credential — is a judgment about the connector, not a rename." + "rationale": "A published connector row lands whole in `sys_metadata`, so an inline `token` / `key` / `password` / `clientSecret` is cleartext at rest, readable through the data API (the class a credential-persistence survey measured: any authored artefact whose schema permits an inline credential lands it there). No mechanical rewrite exists: whether the entry should become a `none` descriptor or a provider-bound instance with a `credentialRef` — and which secret store receives the credential — is a judgment about the connector, not a rename." }, { "surface": "dashboard.widgets[].compareTo: { offset: '7d' | '1M' | … } (every duration except '1y')", @@ -602,7 +602,7 @@ "replacement": "fields", "migrationId": "enhanced-api-error-field-errors-renamed", "toMajor": 17, - "rationale": "The wire has always carried `fields` — the validators, import coercion, validation-failure.ts, @objectstack/client and the console's field-error extractor all say `fields`, and nothing ever emitted `fieldErrors`, so a reader keying on it was reading a field no server sent (ADR-0078's silently-inert declaration, on the error envelope). This is a RESPONSE surface: no stack, example or template carries the key, so there is no source for the chain to rewrite — the schema tombstones it via retiredKey() and consumers move their read themselves. ADR-0114 D4, #3977." + "rationale": "The wire has always carried `fields` — the validators, import coercion, validation-failure.ts, @objectstack/client and the console's field-error extractor all say `fields`, and nothing ever emitted `fieldErrors`, so a reader keying on it was reading a field no server sent (ADR-0078's silently-inert declaration, on the error envelope). This is a RESPONSE surface: no stack, example or template carries the key, so there is no source for the chain to rewrite — the schema tombstones it via retiredKey() and consumers move their read themselves. ADR-0114 D4 (the field-level error code catalog)." }, { "surface": "automation.etlPipeline / automation.etlPipelineRun / automation.etlSource / automation.etlDestination / automation.etlTransformation (the whole L2 layer of automation/etl.zod.ts, its four enums and the `ETL` factory — 9 defs, 27 exported names)", @@ -693,7 +693,7 @@ "replacement": "maxRetries <= 10, and backoffMultiplier >= 1", "migrationId": "job-retry-policy-constraints-tightened", "toMajor": 17, - "rationale": "The converged RetryPolicy (#4661) keeps the automation side's bounds, which the job side never had: `maxRetries` is capped at 10 and `backoffMultiplier` floored at 1. Neither has a lossless rewrite. Clamping `maxRetries: 20` to 10 would halve a retry budget its author chose, and a `backoffMultiplier` below 1 describes a delay that SHRINKS on each attempt — retrying a failing dependency ever faster, which is the opposite of backoff and was never a shape the engine meant to offer. Both now fail at parse time with the bound named, rather than being silently reinterpreted. Choosing the replacement count (or accepting the cap) is the author's call." + "rationale": "The RetryPolicy converged onto one declaration from its automation and system copies keeps the automation side's bounds, which the job side never had: `maxRetries` is capped at 10 and `backoffMultiplier` floored at 1. Neither has a lossless rewrite. Clamping `maxRetries: 20` to 10 would halve a retry budget its author chose, and a `backoffMultiplier` below 1 describes a delay that SHRINKS on each attempt — retrying a failing dependency ever faster, which is the opposite of backoff and was never a shape the engine meant to offer. Both now fail at parse time with the bound named, rather than being silently reinterpreted. Choosing the replacement count (or accepting the cap) is the author's call." }, { "surface": "api.listNotifications cursor — the key on BOTH halves of GET /api/v1/notifications (ListNotificationsRequestSchema and ListNotificationsResponseSchema) and the cursor argument of the client SDK call client.notifications.list(). The same entry covers the limit default: the request schema no longer declares default(20)", @@ -735,7 +735,7 @@ "replacement": "nothing on this table — delete the key from any authored `sys_position` seed row (stack `data` entries) or data-door write that still carries it. There are no direct position-level permission strings anywhere on the platform: capability reaches a position ONLY through permission-set bindings (`sys_position_permission_set` rows, created in Setup or by an app's kernel:ready binder) and is resolved from the position `name` at request time. A value that was recording intent as documentation belongs in `description`, which remains declared", "migrationId": "position-permissions-column-retired", "toMajor": 17, - "rationale": "Maintainer ruling 2026-08-20 (#9885), ADR-0049 enforce-or-remove: REMOVE. The object-scoped census (all sys_position-naming files, with same-object positive controls resolving `active` / `delegatable` / `is_default` / `name` to real readers) measured the column at zero on both sides: the only row writers — the builtin and declared position bootstrappers — set label / description / managed_by / active / is_default, and position→grant resolution consults `sys_position_permission_set` rows plus the position `name`, never this column. Its only in-repo reference was the clone_position action copying it between rows — a copy of a value nothing writes. objectui was searched under the same discipline (evidenceScope closure): no console surface names the column — the position pickers and Setup views read name / label / id only, so a designer preview consumer does not exist either. That left a declared free-text grant catalogue on a security object that no runtime enforced: an author — human or AI — who filled it believed they granted permission strings directly on the position, and nothing refused or honoured the value. This is a platform-object COLUMN retirement, not a spec-key retirement, so the bookkeeping follows the ups-delegated-from-column-retired shape: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — PositionSchema never declared `permissions`, and the surface ratchets are expected byte-identical), no liveness-ledger row is added (the ledger walks PositionSchema's shape, which never carried the key — a row would be an orphan), and the disposition is a SEMANTIC entry rather than a D2 conversion: no conversion in the chain rewrites seed rows today and the measured author base is zero, while the loud channel already exists at runtime — the engine schema preflight refuses an undeclared field with 400 INVALID_FIELD before the driver or any hook runs — so this entry carries the prescription and the refusal carries the enforcement. The live-authoring half is the PositionSchema strict-parse guidance for `permissions`, which names the binding table in the rejection. ⚠️ Existing physical columns are deliberately untouched: schema sync is additive (ADR-0045), so a deployed database keeps the column; the platform stops declaring, projecting or accepting it. Zero producers means no rows are expected to carry a value; no backfill or destructive DDL is required or wanted. If position-level direct grants ever become a real need, the column is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce or do not declare." + "rationale": "Maintainer ruling 2026-08-20 on the finding that nothing writes or reads this column, ADR-0049 enforce-or-remove: REMOVE. The object-scoped census (all sys_position-naming files, with same-object positive controls resolving `active` / `delegatable` / `is_default` / `name` to real readers) measured the column at zero on both sides: the only row writers — the builtin and declared position bootstrappers — set label / description / managed_by / active / is_default, and position→grant resolution consults `sys_position_permission_set` rows plus the position `name`, never this column. Its only in-repo reference was the clone_position action copying it between rows — a copy of a value nothing writes. objectui was searched under the same discipline (evidenceScope closure): no console surface names the column — the position pickers and Setup views read name / label / id only, so a designer preview consumer does not exist either. That left a declared free-text grant catalogue on a security object that no runtime enforced: an author — human or AI — who filled it believed they granted permission strings directly on the position, and nothing refused or honoured the value. This is a platform-object COLUMN retirement, not a spec-key retirement, so the bookkeeping follows the ups-delegated-from-column-retired shape: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — PositionSchema never declared `permissions`, and the surface ratchets are expected byte-identical), no liveness-ledger row is added (the ledger walks PositionSchema's shape, which never carried the key — a row would be an orphan), and the disposition is a SEMANTIC entry rather than a D2 conversion: no conversion in the chain rewrites seed rows today and the measured author base is zero, while the loud channel already exists at runtime — the engine schema preflight refuses an undeclared field with 400 INVALID_FIELD before the driver or any hook runs — so this entry carries the prescription and the refusal carries the enforcement. The live-authoring half is the PositionSchema strict-parse guidance for `permissions`, which names the binding table in the rejection. ⚠️ Existing physical columns are deliberately untouched: schema sync is additive (ADR-0045), so a deployed database keeps the column; the platform stops declaring, projecting or accepting it. Zero producers means no rows are expected to carry a value; no backfill or destructive DDL is required or wanted. If position-level direct grants ever become a real need, the column is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce or do not declare." }, { "surface": "data.query.aggregations[].function ('array_agg' / 'string_agg')", @@ -868,7 +868,7 @@ "replacement": "nothing on this table — delete the key from any authored `sys_user_permission_set` seed row (stack `data` entries) or data-door write that still carries it. Delegation semantics live on `sys_user_position`, where `delegated_from` remains declared AND runtime-enforced: the delegated-admin gate is what makes a position insert a delegation, and the explain engine attributes \"via delegation from X, until Y\". A permission-set grant that needs a provenance note keeps `reason` (free text), which remains declared on both grant tables", "migrationId": "ups-delegated-from-column-retired", "toMajor": 17, - "rationale": "Maintainer ruling 2026-08-18 (#9730), ADR-0049 enforce-or-remove: REMOVE. The runtime delegation gate is structurally scoped to sys_user_position (`isDelegationWrite` returns false for every other object, so `assertSelfDelegation` is unreachable for this table), and the explain engine reads delegation provenance from sys_user_position rows only. On sys_user_permission_set the column was therefore declared and data-door-writable while NO runtime consumer read it — its only enforcement was an authoring-time lint (the D3 \"delegation row needs a reason\" rule), which a row written through the generic data door never meets. That is declared-but-unenforced in its pure form, on a security object: an author who stamped delegated_from on a permission-set grant believed they constrained delegation, and nothing refused or honoured it. Producers measured at zero — the only object literals naming both the table and the column were lint test fixtures. This is a platform-object COLUMN retirement, not a spec-key retirement, so the bookkeeping follows the audit-log-action-enum-retired shape: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — the surface ratchets are expected byte-identical), and the disposition is a SEMANTIC entry rather than a D2 conversion. A conversion over stack `data` seed records would be mechanically expressible, but no conversion in the chain rewrites seed rows today and the measured author base is zero; the loud channel already exists at runtime — the engine schema preflight refuses an undeclared field with 400 INVALID_FIELD before the driver or any hook runs — so this entry carries the prescription and the refusal carries the enforcement. ⚠️ Existing physical columns are deliberately untouched: schema sync is additive (ADR-0045), so a deployed database keeps the column; the platform stops declaring, projecting or accepting it. Zero producers means no rows are expected to carry a value; no backfill or destructive DDL is required or wanted. If delegation at permission-set granularity ever becomes a real need, the column is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce or do not declare." + "rationale": "Maintainer ruling 2026-08-18 on the finding that the delegation gate never reads this column on this object, ADR-0049 enforce-or-remove: REMOVE. The runtime delegation gate is structurally scoped to sys_user_position (`isDelegationWrite` returns false for every other object, so `assertSelfDelegation` is unreachable for this table), and the explain engine reads delegation provenance from sys_user_position rows only. On sys_user_permission_set the column was therefore declared and data-door-writable while NO runtime consumer read it — its only enforcement was an authoring-time lint (the D3 \"delegation row needs a reason\" rule), which a row written through the generic data door never meets. That is declared-but-unenforced in its pure form, on a security object: an author who stamped delegated_from on a permission-set grant believed they constrained delegation, and nothing refused or honoured it. Producers measured at zero — the only object literals naming both the table and the column were lint test fixtures. This is a platform-object COLUMN retirement, not a spec-key retirement, so the bookkeeping follows the audit-log-action-enum-retired shape: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — the surface ratchets are expected byte-identical), and the disposition is a SEMANTIC entry rather than a D2 conversion. A conversion over stack `data` seed records would be mechanically expressible, but no conversion in the chain rewrites seed rows today and the measured author base is zero; the loud channel already exists at runtime — the engine schema preflight refuses an undeclared field with 400 INVALID_FIELD before the driver or any hook runs — so this entry carries the prescription and the refusal carries the enforcement. ⚠️ Existing physical columns are deliberately untouched: schema sync is additive (ADR-0045), so a deployed database keeps the column; the platform stops declaring, projecting or accepting it. Zero producers means no rows are expected to carry a value; no backfill or destructive DDL is required or wanted. If delegation at permission-set granularity ever becomes a real need, the column is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce or do not declare." }, { "surface": "ui.ViewFilterRule value — the third key of a view filter rule, on every carrier of ViewFilterRuleSchema: ListView.filter, a list view tab filter, Page.filterBy, a related-list component filter and a lookup picker filter. It accepted any declared scalar or array for EVERY operator; the accepted shape is now decided by the rule operator — in / not_in require an array, between requires exactly two bounds, and every other operator is unchanged", @@ -889,7 +889,7 @@ "replacement": "the live mechanisms the slot only ever pointed at: `state_machine` validation rules for record state machines, approval flow nodes on the approvals runtime (ADR-0019) for approvals, lifecycle hooks + `record_change` flows (service-automation) for record-triggered automation", "migrationId": "workflow-service-slot-retired", "toMajor": 17, - "rationale": "The workflow slot was declared end to end and implemented nowhere: no code in either repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches were plugin-dev's retired stub probe and the generic discovery walk), no implementation of any WorkflowProtocol method ever existed, and no host ever mounted `/api/v1/workflow` (the pre-#3586 DEFAULT_DISPATCHER_ROUTES listed it among routes that never existed). Every part of it was ADR-0078's silently-inert declaration: a CoreServiceName nothing filled, a contract nothing implemented, a protocol nothing served, a discovery route field no builder could truthfully populate. These are TS/API surfaces and a discovery RESPONSE field — never stored in stack metadata, so there is no source for the chain to rewrite; consumers of the deleted types move their imports themselves. ADR-0049 / ADR-0078, #4451." + "rationale": "The workflow slot was declared end to end and implemented nowhere: no code in either repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches were plugin-dev's retired stub probe and the generic discovery walk), no implementation of any WorkflowProtocol method ever existed, and no host ever mounted `/api/v1/workflow` (DEFAULT_DISPATCHER_ROUTES, before it was retired as a stale list, named it among routes that never existed). Every part of it was ADR-0078's silently-inert declaration: a CoreServiceName nothing filled, a contract nothing implemented, a protocol nothing served, a discovery route field no builder could truthfully populate. These are TS/API surfaces and a discovery RESPONSE field — never stored in stack metadata, so there is no source for the chain to rewrite; consumers of the deleted types move their imports themselves. ADR-0049 / ADR-0078." } ], "removed": [] @@ -1312,7 +1312,7 @@ "replacement": "nothing, for the common intent (escalate on timeout): an escalation block carrying timeoutHours is live by default. To declare an SLA OFF while keeping its configuration, write enabled: false explicitly — which is now the spelling the escalation sweep actually reads", "migrationId": "approval-escalation-enabled-default-flip", "toMajor": 17, - "rationale": "A DECLARED-DEFAULT CORRECTION plus the enforcement that makes the key real (#12278, maintainer ruling 2026-08-27) — the same category as protocol 17's `import-run-automations-declared-default-corrected`: the schema promised `enabled` defaults to `false` (SLA off) while the plugin-approvals sweep never read the key at all — any escalation block with a positive `timeoutHours` escalated, and with `action: 'auto_approve'` that silently approved requests their author had declared off the clock. The flip moves the default to `true` and, in the same change, the sweep starts honouring an explicit `enabled: false`. The feature-level switch is whether an `escalation` block exists at all; within a block carrying `timeoutHours`, escalation is on unless explicitly turned off. Deployed metadata that OMITS `enabled` does not change behaviour: it escalated before (the sweep ignored the key) and escalates after (the parse materializes `true`). Stored request snapshots written before the flip carry a MATERIALIZED `enabled: false` (the approval-node executor parses config through the old schema before snapshotting), so the sweep keeps a read-side legacy window keyed on the snapshot's `created_at`: pre-flip snapshots keep escalating exactly as they do today, and the window retires itself as those pending requests drain. What DOES change is that an explicit `enabled: false` finally binds — a flow that authored it (e.g. the console toggle switched off after a timeout was set) stops escalating on requests opened after the upgrade, which is the declared intent being honoured." + "rationale": "A DECLARED-DEFAULT CORRECTION plus the enforcement that makes the key real (maintainer ruling 2026-08-27, which moved the declared default to what the sweep had always done) — the same category as protocol 17's `import-run-automations-declared-default-corrected`: the schema promised `enabled` defaults to `false` (SLA off) while the plugin-approvals sweep never read the key at all — any escalation block with a positive `timeoutHours` escalated, and with `action: 'auto_approve'` that silently approved requests their author had declared off the clock. The flip moves the default to `true` and, in the same change, the sweep starts honouring an explicit `enabled: false`. The feature-level switch is whether an `escalation` block exists at all; within a block carrying `timeoutHours`, escalation is on unless explicitly turned off. Deployed metadata that OMITS `enabled` does not change behaviour: it escalated before (the sweep ignored the key) and escalates after (the parse materializes `true`). Stored request snapshots written before the flip carry a MATERIALIZED `enabled: false` (the approval-node executor parses config through the old schema before snapshotting), so the sweep keeps a read-side legacy window keyed on the snapshot's `created_at`: pre-flip snapshots keep escalating exactly as they do today, and the window retires itself as those pending requests drain. What DOES change is that an explicit `enabled: false` finally binds — a flow that authored it (e.g. the console toggle switched off after a timeout was set) stops escalating on requests opened after the upgrade, which is the declared intent being honoured." }, { "surface": "sys_audit_log.action — the values 'export' and 'permission_change' left the select enum declared by plugin-audit (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts). The same two values also left the shipped list-view filters on that object: 'permission_change' from the auth_events view and 'export' from the config_changes view", @@ -1333,7 +1333,7 @@ "replacement": "(removed — no replacement flag; the capabilities are not advertised)", "migrationId": "auth-config-unadvertised-reserved-features", "toMajor": 17, - "rationale": "Both flags were served by `GET /api/v1/auth/config` from introduction and read by no client: no login UI anywhere renders a passkey or magic-link affordance off them, so the payload advertised two sign-in methods a user could never reach, and a deployer setting `plugins.passkeys` / `plugins.magicLink` flipped a switch with no observable effect (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-11 on #7481 chose remove over keep-as-reserved). The two are not equally empty: nothing at all is wired behind `passkeys`, whereas `magicLink`'s better-auth endpoints are live and only their advertisement was withdrawn. This is a RESPONSE surface — nobody authors or persists an `AuthFeaturesConfig` — so there is no source for the chain to rewrite; the schema tombstones both keys via retiredKey() and consumers drop their read. The withdrawal is conditional: both return to the payload in the change that ships the login UI (objectui#4179). ADR-0049, #7481." + "rationale": "Both flags were served by `GET /api/v1/auth/config` from introduction and read by no client: no login UI anywhere renders a passkey or magic-link affordance off them, so the payload advertised two sign-in methods a user could never reach, and a deployer setting `plugins.passkeys` / `plugins.magicLink` flipped a switch with no observable effect (ADR-0049 enforce-or-remove; the maintainer ruling of 2026-08-11 chose remove over keep-as-reserved, so that a deployer cannot flip a flag that does nothing anywhere). The two are not equally empty: nothing at all is wired behind `passkeys`, whereas `magicLink`'s better-auth endpoints are live and only their advertisement was withdrawn. This is a RESPONSE surface — nobody authors or persists an `AuthFeaturesConfig` — so there is no source for the chain to rewrite; the schema tombstones both keys via retiredKey() and consumers drop their read. The withdrawal is conditional: both return to the payload in the change that ships the login UI (flag-gated passkey and magic-link entry points, which objectui defers until the maintainer schedules them). ADR-0049." }, { "surface": "the protocol-17 authoring schemas closed against undeclared keys by the unknown-key strictness wave — `automation/` (flow and its six nested blocks, control-flow, state-machine, webhook, time-relative trigger, flow function), `security/` (permission sets, RLS policies, sharing rules) and `identity/position`, `ui/` (responsive, theme, chart, `AriaProps`, fifteen `view` sub-blocks, `ViewItem`, `userFilters`) — plus the `view` write-path identity precondition one level above them", @@ -1354,7 +1354,7 @@ "replacement": "`errors: ApiError[]` (was `error: string` — read `row.errors?.[0]?.message`, branch on `row.errors?.[0]?.code`), `data` (was `record`), and `index` (new — the row's position in the request array)", "migrationId": "batch-row-result-schema-shape", "toMajor": 17, - "rationale": "The rows the three bulk-write endpoints emitted had drifted from the schema that declared them: `BatchOperationResultSchema`, the client SDK's exported `BatchOperationResult` type and the reference docs all said `errors: ApiError[]` / `data` / `index`, while the wire carried `error: string` / `record` and never sent `index` at all. A TypeScript consumer written against the published type compiled, validated and read `undefined` at runtime — the declared-but-not-delivered shape this registry exists to close, on the response envelope (ADR-0119 D4 deferred the reconciliation off a bug fix; this is that tracked change, shipped in the 17 major window). The ADR-0119/#4620 rollback marking is structured in the same move: the `ROLLED_BACK:` / `NOT_ATTEMPTED:` message-string prefixes become registered `ApiError.code` values (message keeps the human-readable cause and causal row index), so \"attempted and undone\" vs \"never ran\" is machine-readable instead of a regex convention. A RESPONSE surface — nothing stored in stack metadata carries a batch row, so there is no source for the chain to rewrite; consumers of the legacy keys move their reads themselves. Off-contract readers only: the legacy keys were never in the schema or the SDK types, so a typed consumer needs no change. #4793." + "rationale": "The rows the three bulk-write endpoints emitted had drifted from the schema that declared them: `BatchOperationResultSchema`, the client SDK's exported `BatchOperationResult` type and the reference docs all said `errors: ApiError[]` / `data` / `index`, while the wire carried `error: string` / `record` and never sent `index` at all. A TypeScript consumer written against the published type compiled, validated and read `undefined` at runtime — the declared-but-not-delivered shape this registry exists to close, on the response envelope (ADR-0119 D4 deferred the reconciliation off a bug fix; this is that tracked change, shipped in the 17 major window). The ADR-0119 rollback marking, which the fix making `deleteManyData` and `updateManyData` honour `atomic` carried to those two endpoints, is structured in the same move: the `ROLLED_BACK:` / `NOT_ATTEMPTED:` message-string prefixes become registered `ApiError.code` values (message keeps the human-readable cause and causal row index), so \"attempted and undone\" vs \"never ran\" is machine-readable instead of a regex convention. A RESPONSE surface — nothing stored in stack metadata carries a batch row, so there is no source for the chain to rewrite; consumers of the legacy keys move their reads themselves. Off-contract readers only: the legacy keys were never in the schema or the SDK types, so a typed consumer needs no change. Ruled 2026-08-03: the implementation moves to the schema's shape as a hard cut in the 17 major, with no dual-emit transition." }, { "surface": "client.DeleteDataResult.deleted (the return of `client.data.delete()`)", @@ -1368,7 +1368,7 @@ "replacement": "a catalog descriptor drops `authentication` (or sets `{ type: \"none\" }`) and documents the auth scheme in `description`; a dispatchable instance declares `provider` and references its credential with `auth: { type, credentialRef }` (ADR-0097 §3). Runtime `registerConnector` calls are unaffected — the runtime shape still carries resolved secrets inline.", "migrationId": "connector-inline-authentication-publish-refused", "toMajor": 17, - "rationale": "A published connector row lands whole in `sys_metadata`, so an inline `token` / `key` / `password` / `clientSecret` is cleartext at rest, readable through the data API (#7990). No mechanical rewrite exists: whether the entry should become a `none` descriptor or a provider-bound instance with a `credentialRef` — and which secret store receives the credential — is a judgment about the connector, not a rename." + "rationale": "A published connector row lands whole in `sys_metadata`, so an inline `token` / `key` / `password` / `clientSecret` is cleartext at rest, readable through the data API (the class a credential-persistence survey measured: any authored artefact whose schema permits an inline credential lands it there). No mechanical rewrite exists: whether the entry should become a `none` descriptor or a provider-bound instance with a `credentialRef` — and which secret store receives the credential — is a judgment about the connector, not a rename." }, { "surface": "dashboard.widgets[].compareTo: { offset: '7d' | '1M' | … } (every duration except '1y')", @@ -1494,7 +1494,7 @@ "replacement": "fields", "migrationId": "enhanced-api-error-field-errors-renamed", "toMajor": 17, - "rationale": "The wire has always carried `fields` — the validators, import coercion, validation-failure.ts, @objectstack/client and the console's field-error extractor all say `fields`, and nothing ever emitted `fieldErrors`, so a reader keying on it was reading a field no server sent (ADR-0078's silently-inert declaration, on the error envelope). This is a RESPONSE surface: no stack, example or template carries the key, so there is no source for the chain to rewrite — the schema tombstones it via retiredKey() and consumers move their read themselves. ADR-0114 D4, #3977." + "rationale": "The wire has always carried `fields` — the validators, import coercion, validation-failure.ts, @objectstack/client and the console's field-error extractor all say `fields`, and nothing ever emitted `fieldErrors`, so a reader keying on it was reading a field no server sent (ADR-0078's silently-inert declaration, on the error envelope). This is a RESPONSE surface: no stack, example or template carries the key, so there is no source for the chain to rewrite — the schema tombstones it via retiredKey() and consumers move their read themselves. ADR-0114 D4 (the field-level error code catalog)." }, { "surface": "automation.etlPipeline / automation.etlPipelineRun / automation.etlSource / automation.etlDestination / automation.etlTransformation (the whole L2 layer of automation/etl.zod.ts, its four enums and the `ETL` factory — 9 defs, 27 exported names)", @@ -1585,7 +1585,7 @@ "replacement": "maxRetries <= 10, and backoffMultiplier >= 1", "migrationId": "job-retry-policy-constraints-tightened", "toMajor": 17, - "rationale": "The converged RetryPolicy (#4661) keeps the automation side's bounds, which the job side never had: `maxRetries` is capped at 10 and `backoffMultiplier` floored at 1. Neither has a lossless rewrite. Clamping `maxRetries: 20` to 10 would halve a retry budget its author chose, and a `backoffMultiplier` below 1 describes a delay that SHRINKS on each attempt — retrying a failing dependency ever faster, which is the opposite of backoff and was never a shape the engine meant to offer. Both now fail at parse time with the bound named, rather than being silently reinterpreted. Choosing the replacement count (or accepting the cap) is the author's call." + "rationale": "The RetryPolicy converged onto one declaration from its automation and system copies keeps the automation side's bounds, which the job side never had: `maxRetries` is capped at 10 and `backoffMultiplier` floored at 1. Neither has a lossless rewrite. Clamping `maxRetries: 20` to 10 would halve a retry budget its author chose, and a `backoffMultiplier` below 1 describes a delay that SHRINKS on each attempt — retrying a failing dependency ever faster, which is the opposite of backoff and was never a shape the engine meant to offer. Both now fail at parse time with the bound named, rather than being silently reinterpreted. Choosing the replacement count (or accepting the cap) is the author's call." }, { "surface": "api.listNotifications cursor — the key on BOTH halves of GET /api/v1/notifications (ListNotificationsRequestSchema and ListNotificationsResponseSchema) and the cursor argument of the client SDK call client.notifications.list(). The same entry covers the limit default: the request schema no longer declares default(20)", @@ -1627,7 +1627,7 @@ "replacement": "nothing on this table — delete the key from any authored `sys_position` seed row (stack `data` entries) or data-door write that still carries it. There are no direct position-level permission strings anywhere on the platform: capability reaches a position ONLY through permission-set bindings (`sys_position_permission_set` rows, created in Setup or by an app's kernel:ready binder) and is resolved from the position `name` at request time. A value that was recording intent as documentation belongs in `description`, which remains declared", "migrationId": "position-permissions-column-retired", "toMajor": 17, - "rationale": "Maintainer ruling 2026-08-20 (#9885), ADR-0049 enforce-or-remove: REMOVE. The object-scoped census (all sys_position-naming files, with same-object positive controls resolving `active` / `delegatable` / `is_default` / `name` to real readers) measured the column at zero on both sides: the only row writers — the builtin and declared position bootstrappers — set label / description / managed_by / active / is_default, and position→grant resolution consults `sys_position_permission_set` rows plus the position `name`, never this column. Its only in-repo reference was the clone_position action copying it between rows — a copy of a value nothing writes. objectui was searched under the same discipline (evidenceScope closure): no console surface names the column — the position pickers and Setup views read name / label / id only, so a designer preview consumer does not exist either. That left a declared free-text grant catalogue on a security object that no runtime enforced: an author — human or AI — who filled it believed they granted permission strings directly on the position, and nothing refused or honoured the value. This is a platform-object COLUMN retirement, not a spec-key retirement, so the bookkeeping follows the ups-delegated-from-column-retired shape: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — PositionSchema never declared `permissions`, and the surface ratchets are expected byte-identical), no liveness-ledger row is added (the ledger walks PositionSchema's shape, which never carried the key — a row would be an orphan), and the disposition is a SEMANTIC entry rather than a D2 conversion: no conversion in the chain rewrites seed rows today and the measured author base is zero, while the loud channel already exists at runtime — the engine schema preflight refuses an undeclared field with 400 INVALID_FIELD before the driver or any hook runs — so this entry carries the prescription and the refusal carries the enforcement. The live-authoring half is the PositionSchema strict-parse guidance for `permissions`, which names the binding table in the rejection. ⚠️ Existing physical columns are deliberately untouched: schema sync is additive (ADR-0045), so a deployed database keeps the column; the platform stops declaring, projecting or accepting it. Zero producers means no rows are expected to carry a value; no backfill or destructive DDL is required or wanted. If position-level direct grants ever become a real need, the column is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce or do not declare." + "rationale": "Maintainer ruling 2026-08-20 on the finding that nothing writes or reads this column, ADR-0049 enforce-or-remove: REMOVE. The object-scoped census (all sys_position-naming files, with same-object positive controls resolving `active` / `delegatable` / `is_default` / `name` to real readers) measured the column at zero on both sides: the only row writers — the builtin and declared position bootstrappers — set label / description / managed_by / active / is_default, and position→grant resolution consults `sys_position_permission_set` rows plus the position `name`, never this column. Its only in-repo reference was the clone_position action copying it between rows — a copy of a value nothing writes. objectui was searched under the same discipline (evidenceScope closure): no console surface names the column — the position pickers and Setup views read name / label / id only, so a designer preview consumer does not exist either. That left a declared free-text grant catalogue on a security object that no runtime enforced: an author — human or AI — who filled it believed they granted permission strings directly on the position, and nothing refused or honoured the value. This is a platform-object COLUMN retirement, not a spec-key retirement, so the bookkeeping follows the ups-delegated-from-column-retired shape: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — PositionSchema never declared `permissions`, and the surface ratchets are expected byte-identical), no liveness-ledger row is added (the ledger walks PositionSchema's shape, which never carried the key — a row would be an orphan), and the disposition is a SEMANTIC entry rather than a D2 conversion: no conversion in the chain rewrites seed rows today and the measured author base is zero, while the loud channel already exists at runtime — the engine schema preflight refuses an undeclared field with 400 INVALID_FIELD before the driver or any hook runs — so this entry carries the prescription and the refusal carries the enforcement. The live-authoring half is the PositionSchema strict-parse guidance for `permissions`, which names the binding table in the rejection. ⚠️ Existing physical columns are deliberately untouched: schema sync is additive (ADR-0045), so a deployed database keeps the column; the platform stops declaring, projecting or accepting it. Zero producers means no rows are expected to carry a value; no backfill or destructive DDL is required or wanted. If position-level direct grants ever become a real need, the column is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce or do not declare." }, { "surface": "data.query.aggregations[].function ('array_agg' / 'string_agg')", @@ -1760,7 +1760,7 @@ "replacement": "nothing on this table — delete the key from any authored `sys_user_permission_set` seed row (stack `data` entries) or data-door write that still carries it. Delegation semantics live on `sys_user_position`, where `delegated_from` remains declared AND runtime-enforced: the delegated-admin gate is what makes a position insert a delegation, and the explain engine attributes \"via delegation from X, until Y\". A permission-set grant that needs a provenance note keeps `reason` (free text), which remains declared on both grant tables", "migrationId": "ups-delegated-from-column-retired", "toMajor": 17, - "rationale": "Maintainer ruling 2026-08-18 (#9730), ADR-0049 enforce-or-remove: REMOVE. The runtime delegation gate is structurally scoped to sys_user_position (`isDelegationWrite` returns false for every other object, so `assertSelfDelegation` is unreachable for this table), and the explain engine reads delegation provenance from sys_user_position rows only. On sys_user_permission_set the column was therefore declared and data-door-writable while NO runtime consumer read it — its only enforcement was an authoring-time lint (the D3 \"delegation row needs a reason\" rule), which a row written through the generic data door never meets. That is declared-but-unenforced in its pure form, on a security object: an author who stamped delegated_from on a permission-set grant believed they constrained delegation, and nothing refused or honoured it. Producers measured at zero — the only object literals naming both the table and the column were lint test fixtures. This is a platform-object COLUMN retirement, not a spec-key retirement, so the bookkeeping follows the audit-log-action-enum-retired shape: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — the surface ratchets are expected byte-identical), and the disposition is a SEMANTIC entry rather than a D2 conversion. A conversion over stack `data` seed records would be mechanically expressible, but no conversion in the chain rewrites seed rows today and the measured author base is zero; the loud channel already exists at runtime — the engine schema preflight refuses an undeclared field with 400 INVALID_FIELD before the driver or any hook runs — so this entry carries the prescription and the refusal carries the enforcement. ⚠️ Existing physical columns are deliberately untouched: schema sync is additive (ADR-0045), so a deployed database keeps the column; the platform stops declaring, projecting or accepting it. Zero producers means no rows are expected to carry a value; no backfill or destructive DDL is required or wanted. If delegation at permission-set granularity ever becomes a real need, the column is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce or do not declare." + "rationale": "Maintainer ruling 2026-08-18 on the finding that the delegation gate never reads this column on this object, ADR-0049 enforce-or-remove: REMOVE. The runtime delegation gate is structurally scoped to sys_user_position (`isDelegationWrite` returns false for every other object, so `assertSelfDelegation` is unreachable for this table), and the explain engine reads delegation provenance from sys_user_position rows only. On sys_user_permission_set the column was therefore declared and data-door-writable while NO runtime consumer read it — its only enforcement was an authoring-time lint (the D3 \"delegation row needs a reason\" rule), which a row written through the generic data door never meets. That is declared-but-unenforced in its pure form, on a security object: an author who stamped delegated_from on a permission-set grant believed they constrained delegation, and nothing refused or honoured it. Producers measured at zero — the only object literals naming both the table and the column were lint test fixtures. This is a platform-object COLUMN retirement, not a spec-key retirement, so the bookkeeping follows the audit-log-action-enum-retired shape: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — the surface ratchets are expected byte-identical), and the disposition is a SEMANTIC entry rather than a D2 conversion. A conversion over stack `data` seed records would be mechanically expressible, but no conversion in the chain rewrites seed rows today and the measured author base is zero; the loud channel already exists at runtime — the engine schema preflight refuses an undeclared field with 400 INVALID_FIELD before the driver or any hook runs — so this entry carries the prescription and the refusal carries the enforcement. ⚠️ Existing physical columns are deliberately untouched: schema sync is additive (ADR-0045), so a deployed database keeps the column; the platform stops declaring, projecting or accepting it. Zero producers means no rows are expected to carry a value; no backfill or destructive DDL is required or wanted. If delegation at permission-set granularity ever becomes a real need, the column is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce or do not declare." }, { "surface": "ui.ViewFilterRule value — the third key of a view filter rule, on every carrier of ViewFilterRuleSchema: ListView.filter, a list view tab filter, Page.filterBy, a related-list component filter and a lookup picker filter. It accepted any declared scalar or array for EVERY operator; the accepted shape is now decided by the rule operator — in / not_in require an array, between requires exactly two bounds, and every other operator is unchanged", @@ -1781,7 +1781,7 @@ "replacement": "the live mechanisms the slot only ever pointed at: `state_machine` validation rules for record state machines, approval flow nodes on the approvals runtime (ADR-0019) for approvals, lifecycle hooks + `record_change` flows (service-automation) for record-triggered automation", "migrationId": "workflow-service-slot-retired", "toMajor": 17, - "rationale": "The workflow slot was declared end to end and implemented nowhere: no code in either repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches were plugin-dev's retired stub probe and the generic discovery walk), no implementation of any WorkflowProtocol method ever existed, and no host ever mounted `/api/v1/workflow` (the pre-#3586 DEFAULT_DISPATCHER_ROUTES listed it among routes that never existed). Every part of it was ADR-0078's silently-inert declaration: a CoreServiceName nothing filled, a contract nothing implemented, a protocol nothing served, a discovery route field no builder could truthfully populate. These are TS/API surfaces and a discovery RESPONSE field — never stored in stack metadata, so there is no source for the chain to rewrite; consumers of the deleted types move their imports themselves. ADR-0049 / ADR-0078, #4451." + "rationale": "The workflow slot was declared end to end and implemented nowhere: no code in either repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches were plugin-dev's retired stub probe and the generic discovery walk), no implementation of any WorkflowProtocol method ever existed, and no host ever mounted `/api/v1/workflow` (DEFAULT_DISPATCHER_ROUTES, before it was retired as a stale list, named it among routes that never existed). Every part of it was ADR-0078's silently-inert declaration: a CoreServiceName nothing filled, a contract nothing implemented, a protocol nothing served, a discovery route field no builder could truthfully populate. These are TS/API surfaces and a discovery RESPONSE field — never stored in stack metadata, so there is no source for the chain to rewrite; consumers of the deleted types move their imports themselves. ADR-0049 / ADR-0078." } ], "removed": [] diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 39a2242e499..1c6e9147a99 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -1587,7 +1587,8 @@ const step17: MigrationStep = { + 'the escalation sweep actually reads', reason: 'A DECLARED-DEFAULT CORRECTION plus the enforcement that makes the key real ' - + "(#12278, maintainer ruling 2026-08-27) — the same category as protocol 17's " + + "(maintainer ruling 2026-08-27, which moved the declared default to what the sweep had " + + "always done) — the same category as protocol 17's " + '`import-run-automations-declared-default-corrected`: the schema promised ' + '`enabled` defaults to `false` (SLA off) while the plugin-approvals sweep never ' + 'read the key at all — any escalation block with a positive `timeoutHours` ' @@ -1778,14 +1779,16 @@ const step17: MigrationStep = { + 'client: no login UI anywhere renders a passkey or magic-link affordance off them, so ' + 'the payload advertised two sign-in methods a user could never reach, and a deployer ' + 'setting `plugins.passkeys` / `plugins.magicLink` flipped a switch with no observable ' - + 'effect (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-11 on #7481 chose remove ' - + 'over keep-as-reserved). The two are not equally empty: nothing at all is wired behind ' + + 'effect (ADR-0049 enforce-or-remove; the maintainer ruling of 2026-08-11 chose remove ' + + 'over keep-as-reserved, so that a deployer cannot flip a flag that does nothing ' + + 'anywhere). The two are not equally empty: nothing at all is wired behind ' + '`passkeys`, whereas `magicLink`\'s better-auth endpoints are live and only their ' + 'advertisement was withdrawn. This is a RESPONSE surface — nobody authors or persists ' + 'an `AuthFeaturesConfig` — so there is no source for the chain to rewrite; the schema ' + 'tombstones both keys via retiredKey() and consumers drop their read. The withdrawal is ' + 'conditional: both return to the payload in the change that ships the login UI ' - + '(objectui#4179). ADR-0049, #7481.', + + '(flag-gated passkey and magic-link entry points, which objectui defers until the ' + + 'maintainer schedules them). ADR-0049.', acceptanceCriteria: 'No client reads `features.passkeys` or `features.magicLink` off `/api/v1/auth/config`; ' + 'a client that gated UI on either now treats the capability as absent rather than ' @@ -1927,14 +1930,18 @@ const step17: MigrationStep = { + 'validated and read `undefined` at runtime — the declared-but-not-delivered shape this ' + 'registry exists to close, on the response envelope (ADR-0119 D4 deferred the ' + 'reconciliation off a bug fix; this is that tracked change, shipped in the 17 major ' - + 'window). The ADR-0119/#4620 rollback marking is structured in the same move: the ' + + 'window). The ADR-0119 rollback marking, which the fix making `deleteManyData` and ' + + '`updateManyData` honour `atomic` carried to those two endpoints, is structured in the ' + + 'same move: the ' + '`ROLLED_BACK:` / `NOT_ATTEMPTED:` message-string prefixes become registered ' + '`ApiError.code` values (message keeps the human-readable cause and causal row index), ' + 'so "attempted and undone" vs "never ran" is machine-readable instead of a regex ' + 'convention. A RESPONSE surface — nothing stored in stack metadata carries a batch ' + 'row, so there is no source for the chain to rewrite; consumers of the legacy keys ' + 'move their reads themselves. Off-contract readers only: the legacy keys were never ' - + 'in the schema or the SDK types, so a typed consumer needs no change. #4793.', + + 'in the schema or the SDK types, so a typed consumer needs no change. Ruled 2026-08-03: ' + + 'the implementation moves to the schema\'s shape as a hard cut in the 17 major, with no ' + + 'dual-emit transition.', acceptanceCriteria: 'No consumer reads `row.error` or `row.record` on a batch result row; failures are read ' + 'from `row.errors` (message via `errors[0].message`, rollback state via ' @@ -2000,9 +2007,11 @@ const step17: MigrationStep = { 'still carries resolved secrets inline.', reason: 'A published connector row lands whole in `sys_metadata`, so an inline `token` / `key` ' + - '/ `password` / `clientSecret` is cleartext at rest, readable through the data API ' + - '(#7990). No mechanical rewrite exists: whether the entry should become a `none` ' + - 'descriptor or a provider-bound instance with a `credentialRef` — and which secret ' + + '/ `password` / `clientSecret` is cleartext at rest, readable through the data API (the ' + + 'class a credential-persistence survey measured: any authored artefact whose schema ' + + 'permits an inline credential lands it there). No mechanical rewrite exists: whether ' + + 'the entry should become a `none` descriptor or a provider-bound instance with a ' + + '`credentialRef` — and which secret ' + 'store receives the credential — is a judgment about the connector, not a rename.', acceptanceCriteria: 'Every authored connector entry parses through `DeclarativeConnectorEntrySchema`; no ' + @@ -2822,7 +2831,8 @@ const step17: MigrationStep = { 'was reading a field no server sent (ADR-0078\'s silently-inert declaration, on the ' + 'error envelope). This is a RESPONSE surface: no stack, example or template carries ' + 'the key, so there is no source for the chain to rewrite — the schema tombstones it ' + - 'via retiredKey() and consumers move their read themselves. ADR-0114 D4, #3977.', + 'via retiredKey() and consumers move their read themselves. ADR-0114 D4 (the ' + + 'field-level error code catalog).', acceptanceCriteria: 'No consumer reads `error.fieldErrors`; per-field validation detail is read from ' + '`error.fields`, and constructing an EnhancedApiError with `fieldErrors` fails to parse ' + @@ -3524,7 +3534,8 @@ const step17: MigrationStep = { surface: 'job.retryPolicy.maxRetries (> 10) / job.retryPolicy.backoffMultiplier (< 1)', replacement: 'maxRetries <= 10, and backoffMultiplier >= 1', reason: - 'The converged RetryPolicy (#4661) keeps the automation side\'s bounds, which the job ' + 'The RetryPolicy converged onto one declaration from its automation and system copies ' + + 'keeps the automation side\'s bounds, which the job ' + 'side never had: `maxRetries` is capped at 10 and `backoffMultiplier` floored at 1. ' + 'Neither has a lossless rewrite. Clamping `maxRetries: 20` to 10 would halve a ' + 'retry budget its author chose, and a `backoffMultiplier` below 1 describes a delay ' @@ -3835,7 +3846,8 @@ const step17: MigrationStep = { + 'A value that was recording intent as documentation belongs in `description`, ' + 'which remains declared', reason: - 'Maintainer ruling 2026-08-20 (#9885), ADR-0049 enforce-or-remove: REMOVE. The ' + 'Maintainer ruling 2026-08-20 on the finding that nothing writes or reads this column, ' + + 'ADR-0049 enforce-or-remove: REMOVE. The ' + 'object-scoped census (all sys_position-naming files, with same-object positive ' + 'controls resolving `active` / `delegatable` / `is_default` / `name` to real ' + 'readers) measured the column at zero on both sides: the only row writers — the ' @@ -4767,7 +4779,8 @@ const step17: MigrationStep = { + 'until Y". A permission-set grant that needs a provenance note keeps `reason` ' + '(free text), which remains declared on both grant tables', reason: - 'Maintainer ruling 2026-08-18 (#9730), ADR-0049 enforce-or-remove: REMOVE. The ' + 'Maintainer ruling 2026-08-18 on the finding that the delegation gate never reads this ' + + 'column on this object, ADR-0049 enforce-or-remove: REMOVE. The ' + 'runtime delegation gate is structurally scoped to sys_user_position ' + '(`isDelegationWrite` returns false for every other object, so ' + '`assertSelfDelegation` is unreachable for this table), and the explain engine ' @@ -4941,13 +4954,14 @@ const step17: MigrationStep = { + 'repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches ' + 'were plugin-dev\'s retired stub probe and the generic discovery walk), no ' + 'implementation of any WorkflowProtocol method ever existed, and no host ever ' - + 'mounted `/api/v1/workflow` (the pre-#3586 DEFAULT_DISPATCHER_ROUTES listed it among ' + + 'mounted `/api/v1/workflow` (DEFAULT_DISPATCHER_ROUTES, before it was retired as a ' + + 'stale list, named it among ' + 'routes that never existed). Every part of it was ADR-0078\'s silently-inert ' + 'declaration: a CoreServiceName nothing filled, a contract nothing implemented, a ' + 'protocol nothing served, a discovery route field no builder could truthfully ' + 'populate. These are TS/API surfaces and a discovery RESPONSE field — never stored ' + 'in stack metadata, so there is no source for the chain to rewrite; consumers of the ' - + 'deleted types move their imports themselves. ADR-0049 / ADR-0078, #4451.', + + 'deleted types move their imports themselves. ADR-0049 / ADR-0078.', acceptanceCriteria: 'No import of IWorkflowService, WorkflowProtocol or the Get/WorkflowState/Config/' + 'Transition types resolves; no code calls getService(\'workflow\') or reads ' @@ -6049,7 +6063,8 @@ const step18: MigrationStep = { + 'exactly `lat`, `lng`, `altitude`, `accuracy`. Every rejection carries the surface, the ' + 'offending key and a rename (`postal_code` / `zipCode` / `zip` / `postcode` → `postalCode`, ' + '`latitude` → `lat`, `longitude` → `lng`). A key that names no declared member is removed ' - + 'at the producer — never tolerated at a consumer (AGENTS.md #0.1)', + + 'at the producer — never tolerated at a consumer: an alias for an off-spec key in a ' + + 'consumer stays forbidden (contract-first — fix the metadata, not the runtime)', reason: 'Maintainer ruling 2026-09-01, option A: both value classes refuse undeclared keys. Both ' + 'value classes were all-optional ' @@ -6102,7 +6117,8 @@ const step18: MigrationStep = { + 'admin holds no app-authored set in which to write the per-object `false` that would ' + 'have won. So an application could declare an object exportable by nobody, ship, and be ' + 'silently wrong on an exfiltration boundary — declared ≠ enforced, on the axis where a ' - + 'silent gap costs the most. This is #5491 applied to export: that change removed ' + + 'silent gap costs the most. This is the 2026-08-07 ruling on the member baseline ' + + 'applied to export: that change removed ' + '`member_default`\'s CRUD wildcard because a wildcard in a set every principal resolves ' + 'is not a default but a floor nobody can get under; the export wildcard survived by ' + 'omission rather than by decision, one tier up. It cannot be mechanically converted, in ' @@ -6114,7 +6130,8 @@ const step18: MigrationStep = { + 'was never the defect (controls C1–C3 of the same run show it enforcing exactly), ' + 'specific-over-wildcard precedence is unchanged, `allowExport` on a `"*"` entry remains a ' + 'supported authoring shape in an app\'s OWN sets, and READ is untouched — an admin still ' - + 'sees every record they saw before. ADR-0087, maintainer ruling 2026-08-15, #8681.', + + 'sees every record they saw before. ADR-0087; maintainer ruling 2026-08-15, which ' + + 'removed `allowExport` from the wildcard entry of both shipped admin sets.', acceptanceCriteria: 'For every principal whose ADMIN export you rely on, the grant is now authored where you ' + 'control it: an app/environment permission set held by that principal names each object ' @@ -6149,7 +6166,8 @@ const step18: MigrationStep = { + 'replacement that can be DERIVED from what was written: a blank names no unit, so the root the ' + 'author meant is not recoverable, and the platform must not pick one.', reason: - 'Maintainer ruling A on #19461 (decision batch #217 item 1, 2026-09-23 「217 同意」). ' + 'Maintainer ruling A, 2026-09-23: an empty or whitespace-only `businessUnit` is refused ' + + 'at parse, and stored scopes are not rewritten. ' + '`AdminScopeSchema` declared `businessUnit` as a bare string with no minimum, so ' + '`{ businessUnit: \'\' }` and `{ businessUnit: \' \' }` parsed green — measured against the ' + 'published spec 17.4.0 and re-measured on `main` before the change. This narrows a published ' @@ -6269,7 +6287,9 @@ const step18: MigrationStep = { + 'unit (ai/conversation.zod.ts)', replacement: 'durationSeconds — rename the key; the value is unchanged', reason: - 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + 'Maintainer ruling B (2026-09-02, extended on 2026-09-05 to runtime-emitted durations): ' + + 'the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying ' + + 'value, never only in the describe prose, and no existing offender is grandfathered. ' + 'It stands alone because it is the only offender in ai/ and the only one on its file. ' + 'What makes the bare name worth a registry row rather than a quiet edit is the company ' + 'it kept: every other number on ConversationAnalytics is a COUNT — totalMessages, ' @@ -6283,7 +6303,7 @@ const step18: MigrationStep = { + 'runtime and handed to a consumer, never authored by hand and never stored as a ' + 'sys_metadata row, so the conversion chain has no seam that would ever see one — the ' + 'same disposition every runtime-emitted measurement in this stack has taken. ' - + '#15680, #14478, ADR-0087.', + + 'ADR-0087.', acceptanceCriteria: 'Every producer that BUILDS a ConversationAnalytics spells durationSeconds, and every ' + 'consumer that reads a session length reads durationSeconds. Authoring duration fails ' @@ -6645,7 +6665,9 @@ const step18: MigrationStep = { + 'rebuilt from source (`os build` / `composeStacks(…, { manifest: \'preserve\' })` no ' + 'longer folds them into a body), and a hand-written `packages[]` entry drops them.', reason: - 'A classification error, not a new special case (#15219; epic #14122 / #14512). ' + 'A classification error, not a new special case (maintainer ruling A, 2026-09-04: both ' + + 'keys are artifact envelope keys, top level only, never inside `packages[]` — decided ' + + 'while one artifact was being taught to carry several co-owning packages). ' + '`plugins` and `devPlugins` were the only members of the assembled-body key set whose ' + 'values are runtime ASSEMBLY instructions rather than serialisable metadata: `plugins` ' + 'holds what a host hands to `kernel.use()` — live plugin instances, manifests or package ' @@ -6675,7 +6697,8 @@ const step18: MigrationStep = { "explicit `auth: { audience: { posture: 'open' | 'email_domain', selfRegistrationPermissionSet: '' } }` " + '(deployments that intend open self-registration only)', reason: - 'The default audience posture flipped in #11739: an UNDECLARED `audience` now means ' + + 'The default audience posture flipped when one declared posture replaced the emergent ' + + 'self-registration default: an UNDECLARED `audience` now means ' + '`invite_only` — email/password self-registration (and social-provider JIT sign-up) is ' + 'refused with 403 SELF_REGISTRATION_CLOSED unless the address holds a pending invitation. ' + 'Previously the emergent default was open self-registration with no email verification. ' + @@ -6884,9 +6907,10 @@ const step18: MigrationStep = { + 'conflicting key groups with row counts, and `os migrate plan` reports the blocked ' + '`create_index` with the same groups (ADR-0120 D4) — but which of the duplicate rows keeps ' + 'the number is a business decision no migration entry can make. Maintainer ruling ' - + '2026-08-31 (hotcrm#1301): an auto-number that may repeat is not an identifier, so unique ' - + 'is the platform default and opting out is the declaration, not the other way round ' - + '(#13894).', + + '2026-08-31, on a downstream CRM\'s measurement that eight of its nine auto-numbered ' + + 'business identifiers could be issued twice: an auto-number that may repeat is not an ' + + 'identifier, so unique is the platform default and opting out is the declaration, not ' + + 'the other way round.', acceptanceCriteria: 'Every `autonumber` field without an authored `unique` parses to `unique: \'organization\'` ' + '(`FieldSchema.parse({ type: \'autonumber\' }).unique === \'organization\'`, and through ' @@ -6916,7 +6940,7 @@ const step18: MigrationStep = { + 'uses `SnakeCaseIdentifierSchema` or `SystemIdentifierSchema` from ' + '`@objectstack/spec/shared` directly — both stay published.)', reason: - 'Maintainer ruling 2026-09-01 on #13612 (director decision batch C, ' + 'Maintainer ruling 2026-09-01 (director decision batch C, ' + 'verbatim 「同意」: retire) — ADR-0049 enforce-or-remove. The brands ' + 'promised compile-time safety ("you cannot pass an ObjectName where a ' + 'FieldName is expected") that no consumer could obtain: no schema in ' @@ -7016,13 +7040,14 @@ const step18: MigrationStep = { + 'prescription. Metadata at rest is untouched: raw `.parse()`/`.safeParse()` still accept ' + 'the old shape, the security gate\'s derived enforcement stays, and the lint rule ' + '`relationship/master-detail-required` stays `warning` until its own v18 promotion ' - + '(#8772 Direction 1)', + + '(Direction 1 of the 2026-08-16 maintainer ruling whose Direction 2 this is)', reason: 'A `controlled_by_parent` detail derives ALL of its record access from the master that its ' + '`master_detail` reference names (ADR-0055). With the reference not `required`, an insert ' + 'may omit the master FK: the row lands with a null FK that the derived read filter ' + '`masterFK IN (accessible master ids)` can never match — unreadable by everyone — and ' - + 'every later by-id write answers `422 MISSING_REQUIRED_FIELD`. #8772 measured that only ' + + 'every later by-id write answers `422 MISSING_REQUIRED_FIELD`. The finding behind the ' + + 'ruling measured that only ' + 'the security gate closed this shape while the declaration surface still accepted it. ' + 'The maintainer ruling (2026-08-16, Direction 2) makes the unsafe shape impossible to ' + 'NEWLY declare at the builder; whether to keep `required: false` was never a real choice ' @@ -7118,7 +7143,8 @@ const step18: MigrationStep = { + 'comparisons, flat in lists, and field-to-field comparisons between single-valued columns ' + 'lower and evaluate exactly as before', reason: - 'Ruling A on #19886 refused a list under != and in the equality slot; stage 2d closes the ' + 'Ruling A of 2026-09-24 refused a list under != and in the equality slot, holding both to ' + + 'the declared comparand — a literal or a `{ $field }` reference; stage 2d closes the ' + 'same fault one position over, measured through the real plugin-security on driver-sql and ' + 'driver-memory. !(record.status in [["closed", "archived"]]) lowered to a negated $in whose ' + 'only member was a list, which the strictly comparing write-check evaluator matched on no ' @@ -7206,7 +7232,8 @@ const step18: MigrationStep = { + 'or rollback step, or compares an estimate with what happened, so there is no live ' + 'mechanism to declare a duration to', reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on #14477 (ruled A: retire per ' + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on the unread deadline keys ' + + '(ruled A: retire per ' + 'family). Three minute-shaped keys, at three nested sites, sat in the exported ' + 'change-management schemas and in the generated reference docs — an author could write ' + '`estimatedMinutes: 15` on a rollback step and reasonably expect it to feed a schedule — ' @@ -7253,7 +7280,8 @@ const step18: MigrationStep = { + 'becomes a product capability it re-declares fresh, through the enforce route of ' + 'ADR-0049 — the engine first, the vocabulary second', reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 on #15513 (ruled A: retire the ' + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 on the families\' remaining keys ' + + 'and defs (ruled A: retire the ' + 'three compliance-shaped families whole via RETIRED_DEFS_BY_MAJOR, the ' + 'integration/ErrorMappingConfig precedent; not roadmapped). Six defs and roughly fifty ' + 'declared keys sat on the exported surface and in the generated reference docs, and were ' @@ -7266,7 +7294,8 @@ const step18: MigrationStep = { + 'read as gates the platform enforced, and neither ever did — the worst form of the ' + 'declared-but-unenforced shape, on a security-adjacent surface. Tagging the family ' + '`[EXPERIMENTAL — not enforced]` was the fallback the ruling did not take (a human-only ' - + 'signal). The #14477 duration-key tombstones (three nested sites, ' + + 'signal). The duration-key tombstones of the 2026-09-02 per-family ruling (three nested ' + + 'sites, ' + '`RETIRED_KEYS_BY_MAJOR[18]`, D3 `change-management-duration-keys-retired`) leave with ' + 'their defs\' source; their registry entries stay as history. Why D3 semantic and not a ' + 'D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` ' @@ -7658,7 +7687,7 @@ const step18: MigrationStep = { + 'a self-provided transport. A config naming `postgres` or `nats` never ' + 'worked: pick `redis`, or register the transport yourself under `custom`', reason: - 'Maintainer ruling on objectstack-ai/cloud#1626 (2026-08-24, option B ' + 'Maintainer ruling of 2026-08-24 on the cluster driver line-up (option B ' + 'adopted): single-node is the ObjectOS EE boundary, multi-node is Cloud ' + 'differentiation, and a DB-first postgres cluster driver is not built ' + 'absent concrete customer pull. The ruling\'s principle rider decides ' @@ -8927,7 +8956,9 @@ const step18: MigrationStep = { + 'in the device-flow response body', replacement: 'intervalSeconds — rename the key; the value (seconds, default 2) is unchanged', reason: - 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + 'Maintainer ruling B (2026-09-02, extended on 2026-09-05 to runtime-emitted durations): ' + + 'the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying ' + + 'value, never only in the describe prose, and no existing offender is grandfathered. ' + 'This key was ATTRIBUTED to RFC 8628 by the campaign card and reached this card only after ' + 'the attribution failed verification, so the evidence is recorded here rather than left in a ' + 'PR body. Ruling B exempts a key that mirrors a name fixed outside this repo, declared on the ' @@ -8940,8 +8971,7 @@ const step18: MigrationStep = { + 'marked key is exempted permanently and silently, while a wrongly renamed one is visible. ' + 'A SEMANTIC entry rather than a D2 conversion because the shape is RUNTIME-EMITTED — the ' + 'body of POST /api/v1/auth/device/request, never a stack collection member and never a ' - + 'sys_metadata row, so the conversion chain has no seam that would see one. #15677, #14478, ' - + 'ADR-0087.', + + 'sys_metadata row, so the conversion chain has no seam that would see one. ADR-0087.', acceptanceCriteria: 'No producer emits `interval` and no consumer reads it. The old spelling is a retiredKey() ' + 'tombstone, so authoring it fails tsc (the key types never) and fails the parse with the ' @@ -9544,7 +9574,7 @@ const step18: MigrationStep = { + 'milliseconds since the Unix epoch, still Date.now(). Only the key name ' + 'and the declared schema move', reason: - 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): a ' + 'Maintainer ruling B (2026-09-05, on the population the 2026-09-02 rule reaches): a ' + 'duration-shaped z.number() carries its unit in the key NAME, minus two ' + 'structural classes declared ON THE SCHEMA rather than in a gate ledger. ' + 'Epoch instants are the first class. They read to the rule exactly like ' @@ -9569,7 +9599,7 @@ const step18: MigrationStep = { + 'disposition kernel/KernelContext:previewMode already carries on one of ' + 'these very defs, and ruling B prescribes it explicitly: an ADR-0087 ' + 'conversion where the key is authorable, a semantic entry where it is ' - + 'runtime-emitted. #15676, #14478, ADR-0087.', + + 'runtime-emitted. ADR-0087.', acceptanceCriteria: 'No producer emits the old key and no consumer reads it. All four are ' + 'tombstoned with retiredKey(), so each fails tsc at the construction ' @@ -9600,9 +9630,10 @@ const step18: MigrationStep = { + 'mechanism to declare an expiry window or a reminder interval to. `ESignatureConfig` ' + 'itself stays (`provider` / `enabled` / `signers`), unchanged', reason: - 'ADR-0049 enforce-or-remove; the 2026-09-02 ruling on #14477 held this pair on one ' + 'ADR-0049 enforce-or-remove; the 2026-09-02 ruling on the unread deadline keys held this ' + + 'pair on one ' + 'condition — "no roadmap ⇒ they retire with the other three families" — and the ' - + 'maintainer answered it on 2026-09-05 (decision batch #40, no roadmapped e-signature ' + + 'maintainer answered it on 2026-09-05 (no roadmapped e-signature ' + 'consumer), so the ruling\'s own branch resolves to retirement. Two day-shaped keys sat ' + 'on the published authorable surface (`authorable-surface/data.json`) and in the ' + 'generated reference docs — an author could write `expirationDays: 30` and reasonably ' @@ -9767,7 +9798,7 @@ const step18: MigrationStep = { + 'was validating platform event names, it parses through the enums ' + 'instead.)', reason: - 'Maintainer ruling 2026-09-01 on #13613 (director decision batch C, ' + 'Maintainer ruling 2026-09-01 (director decision batch C, ' + 'verbatim 「同意」: retire) — ADR-0049 enforce-or-remove. The schema ' + 'presented itself as the platform\'s event-name grammar while nothing ' + 'that runs consumed its three binding schemas, and the closed enums ' @@ -11847,7 +11878,8 @@ const step18: MigrationStep = { + 'declared on the object that stores the records and enforced by the LifecycleService — ' + 'not a number on this policy document', reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on #14477 (ruled A: retire per ' + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on the unread deadline keys ' + + '(ruled A: retire per ' + 'family). Six hour/minute/day-shaped keys sat on the published authorable surface and ' + 'in the generated reference docs — an author could write `triageDeadlineHours: 4` and ' + 'reasonably expect the platform to escalate after four hours — and read by NOTHING: ' @@ -11894,7 +11926,8 @@ const step18: MigrationStep = { + 'capability it re-declares fresh, through the enforce route of ADR-0049 — the engine ' + 'first, the vocabulary second', reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 on #15513 (ruled A: retire the ' + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 on the families\' remaining keys ' + + 'and defs (ruled A: retire the ' + 'three compliance-shaped families whole via RETIRED_DEFS_BY_MAJOR, the ' + 'integration/ErrorMappingConfig precedent; not roadmapped). Eight defs and roughly ' + 'forty declared keys sat on the exported surface and in the generated reference docs, ' @@ -11910,7 +11943,8 @@ const step18: MigrationStep = { + 'promise the platform never kept, with no error and no feedback. Tagging the family ' + '`[EXPERIMENTAL — not enforced]` was the fallback the ruling did not take: it is a ' + 'human-only signal, and an AI generating from the schema still writes the key and ' - + 'believes it. The #14477 deadline-key tombstones (six sites, `RETIRED_KEYS_BY_MAJOR[18]`, ' + + 'believes it. The deadline-key tombstones of the 2026-09-02 per-family ruling (six sites, ' + + '`RETIRED_KEYS_BY_MAJOR[18]`, ' + 'D3 `incident-response-deadline-keys-retired`) leave with their defs\' source; their ' + 'registry entries stay as history. Why D3 semantic and not a D2 conversion: the chain ' + 'walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto ' @@ -12618,15 +12652,17 @@ const step18: MigrationStep = { + '`timeoutMs` (default 30000) on HttpDestinationConfig, and `buffer.flushIntervalMs` ' + '(default 1000) on LoggingConfig — rename the keys; every value (milliseconds) is unchanged', reason: - 'Director-seat ruling A on #15939, 2026-09-11, carrying the maintainer\'s 「同意」 (decision ' - + 'batch #115), executing the #14478 rule per file. All four keys named milliseconds in a ' + 'Maintainer ruling A, 2026-09-11: the gate that reads a duration key\'s JSDoc lands last, ' + + 'after its offenders are fixed file by file — so this entry executes, per file, the rule ' + + 'that a duration number key carries its unit in its name. All four keys named ' + + 'milliseconds in a ' + 'source JSDoc — "Flush interval in milliseconds", "Initial retry delay in milliseconds", ' + '"Timeout in milliseconds" — and the JSDoc above a key is not what ' + '`content/docs/references/**` renders; `.describe()` is, and none of the four carried one at ' + 'all. Measured by the `check:duration-unit-keys` census on this tree before the change, all ' + 'four read `[name: -] [prose: -]`: no unit in the key and no published prose to supply it, ' + 'so `content/docs/references/system/logging.mdx` printed a bare 5000 / 1000 / 30000 / 1000 ' - + 'and nothing on the page decided milliseconds from seconds. Under the #14478 gate, moving ' + + 'and nothing on the page decided milliseconds from seconds. Under that rule\'s gate, moving ' + 'the unit into the describe alone is itself a violation (unit in prose, none in the name), ' + 'so each key is renamed and given the describe it never had in the same stroke. ' + '⚠️ `flushInterval` was declared TWICE on this file, in two different defs and with two ' @@ -12827,11 +12863,14 @@ const step18: MigrationStep = { 'key unset and let the shared datasource factory scope the default per datasource, or ' + 'compute the config value in code before it enters `defineStack`', reason: - 'The #8336 defect one surface over: a `${…}` placeholder in memory persistence config ' + + 'The unresolved-placeholder defect one surface over from the datasource connection keys, ' + + 'where it is already refused: a `${…}` placeholder in memory persistence config ' + 'is resolved by NOTHING — the driver would create and write a literal `./${DATA_DIR}/…` ' + 'path, or write under the literal placeholder-bearing localStorage key, so the dump ' + 'lands in a wrongly-named location with no error naming the unresolved placeholder ' + - '(#8495; authored under the same false belief the #8336 ruling closes). These two keys ' + + '(authored under the same false belief the 2026-08-13 ruling closes: placeholder syntax ' + + 'in connection-material keys is refused at publish, because nothing resolves it). These ' + + 'two keys ' + 'are config-material like the connection keys, so the parent adjudication applies with ' + 'its reason intact; the memory driver\'s `initialData` stays deliberately UNJUDGED — it ' + 'carries arbitrary record values, where a literal `${…}` may be legitimate data. There ' + @@ -13482,7 +13521,7 @@ const step18: MigrationStep = { + 'expression slots. So an author — very often an AI reading the generated reference page, ' + 'ADR-0033 — who wrote `successCriteria: \'p95 < 300ms\'` got a green parse and no signal, ' + 'indistinguishable from a predicate that ran and answered. ADR-0049 enforce-or-remove, ' - + 'ruled A by the maintainer on 2026-09-18 (director decision batch #160 item 3): by the ' + + 'ruled A by the maintainer on 2026-09-18: by the ' + 'standing criterion that a declared-but-unread capability is kept only when mainstream ' + 'platforms in the domain have it, application platforms do not carry SLI success criteria ' + 'or trace-sampling conditions as authorable application metadata — that lives in ' @@ -15259,7 +15298,8 @@ const step18: MigrationStep = { + 'report, export and cleanup that filters by organization, while a refusal is visible ' + 'at boot and names its flow. Under the `single` posture with the switch on, declare ' + 'NOTHING: the run carries no organization and every tenant-scoped insert beneath it ' - + 'resolves the deployment\'s one organization through the #8844 guard. Under the `group` ' + + 'resolves the deployment\'s one organization through the guard that makes a ' + + 'system-context write resolve the install\'s organization. Under the `group` ' + 'posture with the switch on, declaring is OPTIONAL and both shapes are supported: a ' + 'declared flow behaves exactly as under `isolated` (the declaration bounds SELECTION and ' + 'identity alike), while an UNDECLARED flow arms, reads group-wide — which ADR-0105 D1 ' @@ -15296,7 +15336,8 @@ const step18: MigrationStep = { + 'for, so the gate is a deployment variable read at boot and the global default is OFF. ' + '2026-09-16, reopening the `group` half of that amendment and nothing else: ' + '「group 模式是本地部署的,运行 schedule 应该是可以的,但是你没有权限,可以单独开一个决策卡」 — ' - + 'ruled A′ on #18378. The 2026-09-08 ruling was made for the MULTI-TENANT shape, and ' + + 'ruled A′ the same day: under `group` with the switch on, a flow binds without a ' + + 'declaration. The 2026-09-08 ruling was made for the MULTI-TENANT shape, and ' + '`group` is not one: ADR-0105 D1 defines it as one legal group over one database with ' + 'group-wide visibility and cross-org workflow INHERENT to the shape, so a group-level ' + 'batch job is a capability of the posture rather than the cross-organization task the ' @@ -15436,7 +15477,7 @@ const step18: MigrationStep = { + "direction: change `type` to `'text'`, which is what that field actually was, and " + 'keep the prose that asked for an id in `inlineHelpText`.', reason: - 'Maintainer ruling A′, 2026-09-13 (decision batch #130 item 1), verbatim, ' + 'Maintainer ruling A′, 2026-09-13, verbatim, ' + 'untranslated: 「同意」. ADR-0078 forbids metadata that parses, carries no marking and ' + 'does nothing — and its own worked example of that state is a `lookup` with no ' + '`reference`: the field renders a picker, the picker has no object to query, and ' @@ -15473,15 +15514,17 @@ const step18: MigrationStep = { replacement: '(removed — never implemented; delete the key from the call. It is NOT replaced by ' + '`organizationId`: that member is the delivery row\'s tenant stamp ' - + '(`sys_email.organization_id` pass-through, #11741) and opts into no template overlay ' + + '(`sys_email.organization_id` pass-through, added so the email writer stamps a ' + + 'delivery row\'s organization at the source) and opts into no template overlay ' + 'resolution)', reason: - 'ADR-0049 enforce-or-remove (#11832). `SendTemplateInput.org` was declared as "Tenant id ' + 'ADR-0049 enforce-or-remove. `SendTemplateInput.org` was declared as "Tenant id ' + 'for org-overlay resolution (when supported)" and no implementation ever read it: ' + '`@objectstack/plugin-email` — the only IEmailService implementation — resolves templates ' + 'on `(name, locale)` only, so a caller passing `org` got no org-overlay resolution and no ' - + 'error; the "(when supported)" hedge was the declaration admitting the gap. After #11741 ' - + 'landed `organizationId` beside it, the input carried two org-shaped keys of which one did ' + + 'error; the "(when supported)" hedge was the declaration admitting the gap. After the ' + + 'delivery-row stamp landed `organizationId` beside it, the input carried two org-shaped ' + + 'keys of which one did ' + 'nothing — exactly the shape that invites an AI author to pick the wrong one. There is no ' + 'behaviour to preserve and nothing stored to rewrite: the key only ever appeared in a ' + 'call-time input bag (the `data.engine.update options.upsert` precedent), which is why ' @@ -15741,7 +15784,8 @@ const step18: MigrationStep = { + 'spellings outright: it never fired, because nothing ever emitted them', reason: 'ADR-0049 enforce-or-remove applied to the error vocabulary. No producer has ever ' - + 'emitted any of the three — measured on #9266: outside the enum declaration the ' + + 'emitted any of the three — measured when a sweep of the error catalogue found these ' + + 'three entries publishing no HTTP status: outside the enum declaration the ' + 'only occurrences in the whole repo were two spec tests using them as arbitrary ' + 'fixture strings, and `git log -S` shows they never had a producer since ADR-0112 ' + 'introduced the vocabulary. A catalog member no producer can speak teaches an AI ' @@ -15751,8 +15795,8 @@ const step18: MigrationStep = { + '`driver-sql-upsert-cross-row-identity-merge-refused`) this entry is the ' + 'notification channel. No mechanical rewrite exists: a dead branch has no ' + 'correct mechanical target — the per-row codes carry strictly more information ' - + 'than the envelope code the branch expected. Maintainer ruling 2026-08-18: ' - + '「9266 同意 A」. #9266, ADR-0112, ADR-0049.', + + 'than the envelope code the branch expected. Maintainer ruling 2026-08-18: option A, ' + + 'retire all three from `StandardErrorCode`. ADR-0112, ADR-0049.', acceptanceCriteria: 'No consumer branches on the three retired spellings; batch failure handling reads ' + 'the per-row `results[].errors[].code` (`ROLLED_BACK` / `NOT_ATTEMPTED`) instead ' @@ -15775,8 +15819,8 @@ const step18: MigrationStep = { + 'concurrency limit registers a code for it in its own error-code ledger rather than reusing ' + 'the retired spelling. `QUOTA_EXCEEDED`, its catalogue neighbour, is unchanged.', reason: - 'ADR-0049 enforce-or-remove applied to the ADR-0112 error catalogue. Ruling A on #17707 ' - + '(maintainer 「同意」, decision batch #126 item 2) retired both producerless 429 members; the ' + 'ADR-0049 enforce-or-remove applied to the ADR-0112 error catalogue. Ruling A of ' + + '2026-09-13 (maintainer 「同意」) retired both producerless 429 members; the ' + 'closure-review ruling of 2026-09-24 (letter 留·收窄, maintainer 「其他同意」) narrowed it to ' + 'this code alone after `QUOTA_EXCEEDED` was found emitted by a hosted AI agent route and read ' + 'by the console chatbot plugin. The ledger doctrine in error-code-ledger.zod.ts names a ' @@ -15952,12 +15996,14 @@ const step18: MigrationStep = { + 'pause inside a region. A `try_catch` whose only purpose was to contain the region\'s ' + 'refusal has nothing left to contain and is deleted with it.', reason: - 'Maintainer ruling, decision batch #145 item 5, verbatim and untranslated: 「同意,其他也同' - + '意」, carrying the presented option C; extended by batch #146 「146 同意」, which attached ' - + 'the `end` half (the absorbed #18112) and recorded that #3267 is ruled 禁 — structured ' + 'Maintainer ruling of 2026-09-17, verbatim and untranslated: 「同意,其他也同' + + '意」, carrying the presented option C (a durable pause inside a structured region is ' + + 'refused at authoring time); extended the same day by a second ruling, which attached ' + + 'the `end` half (an `end` node inside a region body is refused as well) and ruled 禁 ' + + 'on building durable pause into structured regions — structured ' + 'regions do not support durable pause and a region body cannot terminate the run, so this ' + 'is that limit\'s authoring-time enforcement rather than an interim. The POPULATION was ' - + 'then fixed by decision batch #153 item 1, letter D (maintainer 「其他同意」): 「inside ' + + 'then fixed by the 2026-09-18 ruling, letter D (maintainer 「其他同意」): 「inside ' + '`loop` / `parallel` branch / `try_catch` (try and catch) bodies at any depth, the node ' + 'types `screen`, `wait`, `approval`, `approval_revise` and `end` are refused by ' + '`FlowSchema.superRefine` … `map` and `subflow` are ⛔ not refused by type.」 A parse-time ' @@ -16607,7 +16653,8 @@ const step18: MigrationStep = { + 'mechanical replacement that preserves a sub-day bucket, because no backend ever produced ' + 'one', reason: - 'ADR-0049 enforce-or-remove (#17296, the card #17206\'s changeset promised). The rest of the ' + 'ADR-0049 enforce-or-remove — the spec-side narrowing promised by the fix that made ' + + 'driver-memory\'s analytics face bucket by its declared granularity. The rest of the ' + 'contract never carried these three: `DateGranularity` (`data/query.zod.ts`) — the ' + 'vocabulary a `groupBy` entry and every driver\'s bucket expression are typed by — declares ' + 'five, `@objectstack/core`\'s `BUCKET_GRANULARITIES` labels the same five, and ' @@ -16642,7 +16689,8 @@ const step18: MigrationStep = { + 're-assigns training on an interval, escalates an expired certification or sends a ' + 'reminder, so there is no live mechanism to declare a duration or deadline to', reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on #14477 (ruled A: retire per ' + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on the unread deadline keys ' + + '(ruled A: retire per ' + 'family). Five minute/day-shaped keys sat on the published authorable surface and in the ' + 'generated reference docs — an author could write `validityDays: 365` and reasonably ' + 'expect a certificate to expire — and read by NOTHING: the schemas are exported from ' @@ -16683,7 +16731,8 @@ const step18: MigrationStep = { + 'it re-declares fresh, through the enforce route of ADR-0049 — the engine first, the ' + 'vocabulary second', reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 on #15513 (ruled A: retire the ' + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 on the families\' remaining keys ' + + 'and defs (ruled A: retire the ' + 'three compliance-shaped families whole via RETIRED_DEFS_BY_MAJOR, the ' + 'integration/ErrorMappingConfig precedent; not roadmapped). Five defs and roughly ' + 'twenty-five declared keys sat on the exported surface and in the generated reference ' @@ -16696,7 +16745,8 @@ const step18: MigrationStep = { + 'and `TrainingPlan.sendReminders` were boolean capability claims of exactly the shape ' + 'ADR-0049 names: an author could write them, parse clean, and get no behaviour and no ' + 'diagnostic. Tagging the family `[EXPERIMENTAL — not enforced]` was the fallback the ' - + 'ruling did not take (a human-only signal). The #14477 deadline-key tombstones (five ' + + 'ruling did not take (a human-only signal). The deadline-key tombstones of the 2026-09-02 ' + + 'per-family ruling (five ' + 'sites, `RETIRED_KEYS_BY_MAJOR[18]`, D3 `training-deadline-keys-retired`) leave with ' + 'their defs\' source; their registry entries stay as history. Why D3 semantic and not a ' + 'D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` ' @@ -16815,8 +16865,9 @@ const step18: MigrationStep = { + 'application must not do. Dropping it takes each overridden key back to the platform bundle’s ' + 'string, and each key it had filled back to the manifest literal. A mechanical notice reading ' + '"(removed)" conveys neither. The two bundles are separate namespaces from this major on ' - + '(ruling batch #132 item 2 letter ②, 2026-09-13), and the item door follows the file door ' - + '(ruling batch #210 item 2 letter B, 2026-09-22: the file door and the item door are two ' + + '(ruling of 2026-09-13, letter ②: a platform bundle schema and a per-app bundle schema, ' + + '`settings` absent from the per-app one), and the item door follows the file door ' + + '(ruling of 2026-09-22, letter B: the file door and the item door are two ' + 'authoring surfaces for ONE app metadata type, so they accept one shape; an admin override of ' + 'platform copy, if ever wanted, is a platform-level feature, not app metadata). ADR-0049 ' + 'enforce-or-remove supplied the question, not the answer — `settings` stays a LIVE platform ' @@ -16950,21 +17001,26 @@ const step18: MigrationStep = { + 'or https, or drops timeoutMs. Each refusal names the key it sits on (url, syncUrl or ' + 'timeoutMs) and prints the spellings above', reason: - '#19977. Each key parsed on its own, so the contract accepted configurations the turso driver ' - + 'refuses when it is built (VALIDATION_ERROR / 400 from the constructor, since the #19893 ' - + 'and #19976 changes) — a datasource published clean and then failed at boot or at test ' - + 'connection. One more it built and then ignored until #20200: syncUrl under a forced remote ' + 'Each key parsed on its own, so the contract accepted configurations the turso driver ' + + 'refuses when it is built (VALIDATION_ERROR / 400 from the constructor, since the fixes ' + + 'that stopped a remote url beside a syncUrl from writing to process memory and an ' + + 'unrecognised url scheme from falling through to an in-memory local engine) — a ' + + 'datasource published clean and then failed at boot or at test connection. One more it ' + + 'built and then ignored until the constructor was taught to refuse it as well: syncUrl ' + + 'under a forced remote ' + 'mode, where the remote client was created without it, no sync ever ran and the sync call ' + 'failed as not supported while the driver reported sync as enabled (measured on the built ' + 'driver). Authoring now refuses exactly the constructor\'s refused set — the same predicates, ' + 'a scheme matched in any letter case, the url read trimmed as both datasource loaders hand ' + 'it over — plus that key, refused at authoring first as the declared-but-not-enforced shape ' - + 'ADR-0049 does not ship, and by the constructor too since #20200. Nothing the constructor ' - + 'accepts is refused (at #19977 that key was the one exception; since #20200 there is none): ' + + 'ADR-0049 does not ship, and by the constructor too since that later fix. Nothing the ' + + 'constructor accepts is refused (when authoring first refused that key it was the one ' + + 'exception; since the constructor refuses it too there is none): ' + 'a forced remote mode keeps its url unjudged, as the constructor does. Stored datasource ' + 'rows are not re-parsed ' + 'when they load, so a stored row still reaches the constructor as written; the constructor ' - + 'refuses the first four shapes there already and, since #20200, also refuses syncUrl under ' + + 'refuses the first four shapes there already and, since that later fix, also refuses ' + + 'syncUrl under ' + 'a forced remote mode and sync with no syncUrl when the datasource boots. What changes here ' + 'is that creating, testing or editing ' + 'its config through the datasource admin service, defineStack or os validate is refused at ' @@ -17063,7 +17119,7 @@ const step18: MigrationStep = { + 'that spread reddened 7 of 12 cases in the consuming repo, so retiring it was measured off ' + 'the table. (2) the widget-config family rode the same spread and really was honoured by ' + 'whichever widget read it — those keys are refused now rather than forwarded, which is the ' - + 'accepted cost of closing the shape (maintainer ruling, decision batch #146 item 4, letter ' + + 'accepted cost of closing the shape (maintainer ruling, letter ' + 'A, 2026-09-17: 「Breaking for authored metadata」, one-shot, no grace window and no dual ' + 'spelling). ⛔ Do not read their rejection as "the renderer ignores them", and ⛔ do not ' + 'answer it by declaring the key on the object\'s FIELD: the bulk surface has no ' @@ -18146,8 +18202,8 @@ const step18: MigrationStep = { + 'stored boundary node is an authoring-surface repair: the native construct for error ' + 'handling is a `try_catch` region (ADR-0031).', reason: - 'Maintainer ruling, decision batch #127 item 5, verbatim and untranslated: ' - + '「16678 具体解释,计划用哪个字段判断经理。其他同意」 — carrying the presented option: the ' + 'Maintainer ruling of 2026-09-13, the clause of the reply that covers this item, verbatim ' + + 'and untranslated: 「其他同意」 — carrying the presented option: the ' + 'protocol is the source of truth; a designer never invents a default the protocol does ' + 'not apply; a default the protocol should have is declared by the protocol; a required ' + 'key has no "unset behaves as". ⛔ NOT losslessly convertible, and the reason is that the ' @@ -18191,7 +18247,9 @@ const step18: MigrationStep = { replacement: 'reconnectIntervalMs, pingIntervalMs, timeoutMs and heartbeatIntervalMs — rename ' + 'each key; every value is unchanged, and so is every default (1000, 30000, 5000, 30000)', reason: - 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + 'Maintainer ruling B (2026-09-02, extended on 2026-09-05 to runtime-emitted durations): ' + + 'the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying ' + + 'value, never only in the describe prose, and no existing offender is grandfathered. ' + 'What makes this shape worth one entry rather than four is the neighbour: on both configs a ' + 'bare duration sits directly beside a bare COUNT — maxReconnectAttempts on the client, ' + 'reconnectAttempts on the server — so `reconnectInterval: 5` and `maxReconnectAttempts: 5` ' @@ -18203,7 +18261,7 @@ const step18: MigrationStep = { + '— neither is a stack collection member and neither is ever stored as a sys_metadata row, so ' + 'the conversion chain has no seam that would see one. The same disposition the ' + 'epoch-instant renames on this file took (epoch-instant-keys-renamed), and what ruling B ' - + 'prescribes for a key that is not authorable metadata. #15677, #14478, ADR-0087.', + + 'prescribes for a key that is not authorable metadata. ADR-0087.', acceptanceCriteria: 'Every WebSocketConfigSchema.parse(…) / WebSocketServerConfigSchema.parse(…) site and every ' + 'literal handed to a WebSocket client or server spells the suffixed keys; authoring any old ' From 96b994e472ba988aa9fae42e3b3c1fde2437e591 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:39:30 +0000 Subject: [PATCH 3/3] test(cli): the migrate-meta guidance pin holds every semantic entry; patch changeset With the remaining families rewritten, no semantic entry's printed guidance carries a tracker id, so the pin covers the whole directory instead of a prefix list: an entry added later, in any family, is held on arrival. REWRITTEN gains the 44 entries this stage rewrote (203 -> 247). Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- ...stage-9-migration-guidance-tracker-free.md | 26 ++++ .../test/migrate-meta-engine-guidance.test.ts | 116 ++++++++++-------- 2 files changed, 90 insertions(+), 52 deletions(-) create mode 100644 .changeset/20233-stage-9-migration-guidance-tracker-free.md diff --git a/.changeset/20233-stage-9-migration-guidance-tracker-free.md b/.changeset/20233-stage-9-migration-guidance-tracker-free.md new file mode 100644 index 00000000000..4ba8d21d292 --- /dev/null +++ b/.changeset/20233-stage-9-migration-guidance-tracker-free.md @@ -0,0 +1,26 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): `os migrate meta` guidance for the remaining migration-entry families states each lesson in words instead of citing tracker numbers + +Clause-②: no + +The ADR-0087 semantic entries are printed by `os migrate meta` as the header, `why:` and +`verify:` lines of a manual change. In the families not yet brought to this line — among them +`turso-*`, `auth-*`, `admin-*`, `ai-*`, `assembled-*`, `change-*`, `device-*`, `epoch-*`, +`incident-*`, `logging-*`, `memory-*`, `send-*`, `standard-*`, `training-*`, `websocket-*`, +`structured-*` and `translation-*` — that text sent the reader to issue-tracker, pull-request, +decision-batch and cross-repository numbers, some of which no longer resolve, for what a +ruling, measurement or fix had decided; it now says what was decided, in the sentence being +read. Verbatim rulings that carried a card or batch number keep only their operative words. +ADR ids are kept, and so are the rule numbers of this repository's own contributor guide. With +this change no semantic entry's printed guidance carries a `#`-numbered tracker id. + +One replacement also named a contributor-guide rule by a number that no longer exists: +`address-location-value-unknown-keys-refused` now states the rule itself — a consumer never +carries an alias for an off-spec key; the metadata is fixed where it is written. + +Text only: no entry id, `surface`, `from` / `to`, conversion or matching logic changes, and the +chain rewrites exactly what it rewrote before. The generated migration registry, +`spec-changes.json` and the protocol upgrade guide carry the same text. diff --git a/packages/cli/test/migrate-meta-engine-guidance.test.ts b/packages/cli/test/migrate-meta-engine-guidance.test.ts index af734bc40c0..b5de16e3d45 100644 --- a/packages/cli/test/migrate-meta-engine-guidance.test.ts +++ b/packages/cli/test/migrate-meta-engine-guidance.test.ts @@ -1,20 +1,8 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * `os migrate meta` — the guidance it prints for the ADR-0087 semantic entries - * of the COVERED families (`engine-*`, `ui-*`, `plugin-*`, `driver-*`, - * `kernel-*`, `system-*`, `datasource-*`, `filter-*`, `action-*`, `data-*`, - * `element-*`, `field-*`, `export-*`, `api-*`, `dataset-*`, `hook-*`, - * `metadata-*`, `rest-*`, `analytics-*`, `view-*`, `package-*`, `object-*`, - * `sharing-*`, `audit-*`, `flow-*`, `http-*`, `inline-*`, `actor-*`, `hot-*`, - * `external-*`, `query-*`, `delete-*`, `etl-*`, `storage-*`, `apimethod-*`, - * `dashboard-*`, `notification-*`, `record-*`, `runtime-*`, `rls-*`, `scim-*`, - * `stack-*`, `evaluated-*`, `aggregation-*`, `authoring-*`, `automation-*`, - * `cache-*`, `tenant-*`, `client-*`, `spec-*`, `cli-*`, `identity-*`, - * `import-*`, `tool-*`, `advanced-*`, `cloud-*`, `startup-*`, `sys-*`, - * `declarative-*`, `sort-*`, `address-*`, `packages-*`, `platform-*`, - * `session-*`, `strategy-*`) states each lesson in words and carries no - * tracker number. + * `os migrate meta` — the guidance it prints for EVERY ADR-0087 semantic entry + * states each lesson in words and carries no tracker number. * * ## What this pins * @@ -24,27 +12,27 @@ * author is shown, so it carries no tracker number: a number sends the reader * to a page that can be deleted (some cited pages already had been), and the * lesson the entry exists to teach then sits behind a dead link instead of in - * the sentence being read. The covered families were rewritten, one staged - * family at a time, to say what each cited ruling, measurement or fix decided; - * ADR ids stay, because an ADR lives in this repository. The whole printed + * the sentence being read. The entries were rewritten, one staged family at a + * time, to say what each cited ruling, measurement or fix decided; ADR ids + * stay, because an ADR lives in this repository. The whole printed * block is held, so `surface` is held as well as the three prose fields. * * The chain reports every semantic entry of every hop it crosses, whatever the * stack authors, so the fixture only has to be a real stack the command loads; * it keeps the lookup and the virtual `formula` field the `engine-*` entries * are about. The CLI replays the chain from the support floor to the highest - * major carrying a covered entry. Each covered block is then located VERBATIM - * in what the terminal printed, and that printed block must hold no `#` - * followed by four or five digits. The file keeps the name it was given when - * `engine-*` was the only covered family. + * major carrying a semantic entry. Each block is then located VERBATIM in what + * the terminal printed, and that printed block must hold no `#` followed by + * four or five digits. The file keeps the name it was given when `engine-*` + * was the only covered family; the staged rewrites have since reached every + * family, so the pin holds the whole directory rather than a prefix list. * * ## Why it cannot pass by reading nothing * - * - The covered set is derived from the registry by id prefix, so an entry - * added later to a covered family is held to the same line on arrival — and - * the derived set must still contain every entry the rewrites covered, and - * every covered prefix must still select at least one entry, so an emptied - * prefix cannot turn every assertion below into a loop over nothing. + * - The covered set is every semantic entry in the registry, so an entry added + * later — in any family, a new family included — is held to the same line on + * arrival; and the set must still contain every entry the rewrites covered, + * so it cannot turn every assertion below into a loop over nothing. * - Each block is asserted PRESENT in stdout before it is asserted clean, so a * renderer change that stopped printing the prose fails here instead of * passing on an absent string. @@ -82,26 +70,10 @@ const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); /** A tracker id as author-shown prose must not carry it: `#` and four or five digits. */ const TRACKER_ID = /#\d{4,5}\b/; -/** The families this pin holds, selected by entry-id prefix. */ -const COVERED_PREFIXES = [ - 'engine-', 'ui-', 'plugin-', 'driver-', 'kernel-', 'system-', - 'datasource-', 'filter-', 'action-', 'data-', 'element-', - 'field-', 'export-', 'api-', 'dataset-', 'hook-', 'metadata-', - 'rest-', 'analytics-', 'view-', 'package-', 'object-', 'sharing-', - 'audit-', 'flow-', 'http-', 'inline-', - 'actor-', 'hot-', 'external-', 'query-', 'delete-', 'etl-', 'storage-', - 'apimethod-', 'dashboard-', 'notification-', 'record-', 'runtime-', 'rls-', - 'scim-', - 'stack-', 'evaluated-', 'aggregation-', 'authoring-', 'automation-', 'cache-', - 'tenant-', 'client-', 'spec-', 'cli-', 'identity-', 'import-', 'tool-', - 'advanced-', 'cloud-', 'startup-', 'sys-', 'declarative-', 'sort-', 'address-', - 'packages-', 'platform-', 'session-', 'strategy-', -]; - /** * The entries rewritten when each family was brought to this line — the - * anti-vacuity floor. A covered entry that carried no tracker id to begin with - * is held by its prefix and needs no row here. + * anti-vacuity floor. An entry that carried no tracker id to begin with needs no + * row here: the whole directory is held. */ const REWRITTEN = [ 'action-bulk-dispatch-contract-undeclared', @@ -111,8 +83,11 @@ const REWRITTEN = [ 'action-session-roles-to-positions', 'actor-user-roles-to-positions', 'address-location-value-unknown-keys-refused', + 'admin-export-wildcard-removed', + 'admin-scope-business-unit-blank-refused', 'advanced-plugin-lifecycle-config-retired', 'aggregation-node-distinct-retired', + 'ai-conversation-analytics-duration-unit-in-key', 'analytics-authorable-unknown-keys-refused', 'analytics-date-range-array-two-bounds-required', 'analytics-query-request-envelope-retired', @@ -122,16 +97,29 @@ const REWRITTEN = [ 'api-runtime-config-durations-unit-in-key', 'api-runtime-create-withdrawn', 'apimethod-enum-shrink', + 'approval-escalation-enabled-default-flip', + 'assembled-package-body-plugins-envelope', + 'audience-posture-default-invite-only', 'audit-log-action-enum-retired', 'audit-log-action-restore-retired', + 'auth-config-unadvertised-reserved-features', 'authoring-schemas-strict-unknown-keys', 'automation-flow-list-route-retired', 'automation-runs-cursor-retired', + 'autonumber-default-unique-organization', + 'batch-row-result-schema-shape', + 'branded-identifier-schemas-retired', 'cache-warmup-scheduled-strategy-retired', + 'cbp-master-detail-required-forced', + 'cel-predicate-one-value-comparand-refused', + 'change-management-duration-keys-retired', + 'change-management-family-retired', 'cli-command-contribution-retired', 'client-delete-result-success', 'client-meta-reset-result-reset', 'cloud-subpath-retired', + 'cluster-driver-dangling-values-removed', + 'connector-inline-authentication-publish-refused', 'dashboard-header-modal-target-page-only', 'dashboard-widget-chart-config-structure-refused', 'dashboard-widget-compareto-offset', @@ -156,6 +144,7 @@ const REWRITTEN = [ 'datasource-credentialsref-mongo-url-no-user-refused', 'declarative-apis-endpoints-live', 'delete-by-id-before-hook-repoint-retired', + 'device-request-response-interval-unit-in-key', 'driver-aggregate-undeclared-key-aliases-removed', 'driver-capabilities-inert-bits-removed', 'driver-options-timeout-to-timeout-ms', @@ -171,8 +160,12 @@ const REWRITTEN = [ 'engine-find-formula-filter-refused', 'engine-find-formula-order-by-refused', 'engine-update-upsert-retired', + 'enhanced-api-error-field-errors-renamed', + 'epoch-instant-keys-renamed', + 'esignature-config-deadline-keys-retired', 'etl-pipeline-layer-retired', 'evaluated-expression-slots-source-required', + 'event-name-schema-retired', 'export-axis-opt-in', 'export-field-meta-constraints-retired', 'export-job-family-retired', @@ -209,7 +202,10 @@ const REWRITTEN = [ 'http-server-runtime-vocabulary-retired', 'identity-api-key-schema-retired', 'import-run-automations-declared-default-corrected', + 'incident-response-deadline-keys-retired', + 'incident-response-family-retired', 'inline-grid-column-currency-scale-refused', + 'job-retry-policy-constraints-tightened', 'kernel-compatibility-matrix-estimated-migration-time-unit-in-key', 'kernel-context-preview-mode-retired', 'kernel-event-bus-retention-unit-in-key', @@ -219,6 +215,8 @@ const REWRITTEN = [ 'kernel-plugin-security-durations-unit-in-key', 'kernel-runtime-config-timeout-unit-in-key', 'kernel-startup-orchestrator-durations-unit-in-key', + 'logging-durations-unit-in-key', + 'memory-persistence-placeholder-refused', 'metadata-customization-protocol-retired', 'metadata-endpoints-switch-radius-repartitioned', 'metadata-manager-config-cache-ttl-unit-in-key', @@ -229,6 +227,7 @@ const REWRITTEN = [ 'object-grid-data-view-data-converged', 'object-grid-default-filters-rule-array', 'object-index-unknown-keys-refused', + 'observability-cel-predicates-retired', 'package-api-contracts-unmounted-entries-retired', 'package-install-request-unknown-keys-refused', 'package-rollback-response-retired', @@ -245,6 +244,7 @@ const REWRITTEN = [ 'plugin-runtime-family-retired', 'plugin-security-scan-result-surface-retired', 'plugin-security-scanner-retired', + 'position-permissions-column-retired', 'query-array-string-agg-retired', 'query-cursor-retired', 'query-distinct-retired', @@ -261,7 +261,10 @@ const REWRITTEN = [ 'rls-predicate-cross-class-field-comparison-refused', 'rls-predicate-stored-list-ordering-refused', 'runtime-httpserver-wrapper-retired', + 'schedule-flow-acting-organization-required', 'scim-provider-object-retired', + 'screen-field-lookup-reference-required', + 'send-template-input-org-retired', 'session-payload-positions-security-axis', 'session-user-language-retired', 'sharing-execution-context-retired', @@ -270,9 +273,12 @@ const REWRITTEN = [ 'spec-type-alias-input-suffix-retired', 'stack-themes-carrier-retired', 'stack-top-level-unknown-keys-refused', + 'standard-error-code-batch-members-retired', + 'standard-error-code-concurrent-limit-exceeded-retired', 'startup-orchestrator-retired', 'storage-service-list-retired', 'strategy-context-aggregation-method-narrowed', + 'structured-region-body-pause-and-end-refused', 'sys-account-issuer-retired', 'system-cache-durations-unit-in-key', 'system-collaboration-durations-unit-in-key', @@ -286,7 +292,13 @@ const REWRITTEN = [ 'system-worker-queue-rate-limit-duration-unit-in-key', 'tenant-schema-cache-ttl-unit-in-key', 'tenant-timeouts-unit-in-key', + 'time-update-interval-sub-day-retired', 'tool-requires-confirmation-retired', + 'training-deadline-keys-retired', + 'training-family-retired', + 'translation-per-app-settings-platform-only', + 'turso-config-transport-mismatch-refused', + 'ui-bulk-action-param-unknown-keys-refused', 'ui-cloud-connection-widgets-unknown-keys-refused', 'ui-form-field-length-malformed-refused', 'ui-form-field-precision-scale-integer-refused', @@ -301,12 +313,16 @@ const REWRITTEN = [ 'ui-record-blocks-unknown-keys-refused', 'ui-reference-rail-unknown-keys-refused', 'ui-widget-i18n-family-retired', + 'ups-delegated-from-column-retired', 'view-filter-rule-absent-value-refused', 'view-filter-rule-scalar-operator-array-refused', 'view-filter-rule-value-shaped-by-operator', 'view-management-protocol-retired', 'view-overlay-options-bag-judged', 'view-pagination-page-size-default-50', + 'wait-node-event-config-required', + 'websocket-durations-unit-in-key', + 'workflow-service-slot-retired', ]; interface FamilyEntry { @@ -318,10 +334,9 @@ interface FamilyEntry { acceptanceCriteria: string; } +/** Every semantic entry of every major: the pin holds the whole directory. */ const FAMILY: FamilyEntry[] = Object.entries(MIGRATIONS_BY_MAJOR).flatMap(([major, step]) => - step.semantic - .filter((s) => COVERED_PREFIXES.some((prefix) => s.id.startsWith(prefix))) - .map((s) => ({ ...s, toMajor: Number(major) })), + step.semantic.map((s) => ({ ...s, toMajor: Number(major) })), ); /** The block the command prints for one semantic TODO, exactly as `meta.ts` lays it out. */ @@ -378,7 +393,7 @@ afterAll(() => { try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); -describe('os migrate meta — the guidance of the covered families carries no tracker number', () => { +describe('os migrate meta — the guidance of every semantic entry carries no tracker number', () => { it('the detector fires on a tracker id and stays dark on every other number shape', () => { expect(TRACKER_ID.test(`see #${'9'.repeat(4)}`)).toBe(true); expect(TRACKER_ID.test(`see #${'9'.repeat(5)}`)).toBe(true); @@ -387,11 +402,8 @@ describe('os migrate meta — the guidance of the covered families carries no tr expect(TRACKER_ID.test('ADR-0112')).toBe(false); }); - it('selects every covered family, including every entry the rewrites covered', () => { + it('holds every semantic entry, including every entry the rewrites covered', () => { const ids = FAMILY.map((e) => e.id); - for (const prefix of COVERED_PREFIXES) { - expect(ids.some((id) => id.startsWith(prefix)), `no entry selected for ${prefix}`).toBe(true); - } for (const id of REWRITTEN) expect(ids, `family lost ${id}`).toContain(id); });