From ec7bb507a58efd5fe3ddf9bce01e06926ed0b8aa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 23:45:06 +0000 Subject: [PATCH 1/3] fix(spec): stage-6 migration guidance states each lesson in words, not tracker numbers The reason / replacement / acceptanceCriteria text (and one surface) of the rest-, analytics-, view-, package-, object-, sharing-, audit-, flow- and http- ADR-0087 semantic entries, plus the two carry-overs from stage 5 (api-error-retry-after-unit-in-key names its 2026-09-05 population ruling; inline-grid-column-currency-scale-refused drops the ruling-record ids and batch numbers its field sibling already dropped). Text only. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- ...nalytics-query-request-envelope-retired.ts | 4 +++- .../17.audit-log-action-enum-retired.ts | 7 +++--- .../17.audit-log-action-restore-retired.ts | 16 +++++++------ .../17.flow-retry-max-retries-required.ts | 2 +- .../17.http-request-errors-total-retired.ts | 9 +++---- ....http-server-runtime-vocabulary-retired.ts | 12 ++++++---- ....package-uninstall-explicit-all-tenants.ts | 7 +++--- .../17.rest-server-openapi31-block-removed.ts | 5 ++-- .../17.sharing-execution-context-retired.ts | 20 +++++++++------- .../17.sharing-rule-recipient-reconcile.ts | 8 ++++--- ...ew-filter-rule-value-shaped-by-operator.ts | 10 ++++---- .../17.view-management-protocol-retired.ts | 7 +++--- ...alytics-authorable-unknown-keys-refused.ts | 10 +++++--- ...cs-date-range-array-two-bounds-required.ts | 23 ++++++++++-------- ...-dimension-date-range-vocabulary-closed.ts | 10 ++++---- .../18.api-error-retry-after-unit-in-key.ts | 3 ++- ...cision-branch-expression-absent-refused.ts | 2 +- ...low-decision-edge-branching-first-match.ts | 3 ++- ...ondition-evaluated-slot-source-required.ts | 12 ++++++---- ...low-predicate-slot-blank-string-refused.ts | 9 +++---- ...line-grid-column-currency-scale-refused.ts | 6 ++--- .../18.object-block-sort-item-array.ts | 12 ++++++---- ...18.object-grid-data-view-data-converged.ts | 19 ++++++++------- ....object-grid-default-filters-rule-array.ts | 6 +++-- .../18.object-index-unknown-keys-refused.ts | 9 ++++--- ...api-contracts-unmounted-entries-retired.ts | 8 +++---- ...ge-install-request-unknown-keys-refused.ts | 5 ++-- .../18.package-rollback-response-retired.ts | 17 +++++++------ ...est-api-endpoint-handler-status-retired.ts | 24 +++++++++++-------- ...8.rest-api-plugin-durations-unit-in-key.ts | 4 ++-- ...18.rest-server-config-dead-keys-retired.ts | 10 ++++---- ...8.view-filter-rule-absent-value-refused.ts | 3 ++- ...lter-rule-scalar-operator-array-refused.ts | 10 ++++---- .../18.view-overlay-options-bag-judged.ts | 2 +- ...18.view-pagination-page-size-default-50.ts | 7 +++--- 35 files changed, 189 insertions(+), 132 deletions(-) diff --git a/packages/spec/src/migrations/entries/semantic/17.analytics-query-request-envelope-retired.ts b/packages/spec/src/migrations/entries/semantic/17.analytics-query-request-envelope-retired.ts index b4eb7c1fe59..2b7cd6a578a 100644 --- a/packages/spec/src/migrations/entries/semantic/17.analytics-query-request-envelope-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.analytics-query-request-envelope-retired.ts @@ -8,7 +8,9 @@ export const entry: SemanticMigration = { replacement: 'bare AnalyticsQuery body (top-level cube/measures/dimensions/where/...)', reason: 'The { cube, query: {...} } envelope was an HTTP-wire dialect of the retired degraded ' + - 'analytics shim (#3891), never stored in stack metadata — there is no source for the ' + + 'analytics shim (the fallback that answered /analytics/query when no analytics service ' + + 'was installed, and dropped the caller\'s identity and its `where` filter at the door), ' + + 'never stored in stack metadata — there is no source for the ' + 'chain to rewrite. Callers of POST /analytics/query and /analytics/sql must move the ' + 'query.* fields to the body top level themselves.', acceptanceCriteria: diff --git a/packages/spec/src/migrations/entries/semantic/17.audit-log-action-enum-retired.ts b/packages/spec/src/migrations/entries/semantic/17.audit-log-action-enum-retired.ts index 61a86fa6ae0..2444f9fe212 100644 --- a/packages/spec/src/migrations/entries/semantic/17.audit-log-action-enum-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.audit-log-action-enum-retired.ts @@ -23,8 +23,9 @@ export const entry: SemanticMigration = { + 'on every deployment, and still is — what changed is that the contract no longer ' + 'promises otherwise', reason: - 'Maintainer ruling 2026-08-12 (#7675), the retirement half of a two-half verdict: the ' - + 'cheap writers get built (#8144 login/logout, #8145 config_change) and the enum ' + '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 ' @@ -54,7 +55,7 @@ export const entry: SemanticMigration = { + '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, #8147.', + + 'genuinely destructive reading of this change. ADR-0049 / ADR-0087.', acceptanceCriteria: 'No consumer filters `sys_audit_log` on `action = "export"` or ' + '`action = "permission_change"` expecting rows: both were empty everywhere before ' diff --git a/packages/spec/src/migrations/entries/semantic/17.audit-log-action-restore-retired.ts b/packages/spec/src/migrations/entries/semantic/17.audit-log-action-restore-retired.ts index 1e5f5e768d2..b4e5902acee 100644 --- a/packages/spec/src/migrations/entries/semantic/17.audit-log-action-restore-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.audit-log-action-restore-retired.ts @@ -24,10 +24,11 @@ export const entry: SemanticMigration = { + '`sys_audit_log` on this value was reading an empty result set on every deployment, ' + 'and still is — what changed is that the contract no longer promises otherwise. If ' + 'you were counting on a restore trail, the capability itself is the missing piece ' - + '(#1883, #3146), not this enum row', + + '(an undelete / purge permission lifecycle and a soft-delete recycle bin, neither built ' + + 'yet), not this enum row', reason: 'The same maintainer ruling as `audit-log-action-enum-retired`, carried to the one ' - + "value #7675's own survey did not name (#8315, triage 2026-08-13). 原则记录:空 " + + "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 " @@ -37,7 +38,8 @@ export const entry: SemanticMigration = { + '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 (#8011) — a ' + + '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) ' @@ -50,16 +52,16 @@ export const entry: SemanticMigration = { + '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 (#1883 pm:on-hold, #3146 ' - + 'status:parked). If that capability lands, this value returns WITH its writer — the ' + + '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, #8315, ' - + '#7675, #8147.', + + 'the one genuinely destructive reading of this change. ADR-0049 / ADR-0087.', acceptanceCriteria: '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. ' diff --git a/packages/spec/src/migrations/entries/semantic/17.flow-retry-max-retries-required.ts b/packages/spec/src/migrations/entries/semantic/17.flow-retry-max-retries-required.ts index 37228b4a2c9..ccccb64271b 100644 --- a/packages/spec/src/migrations/entries/semantic/17.flow-retry-max-retries-required.ts +++ b/packages/spec/src/migrations/entries/semantic/17.flow-retry-max-retries-required.ts @@ -19,7 +19,7 @@ export const entry: SemanticMigration = { reason: 'maxRetries had two defaults — FlowSchema `.default(0)` and the engine\'s ' + '`maxRetries ?? 3` — so an unstated count retried 0 times through the schema and 3 ' + - 'times through a hand-built definition (#4247). With the engine\'s copy removed the ' + + 'times through a hand-built definition. With the engine\'s copy removed the ' + 'unstated count is unambiguously 0, and retrying zero times is exactly ' + "`strategy: 'fail'`, so the schema now refuses the combination instead of it silently " + 'doing nothing. There is no lossless rewrite: 0 preserves the behaviour a parsed flow ' + diff --git a/packages/spec/src/migrations/entries/semantic/17.http-request-errors-total-retired.ts b/packages/spec/src/migrations/entries/semantic/17.http-request-errors-total-retired.ts index 06b4b0e88ac..25e185f655b 100644 --- a/packages/spec/src/migrations/entries/semantic/17.http-request-errors-total-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.http-request-errors-total-retired.ts @@ -21,8 +21,9 @@ export const entry: SemanticMigration = { + '`@objectstack/runtime`\'s `instrumentRouteHandler`, applied only by the dispatcher\'s ' + 'own route Proxy — so the series never saw auth\'s `getRawApp()` mount, the REST data ' + 'API via `RouteManager`, or any other inbound surface. Its two siblings in the same ' - + 'family were moved to the transport seam (#9650/#9835 for the counter, #9834/#10004 ' - + 'for the histogram) and this one could not follow: `HttpResponseObservation` carries ' + + 'family were moved to the transport seam (the request counter, then the latency ' + + 'histogram, both through the response-observing hook the transport was given) and this ' + + 'one could not follow: `HttpResponseObservation` carries ' + '`{method, routePattern, status, elapsedMs}` and NO throw signal of any kind, so every ' + 'transport-side shape would have counted a DIFFERENT population rather than the same ' + 'one more widely. The divergence was measured in both directions — the dispatcher ' @@ -38,8 +39,8 @@ export const entry: SemanticMigration = { + 'the series in its own dashboard or alert file, outside this repo. That is exactly why ' + 'this entry exists: for an operator whose Grafana keys on the string, the ledger is the ' + 'only notification channel there is. Same disposition, and the same reason, as ' - + '`runtime-httpserver-wrapper-retired` (#5122) and `enhanced-api-error-field-errors-renamed` ' - + '(#3977). ADR-0049 / ADR-0087, #9834.', + + '`runtime-httpserver-wrapper-retired` and `enhanced-api-error-field-errors-renamed`. ' + + 'ADR-0049 / ADR-0087.', acceptanceCriteria: 'No dashboard, alert rule or exporter config names `http_request_errors_total`: the ' + 'series stops receiving samples the moment 17.2.0 is deployed, so a panel keyed on it ' diff --git a/packages/spec/src/migrations/entries/semantic/17.http-server-runtime-vocabulary-retired.ts b/packages/spec/src/migrations/entries/semantic/17.http-server-runtime-vocabulary-retired.ts index b195cc7cdfa..84c5736db4f 100644 --- a/packages/spec/src/migrations/entries/semantic/17.http-server-runtime-vocabulary-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.http-server-runtime-vocabulary-retired.ts @@ -18,8 +18,8 @@ export const entry: SemanticMigration = { + 'record can only disagree with them. Server-level configuration that IS authorable ' + 'lives on `defineStack({ server })` / `StackServerConfigSchema`, which is unaffected)', reason: - 'The second and final ADR-0049 pass over `system/http-server.zod.ts`. #4938 removed the ' - + 'CONFIG half (`HttpServerConfigSchema`, nine keys, zero readers, zero authoring ' + 'The second and final ADR-0049 pass over `system/http-server.zod.ts`. The first removed ' + + 'the CONFIG half (`HttpServerConfigSchema`, nine keys, zero readers, zero authoring ' + 'entry); this removes the RUNTIME half — a 7-member lifecycle event union with a ' + 'timestamped envelope, an eight-boolean capability report, and a five-state status ' + 'record with connection and request counters. Nothing ever emitted, consumed or ' @@ -39,10 +39,12 @@ export const entry: SemanticMigration = { + 'this file when there was one. ' + 'With no carrier key there is nothing to tombstone, and with no author there is no ' + 'source or `sys_metadata` row for a D2 conversion to rewrite: RETIRED_DEFS_BY_MAJOR ' - + 'plus this entry are the declaration — route 3, the same shape as #4938 in this very ' - + 'file, #4834, #4988 and #5055. If host-implementer conformance becomes a real ' + + 'plus this entry are the declaration — route 3, the same shape as the config half\'s ' + + 'removal in this very file and the earlier removals of the dynamic plugin-loading family, ' + + 'the `ui/` interaction configs and the widget / i18n shapes. If host-implementer ' + + 'conformance becomes a real ' + 'requirement it returns through the ENFORCE route: an adapter contract with a checker ' - + 'behind it, vocabulary second. ADR-0049, #5295.', + + 'behind it, vocabulary second. ADR-0049.', acceptanceCriteria: 'No source imports `ServerEvent`, `ServerEventType`, `ServerEventSchema`, ' + '`ServerCapabilities`, `ServerCapabilitiesSchema`, `ServerCapabilitiesParsed`, ' diff --git a/packages/spec/src/migrations/entries/semantic/17.package-uninstall-explicit-all-tenants.ts b/packages/spec/src/migrations/entries/semantic/17.package-uninstall-explicit-all-tenants.ts index f1c65d66bd4..6cd25d22a99 100644 --- a/packages/spec/src/migrations/entries/semantic/17.package-uninstall-explicit-all-tenants.ts +++ b/packages/spec/src/migrations/entries/semantic/17.package-uninstall-explicit-all-tenants.ts @@ -8,7 +8,8 @@ export const entry: SemanticMigration = { replacement: 'explicit `allTenants: true` for a cross-tenant uninstall, or an `organizationId` to scope it', reason: 'An uninstall that named no organization matched EVERY organization\'s rows — measured ' - + 'at 5 of 5 deleted, including a foreign org\'s (#7705, #7780). That width was never ' + + 'at 5 of 5 deleted, including a foreign org\'s, while uninstall\'s orphaned-row defect was ' + + 'being repaired. That width was never ' + 'chosen; it fell out of a missing argument, and the two transports of the same route ' + 'disagreed because of it. In protocol 17 the call is REFUSED instead: neither ' + '`organizationId` nor `allTenants: true` answers 400 `TENANT_SCOPE_REQUIRED` and ' @@ -19,7 +20,7 @@ export const entry: SemanticMigration = { + 'at the call site — which is the whole reason the parameter had to become explicit ' + 'rather than conventional. Nothing in authored metadata spells this: it is a runtime ' + 'call-site contract, so it is one semantic TODO for operators and API callers rather ' - + 'than a stack conversion — the same disposition `rest-requireauth-default-flip` (#12) ' + + 'than a stack conversion — the same disposition `rest-requireauth-default-flip` (protocol 12) ' + 'takes for its own default flip.', acceptanceCriteria: 'Every caller of `deletePackage` states its tenant scope. A caller that intends an ' @@ -31,5 +32,5 @@ export const entry: SemanticMigration = { + 'that script was relying on the cross-tenant reading and must now say so on purpose. ' + 'The org-scoped path is unchanged — an uninstall carrying an `organizationId` still ' + 'removes that org\'s rows AND the environment-wide (`organization_id IS NULL`) rows, ' - + 'exactly as #7705 left it.', + + 'exactly as the orphaned-row repair left it.', }; diff --git a/packages/spec/src/migrations/entries/semantic/17.rest-server-openapi31-block-removed.ts b/packages/spec/src/migrations/entries/semantic/17.rest-server-openapi31-block-removed.ts index ba72a8c65da..0f2e4e04ac1 100644 --- a/packages/spec/src/migrations/entries/semantic/17.rest-server-openapi31-block-removed.ts +++ b/packages/spec/src/migrations/entries/semantic/17.rest-server-openapi31-block-removed.ts @@ -18,13 +18,14 @@ export const entry: SemanticMigration = { + 'only `api`/`crud`/`metadata`/`batch`/`routes`, and the served /openapi.json is the ' + 'pre-generated @objectstack/spec contract enriched with the live server URL and the ' + 'registered objects — a webhook declared here never appeared in any served document ' - + '(ADR-0049; the #3197 connector-webhook shape one layer up). There is no behaviour ' + + '(ADR-0049; the declared-but-unconsumed shape an earlier audit found in the connector ' + + 'webhook and event enums one layer up). There is no behaviour ' + 'to preserve and nothing stored to rewrite: `RestServerConfig` is plugin TS ' + 'configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), never ' + "a `sys_metadata` shape — the stack tree's `api` block declares only its four " + 'scoping/auth knobs. The three schemas are removed with the key (zero import-level ' + 'consumers in objectstack / cloud / objectui); the key itself is tombstoned because ' - + 'the schema is not `.strict()` and a plain delete would strip it silently. #4579.', + + 'the schema is not `.strict()` and a plain delete would strip it silently.', acceptanceCriteria: 'No `RestServerConfig` value passed to the REST plugin (or `plugin-hono-server` ' + '`restConfig`) carries `openApi31` — a config that includes it now fails the parse ' diff --git a/packages/spec/src/migrations/entries/semantic/17.sharing-execution-context-retired.ts b/packages/spec/src/migrations/entries/semantic/17.sharing-execution-context-retired.ts index 4807dd330a5..7e3f7c207ba 100644 --- a/packages/spec/src/migrations/entries/semantic/17.sharing-execution-context-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.sharing-execution-context-retired.ts @@ -12,25 +12,29 @@ export const entry: SemanticMigration = { + '`isSystem`) that sharing, approval and report enforcement signatures used to name', replacement: '`ExecutionContext` from `@objectstack/spec` — the complete ' - + '`resolveAuthzContext` envelope the contracts have declared since #6523. Every one ' + + '`resolveAuthzContext` envelope the sharing, approval and report contracts have declared ' + + 'since they converged onto it. Every one ' + 'of the retired type\'s six fields exists on it under the same name and type, so a ' + 'value that satisfied the old type already satisfies the envelope: only the ' + 'annotation is rewritten, never the value', reason: - 'ADR-0049 enforce-or-remove, completing the #6206 ruling (2026-08-07: enforcement ' - + 'adjudicates on the WHOLE envelope, never a per-site subset). This type was the ' + 'ADR-0049 enforce-or-remove, completing the maintainer\'s ruling of 2026-08-07 on the ' + + 'share-link context (enforcement adjudicates on the WHOLE envelope, never a per-site ' + + 'subset). This type was the ' + 'declared context parameter of 36 signatures across three contracts — ' + '`ISharingService` / `ISharingRuleService`, `IApprovalService`, `IReportService` — ' + 'and it omitted four fields those gates need: `accessible_org_ids` (under the ' + '`group` tenancy posture this IS the Layer 0 wall, ADR-0105 D2), `org_user_ids`, ' + '`posture` (ADR-0095 D2) and `tabPermissions`. Its damage ran in the MIRROR ' - + 'direction of the share-link twin (#6430 / PR #6511): nothing trimmed the VALUES — ' + + 'direction of the share-link twin, which that same ruling moved onto the whole context: ' + + 'nothing trimmed the VALUES — ' + "the engine middleware always handed the whole context down — it was the declared " + 'TYPE that was narrow, so an implementation could not READ what it had been given ' + 'without casting out of its own contract (`const posture = (context as any).posture` ' - + "in plugin-approvals' privileged-override gate). #6523 / PR #7068 converged the " - + 'contracts, PR #7140 and PR #7206 re-annotated the four implementations, and this ' - + 'card removes the now-unreferenced declaration (#7070, #7218). ' + + "in plugin-approvals' privileged-override gate). One change converged the " + + 'contracts, two more re-annotated the four implementations (sharing and audit, then ' + + 'approvals and reports), and this change removes the now-unreferenced declaration, the ' + + 'deletion that split had deferred. ' + 'Why this needs a ledger entry despite nothing in-repo referencing it: it is the ' + '`export-field-meta-constraints-retired` / `hook-context-session-roles-retired` ' + 'disposition — a PUBLISHED TypeScript surface with no spec schema, so there is no ' @@ -39,7 +43,7 @@ export const entry: SemanticMigration = { + 'Why D3 semantic and not a D2 conversion: nothing authored or stored changes shape. ' + 'The name is only ever spelled inside a consumer\'s own TypeScript, so no ' + '`objectstack migrate meta` transform can reach it, and no `sys_metadata` row ' - + 'carries it. ADR-0049 / ADR-0087, #7218.', + + 'carries it. ADR-0049 / ADR-0087.', acceptanceCriteria: 'No source of yours imports `SharingExecutionContext` from `@objectstack/spec` or ' + '`@objectstack/plugin-sharing`; each such import becomes `ExecutionContext` from ' diff --git a/packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts b/packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts index 7ede3147ae7..9256726b679 100644 --- a/packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts +++ b/packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts @@ -33,9 +33,11 @@ export const entry: SemanticMigration = { + 'runtime-reserved and deliberately non-authorable (there is no `sys_queue` yet). Note ' + 'the two neighbouring conversions cover DIFFERENT faces of this schema and not this ' + 'one: `sharing-recipient-role-to-position` is the ADR-0090 role → position rename and ' - + '`sharing-rule-access-level-full-to-edit` is the access-level vocabulary. Registered by ' - + 'the #6350 stock reconciliation. ADR-0078 / ADR-0090 D3 / ADR-0087, #1878 (backfilled ' - + '#6350).', + + '`sharing-rule-access-level-full-to-edit` is the access-level vocabulary. The change came ' + + 'out of the metadata property liveness audit, which found security properties parsed but ' + + 'never enforced, and was registered late, by the stock reconciliation that compared the ' + + 'breaking changesets already on the v17 release train against this ledger. ADR-0078 / ' + + 'ADR-0090 D3 / ADR-0087.', acceptanceCriteria: 'No sharing rule names `group` or `guest`, and none carries `type: owner`; stale ' + 'definitions now FAIL parse with the valid options listed, so the sweep is "fix until ' diff --git a/packages/spec/src/migrations/entries/semantic/17.view-filter-rule-value-shaped-by-operator.ts b/packages/spec/src/migrations/entries/semantic/17.view-filter-rule-value-shaped-by-operator.ts index f6a06f69cbf..f0fc12c7bcc 100644 --- a/packages/spec/src/migrations/entries/semantic/17.view-filter-rule-value-shaped-by-operator.ts +++ b/packages/spec/src/migrations/entries/semantic/17.view-filter-rule-value-shaped-by-operator.ts @@ -20,15 +20,17 @@ export const entry: SemanticMigration = { + 'scalar operator carrying an array, a string operator carrying a number, and a unary ' + 'operator carrying an ignored value all still parse', reason: - 'A publish-time gate catching up to a query-time one, not a new rule. #5869 / PR ' - + '#6209 closed the RUNTIME half: `assertListComparandShapes` ' + '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 #5685 already ruled on the opposite error: a schema stricter than ' + + '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 ' @@ -58,7 +60,7 @@ export const entry: SemanticMigration = { + '`operator: "in", value: ""` is an UNFINISHED row, not a filter — decide what it was ' + 'meant to select rather than mechanically rewriting it to [""], which is a real and ' + 'different predicate. And a view that already carried one of these shapes was never ' - + 'returning filtered rows: it answered 400 INVALID_FILTER on render (#5869), so ' + + 'returning filtered rows: it answered 400 INVALID_FILTER on render, so ' + 're-check what the view is supposed to show rather than assuming the old result set ' + 'was correct.', }; diff --git a/packages/spec/src/migrations/entries/semantic/17.view-management-protocol-retired.ts b/packages/spec/src/migrations/entries/semantic/17.view-management-protocol-retired.ts index df3d4c5f953..9a1f52e6a04 100644 --- a/packages/spec/src/migrations/entries/semantic/17.view-management-protocol-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.view-management-protocol-retired.ts @@ -32,7 +32,8 @@ export const entry: SemanticMigration = { + '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: ' - + '#5948\'s issue body AND its 2026-08-07 maintainer ruling both read ' + + '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 ' @@ -42,7 +43,7 @@ export const entry: SemanticMigration = { + '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, #6239.', + + '2026-08-07.', acceptanceCriteria: 'No source imports `ListViewsRequest(Schema)`, `ListViewsResponse(Schema)`, ' + '`GetViewRequest(Schema)`, `GetViewResponse(Schema)`, `CreateViewRequest(Schema)`, ' @@ -53,5 +54,5 @@ export const entry: SemanticMigration = { + '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 #5948 meant.', + + 'still resolve — they are the shapes that ruling meant.', }; diff --git a/packages/spec/src/migrations/entries/semantic/18.analytics-authorable-unknown-keys-refused.ts b/packages/spec/src/migrations/entries/semantic/18.analytics-authorable-unknown-keys-refused.ts index 6f617596d02..5d63b473a69 100644 --- a/packages/spec/src/migrations/entries/semantic/18.analytics-authorable-unknown-keys-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.analytics-authorable-unknown-keys-refused.ts @@ -18,16 +18,20 @@ export const entry: SemanticMigration = { + 'query gets the `where` prescription). A key that names no supported capability is simply ' + 'removed', reason: - 'The #4001 strictness campaign\'s data/ batch D. These shapes parsed `.strip` — an ' + 'The unknown-key strictness campaign (the sweep that ended silent stripping of undeclared ' + + 'keys as the default, one schema family at a time), its data/ batch. These shapes parsed ' + + '`.strip` — an ' + 'undeclared key on an authored cube was silently dropped, so a join authored with a ' + 'typo\'d `relationship` registered with the `many_to_one` default (a different join than ' + 'the author declared) and a metric\'s misspelled key vanished under a successful parse. ' - + 'The subtle half: `/analytics/query`\'s top level has been strict since #3878, but ' + + 'The subtle half: `/analytics/query`\'s top level has been strict since the degraded ' + + 'shim\'s envelope dialect was retired (one URL, one request body), but ' + 'top-level strictness does not recurse — `timeDimensions: [{ dimension, granuarity: ' + '\'day\' }]` rode through the strict wrapper with the typo stripped, bucketing the whole ' + 'range as one group under an ordinary 200. Undeclared keys on all eight sites are now ' + 'refused at parse time with a prescriptive message. (One of the eight — the nested metric ' - + '`filters[]` item — was itself removed later in this major: #10414, `metric-filters-removed`.)', + + '`filters[]` item — was itself removed later in this major, because nothing ever read it: ' + + '`metric-filters-removed`.)', acceptanceCriteria: 'Every cube in `defineStack({ analyticsCubes })` / `defineCube` parses with only declared ' + 'keys at every level (cube, refreshKey, measures, dimensions, joins); ' diff --git a/packages/spec/src/migrations/entries/semantic/18.analytics-date-range-array-two-bounds-required.ts b/packages/spec/src/migrations/entries/semantic/18.analytics-date-range-array-two-bounds-required.ts index 9b9c51dd1d2..3c73362f379 100644 --- a/packages/spec/src/migrations/entries/semantic/18.analytics-date-range-array-two-bounds-required.ts +++ b/packages/spec/src/migrations/entries/semantic/18.analytics-date-range-array-two-bounds-required.ts @@ -16,8 +16,9 @@ export const entry: SemanticMigration = { replacement: 'exactly two string bounds — `[start, end]`. A ONE-ELEMENT window is that day written as ' + 'BOTH bounds: `[\'2026-01-01\']` becomes `[\'2026-01-01\', \'2026-01-01\']`, the shape ' - + 'the shipped #16322 migration table already prescribes for a single day, and the shape ' - + 'all four analytics faces have selected that one day with since PR #17593. ⛔ The EMPTY ' + + 'the shipped migration table for the closed preset vocabulary already prescribes for a ' + + 'single day, and the shape all four analytics faces have selected that one day with since ' + + 'the fix that made them read the array arm one way. ⛔ The EMPTY ' + 'array and THREE-OR-MORE bounds have NO replacement that can be derived from what was ' + 'written: an empty array names no window at all, and a 3+ array names no pair — decide ' + 'the window the widget was meant to show and write its two bounds, or drop the ' @@ -25,17 +26,19 @@ export const entry: SemanticMigration = { + 'time-bounded). A relative window is a preset name from the closed vocabulary ' + '(`\'last_7_days\'`) or a date-macro pair (`[\'{7_days_ago}\', \'{today}\']`).', reason: - 'Maintainer ruling A on #17598 (decision batch #117 item 3, 2026-09-12, re-affirmed ' - + '2026-09-13): the array arm was a bare `z.array(z.string())` with NO length constraint, ' + 'Maintainer ruling A of 2026-09-12, re-affirmed 2026-09-13, which tightened the array arm ' + + 'to exactly two string bounds: the arm was a bare `z.array(z.string())` with NO length ' + + 'constraint, ' + 'while the refusal sentence in the same source file said verbatim that "an explicit ' - + 'window is the two-element array [start, end]" and #16322\'s shipped migration table ' - + 'told an author to write a single day as `[\'2026-01-20\', \'2026-01-20\']`. So only the ' - + 'TYPE was weaker than the prose beside it, and #17124 measured what that bought: one ' + + 'window is the two-element array [start, end]" and the shipped migration table for the ' + + 'closed preset vocabulary told an author to write a single day as ' + + '`[\'2026-01-20\', \'2026-01-20\']`. So only the TYPE was weaker than the prose beside it, ' + + 'and a measurement of one authored document on each face found what that bought: one ' + 'authored `[\'2026-01-01\']` meant a point window on ObjectQLStrategy, NO time clause at ' + 'all on NativeSQLStrategy (the whole of history), an unbounded-above window in the ' + 'draft-preview evaluator, and a shifted point window in DatasetExecutor.runCompare — the ' - + 'same document, four backends, four different numbers, no error on any of them. PR ' - + '#17593 made all four faces refuse it with the ADR-0112 envelope `400 ' + + 'same document, four backends, four different numbers, no error on any of them. The fix ' + + 'that followed made all four faces refuse it with the ADR-0112 envelope `400 ' + 'ANALYTICS_DATE_RANGE_UNRECOGNIZED`, which left the contract door LOOSER than every ' + 'reader behind it; this narrowing closes that gap at the door. ⚠️ No D2 conversion and ' + 'no stored-metadata rewrite, deliberately: rewriting `[\'2026-01-01\']` to the same day ' @@ -43,7 +46,7 @@ export const entry: SemanticMigration = { + 'rather than a window whose end they forgot — and for the empty array and 3+ bounds ' + 'there is nothing to decide FROM. The blast radius is the WIDGET, not the page: a stored ' + 'dashboard carrying a now-refused range loses that widget with the accurate refusal ' - + 'shown, and the dashboard still loads. Since PR #17593 every such stored range already ' + + 'shown, and the dashboard still loads. Since that fix every such stored range already ' + 'failed at QUERY time with the same code and status, so this adds no new class of ' + 'breakage — it moves the refusal to authoring time and states it accurately. ' + 'ADR-0049 / ADR-0087 / ADR-0112.', diff --git a/packages/spec/src/migrations/entries/semantic/18.analytics-time-dimension-date-range-vocabulary-closed.ts b/packages/spec/src/migrations/entries/semantic/18.analytics-time-dimension-date-range-vocabulary-closed.ts index 716a1018966..fbc88395db2 100644 --- a/packages/spec/src/migrations/entries/semantic/18.analytics-time-dimension-date-range-vocabulary-closed.ts +++ b/packages/spec/src/migrations/entries/semantic/18.analytics-time-dimension-date-range-vocabulary-closed.ts @@ -24,9 +24,10 @@ export const entry: SemanticMigration = { + '\'2026-01-20\']` for the single day a bare ISO string used to mean on SQL, ' + '`[\'2026-01-01\', \'2026-01-31\']`, or `[\'{7_days_ago}\', \'{today}\']` in date-macro tokens', reason: - 'Maintainer ruling on #16041 (decision batch #57, option A — contract first, 2026-09-06): ' - + 'the protocol is the baseline, so the vocabulary is declared once in the schema and the ' - + 'drivers align to it (#16322) instead of each guessing. The arm was a bare `z.string()` ' + 'Maintainer ruling of 2026-09-06 on the analytics date-range string (option A — contract ' + + 'first): the protocol is the baseline, so the vocabulary is declared once in the schema and ' + + 'the drivers align to it, in a driver change of their own, instead of each guessing. The ' + + 'arm was a bare `z.string()` ' + 'whose only documented example, `"Last 7 days"`, no driver could parse: driver-memory ' + 'recognised exactly `today` and a case-sensitive `last N ` and fell every other ' + 'string through to a `[range, range]` pseudo-window that — measured through mingo on ' @@ -35,7 +36,8 @@ export const entry: SemanticMigration = { + 'as a single ISO day. A dashboard asking for one week silently got all of history on one ' + 'backend and one day on the other, with no error on either. The string arm is now ' + '`z.enum(DATE_RANGE_PRESETS)` — derived from `data/date-range-presets.ts`, the vocabulary\'s ' - + 'single source of truth since #4614, so the two cannot drift — and any other string is ' + + 'single source of truth since the dashboard date filter\'s three copies of the list were ' + + 'folded into it, so the two cannot drift — and any other string is ' + 'refused at parse time with one prescriptive issue at the field\'s own path; the runtime ' + 'door answers the ADR-0112 envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` ' + '(`api/error-code-ledger.zod.ts`). ⚠️ No D2 conversion and no stored-metadata rewrite: ' diff --git a/packages/spec/src/migrations/entries/semantic/18.api-error-retry-after-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.api-error-retry-after-unit-in-key.ts index c0734654311..efd1d90654a 100644 --- a/packages/spec/src/migrations/entries/semantic/18.api-error-retry-after-unit-in-key.ts +++ b/packages/spec/src/migrations/entries/semantic/18.api-error-retry-after-unit-in-key.ts @@ -8,7 +8,8 @@ export const entry: SemanticMigration = { replacement: 'retryAfterSeconds — rename the key; the value (seconds) is unchanged', reason: 'Maintainer ruling B of 2026-09-02 on duration-shaped keys: the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' - + 'BREAKING ON THE WIRE, and ruled in deliberately: the ruling puts the ~16 runtime-emitted ' + + 'BREAKING ON THE WIRE, and ruled in deliberately: its 2026-09-05 population ruling puts the ' + + '~16 runtime-emitted ' + 'measurements in scope because they are read by humans and agents even if nobody authors ' + 'them, and names ApiError.retryAfter explicitly, with its own BREAKING note. The ambiguity ' + 'here is sharper than the usual bare duration. A consumer meets TWO retry-after values on ' diff --git a/packages/spec/src/migrations/entries/semantic/18.flow-decision-branch-expression-absent-refused.ts b/packages/spec/src/migrations/entries/semantic/18.flow-decision-branch-expression-absent-refused.ts index d2e7ee68f64..b2c10d2fe30 100644 --- a/packages/spec/src/migrations/entries/semantic/18.flow-decision-branch-expression-absent-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.flow-decision-branch-expression-absent-refused.ts @@ -31,7 +31,7 @@ export const entry: SemanticMigration = { + 'with no `conditions` the node routes by its out-edges alone, so the out-edge that branch ' + 'labelled is no longer held back', reason: - 'Card #19961. `DecisionConditionSchema` declares a branch `{ label, expression }` with ' + '`DecisionConditionSchema` declares a branch `{ label, expression }` with ' + '`expression` a required `z.string()`, but nothing parses a decision node\'s open config ' + 'against it, and the expression-ledger resolver skipped an absent value as "not authored" — ' + 'so a branch with no predicate passed `FlowSchema.parse`, `AutomationEngine.registerFlow` and ' diff --git a/packages/spec/src/migrations/entries/semantic/18.flow-decision-edge-branching-first-match.ts b/packages/spec/src/migrations/entries/semantic/18.flow-decision-edge-branching-first-match.ts index fe97c792c52..2496cdc6cc4 100644 --- a/packages/spec/src/migrations/entries/semantic/18.flow-decision-edge-branching-first-match.ts +++ b/packages/spec/src/migrations/entries/semantic/18.flow-decision-edge-branching-first-match.ts @@ -39,7 +39,8 @@ export const entry: SemanticMigration = { 'A DEFAULT FLIP of a shipped node type, ruled rather than patched: the schema, the docs ' + 'and the engine\'s own comment all called an edge-branched decision an exclusive gateway ' + 'while the traversal took EVERY out-edge whose condition held, one after another, and ' - + 'reported nothing — hotcrm#1555 rendered a refusal screen AND ran the conversion in one ' + + 'reported nothing — a CRM application\'s lead-conversion flow rendered a refusal screen AND ' + + 'ran the conversion in one ' + 'execution. The traversal now matches the declaration (BPMN exclusive gateway, ' + 'Salesforce Flow Decision, n8n Switch default), and the every-true-edge behaviour is the ' + 'BPMN inclusive gateway an author must write down. The KEY converts mechanically and ' diff --git a/packages/spec/src/migrations/entries/semantic/18.flow-edge-condition-evaluated-slot-source-required.ts b/packages/spec/src/migrations/entries/semantic/18.flow-edge-condition-evaluated-slot-source-required.ts index b8c1cf8c886..b5b5d70cd9f 100644 --- a/packages/spec/src/migrations/entries/semantic/18.flow-edge-condition-evaluated-slot-source-required.ts +++ b/packages/spec/src/migrations/entries/semantic/18.flow-edge-condition-evaluated-slot-source-required.ts @@ -13,7 +13,7 @@ export const entry: SemanticMigration = { + 'authored either as an expression envelope carrying only ast ({ dialect: \'cel\', ast: … } ' + 'with no source), or with a source that is blank after trimming, through the envelope key ' + '({ dialect: \'cel\', source: \' \' }) or the bare-string shorthand for it ' - + '(condition: \' \'). The node slot joined this entry with #17322 and #17495, which rebound ' + + '(condition: \' \'). The node slot joined this entry with the two later changes that rebound ' + 'AutomationEngine.registerFlow and objectstack validate to the edge door\'s own rule rather ' + 'than deriving a second one; it is the same decision reaching the second slot, which is why ' + 'it is named here instead of in an entry of its own. Reachable wherever a flow is authored ' @@ -29,14 +29,18 @@ export const entry: SemanticMigration = { + 'edge rather than preserving it. An `ast` BESIDE a string `source` is untouched and stays ' + 'admitted everywhere', reason: - 'Card #15807 (the #15430 / #15662 lineage): `FlowEdgeSchema.condition` now composes ' + 'The evaluated-slot rule, carried to the edge condition — the line that first refused an ' + + '`ast`-only envelope no engine can evaluate, and refused a non-string node predicate at ' + + 'registration instead of letting the evaluator answer it a silent `false`: ' + + '`FlowEdgeSchema.condition` now composes ' + '`EvaluatedExpressionInputSchema` instead of `ExpressionInputSchema`, so an evaluated slot ' + 'is held to what the engine can actually run. The engine reads `source` alone ' + '(`cel-engine.ts` `evaluate`: "AST-only evaluation not yet supported; persist `source`"), ' + 'so both refused spellings landed in its empty-source arm and answered a SILENT `false` on ' + 'every release that carried them — they parsed, registered, passed `objectstack validate`, ' - + 'and then produced a branch that quietly never fired (measured on #15430, comment ' - + '5550509137). The refusal is one rule with one sentence, ' + + 'and then produced a branch that quietly never fired (measured on 2026-09-05 by driving an ' + + '`ast`-only envelope through `AutomationEngine.evaluateCondition` directly). The refusal is ' + + 'one rule with one sentence, ' + '`EVALUATED_EXPRESSION_SOURCE_REQUIRED`. ' + '⚠️ No D2 conversion is possible, and this is exactly why the change needs a D3 entry ' + 'rather than none. An `ast`-only envelope carries no `source` to derive one from — ' diff --git a/packages/spec/src/migrations/entries/semantic/18.flow-predicate-slot-blank-string-refused.ts b/packages/spec/src/migrations/entries/semantic/18.flow-predicate-slot-blank-string-refused.ts index 3a64d169305..509546525d5 100644 --- a/packages/spec/src/migrations/entries/semantic/18.flow-predicate-slot-blank-string-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.flow-predicate-slot-blank-string-refused.ts @@ -35,15 +35,16 @@ export const entry: SemanticMigration = { + '`condition` turns a never-firing edge into an always-firing one ' + '(`flow-edge-condition-evaluated-slot-source-required`)', reason: - 'Card #17493, ruling A (5651023407). Both slots are declared bare CEL text (`z.string()`) ' + 'Maintainer ruling A of 2026-09-13: the two sibling predicate slots refuse a blank string ' + + 'at authoring. Both slots are declared bare CEL text (`z.string()`) ' + 'and both admitted a blank string at every door: the expression ledger resolver skipped it ' + 'as "not authored", and `AutomationEngine.evaluateCondition` answered it `false` — so a ' + 'decision branch carrying it was never taken, with nothing said at any layer, and a screen ' - + 'field carrying it was shown with its predicate ignored. #15572 had pinned that ' + + 'field carrying it was shown with its predicate ignored. An earlier fix had pinned that ' + 'admission as correct because the two sides agreed. The ruling is that self-consistency ' + 'between parser and evaluator is not a defence when the author\'s intent is silently ' - + 'dropped — the third instance of one rule, after #17322 (the structural `config.condition`) ' - + 'and #15811 (a blank evaluated `source`). The blank is now refused at `FlowSchema.parse`, ' + + 'dropped — the third instance of one rule, after the structural `config.condition` and a ' + + 'blank evaluated `source`. The blank is now refused at `FlowSchema.parse`, ' + 'at `AutomationEngine.registerFlow` (which parses first) and at `objectstack validate`, all ' + 'three through `predicateSlotRefusal`, leading with `PREDICATE_SLOT_STRING_REFUSAL`. ' + '⚠️ No D2 conversion, and the reason is the judgment this entry delegates: the blank is ' diff --git a/packages/spec/src/migrations/entries/semantic/18.inline-grid-column-currency-scale-refused.ts b/packages/spec/src/migrations/entries/semantic/18.inline-grid-column-currency-scale-refused.ts index 3df12281d19..00fc695370b 100644 --- a/packages/spec/src/migrations/entries/semantic/18.inline-grid-column-currency-scale-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.inline-grid-column-currency-scale-refused.ts @@ -13,9 +13,9 @@ export const entry: SemanticMigration = { + 'computed amount is rounded to. ⛔ Nothing replaces the key: do not re-declare its value under ' + 'any other key.', reason: - 'Maintainer ruling 5791803339 (batch #215 item 1, letter B) retired `scale` from the ' - + '`currency` field type, and ruling 5805782503 (batch #218 item 2, letter 乙 — a currency\'s ' - + 'ISO 4217 minor unit decides its display) worded the remedy. Neither reached the inline grid ' + 'The maintainer\'s ruling of 2026-09-23 (option B) retired `scale` from the `currency` field ' + + 'type, and the ruling of 2026-09-24 (option 乙 — a currency\'s ISO 4217 minor unit decides its ' + + 'display) worded the remedy. Neither reached the inline grid ' + 'column, the strict mirror of the console grid\'s column, which still offered per-column ' + 'decimals on a `currency` column; triage read the column as inherited from both rulings, so ' + '`InlineGridColumnSchema` now refuses the key on a column declaring `type: \'currency\'` at ' diff --git a/packages/spec/src/migrations/entries/semantic/18.object-block-sort-item-array.ts b/packages/spec/src/migrations/entries/semantic/18.object-block-sort-item-array.ts index ec773be04e4..7b9b82f2ecf 100644 --- a/packages/spec/src/migrations/entries/semantic/18.object-block-sort-item-array.ts +++ b/packages/spec/src/migrations/entries/semantic/18.object-block-sort-item-array.ts @@ -23,14 +23,16 @@ export const entry: SemanticMigration = { + '`object-grid.defaultSort` is a different key, retired separately by the ' + '`ui__ObjectGridProps__defaultSort` entry.', reason: - 'One `sort` spelling platform-wide, the array (objectui#8221, decision batch #77, ' - + '2026-09-07, maintainer verbatim 「其他同意」, option B; the consumer half is ' - + 'objectui PR #8758, which drops the string arm from `convertSortToQueryParams`). ' - + 'Item 4 of that ruling is this entry\'s subject: 「`ComponentPropsMap` for ' + 'One `sort` spelling platform-wide, the array: the maintainer\'s ruling of 2026-09-07 ' + + '(option B) retired the legacy string `sort` clause, and its consumer half is the objectui ' + + 'change that drops the string arm from `convertSortToQueryParams`. One item of that ' + + 'ruling is this entry\'s subject: 「`ComponentPropsMap` for ' + '`object-calendar` and `object-grid` constrains the `sort` value to the array shape ' + '(today it accepts anything), so the spec, the registrations and the helper agree; ' + 'that is a pull-back to the declared contract, ordinary tier」. The `z.unknown()` at ' - + 'both doors was a read-point record (#7751), the same vintage as the `filter` doors ' + + 'both doors was a read-point record from the change that brought the `object-*` blocks ' + + 'into `ComponentPropsMap` (the maintainer\'s ruling of 2026-08-12), the same vintage as ' + + 'the `filter` doors ' + 'the `element-data-source-and-object-block-filter-rule-array` entry moved, and not an ' + 'exception to the ruling: measured on `@objectstack/spec` 17.2.0 an array, a string ' + 'and a bare NUMBER all returned `success: true` while `bogusProp` was refused by name ' diff --git a/packages/spec/src/migrations/entries/semantic/18.object-grid-data-view-data-converged.ts b/packages/spec/src/migrations/entries/semantic/18.object-grid-data-view-data-converged.ts index cedd05e8ca9..7bc0425e7dc 100644 --- a/packages/spec/src/migrations/entries/semantic/18.object-grid-data-view-data-converged.ts +++ b/packages/spec/src/migrations/entries/semantic/18.object-grid-data-view-data-converged.ts @@ -15,20 +15,21 @@ export const entry: SemanticMigration = { + '`ViewDataSchema` semantics; `staticData` (the deprecated bare-array shortcut the ' + 'renderer still reads) keeps its shape but is not the prescription', reason: - 'Two entries of one contract disagreed on the KIND (objectui#6207, contract-vs-' - + "contract): `ComponentPropsMap['object-grid'].data` said bare array ('Static inline " + 'Two entries of one contract disagreed on the KIND (contract-vs-contract, found by ' + + "objectui's declared-arm parity gate): `ComponentPropsMap['object-grid'].data` said bare " + + "array ('Static inline " + "rows — bypasses the object query') while `ViewDataSchema` — the authority " - + 'objectui#5090 ruled the registry declaration against, pinned by ' + + 'objectui aligned the grid\'s registry declaration to, pinned by ' + '`gridDataInputContract.test.ts`, and what `ObjectGridSchema.data` resolves to — is ' + "an object discriminated on `provider`. Measured on @objectstack/spec@17.2.0: " + "`{ provider: 'value', items: [] }` — the pinned-legal form — was REFUSED by the " + 'props-map entry (`expected array, received object`) while the bare array parsed. ' + 'Whichever authority a value satisfied, the other refused it, and the objectui ' + 'parity gate had to carry the reasoned exemption `object-grid.data:object` to look ' - + 'away. The maintainer ruling (2026-08-25, batch adjudication batch 4; verbatim: ' - + '「同意」, Option A) converged the props-map entry onto `ViewDataSchema`; the ' - + 'bare-array form is the deprecated `staticData` shortcut the objectui#4648 ' - + 'carve-out already refuses to publish. The ruled migration check ran with the ' + + 'away. The maintainer\'s ruling of 2026-08-25 (option A) converged the props-map entry ' + + 'onto `ViewDataSchema`; the bare-array form is the deprecated `staticData` shortcut that ' + + 'objectui\'s deprecated-alias carve-out already refuses to publish as authoring surface. ' + + 'The ruled migration check ran with the ' + 'change: the sweep of generated artifacts, templates and first-party corpora ' + '(examples/, skills/, create-objectstack, spec fixtures) found ZERO bare-array ' + '`data` authors, so no rewrite ships — this entry carries the prescription for ' @@ -40,6 +41,6 @@ export const entry: SemanticMigration = { + "`data: [...]` writes `data: { provider: 'value', items: [...] }` — same rows, " + 'one wrapping object. Downstream (objectui, after a released spec version reaches ' + 'the pin): the `object-grid.data:object` exemption entry in ' - + '`registry-inputs-spec-parity.test.ts` becomes deletable, which is what closes ' - + 'objectui#6207.', + + '`registry-inputs-spec-parity.test.ts` becomes deletable, which is what closes the ' + + 'objectui finding that the two authorities disagreed.', }; diff --git a/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-rule-array.ts b/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-rule-array.ts index 65d272dc522..5093e992e62 100644 --- a/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-rule-array.ts +++ b/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-rule-array.ts @@ -29,8 +29,10 @@ export const entry: SemanticMigration = { + 'is read only when filter is absent, and its own description has prescribed filter all ' + 'along', reason: - '#19514, out of objectui#9050 ruling C-prime (maintainer 2026-09-20, verbatim, ' - + 'untranslated): 「the differences are the protocol\'s to close」. This is the SAME value ' + 'The protocol half of the maintainer\'s ruling C-prime of 2026-09-20 on objectui\'s ' + + 'render-time filter converter — the protocol is the only refusal set, so a document it ' + + 'accepts never throws at render time — verbatim, untranslated: 「the differences are the ' + + 'protocol\'s to close」. This is the SAME value ' + 'in the SAME role as filter — the key\'s own description says it is read only when ' + 'filter is absent — and the consumer reads it through the SAME lowering sink, so every ' + 'refusal that sink can give was reachable from a document the protocol had just ' diff --git a/packages/spec/src/migrations/entries/semantic/18.object-index-unknown-keys-refused.ts b/packages/spec/src/migrations/entries/semantic/18.object-index-unknown-keys-refused.ts index 426fac3f752..6bfc1a09e32 100644 --- a/packages/spec/src/migrations/entries/semantic/18.object-index-unknown-keys-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.object-index-unknown-keys-refused.ts @@ -7,14 +7,17 @@ export const entry: SemanticMigration = { surface: 'object `indexes[]` entries (`IndexSchema`) — undeclared keys', replacement: 'the declared surface: `name` / `fields` / `unique` (ADR-0120 scope). A key that ' + 'names no declared capability is simply removed. `where` — the console fallback editor\'s ' - + 'drifted spelling for a partial-index predicate, removed from the editor by objectui#4772 — ' + + 'drifted spelling for a partial-index predicate, removed when objectui converged that editor ' + + 'onto `IndexSchema` — ' + 'gets a curated prescription: partial indexes are built at the database layer ' + '(`CREATE [UNIQUE] INDEX … WHERE` from a runtime migration), never declared here', reason: - 'The #4001 strictness campaign\'s 批 20 held site 14 open on a measured #5114-class risk: ' + 'The unknown-key strictness campaign held this site open on a measured risk, the kind that ' + + 'had already made a console save answer 422 (a strict schema refusing a key the console ' + + 'itself writes): ' + 'objectui\'s embedded index editor shipped a drifted hand-copied schema offering `where` ' + 'and `brin`, spliced its output into `object.indexes[]` and PUT the whole object, so ' - + 'closing the shape would have 422\'d a control the console itself rendered. objectui#4772 ' + + 'closing the shape would have 422\'d a control the console itself rendered. objectui then ' + 'converged that editor to the declared surface, spending the hold\'s evidence. Before this ' + 'close an undeclared key on an index parsed clean and was silently dropped — an admin ' + 'filling the old "Partial-index predicate" control got a green save while no driver ever ' diff --git a/packages/spec/src/migrations/entries/semantic/18.package-api-contracts-unmounted-entries-retired.ts b/packages/spec/src/migrations/entries/semantic/18.package-api-contracts-unmounted-entries-retired.ts index c1af2b0ec8a..0e2a2502f32 100644 --- a/packages/spec/src/migrations/entries/semantic/18.package-api-contracts-unmounted-entries-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.package-api-contracts-unmounted-entries-retired.ts @@ -24,14 +24,14 @@ export const entry: SemanticMigration = { + 'platform later serves a package upgrade, dependency-resolution or upload route, its entry ' + 'arrives in the same change that mounts it.', reason: - 'Maintainer ruling 2026-09-23 on #19116 (director seat, decision batch #217 item 4, letter A, ' - + '「217 同意」). The contract map is the declaration SDKs, codegen and AI clients are entitled to ' + 'Maintainer ruling of 2026-09-23 (option A: retire the three contract-map entries that name paths ' + + 'nothing mounts). The contract map is the declaration SDKs, codegen and AI clients are entitled to ' + 'trust, and three of its seven entries named paths the composed runtime mounts nowhere: the ' + 'package dispatcher has no branch for a single-segment POST under /packages and ' + '`@objectstack/rest` mounts only /packages/publish there, so all three answered handled=false ' + 'while the four surviving entries answer 200/201 (measured on one HttpDispatcher over a real ' - + 'SchemaRegistry, #18604) — and the generated reference page printed ' - + 'all three as live endpoints. Unlike `installPackage` (#18058, rebound onto the serving ' + + 'SchemaRegistry) — and the generated reference page printed ' + + 'all three as live endpoints. Unlike `installPackage` (rebound by an earlier fix onto the serving ' + 'POST /api/v1/packages), no serving door existed to rebind them onto, and mounting three ' + 'capabilities with zero measured pull was ruled out (ADR-0049 enforce-or-remove). Zero ' + 'consumers measured at the retiring PR\'s base: across this repository the three paths occur ' diff --git a/packages/spec/src/migrations/entries/semantic/18.package-install-request-unknown-keys-refused.ts b/packages/spec/src/migrations/entries/semantic/18.package-install-request-unknown-keys-refused.ts index 294ffdc7064..8cebf03ac67 100644 --- a/packages/spec/src/migrations/entries/semantic/18.package-install-request-unknown-keys-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.package-install-request-unknown-keys-refused.ts @@ -27,8 +27,9 @@ export const entry: SemanticMigration = { + 'removed. The bare form (a manifest as the whole body) is unchanged: it was already ' + 'closed, and it still carries no install options.', reason: - 'One rule for the whole install contract (decision batch #227 item 3, letter A; ruling ' - + 'record 5856869656). The manifest and the bare form already refused an unknown key by ' + 'One rule for the whole install contract (the maintainer\'s ruling of 2026-09-27, option A: ' + + 'the wrapped form refuses an unknown top-level key by name). The manifest and the bare form ' + + 'already refused an unknown key by ' + 'name; the wrapped top level was the one position still declared strip mode, so ' + '`{ manifest, enabledOnInstall: false }` — a misspelled `enableOnInstall` — parsed green ' + 'with the key DROPPED, and the install door, which answers exactly what this declaration ' diff --git a/packages/spec/src/migrations/entries/semantic/18.package-rollback-response-retired.ts b/packages/spec/src/migrations/entries/semantic/18.package-rollback-response-retired.ts index a1db24c253c..01bbdb17f6c 100644 --- a/packages/spec/src/migrations/entries/semantic/18.package-rollback-response-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.package-rollback-response-retired.ts @@ -23,18 +23,21 @@ export const entry: SemanticMigration = { + '`PackageRollbackRequestSchema` stays published (ruled out of the ' + 'retirement), bound to no route.', reason: - 'Maintainer ruling 2026-08-27 on #12038, sub-question 3A (五问一批, ' - + '「其他接受」). The schema declared a version rollback — ' + 'Maintainer ruling of 2026-08-27 on the client SDK\'s unbound response ' + + 'contracts, sub-question 3A: retire this false declaration first, then ' + + 'author the true one. The schema declared a version rollback — ' + '`{ success, restoredVersion?, message? }`, matching its file header ' + '"Rollback a package" — while the live path it was contract-bound to ' + 'serves the ADR-0067 commit rollback: a different operation with a ' + 'different result. Binding it in the SDK would compile and be false ' - + '(#11925 left a compile-time guard against exactly that substitution). ' + + '(the change that typed the SDK\'s un-annotated return values left a ' + + 'compile-time guard against exactly that substitution). ' + 'Zero consumers measured across objectstack, objectui and cloud ' - + '(#12038 survey §5.2, re-verified at the retiring PR\'s base): only its ' - + 'own unit test and the #11925 negative guard. A published declaration ' - + 'that outran the implementation is the #3877 hazard realised in the ' - + 'opposite direction — not "no declaration" but a WRONG one — and it is ' + + '(the ruling\'s own survey, re-verified at the retiring PR\'s base): only its ' + + 'own unit test and that negative guard. A published declaration that ' + + 'outran the implementation is the hazard of response bodies never checked ' + + 'against the schemas that declare them, realised in the opposite direction ' + + '— not "no declaration" but a WRONG one — and it is ' + 'retired BEFORE the true schema is authored so no window exists in ' + 'which both claims are published.', acceptanceCriteria: diff --git a/packages/spec/src/migrations/entries/semantic/18.rest-api-endpoint-handler-status-retired.ts b/packages/spec/src/migrations/entries/semantic/18.rest-api-endpoint-handler-status-retired.ts index d18d3f71692..25b16c95156 100644 --- a/packages/spec/src/migrations/entries/semantic/18.rest-api-endpoint-handler-status-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.rest-api-endpoint-handler-status-retired.ts @@ -24,9 +24,9 @@ export const entry: SemanticMigration = { + 'pull); if it is ever wanted it re-declares fresh under its own ruling, ' + 'executor first', reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-01 on #13823 ' - + '(director decision batch #27, verbatim 「同意」: remove; enforce ' - + 'excluded). The key was DOCUMENTED to cause a specific runtime behaviour ' + 'ADR-0049 enforce-or-remove; maintainer ruling of 2026-09-01 on this key: ' + + 'remove it with a tombstone; enforce excluded. The key was DOCUMENTED to ' + + 'cause a specific runtime behaviour ' + '— its docstring said a stub handler "returns 501 Not Implemented" — and ' + 'that behaviour has a different cause: every ' + 'DispatcherErrorCode.enum.NOT_IMPLEMENTED site (runtime/src/' @@ -42,13 +42,16 @@ export const entry: SemanticMigration = { + '(pinned sha) and cloud. So an author who wrote handlerStatus: \'stub\' ' + 'expecting the dispatcher to answer 501 got an ordinarily served route, ' + 'and the declaration reported progress to nobody — a declared ≠ enforced ' - + 'gap on the same endpoint vocabulary ApiEndpointSchema closed strictly in ' - + '#5384, and the surface a published skill had been teaching as working ' - + 'machinery (the sentence corrected in #13808 is where this card came ' - + 'from). Bookkeeping: the KEY is tombstoned with retiredKey() on the ' + + 'gap on the same endpoint vocabulary whose ApiEndpointSchema had already ' + + 'been closed strictly once `api` became a registered metadata type, and ' + + 'the surface a published skill had been teaching as working machinery ' + + '(this finding came out of correcting that skill sentence, in a factual ' + + 'sweep of the automation skill). Bookkeeping: the KEY is tombstoned with ' + + 'retiredKey() on the ' + 'non-strict RestApiEndpointSchema (api/RestApiEndpoint:handlerStatus in ' + 'RETIRED_KEYS_BY_MAJOR[18]); the DEFS leave whole — api/HandlerStatus ' - + '(orphan value enum once both carriers are gone, the #3950 rule), ' + + '(orphan value enum once both carriers are gone — an exported value schema ' + + 'with no consumer reads as a capability, so it leaves with its key), ' + 'api/RouteCoverageEntry and api/RouteCoverageReport (route 3: nobody ' + 'ever parsed or constructed one) — all three in RETIRED_DEFS_BY_MAJOR[18]. ' + 'It is a SEMANTIC entry rather than a D2 conversion because there is no ' @@ -59,9 +62,10 @@ export const entry: SemanticMigration = { + 'kernel/Manifest:loading disposition). ENFORCE was excluded by the ruling: ' + 'mounting a 501 stub for stub / planned endpoints is a zero-pull new ' + 'capability, not a repair. The same ruling records the class direction ' - + 'for the two sibling ADR-0049 cards (#13612 / #13613, not ruled by it): ' + + 'for two sibling ADR-0049 findings (the unbound branded identifier schemas ' + + 'and the event-name schema no runtime reads; not ruled by it): ' + 'a declared-but-unenforced key with no pull retires; enforce/bind only on ' - + 'a named consumer or measured pull. ADR-0049 / ADR-0087, #13823.', + + 'a named consumer or measured pull. ADR-0049 / ADR-0087.', acceptanceCriteria: 'No source writes handlerStatus on a RestApiEndpoint: authoring it is now a ' + 'tsc error at the site (the tombstone types the key never) and a parse ' diff --git a/packages/spec/src/migrations/entries/semantic/18.rest-api-plugin-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.rest-api-plugin-durations-unit-in-key.ts index 63e57974348..5a9d60ba460 100644 --- a/packages/spec/src/migrations/entries/semantic/18.rest-api-plugin-durations-unit-in-key.ts +++ b/packages/spec/src/migrations/entries/semantic/18.rest-api-plugin-durations-unit-in-key.ts @@ -10,7 +10,7 @@ export const entry: SemanticMigration = { replacement: 'timeoutMs (milliseconds), cacheTtlSeconds (seconds) and defaultCacheTtlSeconds ' + '(seconds, default 300) — rename each key; every 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 on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + 'RestApiEndpoint is this rule\'s clearest specimen after the founding one: `timeout` in ' + 'MILLISECONDS and `cacheTtl` in SECONDS sat three lines apart on one shape, each unit named ' + 'only in its describe, so the two numbers were indistinguishable at the authoring site and a ' @@ -26,7 +26,7 @@ export const entry: SemanticMigration = { + 'chain has no seam that ever runs on them. That is the disposition ' + 'api/RestApiEndpoint:handlerStatus already carries on this very shape ' + '(rest-api-endpoint-handler-status-retired), and what ruling B prescribes for a key that is ' - + 'not authorable metadata. #15677, #14478, ADR-0087.', + + 'not authorable metadata. ADR-0087.', acceptanceCriteria: 'Every RestApiEndpointSchema.parse(…) and RestApiPluginConfigSchema.parse(…) site spells ' + '`timeoutMs`, `cacheTtlSeconds` and `performance.defaultCacheTtlSeconds`; authoring any old ' diff --git a/packages/spec/src/migrations/entries/semantic/18.rest-server-config-dead-keys-retired.ts b/packages/spec/src/migrations/entries/semantic/18.rest-server-config-dead-keys-retired.ts index ad623108ec2..91578baf873 100644 --- a/packages/spec/src/migrations/entries/semantic/18.rest-server-config-dead-keys-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.rest-server-config-dead-keys-retired.ts @@ -17,9 +17,10 @@ export const entry: SemanticMigration = { + 'Batch atomicity is the per-request `options.atomic` (ADR-0119 D4); upsert is an operation type of ' + 'the generic `POST /data/:object/batch` endpoint, gated by `batch.enableBatchEndpoint`.)', reason: - 'The #14369 liveness census enrolled the four `RestServerConfig` sub-objects and found 15 of their ' + 'The liveness census that enrolled the four `RestServerConfig` sub-objects found 15 of their ' + '32 rows `dead`: parsed, defaulted and normalized into the REST server\'s config by `normalizeConfig` ' - + '(#11984) and never read back. `crud.patterns` and `routes.overrides` described route customization ' + + '(which parses them, rather than casting them, since an earlier fix) and never read back. ' + + '`crud.patterns` and `routes.overrides` described route customization ' + 'the server mounts from fixed pairs; `routes.includeObjects` / `excludeObjects` and `overrides.enabled` ' + '/ `operations` duplicated the object\'s own enforced exposure keys; `nameTransform` and ' + '`objectParamStyle` were enums validated and then ignored; `metadata.endpoints.schema` and ' @@ -32,8 +33,9 @@ export const entry: SemanticMigration = { + 'segment). All four schemas are non-strict `z.object()`s, so each key is a `retiredKey()` tombstone ' + 'and its ledger row stays `dead` with a REMOVED note; `api/CrudEndpointPattern`, the value def of ' + '`crud.patterns`, leaves with it. No D2 conversion: a `RestServerConfig` is plugin TS configuration, ' - + 'never a stack collection member or a `sys_metadata` row (the `openApi31` precedent, #4579). Cloud ' - + 'sweep #14796 @9b6abe0f2fd5: zero hits, structural — cloud never authors a `RestServerConfig`. #14691.', + + 'never a stack collection member or a `sys_metadata` row (the `openApi31` precedent). A closed-set ' + + 'sweep of the cloud repository at 9b6abe0f2fd5: zero hits, structural — cloud never authors a ' + + '`RestServerConfig`.', acceptanceCriteria: 'No `RestServerConfig` value passed to the REST plugin (or `plugin-hono-server` `restConfig`) carries ' + 'any of the ten keys — a config that does now fails `new RestServer(...)` / `createRestApiPlugin().start()` ' diff --git a/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-absent-value-refused.ts b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-absent-value-refused.ts index dfa1c875d4a..eb53a98d1c1 100644 --- a/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-absent-value-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-absent-value-refused.ts @@ -26,7 +26,8 @@ export const entry: SemanticMigration = { + 'row is deleted. The list operators (in / not_in) and the range operator (between) ' + 'refused an absent value before this change and still do, in their own words', reason: - '#19751. The value key\'s own published description has declared since #6227 that every ' + 'The value key\'s own published description has declared, since the value was first shaped ' + + 'by its operator, that every ' + 'operator outside the list, range and unary sets takes a scalar, and that only the unary ' + 'operators ignore the key; the refinement implementing the coupling returned early on an ' + 'absent value for every operator, so a rule with no value parsed green on all thirteen ' diff --git a/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts index e90a8b819e8..112ada52d9c 100644 --- a/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts @@ -26,12 +26,14 @@ export const entry: SemanticMigration = { + 'discarded, so whatever sits there still parses, array included. An omitted value is ' + 'still an omitted value', reason: - '#19514, closing the protocol half of objectui#9050 ruling C-prime (maintainer ' - + '2026-09-20, verbatim, untranslated): 「the differences are the protocol\'s to close」. ' - + 'The value key\'s own published description has declared this rule since #6227 — ' + 'Closing the protocol half of the maintainer\'s ruling C-prime of 2026-09-20 on objectui\'s ' + + 'render-time filter converter — the protocol is the only refusal set, so a document it ' + + 'accepts never throws at render time — verbatim, untranslated: 「the differences are the ' + + 'protocol\'s to close」. The value key\'s own published description has declared this rule ' + + 'since the value was first shaped by its operator — ' + '「every other operator takes a scalar」 — and the refinement that implements the ' + 'coupling returned early for every operator that is neither a list operator nor ' - + 'between, so the entire scalar class was declared and, from #6227 until this change, ' + + 'between, so the entire scalar class was declared and, from then until this change, ' + 'not judged. ' + '⚠️ This REVERSES a reading recorded in the sibling entry ' + 'view-filter-rule-value-shaped-by-operator, which listed a scalar operator carrying an ' diff --git a/packages/spec/src/migrations/entries/semantic/18.view-overlay-options-bag-judged.ts b/packages/spec/src/migrations/entries/semantic/18.view-overlay-options-bag-judged.ts index 27ee9a9b10d..a7582198ace 100644 --- a/packages/spec/src/migrations/entries/semantic/18.view-overlay-options-bag-judged.ts +++ b/packages/spec/src/migrations/entries/semantic/18.view-overlay-options-bag-judged.ts @@ -28,7 +28,7 @@ export const entry: SemanticMigration = { + 'view\'s `options` into the list renderer, which merges `options.KIND` under the top-level block — so a ' + 'key the strict block refuses by name (`timeline.metaFields`) was saved and rendered when spelled ' + '`options.timeline.metaFields`. Measured on `origin/main` @ `8d1f7ab` through the real save. Ruled ' - + 'direction A (maintainer 「其他同意」): judge each `options.KIND` with the kind\'s strict schema and ' + + 'direction A (the maintainer\'s ruling of 2026-09-24): judge each `options.KIND` with the kind\'s strict schema and ' + 'refuse an out-of-contract key by name, as the direct spelling is; refusing the bag whole was ruled ' + 'out because the legacy `options.map` path is live and pinned. Judged key by key, because the ' + 'renderer reads the bag as a per-key underlay of the top-level block: a bag that carries only the ' diff --git a/packages/spec/src/migrations/entries/semantic/18.view-pagination-page-size-default-50.ts b/packages/spec/src/migrations/entries/semantic/18.view-pagination-page-size-default-50.ts index 4c0ca81d74c..8c8e78fac13 100644 --- a/packages/spec/src/migrations/entries/semantic/18.view-pagination-page-size-default-50.ts +++ b/packages/spec/src/migrations/entries/semantic/18.view-pagination-page-size-default-50.ts @@ -16,13 +16,14 @@ export const entry: SemanticMigration = { + 'per page on a view, write it: `pagination: { pageSize: 25 }`', reason: 'A RULED behaviour change on a default, so there is nothing to rewrite and nothing to ' - + 'refuse: the maintainer set the platform display page size to 50 (「9853 默认页大小改为50」, ' - + 'objectui#9853), and the declared default of `PaginationConfigSchema.pageSize` moved ' + + 'refuse: the maintainer\'s ruling of 2026-09-24 set the platform display page size to 50, ' + + 'declared once in the protocol, and the declared default of `PaginationConfigSchema.pageSize` moved ' + 'from 25 to 50. A `pagination` block that omits `pageSize` now parses to 50 — 50 rows ' + 'per page on a paged view, and a fetch ceiling of 50 on a view with no pager (kanban, ' + 'gallery, timeline). A view with no `pagination` block at all parses with none on either ' + 'side; its page size reaches it through the renderer, which is ruled to read the spec ' - + 'default rather than keep its own number (objectui#9853 ruling C′ item 1). Not losslessly ' + + 'default rather than keep its own number (an earlier ruling on the grid\'s page size, which ' + + 'the page-size ruling restated). Not losslessly ' + 'convertible because the question is intent, not text: a mechanical pass that wrote ' + '`pageSize: 25` into every silent view would preserve the old number and defeat the ' + 'ruling, and one that wrote 50 would add nothing the default does not already do. Only ' From 81faf8e1ce8a6ce957899e2a8d6caf6ce3852222 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 23:46:57 +0000 Subject: [PATCH 2/3] fix(spec,cli): regenerate the migration projections; the guidance pin holds the stage-6 families registry.ts, spec-changes.json and docs/protocol-upgrade-guide.md are regenerated by their generators from the rewritten entries. The os migrate meta guidance pin covers rest-, analytics-, view-, package-, object-, sharing-, audit-, flow-, http- and inline-, and its REWRITTEN floor lists the 34 entries this stage rewrote for the first time. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- docs/protocol-upgrade-guide.md | 34 +- .../test/migrate-meta-engine-guidance.test.ts | 40 ++- packages/spec/spec-changes.json | 56 +-- packages/spec/src/migrations/registry.ts | 321 +++++++++++------- 4 files changed, 273 insertions(+), 178 deletions(-) diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index b4e0512f620..40042b74961 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -225,7 +225,7 @@ Finally it removes the 'pdf' member of `view.exportOptions` formats (#8010, main - Why not automatic: A DIVERGENCE, not an inert declaration — which is why it outlived the #4286 sweep that dispositioned every other `data.query.*` member. That sweep asked which keys no executor reads; this one HAD an executor, exactly one out of six. The engine's in-memory fallback (`objectql/src/in-memory-aggregation.ts`) deduplicated the values before applying the function, while `SqlDriver.aggregate`, the Turso `RemoteTransport.aggregate`, `driver-mongodb`'s `buildAggregationStage`, `driver-memory`'s `computeAggregate` and service-analytics' `AGGREGATE_SQL` all ignored the key. So `{ function: 'sum', field: 'amount', distinct: true }` answered a deduplicated sum when the engine fell back in memory and an ordinary sum on every SQL datasource: one query, two numbers, chosen by which backend happened to serve it — and unlike the #6203 / #5907 divergences closed on the same axis, the wrong answer here is a plausible NUMBER rather than a refusal, so nothing surfaced it to the author. Measured blast radius inside the fallback: `sum` and `avg` only — `count` returned from its own branch before reaching the dedupe, `count_distinct` fed the values into a Set (dedupe-then-Set is Set), and dedupe does not move `min`/`max`. ENFORCE was weighed and rejected (maintainer ruling 2026-08-09): `count_distinct` already covers the only spelling anyone has measured demand for, and lowering `SUM(DISTINCT …)` across five faces — two of them then frozen under #5499, a freeze lifted 2026-08-11, after this ruling — buys a shape that is near-universally a modelling mistake. A REQUEST surface — `QueryAST` is the client SDK builder's output and the `POST /data/:object/query` body, never stored in stack metadata — so there is no source for the chain to rewrite and callers move their own queries: the #4286 disposition for `joins`/`cursor`/`distinct`/`windowFunctions`, applied verbatim one level down. ADR-0049, #6815. - Done when: No caller sends `distinct` inside an `aggregations[]` entry, on the wire or through the SDK; a deduplicated count is written as `{ function: 'count_distinct', field }` and reads the same number on every backend. A query still carrying the key fails to parse with the removal prescription — including through `EngineAggregateOptionsSchema`, which reuses `AggregationNodeSchema` by reference — and `POST /api/v1/data/:object/query` answers `400 VALIDATION_FAILED` with a `fields[]` entry at `aggregations..distinct` instead of serving a number. Authoring it is a `tsc` error at the call site. ⚠️ The observable NUMBERS change on exactly one path and that is the point of the change: a `sum`/`avg` that used to be deduplicated by the in-memory fallback now answers what every SQL face has always answered for the same query. Verify against the SQL answer, not against the pre-upgrade fallback answer — the two disagreed, which is why the key is gone. - **`analytics-query-request-envelope-retired`** — `api.analyticsQueryRequest.query` → bare AnalyticsQuery body (top-level cube/measures/dimensions/where/...) - - Why not automatic: The { cube, query: {...} } envelope was an HTTP-wire dialect of the retired degraded analytics shim (#3891), never stored in stack metadata — there is no source for the chain to rewrite. Callers of POST /analytics/query and /analytics/sql must move the query.* fields to the body top level themselves. + - Why not automatic: The { cube, query: {...} } envelope was an HTTP-wire dialect of the retired degraded analytics shim (the fallback that answered /analytics/query when no analytics service was installed, and dropped the caller's identity and its `where` filter at the door), never stored in stack metadata — there is no source for the chain to rewrite. Callers of POST /analytics/query and /analytics/sql must move the query.* fields to the body top level themselves. - Done when: Every /analytics/query and /analytics/sql call sends the bare AnalyticsQuery shape and succeeds; no request answers 400 VALIDATION_FAILED with the envelope prescription. - **`analytics-query-request-format-retired`** — `api.analyticsQueryRequest.format` → (removed — responses are always the JSON envelope; use the export surface for CSV/XLSX) - Why not automatic: The `format` key was declared but never implemented (declared ≠ enforced): every response is the JSON envelope regardless of the requested value, so there is no behaviour to preserve and nothing stored to rewrite. @@ -240,10 +240,10 @@ Finally it removes the 'pdf' member of `view.exportOptions` formats (#8010, main - 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. - 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 (#7675), the retirement half of a two-half verdict: the cheap writers get built (#8144 login/logout, #8145 config_change) 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, #8147. + - 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. - Done when: No consumer filters `sys_audit_log` on `action = "export"` or `action = "permission_change"` expecting rows: both were empty everywhere before this change, so a query that returned data has not been identified and a query that returned nothing behaves identically. Concretely, check three places. (1) Saved queries, dashboards and reports over `sys_audit_log`: a filter naming either value should be deleted, not re-pointed — for permission auditing, filter the permission objects` own `create`/`update` rows by `object_name` instead. (2) Any code branching on the action string (a badge map, a label switch, an `if (row.action === ...)`): the arms for these two values are now unreachable and should go, and a `switch` with an exhaustiveness check over the enum type will now fail to compile if they stay — that compile error is the enforced channel for TypeScript consumers. (3) Custom objects or plugins inserting `sys_audit_log` rows with either value: this is the only case that needs a real decision, because the write will NOT be refused (readonly fields are not validated) — it will simply be 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. -- **`audit-log-action-restore-retired`** — `sys_audit_log.action — the value 'restore' left the select enum declared by plugin-audit (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts). It also left the shipped writes_only list-view filter on that object, and the generated option label in all four plugin-audit translation bundles` → nothing — the value is removed rather than renamed, because it never named an event this platform records. There is no undelete or restore capability to point at: deletes are hard deletes, and the record-level audit writer maps the ObjectQL lifecycle to `create` / `update` / `delete` only. A consumer filtering `sys_audit_log` on this value was reading an empty result set on every deployment, and still is — what changed is that the contract no longer promises otherwise. If you were counting on a restore trail, the capability itself is the missing piece (#1883, #3146), not this enum row - - Why not automatic: The same maintainer ruling as `audit-log-action-enum-retired`, carried to the one value #7675's own survey did not name (#8315, 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 (#8011) — 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 (#1883 pm:on-hold, #3146 status:parked). 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, #8315, #7675, #8147. +- **`audit-log-action-restore-retired`** — `sys_audit_log.action — the value 'restore' left the select enum declared by plugin-audit (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts). It also left the shipped writes_only list-view filter on that object, and the generated option label in all four plugin-audit translation bundles` → nothing — the value is removed rather than renamed, because it never named an event this platform records. There is no undelete or restore capability to point at: deletes are hard deletes, and the record-level audit writer maps the ObjectQL lifecycle to `create` / `update` / `delete` only. A consumer filtering `sys_audit_log` on this value was reading an empty result set on every deployment, and still is — what changed is that the contract no longer promises otherwise. If you were counting on a restore trail, the capability itself is the missing piece (an undelete / purge permission lifecycle and a soft-delete recycle bin, neither built yet), not this enum row + - 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. @@ -362,7 +362,7 @@ ONE AUTHOR-REACHABLE SURFACE reaches this indirectly and is why it is not purely - Why not automatic: Like `driver-aggregate-undeclared-key-aliases-removed` and `driver-sql-distinct-bare-filter-typed`, this entry records a LENIENCY being withdrawn rather than a declared surface: `$regex` was never in `FILTER_OPERATORS` and never a key on `StringOperatorSchema`. That is measured, not assumed — `git log -S'$regex'` over `packages/spec/src` returns only doc comments describing how `$contains` LOWERS to MongoDB (`Contains substring - SQL: LIKE %?% | MongoDB: $regex`), plus the retirement's own contract-half change, which added the name solely as `RETIRED_FILTER_OPERATORS` prescription data. ⚠️ But it differs from those two in the one way that decides the disposition, so a reader should not have to infer it: those were driver CALL ARGUMENTS, code and never stack metadata, whereas a filter IS stored metadata. `FilterConditionSchema` is an OPEN RECORD (`z.record(z.string(), z.unknown())`) because a filter key is a field name, so a stored `{ name: { $regex: 'acme.*' } }` parses GREEN and always will — a `retiredKey()` tombstone cannot exist on an open map, which is exactly why the ledger has to carry this. What such a stack used to get was four different answers from four backends: `driver-sql` and Turso's remote transport compiled it to a LIKE-escaped SUBSTRING (so `a.b` matched only the literal `a.b` and the regex was silently never a regex), `driver-memory` and objectql's `having` ran it as a real `RegExp` (so the same filter also matched `axb`, and an INVALID pattern was caught and answered `false` — zero rows, in silence), and `driver-mongodb` refused it with a bare `Error` carrying no `code` and no `status`. It is now refused everywhere with INVALID_FILTER / 400 naming the replacement. There is deliberately NO D2 conversion and this sits in `semantic` rather than among the mechanical transforms: rewriting `$regex` to `$icontains` is NOT lossless in either direction — a regex metacharacter becomes a literal — so an auto-applied rewrite would silently change which rows a dashboard, report or permission filter selects, a wrong number rather than a missing one. Choosing the substring the pattern MEANT is a judgment about the query, not a transform. ⚠️ This entry covers BOTH HALVES of the maintainer's 2026-08-06 ruling (option B: retire `$regex` loudly and add `$icontains`, rather than make five backends agree on one regex dialect), not just the driver one: the contract half (the `$icontains` declaration, the `$contains` family pinned case-sensitive, and the `RETIRED_FILTER_OPERATORS` prescriptions) landed before the gate that makes a breaking changeset state its ADR-0087 disposition existed, and so was never asked for a ledger entry; the driver half is where the refusal became executable. One surface, one entry, registered from the half that made it observable. ADR-0049 / ADR-0087. - Done when: No stored filter and no request `where` spells `$regex` or `$options` — grep the stack for both. Each one is rewritten by asking what the pattern MEANT, not by transliterating it: a bare substring pattern becomes `$icontains` (or `$contains` when the match must stay case-sensitive), and its metacharacters are dropped rather than escaped, because they were never honoured as a regex on the SQL family in the first place. ⚠️ Expect the answer to CHANGE on any stack that ran on `driver-memory`, `driver-mongodb` or objectql `having`, where the pattern really was evaluated as a regular expression; on the SQL family the rewritten filter returns what it always returned. A pattern that genuinely needs alternation, anchoring or character classes has no filter-level replacement — move that predicate into a formula field or a server-side view, or open an issue for it. Verify by loading the stack: a surviving `$regex` or `$options` is answered INVALID_FILTER / 400 with a message naming the replacement, on every backend. - **`flow-retry-max-retries-required`** — `flow.errorHandling.maxRetries (under strategy: 'retry')` → an explicit count >= 1 (e.g. maxRetries: 3), or strategy: 'fail' - - Why not automatic: maxRetries had two defaults — FlowSchema `.default(0)` and the engine's `maxRetries ?? 3` — so an unstated count retried 0 times through the schema and 3 times through a hand-built definition (#4247). With the engine's copy removed the unstated count is unambiguously 0, and retrying zero times is exactly `strategy: 'fail'`, so the schema now refuses the combination instead of it silently doing nothing. There is no lossless rewrite: 0 preserves the behaviour a parsed flow got but contradicts what its author wrote, and any positive count is a NEW decision about re-running the whole flow with its side effects. That choice is the author's. + - Why not automatic: maxRetries had two defaults — FlowSchema `.default(0)` and the engine's `maxRetries ?? 3` — so an unstated count retried 0 times through the schema and 3 times through a hand-built definition. With the engine's copy removed the unstated count is unambiguously 0, and retrying zero times is exactly `strategy: 'fail'`, so the schema now refuses the combination instead of it silently doing nothing. There is no lossless rewrite: 0 preserves the behaviour a parsed flow got but contradicts what its author wrote, and any positive count is a NEW decision about re-running the whole flow with its side effects. That choice is the author's. - Done when: Every flow declaring `errorHandling.strategy: 'retry'` also declares `maxRetries` >= 1, and each count was chosen knowing a retry replays the flow FROM THE START (records re-created, callouts re-fired); flows that never actually wanted retries say `strategy: 'fail'`. No flow fails to register with the maxRetries prescription. - **`hook-context-session-roles-retired`** — `data.hookContext.session.roles` → (removed — gate on `session.userId` / `session.isSystem`; for PRIVILEGE ask the security service, which reads `permissions` / `positions` / posture off the execution context, ADR-0095 D3) - Why not automatic: Declared on the runtime hook context, read by exactly two consumers, produced by nobody. The two readers were the approvals record lock and the delegation write guard, each opening with `session.roles?.includes('admin')`; ObjectQL's `buildSession()` builds the session field by field and has never written `roles`, and nothing else feeds a HookContext in objectstack, cloud or objectui (cloud's hook consumers read `hookContext?.session?.userId`; objectui's `roles` are the `/auth/me` user payload, a different surface; an ACTION body's `ctx.session` is a different untyped object that does carry `roles`, tracked apart and unaffected). Both branches were therefore dead on every real engine path — an authorization decision in shape only, and a second admin dialect competing with the one ADR-0090 D3 / ADR-0095 D3 sanction. An earlier fix removed both readers, returning the record lock and the delegation guard to the one permission vocabulary; this removes the declaration, per ADR-0049 enforce-or-remove. This is a RUNTIME context, not stored metadata: the engine builds a HookContext per operation and nothing persists one, so no `sys_metadata` row, example or template can carry the key and there is no source for the D2 chain to rewrite — the `openApi31` / `activationEvents` shape, one semantic TODO rather than a stack conversion. The key IS tombstoned (`HookContextSchema` is deliberately not `.strict()` — a plain delete would strip it silently, as a removed field key was measured to be, ADR-0104), so a consumer that parses a context it was handed still meets the prescription. ADR-0049. @@ -375,10 +375,10 @@ No mechanical rewrite exists, in either direction. The refused values carry no r This is a RUNTIME registration API, not stored metadata, so — like `hook-context-session-roles-retired` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and the ledger entry is the notification channel. One metadata surface reaches it INDIRECTLY and is the reason this is not purely a code-side note: a `record-change` flow's start node forwards `config.objectName` verbatim into `registerHook` (`RecordChangeTrigger.start`), so a flow authored with a blank `objectName` used to bind a trigger to EVERY object in the tenant. It now fails to bind instead, loudly — the automation engine's per-flow bind guard warns and the `kernel:bootstrapped` binding audit re-reports it — which is the correct end state, but it is an observable change for that flow. ADR-0078. - Done when: No `registerHook` call site passes an empty `object` target, and none passes an `excludeObjects` list covering every name in its `object` list. Every `record-change` flow start node declares a non-blank `config.objectName`, or omits the key if the flow is genuinely meant to fire on every object. Boot completes with no "[ObjectQL] Hook ... declares an empty `object` target" throw and no "[record-change] ... not bound" warning naming a flow you expect to fire. - **`http-request-errors-total-retired`** — `observability.SEMCONV.httpRequestErrorsTotal (the published metric name http_request_errors_total{method,route}, and its emission from the runtime dispatcher's per-route wrapper)` → the 5xx rate is `http_requests_total{status=~"5.."}` — the TRANSPORT emits that family through the `IHttpServer.afterResponse` seam, so it covers every inbound surface; unhandled-exception rate specifically, which is the one thing the retired counter uniquely reported, is the `errorReporter` (Sentry / Datadog / your adapter), which still fires on every 5xx throw - - Why not automatic: ADR-0049 enforce-or-remove, on a DECLARED-not-enforced metric name. `SEMCONV` published `http_request_errors_total` as part of a stable namespace declared "so hosts can wire alerts/dashboards against it", but the only emitter was `@objectstack/runtime`'s `instrumentRouteHandler`, applied only by the dispatcher's own route Proxy — so the series never saw auth's `getRawApp()` mount, the REST data API via `RouteManager`, or any other inbound surface. Its two siblings in the same family were moved to the transport seam (#9650/#9835 for the counter, #9834/#10004 for the histogram) and this one could not follow: `HttpResponseObservation` carries `{method, routePattern, status, elapsedMs}` and NO throw signal of any kind, so every transport-side shape would have counted a DIFFERENT population rather than the same one more widely. The divergence was measured in both directions — the dispatcher answers its own errors through `errorResponseBase`, which sets a status and does not re-throw, so the old counter MISSED those, while its `catch` incremented unconditionally, so a thrown 4xx WAS counted as an error. And `http_requests_total` already carries a `status` label, so a status-class error counter would be fully derivable from data the transport already publishes. Maintainer ruling 2026-08-20 (option C of four presented, over B "move it to the transport as a status class" and D "keep it dispatcher-scoped and rename it"): RETIRE. A metric NAME is a RESPONSE surface, not authorable metadata — no stack, example or template carries it, so there is no source for a D2 conversion to rewrite and no schema to tombstone; a host names the series in its own dashboard or alert file, outside this repo. That is exactly why this entry exists: for an operator whose Grafana keys on the string, the ledger is the only notification channel there is. Same disposition, and the same reason, as `runtime-httpserver-wrapper-retired` (#5122) and `enhanced-api-error-field-errors-renamed` (#3977). ADR-0049 / ADR-0087, #9834. + - Why not automatic: ADR-0049 enforce-or-remove, on a DECLARED-not-enforced metric name. `SEMCONV` published `http_request_errors_total` as part of a stable namespace declared "so hosts can wire alerts/dashboards against it", but the only emitter was `@objectstack/runtime`'s `instrumentRouteHandler`, applied only by the dispatcher's own route Proxy — so the series never saw auth's `getRawApp()` mount, the REST data API via `RouteManager`, or any other inbound surface. Its two siblings in the same family were moved to the transport seam (the request counter, then the latency histogram, both through the response-observing hook the transport was given) and this one could not follow: `HttpResponseObservation` carries `{method, routePattern, status, elapsedMs}` and NO throw signal of any kind, so every transport-side shape would have counted a DIFFERENT population rather than the same one more widely. The divergence was measured in both directions — the dispatcher answers its own errors through `errorResponseBase`, which sets a status and does not re-throw, so the old counter MISSED those, while its `catch` incremented unconditionally, so a thrown 4xx WAS counted as an error. And `http_requests_total` already carries a `status` label, so a status-class error counter would be fully derivable from data the transport already publishes. Maintainer ruling 2026-08-20 (option C of four presented, over B "move it to the transport as a status class" and D "keep it dispatcher-scoped and rename it"): RETIRE. A metric NAME is a RESPONSE surface, not authorable metadata — no stack, example or template carries it, so there is no source for a D2 conversion to rewrite and no schema to tombstone; a host names the series in its own dashboard or alert file, outside this repo. That is exactly why this entry exists: for an operator whose Grafana keys on the string, the ledger is the only notification channel there is. Same disposition, and the same reason, as `runtime-httpserver-wrapper-retired` and `enhanced-api-error-field-errors-renamed`. ADR-0049 / ADR-0087. - Done when: No dashboard, alert rule or exporter config names `http_request_errors_total`: the series stops receiving samples the moment 17.2.0 is deployed, so a panel keyed on it draws a flat zero that reads as a healthy server rather than as a removed metric — the one failure mode this retirement can produce, and the reason the changeset announces it loudly. A 5xx-rate panel or alert is rewritten to `http_requests_total{status=~"5.."}` and then PROVEN wider, not merely non-empty: make an auth route or a REST data-API route answer 5xx and confirm the new query moves, where the retired counter would not have moved at all. If the signal you were actually alerting on was "a handler threw rather than returning an error envelope", that is the `errorReporter`, not a counter — wire an APM adapter and assert one synthetic 5xx throw arrives. In code, `SEMCONV.httpRequestErrorsTotal` and `RUNTIME_METRICS.httpRequestErrorsTotal` no longer resolve (tsc reports TS2339 at any surviving read) and no `metrics.counter` call names the string. - **`http-server-runtime-vocabulary-retired`** — `system.serverEvent / system.serverEventType / system.serverCapabilities / system.serverStatus (the lifecycle-event, capability-report and status vocabulary of system/http-server.zod.ts — 4 defs, 8 exported names)` → (removed — there is no replacement key, because there was never a key. Server lifecycle is the transport plugin's own start/stop seam; per-request and per-server observability is `system/metrics.zod.ts` and `system/logging.zod.ts` (plus `OS_SERVER_TIMING` for timings), and liveness is the `/health` endpoint. What a transport plugin can DO it states by implementing the kernel plugin contract — the seams it registers are the capability statement, and a self-described capability record can only disagree with them. Server-level configuration that IS authorable lives on `defineStack({ server })` / `StackServerConfigSchema`, which is unaffected) - - Why not automatic: The second and final ADR-0049 pass over `system/http-server.zod.ts`. #4938 removed the CONFIG half (`HttpServerConfigSchema`, nine keys, zero readers, zero authoring entry); this removes the RUNTIME half — a 7-member lifecycle event union with a timestamped envelope, an eight-boolean capability report, and a five-state status record with connection and request counters. Nothing ever emitted, consumed or parsed any of them. This card was HELD for four days rather than queued, on a specific and legitimate doubt: a response/capability vocabulary can be a REFERENCE surface for host implementers, so "zero consumers in this repo" is weaker evidence for one of those than for an authorable key (the CSS-variable rebuttal). The hold was lifted by measuring the reference reader itself rather than by re-running the same grep: `plugin-hono-server`, the one in-tree host implementation, neither implements nor reports any of the three — it names no capability record, no status shape and no event union, and what it registers is routes and middleware through the kernel plugin contract. A declaration-site grep put every declaration in this one file, a quoted-name sweep across objectstack and objectui found no reader outside it, and the control passed in the SAME run: `MiddlewareConfig`, declared twelve lines away, resolves to `packages/runtime/src/middleware.ts`. So the sweep could see a reader in this file when there was one. With no carrier key there is nothing to tombstone, and with no author there is no source or `sys_metadata` row for a D2 conversion to rewrite: RETIRED_DEFS_BY_MAJOR plus this entry are the declaration — route 3, the same shape as #4938 in this very file, #4834, #4988 and #5055. If host-implementer conformance becomes a real requirement it returns through the ENFORCE route: an adapter contract with a checker behind it, vocabulary second. ADR-0049, #5295. + - Why not automatic: The second and final ADR-0049 pass over `system/http-server.zod.ts`. The first removed the CONFIG half (`HttpServerConfigSchema`, nine keys, zero readers, zero authoring entry); this removes the RUNTIME half — a 7-member lifecycle event union with a timestamped envelope, an eight-boolean capability report, and a five-state status record with connection and request counters. Nothing ever emitted, consumed or parsed any of them. This card was HELD for four days rather than queued, on a specific and legitimate doubt: a response/capability vocabulary can be a REFERENCE surface for host implementers, so "zero consumers in this repo" is weaker evidence for one of those than for an authorable key (the CSS-variable rebuttal). The hold was lifted by measuring the reference reader itself rather than by re-running the same grep: `plugin-hono-server`, the one in-tree host implementation, neither implements nor reports any of the three — it names no capability record, no status shape and no event union, and what it registers is routes and middleware through the kernel plugin contract. A declaration-site grep put every declaration in this one file, a quoted-name sweep across objectstack and objectui found no reader outside it, and the control passed in the SAME run: `MiddlewareConfig`, declared twelve lines away, resolves to `packages/runtime/src/middleware.ts`. So the sweep could see a reader in this file when there was one. With no carrier key there is nothing to tombstone, and with no author there is no source or `sys_metadata` row for a D2 conversion to rewrite: RETIRED_DEFS_BY_MAJOR plus this entry are the declaration — route 3, the same shape as the config half's removal in this very file and the earlier removals of the dynamic plugin-loading family, the `ui/` interaction configs and the widget / i18n shapes. If host-implementer conformance becomes a real requirement it returns through the ENFORCE route: an adapter contract with a checker behind it, vocabulary second. ADR-0049. - Done when: No source imports `ServerEvent`, `ServerEventType`, `ServerEventSchema`, `ServerCapabilities`, `ServerCapabilitiesSchema`, `ServerCapabilitiesParsed`, `ServerStatus` or `ServerStatusSchema` from `@objectstack/spec/system` — a grep over consumer code resolves none of them, and `tsc` reports TS2724/TS2305 on any that survives. The route-registration half of the same module still resolves (`RouteHandlerMetadataSchema`, `MiddlewareType`, `MiddlewareConfigSchema`, `MiddlewareConfig`), and `StackServerConfigSchema` — the one authorable server surface — is untouched: a stack declaring `server: { trustProxy, security }` parses exactly as it did in 16.x. - **`import-run-automations-declared-default-corrected`** — `api.ImportRequest runAutomations — the declared default of the key on BOTH import bodies, POST /api/v1/data/:object/import (ImportRequest) and its async twin POST /api/v1/data/:object/import/jobs (CreateImportJobRequest, which IS the same schema object). It was declared default(false) and described as "off by default for bulk"; it is now default(true), which is what the server has always done` → an explicit runAutomations: false on any import request that is meant to load rows without firing triggers/hooks. That spelling is unchanged and has always been the only one the server read — what changes is that omitting the key now DECLARES what it already DID. Callers who want automations on need write nothing - 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 #2922 — 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` (#6361) 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] (#4666), whose `from`/`to` fingerprints are re-derived on every build. Maintainer ruling 2026-08-09 (#6704, disposition A: the spec follows the runtime). ADR-0049 / ADR-0078. @@ -390,8 +390,8 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-conte - Why not automatic: One capability, both halves, never half-deleted (maintainer ruling 2026-08-07, Option A, ruled jointly with #6363). `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` (#4286, `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 (#3899 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 (#3733, 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` (#4052) and the `AnalyticsQueryRequest` envelope keys already take in this major. The `limit` default is declared separately and mechanically, in DEFAULT_CHANGES_BY_MAJOR[17] (#4666), whose `from`/`to` fingerprints are re-derived on every build. ADR-0049 / ADR-0078, #6361. - Done when: No caller sends `cursor` to `GET /api/v1/notifications` and no SDK call site passes it: `client.notifications.list({ cursor })` is a `tsc` error (TS2353, excess property), which is the enforced channel — the removal is loud at compile time for every TypeScript consumer. Reading `response.cursor` no longer type-checks either, and always answered `undefined` before. ⚠️ Behaviour on the wire is deliberately UNCHANGED and must be verified as such: a request still carrying `?cursor=…` is IGNORED, not refused — the domain reads three named query keys and no route validates this query against a schema, so an unknown key has never produced a 400 and does not start doing so here. The declaration stopped promising what the wire never did; the wire did not change. `unreadCount` is untouched (#6363) and still reports the total across the whole matching inbox rather than the window. A caller that omitted `limit` receives the same 50 rows it always received. - **`package-uninstall-explicit-all-tenants`** — `protocol.deletePackage({ packageId }) with no `organizationId` (and its transport, DELETE /api/v1/packages/:id)` → explicit `allTenants: true` for a cross-tenant uninstall, or an `organizationId` to scope it - - Why not automatic: An uninstall that named no organization matched EVERY organization's rows — measured at 5 of 5 deleted, including a foreign org's (#7705, #7780). That width was never chosen; it fell out of a missing argument, and the two transports of the same route disagreed because of it. In protocol 17 the call is REFUSED instead: neither `organizationId` nor `allTenants: true` answers 400 `TENANT_SCOPE_REQUIRED` and deletes nothing, as does supplying both (they are contradictory, not redundant). Whether a given caller meant "this tenant" or "every tenant" is an intent no transform can recover: `resolveActiveOrganizationId` is catch-wrapped, so an accidental org-less call and a deliberate environment-wide one are byte-identical at the call site — which is the whole reason the parameter had to become explicit rather than conventional. Nothing in authored metadata spells this: it is a runtime call-site contract, so it is one semantic TODO for operators and API callers rather than a stack conversion — the same disposition `rest-requireauth-default-flip` (#12) takes for its own default flip. - - Done when: Every caller of `deletePackage` states its tenant scope. A caller that intends an environment-wide uninstall passes `allTenants: true`; a caller that intends a scoped one passes `organizationId`; no caller passes both. An explicit `allTenants: false` is treated as undeclared and refused, since it is not an affirmative request for cross-tenant semantics. Verify the refusal is not merely absorbed: a 400 `TENANT_SCOPE_REQUIRED` reaching a deploy script that previously "succeeded" means that script was relying on the cross-tenant reading and must now say so on purpose. The org-scoped path is unchanged — an uninstall carrying an `organizationId` still removes that org's rows AND the environment-wide (`organization_id IS NULL`) rows, exactly as #7705 left it. + - Why not automatic: An uninstall that named no organization matched EVERY organization's rows — measured at 5 of 5 deleted, including a foreign org's, while uninstall's orphaned-row defect was being repaired. That width was never chosen; it fell out of a missing argument, and the two transports of the same route disagreed because of it. In protocol 17 the call is REFUSED instead: neither `organizationId` nor `allTenants: true` answers 400 `TENANT_SCOPE_REQUIRED` and deletes nothing, as does supplying both (they are contradictory, not redundant). Whether a given caller meant "this tenant" or "every tenant" is an intent no transform can recover: `resolveActiveOrganizationId` is catch-wrapped, so an accidental org-less call and a deliberate environment-wide one are byte-identical at the call site — which is the whole reason the parameter had to become explicit rather than conventional. Nothing in authored metadata spells this: it is a runtime call-site contract, so it is one semantic TODO for operators and API callers rather than a stack conversion — the same disposition `rest-requireauth-default-flip` (protocol 12) takes for its own default flip. + - Done when: Every caller of `deletePackage` states its tenant scope. A caller that intends an environment-wide uninstall passes `allTenants: true`; a caller that intends a scoped one passes `organizationId`; no caller passes both. An explicit `allTenants: false` is treated as undeclared and refused, since it is not an affirmative request for cross-tenant semantics. Verify the refusal is not merely absorbed: a 400 `TENANT_SCOPE_REQUIRED` reaching a deploy script that previously "succeeded" means that script was relying on the cross-tenant reading and must now say so on purpose. The org-scoped path is unchanged — an uninstall carrying an `organizationId` still removes that org's rows AND the environment-wide (`organization_id IS NULL`) rows, exactly as the orphaned-row repair left it. - **`plugin-activation-events-retired`** — `kernel.dynamicLoadRequest.activationEvents / studio.studioPluginManifest.activationEvents` → (removed — delete the key. Every plugin activates immediately on load/registration, which is the only behaviour that has ever existed; `activate()` still runs at registration time. Lazy activation, if built, returns via the enforce route of ADR-0049 through a new ADR, with a vocabulary its executor actually honours) - Why not automatic: Both `activationEvents` keys — and the `ActivationEventSchema` trigger vocabulary they embedded (`onCommand` / `onRoute` / … / `onView`, once the kernel and studio copies had converged on the kernel's structured `{ type, pattern }` shape) — promised lazy plugin activation ("plugins remain dormant until an activation event fires") that no runtime in objectstack, cloud, cloud-v1 or objectui ever implemented: nothing anywhere read the key, every plugin activates immediately, and cloud-v1's own ROADMAP recorded lazy activation as unimplemented (planned v0.4.0). That is the ADR-0049 false-compliance shape in the semantically-lying direction: an author writing `activationEvents: [{ type: 'onMetadataType', pattern: 'flow' }]` expected deferral and got eager activation with a clean parse. Neither parent shape is stored metadata — `StudioPluginManifest` is TS configuration parsed by `defineStudioPlugin` (a root schema, never part of a stack tree) and `DynamicLoadRequest` is a runtime request shape with no caller — so no `sys_metadata` row can carry the key and there is no source for the D2 chain to rewrite; this entry is the D3 record. The kernel key is tombstoned via `retiredKey()` (its schema is not `.strict()`; a plain delete would strip an authored value silently), the studio key is rejected by the strict manifest parse with a guidance prescription (as are its former VS Code-flavoured aliases `activation` / `events` / `onActivate`), and the orphaned `ActivationEventSchema` / `ActivationEvent` exports are removed from `./kernel` and `./studio` with the keys (the lesson of the unwired plugin sandboxing / integrity / approval config removed before this: an exported schema with no consumer is read as a capability). Both keys took ADR-0049's REMOVE answer, not ENFORCE, while protocol 17 was still unreleased. SUPERSEDED ON THE KERNEL SIDE by the maintainer's REMOVE ruling on the rest of the plugin-runtime family (same unreleased major): the whole `DynamicLoadRequest` shape — and the rest of the plugin-runtime family with it — was removed, which took this key's `retiredKey()` tombstone with it. That is strictly stronger than the tombstone, not weaker: there is no longer a `DynamicLoadRequest` to author the key INTO, so the prescription an author needs is no longer "delete this key" but "this request shape does not exist" (see `plugin-runtime-family-retired`). The studio half of this entry is unaffected and still enforced by the strict manifest parse. - Done when: No `defineStudioPlugin` input authors `activationEvents` — authoring it is an unknown key on the strict studio manifest and a parse error carrying the prescription. On the kernel side the stronger criterion of the plugin-runtime family's removal applies instead: there is no `DynamicLoadRequest` type or schema left to author it into at all. No code imports `ActivationEventSchema` / `ActivationEvent` from `@objectstack/spec/kernel` or `@objectstack/spec/studio` (TS2305 after upgrade). Runtime behaviour is byte-identical: plugins loaded eagerly before and after. @@ -426,16 +426,16 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-conte - Why not automatic: `record:details` declared `sections` as a list of section IDs — `["overview", "financials"]` — a shape nothing produced and nothing consumed, while every real page authored the object form. This is authorable metadata on the publish/parse path, so it is the D2 class exactly; it is registered as a SEMANTIC step rather than a mechanical conversion because a string ID carries no field list and the chain cannot invent one — only the author knows which fields the section named `overview` was meant to render. The measurement that made the type change safe is also what makes the prescription unambiguous: the ID-list form had zero read paths and zero producers. objectui's `RecordDetailsRenderer` maps every entry as an object (`s.name` / `s.label` / `s.title` / `s.fields`) with no string branch at all — a string entry spreads into a character map and renders nothing; `@object-ui/types`' `RecordDetailsComponentProps` mirror already declared `Array<{ name?, label?, fields, ... }>`; the Studio block designer can only author `{ label, columns, fields }`; `packages/lint` has modelled it as `nestedSections` all along; and every page in this repo — three showcase pages plus the `sys_user` platform page — authors the object form. So the break lands only on stored metadata written against a declaration nothing ever honoured, and it lands at publish time rather than rewriting data at rest. The same change DECLARED `hideFields`, which the `sys_user` platform page had been authoring undeclared. Registered by the #6350 stock reconciliation: #5611 predates the #6148 completeness gate, so nothing asked it what it had done about the ledger, and the sibling key on the same def — `ui/RecordDetailsProps:layout`, retired by #6350's neighbour — carries a tombstone while this face carried none. ADR-0087, #5611 (backfilled #6350). - Done when: Every `record:details` component in authored metadata spells `sections` as an object array: each entry names the fields it renders (`fields: [...]`), optionally with `label` / `name` / `columns`. `objectstack validate` passes and each detail page renders the same sections, in the same order, with the same fields as before the upgrade — a page whose sections silently render EMPTY is the signature of an ID list left in place. A section that existed only as an ID, with no field list recoverable from the page it belonged to, is a judgement for the author: name the fields it was meant to show, or delete the entry. Fields previously hidden by a convention outside the schema move onto the declared `hideFields`. - **`rest-server-openapi31-block-removed`** — `restServer.openApi31` → (removed — no replacement key exists. Delete the key; for a real outbound webhook use `Webhook` from `@objectstack/spec/automation`. Config-driven OpenAPI 3.1 webhooks/callbacks documentation returns, if ever, via the enforce route of ADR-0049 through a new ADR) - - Why not automatic: The `openApi31` block (`webhooks` / `callbacks` / `jsonSchemaDialect` / `pathItemReferences`, typed by `OpenApi31ExtensionsSchema` with `OpenApiWebhookEventSchema` and `CallbackSchema` under it) promised OpenAPI 3.1 document synthesis nothing delivered: the REST server's `normalizeConfig` forwards only `api`/`crud`/`metadata`/`batch`/`routes`, and the served /openapi.json is the pre-generated @objectstack/spec contract enriched with the live server URL and the registered objects — a webhook declared here never appeared in any served document (ADR-0049; the #3197 connector-webhook shape one layer up). There is no behaviour to preserve and nothing stored to rewrite: `RestServerConfig` is plugin TS configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), never a `sys_metadata` shape — the stack tree's `api` block declares only its four scoping/auth knobs. The three schemas are removed with the key (zero import-level consumers in objectstack / cloud / objectui); the key itself is tombstoned because the schema is not `.strict()` and a plain delete would strip it silently. #4579. + - Why not automatic: The `openApi31` block (`webhooks` / `callbacks` / `jsonSchemaDialect` / `pathItemReferences`, typed by `OpenApi31ExtensionsSchema` with `OpenApiWebhookEventSchema` and `CallbackSchema` under it) promised OpenAPI 3.1 document synthesis nothing delivered: the REST server's `normalizeConfig` forwards only `api`/`crud`/`metadata`/`batch`/`routes`, and the served /openapi.json is the pre-generated @objectstack/spec contract enriched with the live server URL and the registered objects — a webhook declared here never appeared in any served document (ADR-0049; the declared-but-unconsumed shape an earlier audit found in the connector webhook and event enums one layer up). There is no behaviour to preserve and nothing stored to rewrite: `RestServerConfig` is plugin TS configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), never a `sys_metadata` shape — the stack tree's `api` block declares only its four scoping/auth knobs. The three schemas are removed with the key (zero import-level consumers in objectstack / cloud / objectui); the key itself is tombstoned because the schema is not `.strict()` and a plain delete would strip it silently. - Done when: No `RestServerConfig` value passed to the REST plugin (or `plugin-hono-server` `restConfig`) carries `openApi31` — a config that includes it now fails the parse with the retirement prescription instead of being silently stripped. No code imports `OpenApi31Extensions(Schema)`, `Callback(Schema)` or `OpenApiWebhookEvent(Schema)` from `@objectstack/spec/api` (TS2305 after upgrade). The served /openapi.json is byte-identical before and after — the block never reached it. - **`runtime-httpserver-wrapper-retired`** — `runtime.HttpServer (the exported delegating wrapper class)` → register an `IHttpServer` ADAPTER INSTANCE directly as the `http.server` service — `HonoHttpServer` or whatever adapter the host already builds — instead of wrapping one - Why not automatic: `@objectstack/runtime` exported an `HttpServer` class that took an `IHttpServer` in its constructor, declared `implements IHttpServer`, and forwarded only the contract's REQUIRED members (`get` / `post` / `put` / `delete` / `patch` / `use` / `listen` / `close`). It forwarded none of the OPTIONAL ones — `getPort?()`, `getRawApp?()`, `setFallbackHandler?()`. `packages/spec/src/contracts/http-server.ts` instructs consumers to feature-detect exactly those members with `typeof server.X === "function"` and to degrade when absent, so wrapping a capable adapter made every probe answer false and the capability vanish with the adapter underneath providing it the whole time. The sharpest consequence is worth writing down before anyone reaches for a wrapper of the same shape: a host that wrapped `HonoHttpServer` and registered the wrapper as `http.server` would answer 404 to every endpoint its metadata declared, because `setFallbackHandler` — since #5111 the ONLY entry path for declarative `apis:` endpoints — was never forwarded. This is a TS/API contract surface: an HTTP server adapter is CODE, never stack metadata, so there is no authored source for the chain to rewrite and deliberately no schema tombstone — nothing ever ran an adapter through a `.parse()`. That is precisely why this entry must exist: for an untyped JS host the ledger is the only notification channel there is, and for a typed one tsc reports at the construction site. Same disposition, and the same reason, as `storage-service-list-retired` (#5540) and `data-driver-find-stream-retired` (#4484). Registered by the #6350 stock reconciliation, not by the original change: #5122 landed before the #6148 completeness gate existed, so nothing ever asked it what it had done about the ledger. ADR-0049 / ADR-0087, #5122 (backfilled #6350). - Done when: No code constructs `new HttpServer(...)` from `@objectstack/runtime`, and no import of the name resolves — the export is gone, so a typed caller fails to compile at the construction site. A host that was wrapping an adapter registers the ADAPTER INSTANCE as `http.server` instead, and then proves the capability came back: `getPort()` returns the real bound port after `listen(0)`, `getRawApp()` returns the framework-native app, and a declarative `apis:` endpoint declared in metadata answers its route rather than 404 — the last of which is the failure a wrapper produced silently. An adapter that genuinely needs to intercept calls implements `IHttpServer` in full, forwarding the optional members too, rather than declaring `implements` and dropping them. -- **`sharing-execution-context-retired`** — `@objectstack/spec: the exported type `SharingExecutionContext` (`contracts/sharing-service`), and its re-export from @objectstack/plugin-sharing — the six-field context shape (`userId` / `tenantId` / `positions` / `permissions` / `systemPermissions` / `isSystem`) that sharing, approval and report enforcement signatures used to name` → `ExecutionContext` from `@objectstack/spec` — the complete `resolveAuthzContext` envelope the contracts have declared since #6523. Every one of the retired type's six fields exists on it under the same name and type, so a value that satisfied the old type already satisfies the envelope: only the annotation is rewritten, never the value - - Why not automatic: ADR-0049 enforce-or-remove, completing the #6206 ruling (2026-08-07: enforcement adjudicates on the WHOLE envelope, never a per-site subset). This type was the declared context parameter of 36 signatures across three contracts — `ISharingService` / `ISharingRuleService`, `IApprovalService`, `IReportService` — and it omitted four fields those gates need: `accessible_org_ids` (under the `group` tenancy posture this IS the Layer 0 wall, ADR-0105 D2), `org_user_ids`, `posture` (ADR-0095 D2) and `tabPermissions`. Its damage ran in the MIRROR direction of the share-link twin (#6430 / PR #6511): nothing trimmed the VALUES — the engine middleware always handed the whole context down — it was the declared TYPE that was narrow, so an implementation could not READ what it had been given without casting out of its own contract (`const posture = (context as any).posture` in plugin-approvals' privileged-override gate). #6523 / PR #7068 converged the contracts, PR #7140 and PR #7206 re-annotated the four implementations, and this card removes the now-unreferenced declaration (#7070, #7218). Why this needs a ledger entry despite nothing in-repo referencing it: it is the `export-field-meta-constraints-retired` / `hook-context-session-roles-retired` disposition — a PUBLISHED TypeScript surface with no spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry the prescription, and the ledger is the only channel that reaches an upgrader. Why D3 semantic and not a D2 conversion: nothing authored or stored changes shape. The name is only ever spelled inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach it, and no `sys_metadata` row carries it. ADR-0049 / ADR-0087, #7218. +- **`sharing-execution-context-retired`** — `@objectstack/spec: the exported type `SharingExecutionContext` (`contracts/sharing-service`), and its re-export from @objectstack/plugin-sharing — the six-field context shape (`userId` / `tenantId` / `positions` / `permissions` / `systemPermissions` / `isSystem`) that sharing, approval and report enforcement signatures used to name` → `ExecutionContext` from `@objectstack/spec` — the complete `resolveAuthzContext` envelope the sharing, approval and report contracts have declared since they converged onto it. Every one of the retired type's six fields exists on it under the same name and type, so a value that satisfied the old type already satisfies the envelope: only the annotation is rewritten, never the value + - Why not automatic: ADR-0049 enforce-or-remove, completing the maintainer's ruling of 2026-08-07 on the share-link context (enforcement adjudicates on the WHOLE envelope, never a per-site subset). This type was the declared context parameter of 36 signatures across three contracts — `ISharingService` / `ISharingRuleService`, `IApprovalService`, `IReportService` — and it omitted four fields those gates need: `accessible_org_ids` (under the `group` tenancy posture this IS the Layer 0 wall, ADR-0105 D2), `org_user_ids`, `posture` (ADR-0095 D2) and `tabPermissions`. Its damage ran in the MIRROR direction of the share-link twin, which that same ruling moved onto the whole context: nothing trimmed the VALUES — the engine middleware always handed the whole context down — it was the declared TYPE that was narrow, so an implementation could not READ what it had been given without casting out of its own contract (`const posture = (context as any).posture` in plugin-approvals' privileged-override gate). One change converged the contracts, two more re-annotated the four implementations (sharing and audit, then approvals and reports), and this change removes the now-unreferenced declaration, the deletion that split had deferred. Why this needs a ledger entry despite nothing in-repo referencing it: it is the `export-field-meta-constraints-retired` / `hook-context-session-roles-retired` disposition — a PUBLISHED TypeScript surface with no spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry the prescription, and the ledger is the only channel that reaches an upgrader. Why D3 semantic and not a D2 conversion: nothing authored or stored changes shape. The name is only ever spelled inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach it, and no `sys_metadata` row carries it. ADR-0049 / ADR-0087. - Done when: No source of yours imports `SharingExecutionContext` from `@objectstack/spec` or `@objectstack/plugin-sharing`; each such import becomes `ExecutionContext` from `@objectstack/spec` and the build is green. tsc IS a sufficient detector here, unlike the optional-key retirements at this step: the name is gone outright, so every remaining reference is a hard resolution error rather than a silent `undefined`. ⚠️ Then check the direction tsc CANNOT see: widening an annotation never rejects a value, so an enforcement path that only ever received a hand-built six-field object still compiles and still under-adjudicates. Confirm each caller passes the context it was HANDED, unchanged, rather than a literal it assembled — and that any gate of yours reading `posture`, `accessible_org_ids`, `org_user_ids` or `tabPermissions` now reads them declared, with no `as any` in the path. - **`sharing-rule-recipient-reconcile`** — `security.sharingRule.sharedWith.type `group` / `guest`, and owner-type rules (`type: owner` + `ownedBy`)` → `group` → `team` (the enforced runtime vocabulary); `guest` → delete the rule and expose the records through a public form or a share link; `type: owner` → rewrite as a `type: criteria` rule. `business_unit` is newly authorable for the single-unit case - - Why not automatic: The authoring `ShareRecipientType` enum had drifted behind both the ADR-0090 D3 rename and the enforced runtime, in both directions at once. It still offered the pre-rename `group`, which the seed path silently SKIPPED, while omitting two recipients the runtime and bootstrap already enforced (`team` via `sys_team` / `sys_team_member`, and `business_unit`). It also offered `guest`, which had no runtime recipient mapping at all. Each of those is a rule that validated and then materialised nothing — the ADR-0078 shape, and on a SECURITY surface, where the failure is silent under-sharing: the author sees a valid rule and believes a set of people can reach the records, and no error ever contradicts them. Owner-type rules go for a different and sharper reason: they depend on live team / position membership, which the static materialiser cannot track, so they could not be made to work by fixing a name. They return as an enforced form only if membership-reactive re-materialisation is designed. This is a semantic entry rather than a mechanical conversion because only one of the three rewrites is a rename: `group` → `team` is mechanical, but `guest` and `type: owner` have no target — the author has to decide who was actually meant to reach those records and say so in a form the runtime enforces, and a transform that guessed would be inventing an access grant. After this change every authorable recipient and rule type on the SharingRule surface is enforced; the `queue` recipient stays runtime-reserved and deliberately non-authorable (there is no `sys_queue` yet). Note the two neighbouring conversions cover DIFFERENT faces of this schema and not this one: `sharing-recipient-role-to-position` is the ADR-0090 role → position rename and `sharing-rule-access-level-full-to-edit` is the access-level vocabulary. Registered by the #6350 stock reconciliation. ADR-0078 / ADR-0090 D3 / ADR-0087, #1878 (backfilled #6350). + - Why not automatic: The authoring `ShareRecipientType` enum had drifted behind both the ADR-0090 D3 rename and the enforced runtime, in both directions at once. It still offered the pre-rename `group`, which the seed path silently SKIPPED, while omitting two recipients the runtime and bootstrap already enforced (`team` via `sys_team` / `sys_team_member`, and `business_unit`). It also offered `guest`, which had no runtime recipient mapping at all. Each of those is a rule that validated and then materialised nothing — the ADR-0078 shape, and on a SECURITY surface, where the failure is silent under-sharing: the author sees a valid rule and believes a set of people can reach the records, and no error ever contradicts them. Owner-type rules go for a different and sharper reason: they depend on live team / position membership, which the static materialiser cannot track, so they could not be made to work by fixing a name. They return as an enforced form only if membership-reactive re-materialisation is designed. This is a semantic entry rather than a mechanical conversion because only one of the three rewrites is a rename: `group` → `team` is mechanical, but `guest` and `type: owner` have no target — the author has to decide who was actually meant to reach those records and say so in a form the runtime enforces, and a transform that guessed would be inventing an access grant. After this change every authorable recipient and rule type on the SharingRule surface is enforced; the `queue` recipient stays runtime-reserved and deliberately non-authorable (there is no `sys_queue` yet). Note the two neighbouring conversions cover DIFFERENT faces of this schema and not this one: `sharing-recipient-role-to-position` is the ADR-0090 role → position rename and `sharing-rule-access-level-full-to-edit` is the access-level vocabulary. The change came out of the metadata property liveness audit, which found security properties parsed but never enforced, and was registered late, by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger. ADR-0078 / ADR-0090 D3 / ADR-0087. - Done when: No sharing rule names `group` or `guest`, and none carries `type: owner`; stale definitions now FAIL parse with the valid options listed, so the sweep is "fix until nothing raises". ⚠️ Parsing clean is the weaker half — verify the SHARES, because a rule that was silently materialising nothing looked exactly like one that worked. For every rule that named `group`, confirm the `sys_team` it now resolves to has the membership you expected, and that records reach the people the rule was written for. Each former `guest` rule needs an explicit decision about anonymous access — a public form grant or a share link, or knowingly no access at all — and each former owner-type rule needs a `criteria` predicate that names the same population, checked against a representative record. Where a single business unit was meant, use `business_unit`; `unit_and_subordinates` is the subtree and grants strictly more. - **`sort-node-direction-rejected`** — `data.query.orderBy[].direction (SortNode)` → `order` — `orderBy: [{ field: "updated_at", order: "desc" }]`. One word, same values (`asc` / `desc`) - Why not automatic: `SortNodeSchema` was a plain `z.object`, so zod's default `.strip` applied and a sort node spelling its direction `direction` lost the key silently. Measured on `main` before the change: `SortNodeSchema.parse({ field: "updated_at", direction: "desc" })` returned `{ field: "updated_at", order: "asc" }` — the key discarded and `order` falling back to its `asc` default, so the sort ran in the OPPOSITE direction and the request succeeded. Paired with `limit`, which is how a caller asks for "the latest N", that is not a reordered page but a DIFFERENT SET OF ROWS, returned under an ordinary 200 with nothing in the response to distinguish it from the answer that was asked for. `direction` is not a typo: it is the live vocabulary of a neighbouring contract, `IReportService.orderBy`, and `plugin-auth/objectql-adapter.ts` already translated between the two by hand — a translation known to be necessary and enforced nowhere, the ADR-0049 shape. Both doors closed in one change: `SortNodeSchema` is now a `strictObject` carrying `aliases: { direction: "order" }`, and `normalizeSortNodes` in `metadata-protocol` refuses `{ field, direction }` with `400 INVALID_SORT`. The alias is deliberate rather than left to the edit-distance fallback, because no edit distance bridges `direction` → `order` and a bare "unrecognized key" would leave the caller exactly where the silent strip did. This is registered as a semantic entry rather than a mechanical conversion for one reason worth stating: the rewrite itself is trivially mechanical, but a stored `direction: "asc"` is ambiguous evidence — the author may have written it meaning ascending and been silently GIVEN ascending, so the visible behaviour never contradicted them, and only they can say whether the sort they have been reading was the sort they asked for. Registered by the #6350 stock reconciliation: the in-code alias tombstone shipped with #4721, but the ledger half never did, and a retirement needs both — the tombstone is the proof the removal was declared, the ledger entry is what `spec-changes.json`, the upgrade guide and `os migrate meta` project to consumers. ADR-0049 / ADR-0087, #4721 (backfilled #6350). @@ -462,11 +462,11 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-conte - 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. - 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. #5869 / PR #6209 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 #5685 already ruled on the opposite error: 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. - - Done when: Grep your authored views, pages and related-list components for a filter rule whose operator is in, not_in or between (including the alias spellings nin / notIn / notin) and whose value is not an array of the right arity, then wrap or complete it. `os validate` / `os lint` now report each one by path with the operator, the received shape and the corrected shape, so the sweep is mechanical rather than by eye. Two checks are worth doing where it looks unnecessary: a rule reading `operator: "in", value: ""` is an UNFINISHED row, not a filter — decide what it was meant to select rather than mechanically rewriting it to [""], which is a real and different predicate. And a view that already carried one of these shapes was never returning filtered rows: it answered 400 INVALID_FILTER on render (#5869), so re-check what the view is supposed to show rather than assuming the old result set was correct. + - 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. + - Done when: Grep your authored views, pages and related-list components for a filter rule whose operator is in, not_in or between (including the alias spellings nin / notIn / notin) and whose value is not an array of the right arity, then wrap or complete it. `os validate` / `os lint` now report each one by path with the operator, the received shape and the corrected shape, so the sweep is mechanical rather than by eye. Two checks are worth doing where it looks unnecessary: a rule reading `operator: "in", value: ""` is an UNFINISHED row, not a filter — decide what it was meant to select rather than mechanically rewriting it to [""], which is a real and different predicate. And a view that already carried one of these shapes was never returning filtered rows: it answered 400 INVALID_FILTER on render, so re-check what the view is supposed to show rather than assuming the old result set was correct. - **`view-management-protocol-retired`** — `api.listViews / api.getView / api.createView / api.updateView / api.deleteView (the ViewProtocol interface and its ten Request/Response schemas in api/protocol.zod.ts — 10 defs, 25 exported names)` → the two view surfaces that are actually routed. For a view's STORED definition, the generic metadata methods with `type: 'view'` — `getMetaItem` / `getMetaItems` / `saveMetaItem` / `deleteMetaItem`, served at `/api/v1/meta/view/:name`. For the RESOLVED render-time view, `getUiView` (`GetUiViewRequest` / `GetUiViewResponse`), served at `/api/v1/ui/view/:object/:type`. Neither is addressed by a `viewId`, which is the one thing the retired surface offered and the one thing nothing implemented - - 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: #5948's issue body 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, #6239. - - 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 #5948 meant. + - 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. - 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/cli/test/migrate-meta-engine-guidance.test.ts b/packages/cli/test/migrate-meta-engine-guidance.test.ts index ab9574be49b..302be41171f 100644 --- a/packages/cli/test/migrate-meta-engine-guidance.test.ts +++ b/packages/cli/test/migrate-meta-engine-guidance.test.ts @@ -5,7 +5,9 @@ * of the COVERED families (`engine-*`, `ui-*`, `plugin-*`, `driver-*`, * `kernel-*`, `system-*`, `datasource-*`, `filter-*`, `action-*`, `data-*`, * `element-*`, `field-*`, `export-*`, `api-*`, `dataset-*`, `hook-*`, - * `metadata-*`) states each lesson in words and carries no tracker number. + * `metadata-*`, `rest-*`, `analytics-*`, `view-*`, `package-*`, `object-*`, + * `sharing-*`, `audit-*`, `flow-*`, `http-*`, `inline-*`) states each lesson in + * words and carries no tracker number. * * ## What this pins * @@ -78,6 +80,8 @@ 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-', ]; /** @@ -91,10 +95,16 @@ const REWRITTEN = [ 'action-descriptor-resume-authority-default-flip', 'action-engine-facade-find-query-envelope', 'action-session-roles-to-positions', + 'analytics-authorable-unknown-keys-refused', + 'analytics-date-range-array-two-bounds-required', + 'analytics-query-request-envelope-retired', + 'analytics-time-dimension-date-range-vocabulary-closed', 'api-assembled-entry-split', 'api-error-retry-after-unit-in-key', 'api-runtime-config-durations-unit-in-key', 'api-runtime-create-withdrawn', + 'audit-log-action-enum-retired', + 'audit-log-action-restore-retired', 'data-driver-find-stream-retired', 'data-driver-query-omit-object', 'data-engine-batch-retired', @@ -148,9 +158,17 @@ const REWRITTEN = [ 'filter-query-face-comparands-refused-at-save', 'filter-regex-options-retired', 'filter-text-operator-declared-type-refused', + 'flow-decision-branch-expression-absent-refused', + 'flow-decision-edge-branching-first-match', + 'flow-edge-condition-evaluated-slot-source-required', + 'flow-predicate-slot-blank-string-refused', + 'flow-retry-max-retries-required', 'hook-context-session-roles-retired', 'hook-register-empty-object-target-refused', 'hook-register-undispatched-lifecycle-event-refused', + 'http-request-errors-total-retired', + 'http-server-runtime-vocabulary-retired', + 'inline-grid-column-currency-scale-refused', 'kernel-compatibility-matrix-estimated-migration-time-unit-in-key', 'kernel-context-preview-mode-retired', 'kernel-event-bus-retention-unit-in-key', @@ -165,6 +183,14 @@ const REWRITTEN = [ 'metadata-manager-config-cache-ttl-unit-in-key', 'metadata-manager-config-inert-cache-keys-retired', 'metadata-plugin-additional-types-retired', + 'object-block-sort-item-array', + 'object-grid-data-view-data-converged', + 'object-grid-default-filters-rule-array', + 'object-index-unknown-keys-refused', + 'package-api-contracts-unmounted-entries-retired', + 'package-install-request-unknown-keys-refused', + 'package-rollback-response-retired', + 'package-uninstall-explicit-all-tenants', 'plugin-activation-events-retired', 'plugin-auto-restart-never-reinitialised', 'plugin-manifest-contributes-dead-members-retired', @@ -175,6 +201,12 @@ const REWRITTEN = [ 'plugin-runtime-family-retired', 'plugin-security-scan-result-surface-retired', 'plugin-security-scanner-retired', + 'rest-api-endpoint-handler-status-retired', + 'rest-api-plugin-durations-unit-in-key', + 'rest-server-config-dead-keys-retired', + 'rest-server-openapi31-block-removed', + 'sharing-execution-context-retired', + 'sharing-rule-recipient-reconcile', 'system-cache-durations-unit-in-key', 'system-collaboration-durations-unit-in-key', 'system-failover-health-check-interval-unit-in-key', @@ -199,6 +231,12 @@ const REWRITTEN = [ 'ui-record-blocks-unknown-keys-refused', 'ui-reference-rail-unknown-keys-refused', 'ui-widget-i18n-family-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', ]; interface FamilyEntry { diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 1aa99a7201f..aebeedc76ff 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -392,7 +392,7 @@ "replacement": "bare AnalyticsQuery body (top-level cube/measures/dimensions/where/...)", "migrationId": "analytics-query-request-envelope-retired", "toMajor": 17, - "rationale": "The { cube, query: {...} } envelope was an HTTP-wire dialect of the retired degraded analytics shim (#3891), never stored in stack metadata — there is no source for the chain to rewrite. Callers of POST /analytics/query and /analytics/sql must move the query.* fields to the body top level themselves." + "rationale": "The { cube, query: {...} } envelope was an HTTP-wire dialect of the retired degraded analytics shim (the fallback that answered /analytics/query when no analytics service was installed, and dropped the caller's identity and its `where` filter at the door), never stored in stack metadata — there is no source for the chain to rewrite. Callers of POST /analytics/query and /analytics/sql must move the query.* fields to the body top level themselves." }, { "surface": "api.analyticsQueryRequest.format", @@ -427,14 +427,14 @@ "replacement": "nothing, for either value — both are removed rather than renamed, because neither named an event this platform records. For permission changes, read the ordinary `create` / `update` rows on the permission objects themselves: a grant or binding write is an ordinary record write and the generic audit writer already ledgers it, so a second semantically-duplicate row was never minted. For `export` there is no replacement and nothing is lost: no export feature ever wrote an audit row. A consumer filtering `sys_audit_log` on either value was reading an empty result set on every deployment, and still is — what changed is that the contract no longer promises otherwise", "migrationId": "audit-log-action-enum-retired", "toMajor": 17, - "rationale": "Maintainer ruling 2026-08-12 (#7675), the retirement half of a two-half verdict: the cheap writers get built (#8144 login/logout, #8145 config_change) 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, #8147." + "rationale": "Maintainer ruling 2026-08-12 on the audit log's writerless actions, the retirement half of a two-half verdict: the cheap writers get built (`login` / `logout` on the auth session hooks, `config_change` from the settings service) and the enum values with no feature behind them are retired. 原则记录:空 widget + 永远查不到东西的过滤器是可见产品缺陷;审计面宁窄勿谎. The defect was false compliance on a COMPLIANCE surface, which is the sharpest form of ADR-0049 declared-≠-enforced: an auditor reading the action enum believed the platform captured permission changes and data exports, and the shipped list views and dashboard widgets showed them a filter and a tile for exactly those events. Both were permanently empty. Measured by enumerating every `sys_audit_log` writer in the repo — there are exactly two: plugin-audit`s generic hook writer, whose `actionFor` maps afterInsert/Update/Delete to create/update/delete and nothing else, and plugin-auth`s admin user-import. Neither has ever emitted `export` or `permission_change`. This is an enum-VALUE retirement, so the bookkeeping differs from a key retirement in the two ways `hook-body-crypto-hash-removed`, `dataset-measure-array-string-agg-removed` and `action-global-nav-location-removed` already record: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) and the four surface ratchets are expected to be byte-identical (no def changed). It differs from all three in being a SEMANTIC entry rather than a D2 conversion, and the reason is that there is no source to rewrite: `sys_audit_log` is a platform-owned, append-only object whose every field is `readonly: true`. Nobody authors an audit row and nobody authors this enum — the values appear only in rows the runtime writes and in queries consumers send. A conversion rewrites authored metadata or a stored `sys_metadata` row; this surface is neither, so the disposition is the one `BatchOptions.validateOnly` and the notification cursor already take in this major. ⚠️ Historical ROWS are deliberately untouched. A deployment that somehow holds a row with either value keeps it, and keeps reading it back: the enum is not enforced on this object at all (`validateRecord` skips `readonly` fields, and every field here is readonly), so nothing rejects stored history and no backfill is required or wanted. Deleting audit history to satisfy a schema narrowing would be the one genuinely destructive reading of this change. ADR-0049 / ADR-0087." }, { "surface": "sys_audit_log.action — the value 'restore' left the select enum declared by plugin-audit (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts). It also left the shipped writes_only list-view filter on that object, and the generated option label in all four plugin-audit translation bundles", - "replacement": "nothing — the value is removed rather than renamed, because it never named an event this platform records. There is no undelete or restore capability to point at: deletes are hard deletes, and the record-level audit writer maps the ObjectQL lifecycle to `create` / `update` / `delete` only. A consumer filtering `sys_audit_log` on this value was reading an empty result set on every deployment, and still is — what changed is that the contract no longer promises otherwise. If you were counting on a restore trail, the capability itself is the missing piece (#1883, #3146), not this enum row", + "replacement": "nothing — the value is removed rather than renamed, because it never named an event this platform records. There is no undelete or restore capability to point at: deletes are hard deletes, and the record-level audit writer maps the ObjectQL lifecycle to `create` / `update` / `delete` only. A consumer filtering `sys_audit_log` on this value was reading an empty result set on every deployment, and still is — what changed is that the contract no longer promises otherwise. If you were counting on a restore trail, the capability itself is the missing piece (an undelete / purge permission lifecycle and a soft-delete recycle bin, neither built yet), not this enum row", "migrationId": "audit-log-action-restore-retired", "toMajor": 17, - "rationale": "The same maintainer ruling as `audit-log-action-enum-retired`, carried to the one value #7675's own survey did not name (#8315, 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 (#8011) — 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 (#1883 pm:on-hold, #3146 status:parked). 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, #8315, #7675, #8147." + "rationale": "The same maintainer ruling as `audit-log-action-enum-retired`, carried to the one value that ruling's own survey did not name (triage 2026-08-13). 原则记录:空 widget + 永远查不到东西的过滤器是可见产品缺陷;审计面宁窄勿谎. `restore` is the least ambiguous member of the family: the record-level writer could not have produced it even by accident, because `actionFor()` in audit-writers.ts is typed `'create' | 'update' | 'delete' | null` and its caller early-returns on null. A tree-wide search finds no other producer. What made it a card rather than a tidy-up is that TWO shipped declarations asserted the opposite, so a declaration-reading audit scored the action as covered: the `writes_only` list view offered it as a filter value, and the module docblock of auth-event-audit.ts named it among the actions the writer emits. The comment is the ADR-0049 declared-≠-enforced shape in its purest form (the shape a credential-storage audit had to settle by re-measuring two \"hashed at rest\" comments) — a sentence next to a mechanism, contradicted by the type signature of that very mechanism, with nothing in CI able to tell. Both declarations are corrected in one change, and the invariant behind the comment (every declared action has a writer) now has a pin test under it rather than prose. Bookkeeping is identical to the sibling entry, for the same reasons: an enum-VALUE retirement puts nothing in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) and leaves the four surface ratchets byte-identical (no def changed), and it is a SEMANTIC entry rather than a D2 conversion because there is no source to rewrite — `sys_audit_log` is a platform-owned, append-only object whose every field is `readonly: true`, so nobody authors an audit row and nobody authors this enum. ⚠️ This is a statement about the WRITER, not a product stance against undelete. Soft delete/restore is parked, not rejected: the undelete / purge lifecycle and the recycle bin are both held open, not declined. If that capability lands, this value returns WITH its writer — the emission point, its tests, and the view that surfaces it — never as a bare enum row again. ⚠️ Historical ROWS are deliberately untouched, exactly as for the sibling entry: the enum is not enforced on this object at all (`validateRecord` skips `readonly` fields), so any stored row keeps parsing and reading back, and no backfill is required or wanted. Deleting audit history to satisfy a schema narrowing would be the one genuinely destructive reading of this change. ADR-0049 / ADR-0087." }, { "surface": "api.authConfig.features.passkeys / api.authConfig.features.magicLink", @@ -651,7 +651,7 @@ "replacement": "an explicit count >= 1 (e.g. maxRetries: 3), or strategy: 'fail'", "migrationId": "flow-retry-max-retries-required", "toMajor": 17, - "rationale": "maxRetries had two defaults — FlowSchema `.default(0)` and the engine's `maxRetries ?? 3` — so an unstated count retried 0 times through the schema and 3 times through a hand-built definition (#4247). With the engine's copy removed the unstated count is unambiguously 0, and retrying zero times is exactly `strategy: 'fail'`, so the schema now refuses the combination instead of it silently doing nothing. There is no lossless rewrite: 0 preserves the behaviour a parsed flow got but contradicts what its author wrote, and any positive count is a NEW decision about re-running the whole flow with its side effects. That choice is the author's." + "rationale": "maxRetries had two defaults — FlowSchema `.default(0)` and the engine's `maxRetries ?? 3` — so an unstated count retried 0 times through the schema and 3 times through a hand-built definition. With the engine's copy removed the unstated count is unambiguously 0, and retrying zero times is exactly `strategy: 'fail'`, so the schema now refuses the combination instead of it silently doing nothing. There is no lossless rewrite: 0 preserves the behaviour a parsed flow got but contradicts what its author wrote, and any positive count is a NEW decision about re-running the whole flow with its side effects. That choice is the author's." }, { "surface": "data.hookContext.session.roles", @@ -672,14 +672,14 @@ "replacement": "the 5xx rate is `http_requests_total{status=~\"5..\"}` — the TRANSPORT emits that family through the `IHttpServer.afterResponse` seam, so it covers every inbound surface; unhandled-exception rate specifically, which is the one thing the retired counter uniquely reported, is the `errorReporter` (Sentry / Datadog / your adapter), which still fires on every 5xx throw", "migrationId": "http-request-errors-total-retired", "toMajor": 17, - "rationale": "ADR-0049 enforce-or-remove, on a DECLARED-not-enforced metric name. `SEMCONV` published `http_request_errors_total` as part of a stable namespace declared \"so hosts can wire alerts/dashboards against it\", but the only emitter was `@objectstack/runtime`'s `instrumentRouteHandler`, applied only by the dispatcher's own route Proxy — so the series never saw auth's `getRawApp()` mount, the REST data API via `RouteManager`, or any other inbound surface. Its two siblings in the same family were moved to the transport seam (#9650/#9835 for the counter, #9834/#10004 for the histogram) and this one could not follow: `HttpResponseObservation` carries `{method, routePattern, status, elapsedMs}` and NO throw signal of any kind, so every transport-side shape would have counted a DIFFERENT population rather than the same one more widely. The divergence was measured in both directions — the dispatcher answers its own errors through `errorResponseBase`, which sets a status and does not re-throw, so the old counter MISSED those, while its `catch` incremented unconditionally, so a thrown 4xx WAS counted as an error. And `http_requests_total` already carries a `status` label, so a status-class error counter would be fully derivable from data the transport already publishes. Maintainer ruling 2026-08-20 (option C of four presented, over B \"move it to the transport as a status class\" and D \"keep it dispatcher-scoped and rename it\"): RETIRE. A metric NAME is a RESPONSE surface, not authorable metadata — no stack, example or template carries it, so there is no source for a D2 conversion to rewrite and no schema to tombstone; a host names the series in its own dashboard or alert file, outside this repo. That is exactly why this entry exists: for an operator whose Grafana keys on the string, the ledger is the only notification channel there is. Same disposition, and the same reason, as `runtime-httpserver-wrapper-retired` (#5122) and `enhanced-api-error-field-errors-renamed` (#3977). ADR-0049 / ADR-0087, #9834." + "rationale": "ADR-0049 enforce-or-remove, on a DECLARED-not-enforced metric name. `SEMCONV` published `http_request_errors_total` as part of a stable namespace declared \"so hosts can wire alerts/dashboards against it\", but the only emitter was `@objectstack/runtime`'s `instrumentRouteHandler`, applied only by the dispatcher's own route Proxy — so the series never saw auth's `getRawApp()` mount, the REST data API via `RouteManager`, or any other inbound surface. Its two siblings in the same family were moved to the transport seam (the request counter, then the latency histogram, both through the response-observing hook the transport was given) and this one could not follow: `HttpResponseObservation` carries `{method, routePattern, status, elapsedMs}` and NO throw signal of any kind, so every transport-side shape would have counted a DIFFERENT population rather than the same one more widely. The divergence was measured in both directions — the dispatcher answers its own errors through `errorResponseBase`, which sets a status and does not re-throw, so the old counter MISSED those, while its `catch` incremented unconditionally, so a thrown 4xx WAS counted as an error. And `http_requests_total` already carries a `status` label, so a status-class error counter would be fully derivable from data the transport already publishes. Maintainer ruling 2026-08-20 (option C of four presented, over B \"move it to the transport as a status class\" and D \"keep it dispatcher-scoped and rename it\"): RETIRE. A metric NAME is a RESPONSE surface, not authorable metadata — no stack, example or template carries it, so there is no source for a D2 conversion to rewrite and no schema to tombstone; a host names the series in its own dashboard or alert file, outside this repo. That is exactly why this entry exists: for an operator whose Grafana keys on the string, the ledger is the only notification channel there is. Same disposition, and the same reason, as `runtime-httpserver-wrapper-retired` and `enhanced-api-error-field-errors-renamed`. ADR-0049 / ADR-0087." }, { "surface": "system.serverEvent / system.serverEventType / system.serverCapabilities / system.serverStatus (the lifecycle-event, capability-report and status vocabulary of system/http-server.zod.ts — 4 defs, 8 exported names)", "replacement": "(removed — there is no replacement key, because there was never a key. Server lifecycle is the transport plugin's own start/stop seam; per-request and per-server observability is `system/metrics.zod.ts` and `system/logging.zod.ts` (plus `OS_SERVER_TIMING` for timings), and liveness is the `/health` endpoint. What a transport plugin can DO it states by implementing the kernel plugin contract — the seams it registers are the capability statement, and a self-described capability record can only disagree with them. Server-level configuration that IS authorable lives on `defineStack({ server })` / `StackServerConfigSchema`, which is unaffected)", "migrationId": "http-server-runtime-vocabulary-retired", "toMajor": 17, - "rationale": "The second and final ADR-0049 pass over `system/http-server.zod.ts`. #4938 removed the CONFIG half (`HttpServerConfigSchema`, nine keys, zero readers, zero authoring entry); this removes the RUNTIME half — a 7-member lifecycle event union with a timestamped envelope, an eight-boolean capability report, and a five-state status record with connection and request counters. Nothing ever emitted, consumed or parsed any of them. This card was HELD for four days rather than queued, on a specific and legitimate doubt: a response/capability vocabulary can be a REFERENCE surface for host implementers, so \"zero consumers in this repo\" is weaker evidence for one of those than for an authorable key (the CSS-variable rebuttal). The hold was lifted by measuring the reference reader itself rather than by re-running the same grep: `plugin-hono-server`, the one in-tree host implementation, neither implements nor reports any of the three — it names no capability record, no status shape and no event union, and what it registers is routes and middleware through the kernel plugin contract. A declaration-site grep put every declaration in this one file, a quoted-name sweep across objectstack and objectui found no reader outside it, and the control passed in the SAME run: `MiddlewareConfig`, declared twelve lines away, resolves to `packages/runtime/src/middleware.ts`. So the sweep could see a reader in this file when there was one. With no carrier key there is nothing to tombstone, and with no author there is no source or `sys_metadata` row for a D2 conversion to rewrite: RETIRED_DEFS_BY_MAJOR plus this entry are the declaration — route 3, the same shape as #4938 in this very file, #4834, #4988 and #5055. If host-implementer conformance becomes a real requirement it returns through the ENFORCE route: an adapter contract with a checker behind it, vocabulary second. ADR-0049, #5295." + "rationale": "The second and final ADR-0049 pass over `system/http-server.zod.ts`. The first removed the CONFIG half (`HttpServerConfigSchema`, nine keys, zero readers, zero authoring entry); this removes the RUNTIME half — a 7-member lifecycle event union with a timestamped envelope, an eight-boolean capability report, and a five-state status record with connection and request counters. Nothing ever emitted, consumed or parsed any of them. This card was HELD for four days rather than queued, on a specific and legitimate doubt: a response/capability vocabulary can be a REFERENCE surface for host implementers, so \"zero consumers in this repo\" is weaker evidence for one of those than for an authorable key (the CSS-variable rebuttal). The hold was lifted by measuring the reference reader itself rather than by re-running the same grep: `plugin-hono-server`, the one in-tree host implementation, neither implements nor reports any of the three — it names no capability record, no status shape and no event union, and what it registers is routes and middleware through the kernel plugin contract. A declaration-site grep put every declaration in this one file, a quoted-name sweep across objectstack and objectui found no reader outside it, and the control passed in the SAME run: `MiddlewareConfig`, declared twelve lines away, resolves to `packages/runtime/src/middleware.ts`. So the sweep could see a reader in this file when there was one. With no carrier key there is nothing to tombstone, and with no author there is no source or `sys_metadata` row for a D2 conversion to rewrite: RETIRED_DEFS_BY_MAJOR plus this entry are the declaration — route 3, the same shape as the config half's removal in this very file and the earlier removals of the dynamic plugin-loading family, the `ui/` interaction configs and the widget / i18n shapes. If host-implementer conformance becomes a real requirement it returns through the ENFORCE route: an adapter contract with a checker behind it, vocabulary second. ADR-0049." }, { "surface": "api.ImportRequest runAutomations — the declared default of the key on BOTH import bodies, POST /api/v1/data/:object/import (ImportRequest) and its async twin POST /api/v1/data/:object/import/jobs (CreateImportJobRequest, which IS the same schema object). It was declared default(false) and described as \"off by default for bulk\"; it is now default(true), which is what the server has always done", @@ -707,7 +707,7 @@ "replacement": "explicit `allTenants: true` for a cross-tenant uninstall, or an `organizationId` to scope it", "migrationId": "package-uninstall-explicit-all-tenants", "toMajor": 17, - "rationale": "An uninstall that named no organization matched EVERY organization's rows — measured at 5 of 5 deleted, including a foreign org's (#7705, #7780). That width was never chosen; it fell out of a missing argument, and the two transports of the same route disagreed because of it. In protocol 17 the call is REFUSED instead: neither `organizationId` nor `allTenants: true` answers 400 `TENANT_SCOPE_REQUIRED` and deletes nothing, as does supplying both (they are contradictory, not redundant). Whether a given caller meant \"this tenant\" or \"every tenant\" is an intent no transform can recover: `resolveActiveOrganizationId` is catch-wrapped, so an accidental org-less call and a deliberate environment-wide one are byte-identical at the call site — which is the whole reason the parameter had to become explicit rather than conventional. Nothing in authored metadata spells this: it is a runtime call-site contract, so it is one semantic TODO for operators and API callers rather than a stack conversion — the same disposition `rest-requireauth-default-flip` (#12) takes for its own default flip." + "rationale": "An uninstall that named no organization matched EVERY organization's rows — measured at 5 of 5 deleted, including a foreign org's, while uninstall's orphaned-row defect was being repaired. That width was never chosen; it fell out of a missing argument, and the two transports of the same route disagreed because of it. In protocol 17 the call is REFUSED instead: neither `organizationId` nor `allTenants: true` answers 400 `TENANT_SCOPE_REQUIRED` and deletes nothing, as does supplying both (they are contradictory, not redundant). Whether a given caller meant \"this tenant\" or \"every tenant\" is an intent no transform can recover: `resolveActiveOrganizationId` is catch-wrapped, so an accidental org-less call and a deliberate environment-wide one are byte-identical at the call site — which is the whole reason the parameter had to become explicit rather than conventional. Nothing in authored metadata spells this: it is a runtime call-site contract, so it is one semantic TODO for operators and API callers rather than a stack conversion — the same disposition `rest-requireauth-default-flip` (protocol 12) takes for its own default flip." }, { "surface": "kernel.dynamicLoadRequest.activationEvents / studio.studioPluginManifest.activationEvents", @@ -791,7 +791,7 @@ "replacement": "(removed — no replacement key exists. Delete the key; for a real outbound webhook use `Webhook` from `@objectstack/spec/automation`. Config-driven OpenAPI 3.1 webhooks/callbacks documentation returns, if ever, via the enforce route of ADR-0049 through a new ADR)", "migrationId": "rest-server-openapi31-block-removed", "toMajor": 17, - "rationale": "The `openApi31` block (`webhooks` / `callbacks` / `jsonSchemaDialect` / `pathItemReferences`, typed by `OpenApi31ExtensionsSchema` with `OpenApiWebhookEventSchema` and `CallbackSchema` under it) promised OpenAPI 3.1 document synthesis nothing delivered: the REST server's `normalizeConfig` forwards only `api`/`crud`/`metadata`/`batch`/`routes`, and the served /openapi.json is the pre-generated @objectstack/spec contract enriched with the live server URL and the registered objects — a webhook declared here never appeared in any served document (ADR-0049; the #3197 connector-webhook shape one layer up). There is no behaviour to preserve and nothing stored to rewrite: `RestServerConfig` is plugin TS configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), never a `sys_metadata` shape — the stack tree's `api` block declares only its four scoping/auth knobs. The three schemas are removed with the key (zero import-level consumers in objectstack / cloud / objectui); the key itself is tombstoned because the schema is not `.strict()` and a plain delete would strip it silently. #4579." + "rationale": "The `openApi31` block (`webhooks` / `callbacks` / `jsonSchemaDialect` / `pathItemReferences`, typed by `OpenApi31ExtensionsSchema` with `OpenApiWebhookEventSchema` and `CallbackSchema` under it) promised OpenAPI 3.1 document synthesis nothing delivered: the REST server's `normalizeConfig` forwards only `api`/`crud`/`metadata`/`batch`/`routes`, and the served /openapi.json is the pre-generated @objectstack/spec contract enriched with the live server URL and the registered objects — a webhook declared here never appeared in any served document (ADR-0049; the declared-but-unconsumed shape an earlier audit found in the connector webhook and event enums one layer up). There is no behaviour to preserve and nothing stored to rewrite: `RestServerConfig` is plugin TS configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), never a `sys_metadata` shape — the stack tree's `api` block declares only its four scoping/auth knobs. The three schemas are removed with the key (zero import-level consumers in objectstack / cloud / objectui); the key itself is tombstoned because the schema is not `.strict()` and a plain delete would strip it silently." }, { "surface": "runtime.HttpServer (the exported delegating wrapper class)", @@ -802,17 +802,17 @@ }, { "surface": "@objectstack/spec: the exported type `SharingExecutionContext` (`contracts/sharing-service`), and its re-export from @objectstack/plugin-sharing — the six-field context shape (`userId` / `tenantId` / `positions` / `permissions` / `systemPermissions` / `isSystem`) that sharing, approval and report enforcement signatures used to name", - "replacement": "`ExecutionContext` from `@objectstack/spec` — the complete `resolveAuthzContext` envelope the contracts have declared since #6523. Every one of the retired type's six fields exists on it under the same name and type, so a value that satisfied the old type already satisfies the envelope: only the annotation is rewritten, never the value", + "replacement": "`ExecutionContext` from `@objectstack/spec` — the complete `resolveAuthzContext` envelope the sharing, approval and report contracts have declared since they converged onto it. Every one of the retired type's six fields exists on it under the same name and type, so a value that satisfied the old type already satisfies the envelope: only the annotation is rewritten, never the value", "migrationId": "sharing-execution-context-retired", "toMajor": 17, - "rationale": "ADR-0049 enforce-or-remove, completing the #6206 ruling (2026-08-07: enforcement adjudicates on the WHOLE envelope, never a per-site subset). This type was the declared context parameter of 36 signatures across three contracts — `ISharingService` / `ISharingRuleService`, `IApprovalService`, `IReportService` — and it omitted four fields those gates need: `accessible_org_ids` (under the `group` tenancy posture this IS the Layer 0 wall, ADR-0105 D2), `org_user_ids`, `posture` (ADR-0095 D2) and `tabPermissions`. Its damage ran in the MIRROR direction of the share-link twin (#6430 / PR #6511): nothing trimmed the VALUES — the engine middleware always handed the whole context down — it was the declared TYPE that was narrow, so an implementation could not READ what it had been given without casting out of its own contract (`const posture = (context as any).posture` in plugin-approvals' privileged-override gate). #6523 / PR #7068 converged the contracts, PR #7140 and PR #7206 re-annotated the four implementations, and this card removes the now-unreferenced declaration (#7070, #7218). Why this needs a ledger entry despite nothing in-repo referencing it: it is the `export-field-meta-constraints-retired` / `hook-context-session-roles-retired` disposition — a PUBLISHED TypeScript surface with no spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry the prescription, and the ledger is the only channel that reaches an upgrader. Why D3 semantic and not a D2 conversion: nothing authored or stored changes shape. The name is only ever spelled inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach it, and no `sys_metadata` row carries it. ADR-0049 / ADR-0087, #7218." + "rationale": "ADR-0049 enforce-or-remove, completing the maintainer's ruling of 2026-08-07 on the share-link context (enforcement adjudicates on the WHOLE envelope, never a per-site subset). This type was the declared context parameter of 36 signatures across three contracts — `ISharingService` / `ISharingRuleService`, `IApprovalService`, `IReportService` — and it omitted four fields those gates need: `accessible_org_ids` (under the `group` tenancy posture this IS the Layer 0 wall, ADR-0105 D2), `org_user_ids`, `posture` (ADR-0095 D2) and `tabPermissions`. Its damage ran in the MIRROR direction of the share-link twin, which that same ruling moved onto the whole context: nothing trimmed the VALUES — the engine middleware always handed the whole context down — it was the declared TYPE that was narrow, so an implementation could not READ what it had been given without casting out of its own contract (`const posture = (context as any).posture` in plugin-approvals' privileged-override gate). One change converged the contracts, two more re-annotated the four implementations (sharing and audit, then approvals and reports), and this change removes the now-unreferenced declaration, the deletion that split had deferred. Why this needs a ledger entry despite nothing in-repo referencing it: it is the `export-field-meta-constraints-retired` / `hook-context-session-roles-retired` disposition — a PUBLISHED TypeScript surface with no spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry the prescription, and the ledger is the only channel that reaches an upgrader. Why D3 semantic and not a D2 conversion: nothing authored or stored changes shape. The name is only ever spelled inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach it, and no `sys_metadata` row carries it. ADR-0049 / ADR-0087." }, { "surface": "security.sharingRule.sharedWith.type `group` / `guest`, and owner-type rules (`type: owner` + `ownedBy`)", "replacement": "`group` → `team` (the enforced runtime vocabulary); `guest` → delete the rule and expose the records through a public form or a share link; `type: owner` → rewrite as a `type: criteria` rule. `business_unit` is newly authorable for the single-unit case", "migrationId": "sharing-rule-recipient-reconcile", "toMajor": 17, - "rationale": "The authoring `ShareRecipientType` enum had drifted behind both the ADR-0090 D3 rename and the enforced runtime, in both directions at once. It still offered the pre-rename `group`, which the seed path silently SKIPPED, while omitting two recipients the runtime and bootstrap already enforced (`team` via `sys_team` / `sys_team_member`, and `business_unit`). It also offered `guest`, which had no runtime recipient mapping at all. Each of those is a rule that validated and then materialised nothing — the ADR-0078 shape, and on a SECURITY surface, where the failure is silent under-sharing: the author sees a valid rule and believes a set of people can reach the records, and no error ever contradicts them. Owner-type rules go for a different and sharper reason: they depend on live team / position membership, which the static materialiser cannot track, so they could not be made to work by fixing a name. They return as an enforced form only if membership-reactive re-materialisation is designed. This is a semantic entry rather than a mechanical conversion because only one of the three rewrites is a rename: `group` → `team` is mechanical, but `guest` and `type: owner` have no target — the author has to decide who was actually meant to reach those records and say so in a form the runtime enforces, and a transform that guessed would be inventing an access grant. After this change every authorable recipient and rule type on the SharingRule surface is enforced; the `queue` recipient stays runtime-reserved and deliberately non-authorable (there is no `sys_queue` yet). Note the two neighbouring conversions cover DIFFERENT faces of this schema and not this one: `sharing-recipient-role-to-position` is the ADR-0090 role → position rename and `sharing-rule-access-level-full-to-edit` is the access-level vocabulary. Registered by the #6350 stock reconciliation. ADR-0078 / ADR-0090 D3 / ADR-0087, #1878 (backfilled #6350)." + "rationale": "The authoring `ShareRecipientType` enum had drifted behind both the ADR-0090 D3 rename and the enforced runtime, in both directions at once. It still offered the pre-rename `group`, which the seed path silently SKIPPED, while omitting two recipients the runtime and bootstrap already enforced (`team` via `sys_team` / `sys_team_member`, and `business_unit`). It also offered `guest`, which had no runtime recipient mapping at all. Each of those is a rule that validated and then materialised nothing — the ADR-0078 shape, and on a SECURITY surface, where the failure is silent under-sharing: the author sees a valid rule and believes a set of people can reach the records, and no error ever contradicts them. Owner-type rules go for a different and sharper reason: they depend on live team / position membership, which the static materialiser cannot track, so they could not be made to work by fixing a name. They return as an enforced form only if membership-reactive re-materialisation is designed. This is a semantic entry rather than a mechanical conversion because only one of the three rewrites is a rename: `group` → `team` is mechanical, but `guest` and `type: owner` have no target — the author has to decide who was actually meant to reach those records and say so in a form the runtime enforces, and a transform that guessed would be inventing an access grant. After this change every authorable recipient and rule type on the SharingRule surface is enforced; the `queue` recipient stays runtime-reserved and deliberately non-authorable (there is no `sys_queue` yet). Note the two neighbouring conversions cover DIFFERENT faces of this schema and not this one: `sharing-recipient-role-to-position` is the ADR-0090 role → position rename and `sharing-rule-access-level-full-to-edit` is the access-level vocabulary. The change came out of the metadata property liveness audit, which found security properties parsed but never enforced, and was registered late, by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger. ADR-0078 / ADR-0090 D3 / ADR-0087." }, { "surface": "data.query.orderBy[].direction (SortNode)", @@ -875,14 +875,14 @@ "replacement": "an ARRAY for in / not_in (a single value becomes a one-element list: value: \"won\" becomes value: [\"won\"]), and a two-element [min, max] array for between. The empty list [] stays legal for in / not_in and keeps its meaning. Nothing else moves: a scalar operator carrying an array, a string operator carrying a number, and a unary operator carrying an ignored value all still parse", "migrationId": "view-filter-rule-value-shaped-by-operator", "toMajor": 17, - "rationale": "A publish-time gate catching up to a query-time one, not a new rule. #5869 / PR #6209 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 #5685 already ruled on the opposite error: 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." + "rationale": "A publish-time gate catching up to a query-time one, not a new rule. An earlier fix closed the RUNTIME half: `assertListComparandShapes` (@objectstack/objectql, filter-comparand-shape.ts) refuses a lowered `{ stage: { $nin: \"won\" } }` with a named 400 INVALID_FILTER, and before that it was a 500. The authoring surface stayed silent, so the failure was two-stage: the view published cleanly and only broke when someone opened it. That file names this very schema as the reachable authoring source of the defect. The tightening MIRRORS that gate exactly — three constraints, one for one — and deliberately goes no further, because an earlier fix already settled the opposite error (the ordering operators' comparand widened to the strings the platform itself produces): a schema stricter than the runtime \"in ways the runtime deliberately allows\" was the WRONG side and was widened to match. So `in: []` is still accepted (a declared predicate both drivers implement), `equals: [\"a\",\"b\"]` is still accepted (it lowers to a deep-equality comparand), and `is_empty: \"\"` is still accepted (the null predicates take their direction from the operator NAME — convertComparison ignores the value position, and the ObjectUI client deliberately sends a truthy placeholder there). ⚠️ Metadata AT REST is deliberately NOT rewritten, and there is no D2 conversion. A D2 entry replays a shape the platform once WROTE and renamed; this shape was never written by any first-party producer (every in / not_in rule in this repo, in objectui and in the cloud repo already carries an array — measured) and has never EXECUTED, since it 400s on first render today. Coercing it at load would be the platform guessing intent rather than replaying a rename, and it cannot guess honestly: value: \"\" would become the predicate [\"\"] (a real filter on the empty string) rather than the \"not filled in yet\" a console row means, and between: 5 has no defensible second bound at all. The read path does not re-validate stored rows (applyConversionsToStoredItem never validates, by its own contract), so no stored view becomes unreadable; what changes is that RE-SAVING such a view is refused at the write gate naming `value`, instead of storing a filter that 400s. ADR-0049 / ADR-0078 / ADR-0112." }, { "surface": "api.listViews / api.getView / api.createView / api.updateView / api.deleteView (the ViewProtocol interface and its ten Request/Response schemas in api/protocol.zod.ts — 10 defs, 25 exported names)", "replacement": "the two view surfaces that are actually routed. For a view's STORED definition, the generic metadata methods with `type: 'view'` — `getMetaItem` / `getMetaItems` / `saveMetaItem` / `deleteMetaItem`, served at `/api/v1/meta/view/:name`. For the RESOLVED render-time view, `getUiView` (`GetUiViewRequest` / `GetUiViewResponse`), served at `/api/v1/ui/view/:object/:type`. Neither is addressed by a `viewId`, which is the one thing the retired surface offered and the one thing nothing implemented", "migrationId": "view-management-protocol-retired", "toMajor": 17, - "rationale": "A complete viewId-addressed CRUD surface — list (with a list/form filter), read, create, patch, delete — with none of the three things a protocol method needs. Measured on origin/main immediately before the removal: no implementation (`packages/metadata-protocol/src/protocol.ts` declares no `listViews` / `getView` / `createView` / `updateView` / `deleteView`; its only view resolver is `getUiView`), no route (`packages/rest/src/rest-server.ts` never mentions `viewId`, so nothing viewId-addressed is reachable over HTTP at all), and no caller (the only `ViewProtocol` mention outside its own file was the services checklist, which already recorded the five as declared-and-unrouted). The look-alike hits a bare-name grep turns up are all different contracts: `metadata-manager.ts`'s `getView(name: string)` is another class, and objectui's `getView(objectName, viewId)` resolves through `client.meta.getItem('view', …)`, i.e. the metadata route. What makes this worth a removal rather than a note is that the cost is already measured. A declared surface that is name-identical and semantics-adjacent to a real one is an attractive nuisance in every grep, and it mis-directed a decision once: #5948's issue body 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, #6239." + "rationale": "A complete viewId-addressed CRUD surface — list (with a list/form filter), read, create, patch, delete — with none of the three things a protocol method needs. Measured on origin/main immediately before the removal: no implementation (`packages/metadata-protocol/src/protocol.ts` declares no `listViews` / `getView` / `createView` / `updateView` / `deleteView`; its only view resolver is `getUiView`), no route (`packages/rest/src/rest-server.ts` never mentions `viewId`, so nothing viewId-addressed is reachable over HTTP at all), and no caller (the only `ViewProtocol` mention outside its own file was the services checklist, which already recorded the five as declared-and-unrouted). The look-alike hits a bare-name grep turns up are all different contracts: `metadata-manager.ts`'s `getView(name: string)` is another class, and objectui's `getView(objectName, viewId)` resolves through `client.meta.getItem('view', …)`, i.e. the metadata route. What makes this worth a removal rather than a note is that the cost is already measured. A declared surface that is name-identical and semantics-adjacent to a real one is an attractive nuisance in every grep, and it mis-directed a decision once: The issue asking what `GET /ui/view/:object/:type` answers AND its 2026-08-07 maintainer ruling both read `GetViewResponseSchema` (zero implementations) as the contract of `GET /ui/view/:object/:type`, whose declared response is `GetUiViewResponseSchema` — one word apart, 250 lines up. That ruling's reasoning happened to survive the mix-up (\"nobody can consume `{object, view}` successfully today\" was true, though not for the stated reason), which is the luck this removal stops relying on. Route 3: none of the ten was a key on an authorable shape, nothing parsed them, so there is no tombstone and no D2 conversion — RETIRED_DEFS_BY_MAJOR plus this entry are the declaration. If reading and writing ONE view by id becomes a real requirement it returns implementation-first. ADR-0049, ADR-0087, maintainer ruling 2026-08-07." }, { "surface": "CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / discovery routes.workflow / RestApiRouteCategory workflow", @@ -1284,7 +1284,7 @@ "replacement": "bare AnalyticsQuery body (top-level cube/measures/dimensions/where/...)", "migrationId": "analytics-query-request-envelope-retired", "toMajor": 17, - "rationale": "The { cube, query: {...} } envelope was an HTTP-wire dialect of the retired degraded analytics shim (#3891), never stored in stack metadata — there is no source for the chain to rewrite. Callers of POST /analytics/query and /analytics/sql must move the query.* fields to the body top level themselves." + "rationale": "The { cube, query: {...} } envelope was an HTTP-wire dialect of the retired degraded analytics shim (the fallback that answered /analytics/query when no analytics service was installed, and dropped the caller's identity and its `where` filter at the door), never stored in stack metadata — there is no source for the chain to rewrite. Callers of POST /analytics/query and /analytics/sql must move the query.* fields to the body top level themselves." }, { "surface": "api.analyticsQueryRequest.format", @@ -1319,14 +1319,14 @@ "replacement": "nothing, for either value — both are removed rather than renamed, because neither named an event this platform records. For permission changes, read the ordinary `create` / `update` rows on the permission objects themselves: a grant or binding write is an ordinary record write and the generic audit writer already ledgers it, so a second semantically-duplicate row was never minted. For `export` there is no replacement and nothing is lost: no export feature ever wrote an audit row. A consumer filtering `sys_audit_log` on either value was reading an empty result set on every deployment, and still is — what changed is that the contract no longer promises otherwise", "migrationId": "audit-log-action-enum-retired", "toMajor": 17, - "rationale": "Maintainer ruling 2026-08-12 (#7675), the retirement half of a two-half verdict: the cheap writers get built (#8144 login/logout, #8145 config_change) 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, #8147." + "rationale": "Maintainer ruling 2026-08-12 on the audit log's writerless actions, the retirement half of a two-half verdict: the cheap writers get built (`login` / `logout` on the auth session hooks, `config_change` from the settings service) and the enum values with no feature behind them are retired. 原则记录:空 widget + 永远查不到东西的过滤器是可见产品缺陷;审计面宁窄勿谎. The defect was false compliance on a COMPLIANCE surface, which is the sharpest form of ADR-0049 declared-≠-enforced: an auditor reading the action enum believed the platform captured permission changes and data exports, and the shipped list views and dashboard widgets showed them a filter and a tile for exactly those events. Both were permanently empty. Measured by enumerating every `sys_audit_log` writer in the repo — there are exactly two: plugin-audit`s generic hook writer, whose `actionFor` maps afterInsert/Update/Delete to create/update/delete and nothing else, and plugin-auth`s admin user-import. Neither has ever emitted `export` or `permission_change`. This is an enum-VALUE retirement, so the bookkeeping differs from a key retirement in the two ways `hook-body-crypto-hash-removed`, `dataset-measure-array-string-agg-removed` and `action-global-nav-location-removed` already record: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) and the four surface ratchets are expected to be byte-identical (no def changed). It differs from all three in being a SEMANTIC entry rather than a D2 conversion, and the reason is that there is no source to rewrite: `sys_audit_log` is a platform-owned, append-only object whose every field is `readonly: true`. Nobody authors an audit row and nobody authors this enum — the values appear only in rows the runtime writes and in queries consumers send. A conversion rewrites authored metadata or a stored `sys_metadata` row; this surface is neither, so the disposition is the one `BatchOptions.validateOnly` and the notification cursor already take in this major. ⚠️ Historical ROWS are deliberately untouched. A deployment that somehow holds a row with either value keeps it, and keeps reading it back: the enum is not enforced on this object at all (`validateRecord` skips `readonly` fields, and every field here is readonly), so nothing rejects stored history and no backfill is required or wanted. Deleting audit history to satisfy a schema narrowing would be the one genuinely destructive reading of this change. ADR-0049 / ADR-0087." }, { "surface": "sys_audit_log.action — the value 'restore' left the select enum declared by plugin-audit (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts). It also left the shipped writes_only list-view filter on that object, and the generated option label in all four plugin-audit translation bundles", - "replacement": "nothing — the value is removed rather than renamed, because it never named an event this platform records. There is no undelete or restore capability to point at: deletes are hard deletes, and the record-level audit writer maps the ObjectQL lifecycle to `create` / `update` / `delete` only. A consumer filtering `sys_audit_log` on this value was reading an empty result set on every deployment, and still is — what changed is that the contract no longer promises otherwise. If you were counting on a restore trail, the capability itself is the missing piece (#1883, #3146), not this enum row", + "replacement": "nothing — the value is removed rather than renamed, because it never named an event this platform records. There is no undelete or restore capability to point at: deletes are hard deletes, and the record-level audit writer maps the ObjectQL lifecycle to `create` / `update` / `delete` only. A consumer filtering `sys_audit_log` on this value was reading an empty result set on every deployment, and still is — what changed is that the contract no longer promises otherwise. If you were counting on a restore trail, the capability itself is the missing piece (an undelete / purge permission lifecycle and a soft-delete recycle bin, neither built yet), not this enum row", "migrationId": "audit-log-action-restore-retired", "toMajor": 17, - "rationale": "The same maintainer ruling as `audit-log-action-enum-retired`, carried to the one value #7675's own survey did not name (#8315, 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 (#8011) — 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 (#1883 pm:on-hold, #3146 status:parked). 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, #8315, #7675, #8147." + "rationale": "The same maintainer ruling as `audit-log-action-enum-retired`, carried to the one value that ruling's own survey did not name (triage 2026-08-13). 原则记录:空 widget + 永远查不到东西的过滤器是可见产品缺陷;审计面宁窄勿谎. `restore` is the least ambiguous member of the family: the record-level writer could not have produced it even by accident, because `actionFor()` in audit-writers.ts is typed `'create' | 'update' | 'delete' | null` and its caller early-returns on null. A tree-wide search finds no other producer. What made it a card rather than a tidy-up is that TWO shipped declarations asserted the opposite, so a declaration-reading audit scored the action as covered: the `writes_only` list view offered it as a filter value, and the module docblock of auth-event-audit.ts named it among the actions the writer emits. The comment is the ADR-0049 declared-≠-enforced shape in its purest form (the shape a credential-storage audit had to settle by re-measuring two \"hashed at rest\" comments) — a sentence next to a mechanism, contradicted by the type signature of that very mechanism, with nothing in CI able to tell. Both declarations are corrected in one change, and the invariant behind the comment (every declared action has a writer) now has a pin test under it rather than prose. Bookkeeping is identical to the sibling entry, for the same reasons: an enum-VALUE retirement puts nothing in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) and leaves the four surface ratchets byte-identical (no def changed), and it is a SEMANTIC entry rather than a D2 conversion because there is no source to rewrite — `sys_audit_log` is a platform-owned, append-only object whose every field is `readonly: true`, so nobody authors an audit row and nobody authors this enum. ⚠️ This is a statement about the WRITER, not a product stance against undelete. Soft delete/restore is parked, not rejected: the undelete / purge lifecycle and the recycle bin are both held open, not declined. If that capability lands, this value returns WITH its writer — the emission point, its tests, and the view that surfaces it — never as a bare enum row again. ⚠️ Historical ROWS are deliberately untouched, exactly as for the sibling entry: the enum is not enforced on this object at all (`validateRecord` skips `readonly` fields), so any stored row keeps parsing and reading back, and no backfill is required or wanted. Deleting audit history to satisfy a schema narrowing would be the one genuinely destructive reading of this change. ADR-0049 / ADR-0087." }, { "surface": "api.authConfig.features.passkeys / api.authConfig.features.magicLink", @@ -1543,7 +1543,7 @@ "replacement": "an explicit count >= 1 (e.g. maxRetries: 3), or strategy: 'fail'", "migrationId": "flow-retry-max-retries-required", "toMajor": 17, - "rationale": "maxRetries had two defaults — FlowSchema `.default(0)` and the engine's `maxRetries ?? 3` — so an unstated count retried 0 times through the schema and 3 times through a hand-built definition (#4247). With the engine's copy removed the unstated count is unambiguously 0, and retrying zero times is exactly `strategy: 'fail'`, so the schema now refuses the combination instead of it silently doing nothing. There is no lossless rewrite: 0 preserves the behaviour a parsed flow got but contradicts what its author wrote, and any positive count is a NEW decision about re-running the whole flow with its side effects. That choice is the author's." + "rationale": "maxRetries had two defaults — FlowSchema `.default(0)` and the engine's `maxRetries ?? 3` — so an unstated count retried 0 times through the schema and 3 times through a hand-built definition. With the engine's copy removed the unstated count is unambiguously 0, and retrying zero times is exactly `strategy: 'fail'`, so the schema now refuses the combination instead of it silently doing nothing. There is no lossless rewrite: 0 preserves the behaviour a parsed flow got but contradicts what its author wrote, and any positive count is a NEW decision about re-running the whole flow with its side effects. That choice is the author's." }, { "surface": "data.hookContext.session.roles", @@ -1564,14 +1564,14 @@ "replacement": "the 5xx rate is `http_requests_total{status=~\"5..\"}` — the TRANSPORT emits that family through the `IHttpServer.afterResponse` seam, so it covers every inbound surface; unhandled-exception rate specifically, which is the one thing the retired counter uniquely reported, is the `errorReporter` (Sentry / Datadog / your adapter), which still fires on every 5xx throw", "migrationId": "http-request-errors-total-retired", "toMajor": 17, - "rationale": "ADR-0049 enforce-or-remove, on a DECLARED-not-enforced metric name. `SEMCONV` published `http_request_errors_total` as part of a stable namespace declared \"so hosts can wire alerts/dashboards against it\", but the only emitter was `@objectstack/runtime`'s `instrumentRouteHandler`, applied only by the dispatcher's own route Proxy — so the series never saw auth's `getRawApp()` mount, the REST data API via `RouteManager`, or any other inbound surface. Its two siblings in the same family were moved to the transport seam (#9650/#9835 for the counter, #9834/#10004 for the histogram) and this one could not follow: `HttpResponseObservation` carries `{method, routePattern, status, elapsedMs}` and NO throw signal of any kind, so every transport-side shape would have counted a DIFFERENT population rather than the same one more widely. The divergence was measured in both directions — the dispatcher answers its own errors through `errorResponseBase`, which sets a status and does not re-throw, so the old counter MISSED those, while its `catch` incremented unconditionally, so a thrown 4xx WAS counted as an error. And `http_requests_total` already carries a `status` label, so a status-class error counter would be fully derivable from data the transport already publishes. Maintainer ruling 2026-08-20 (option C of four presented, over B \"move it to the transport as a status class\" and D \"keep it dispatcher-scoped and rename it\"): RETIRE. A metric NAME is a RESPONSE surface, not authorable metadata — no stack, example or template carries it, so there is no source for a D2 conversion to rewrite and no schema to tombstone; a host names the series in its own dashboard or alert file, outside this repo. That is exactly why this entry exists: for an operator whose Grafana keys on the string, the ledger is the only notification channel there is. Same disposition, and the same reason, as `runtime-httpserver-wrapper-retired` (#5122) and `enhanced-api-error-field-errors-renamed` (#3977). ADR-0049 / ADR-0087, #9834." + "rationale": "ADR-0049 enforce-or-remove, on a DECLARED-not-enforced metric name. `SEMCONV` published `http_request_errors_total` as part of a stable namespace declared \"so hosts can wire alerts/dashboards against it\", but the only emitter was `@objectstack/runtime`'s `instrumentRouteHandler`, applied only by the dispatcher's own route Proxy — so the series never saw auth's `getRawApp()` mount, the REST data API via `RouteManager`, or any other inbound surface. Its two siblings in the same family were moved to the transport seam (the request counter, then the latency histogram, both through the response-observing hook the transport was given) and this one could not follow: `HttpResponseObservation` carries `{method, routePattern, status, elapsedMs}` and NO throw signal of any kind, so every transport-side shape would have counted a DIFFERENT population rather than the same one more widely. The divergence was measured in both directions — the dispatcher answers its own errors through `errorResponseBase`, which sets a status and does not re-throw, so the old counter MISSED those, while its `catch` incremented unconditionally, so a thrown 4xx WAS counted as an error. And `http_requests_total` already carries a `status` label, so a status-class error counter would be fully derivable from data the transport already publishes. Maintainer ruling 2026-08-20 (option C of four presented, over B \"move it to the transport as a status class\" and D \"keep it dispatcher-scoped and rename it\"): RETIRE. A metric NAME is a RESPONSE surface, not authorable metadata — no stack, example or template carries it, so there is no source for a D2 conversion to rewrite and no schema to tombstone; a host names the series in its own dashboard or alert file, outside this repo. That is exactly why this entry exists: for an operator whose Grafana keys on the string, the ledger is the only notification channel there is. Same disposition, and the same reason, as `runtime-httpserver-wrapper-retired` and `enhanced-api-error-field-errors-renamed`. ADR-0049 / ADR-0087." }, { "surface": "system.serverEvent / system.serverEventType / system.serverCapabilities / system.serverStatus (the lifecycle-event, capability-report and status vocabulary of system/http-server.zod.ts — 4 defs, 8 exported names)", "replacement": "(removed — there is no replacement key, because there was never a key. Server lifecycle is the transport plugin's own start/stop seam; per-request and per-server observability is `system/metrics.zod.ts` and `system/logging.zod.ts` (plus `OS_SERVER_TIMING` for timings), and liveness is the `/health` endpoint. What a transport plugin can DO it states by implementing the kernel plugin contract — the seams it registers are the capability statement, and a self-described capability record can only disagree with them. Server-level configuration that IS authorable lives on `defineStack({ server })` / `StackServerConfigSchema`, which is unaffected)", "migrationId": "http-server-runtime-vocabulary-retired", "toMajor": 17, - "rationale": "The second and final ADR-0049 pass over `system/http-server.zod.ts`. #4938 removed the CONFIG half (`HttpServerConfigSchema`, nine keys, zero readers, zero authoring entry); this removes the RUNTIME half — a 7-member lifecycle event union with a timestamped envelope, an eight-boolean capability report, and a five-state status record with connection and request counters. Nothing ever emitted, consumed or parsed any of them. This card was HELD for four days rather than queued, on a specific and legitimate doubt: a response/capability vocabulary can be a REFERENCE surface for host implementers, so \"zero consumers in this repo\" is weaker evidence for one of those than for an authorable key (the CSS-variable rebuttal). The hold was lifted by measuring the reference reader itself rather than by re-running the same grep: `plugin-hono-server`, the one in-tree host implementation, neither implements nor reports any of the three — it names no capability record, no status shape and no event union, and what it registers is routes and middleware through the kernel plugin contract. A declaration-site grep put every declaration in this one file, a quoted-name sweep across objectstack and objectui found no reader outside it, and the control passed in the SAME run: `MiddlewareConfig`, declared twelve lines away, resolves to `packages/runtime/src/middleware.ts`. So the sweep could see a reader in this file when there was one. With no carrier key there is nothing to tombstone, and with no author there is no source or `sys_metadata` row for a D2 conversion to rewrite: RETIRED_DEFS_BY_MAJOR plus this entry are the declaration — route 3, the same shape as #4938 in this very file, #4834, #4988 and #5055. If host-implementer conformance becomes a real requirement it returns through the ENFORCE route: an adapter contract with a checker behind it, vocabulary second. ADR-0049, #5295." + "rationale": "The second and final ADR-0049 pass over `system/http-server.zod.ts`. The first removed the CONFIG half (`HttpServerConfigSchema`, nine keys, zero readers, zero authoring entry); this removes the RUNTIME half — a 7-member lifecycle event union with a timestamped envelope, an eight-boolean capability report, and a five-state status record with connection and request counters. Nothing ever emitted, consumed or parsed any of them. This card was HELD for four days rather than queued, on a specific and legitimate doubt: a response/capability vocabulary can be a REFERENCE surface for host implementers, so \"zero consumers in this repo\" is weaker evidence for one of those than for an authorable key (the CSS-variable rebuttal). The hold was lifted by measuring the reference reader itself rather than by re-running the same grep: `plugin-hono-server`, the one in-tree host implementation, neither implements nor reports any of the three — it names no capability record, no status shape and no event union, and what it registers is routes and middleware through the kernel plugin contract. A declaration-site grep put every declaration in this one file, a quoted-name sweep across objectstack and objectui found no reader outside it, and the control passed in the SAME run: `MiddlewareConfig`, declared twelve lines away, resolves to `packages/runtime/src/middleware.ts`. So the sweep could see a reader in this file when there was one. With no carrier key there is nothing to tombstone, and with no author there is no source or `sys_metadata` row for a D2 conversion to rewrite: RETIRED_DEFS_BY_MAJOR plus this entry are the declaration — route 3, the same shape as the config half's removal in this very file and the earlier removals of the dynamic plugin-loading family, the `ui/` interaction configs and the widget / i18n shapes. If host-implementer conformance becomes a real requirement it returns through the ENFORCE route: an adapter contract with a checker behind it, vocabulary second. ADR-0049." }, { "surface": "api.ImportRequest runAutomations — the declared default of the key on BOTH import bodies, POST /api/v1/data/:object/import (ImportRequest) and its async twin POST /api/v1/data/:object/import/jobs (CreateImportJobRequest, which IS the same schema object). It was declared default(false) and described as \"off by default for bulk\"; it is now default(true), which is what the server has always done", @@ -1599,7 +1599,7 @@ "replacement": "explicit `allTenants: true` for a cross-tenant uninstall, or an `organizationId` to scope it", "migrationId": "package-uninstall-explicit-all-tenants", "toMajor": 17, - "rationale": "An uninstall that named no organization matched EVERY organization's rows — measured at 5 of 5 deleted, including a foreign org's (#7705, #7780). That width was never chosen; it fell out of a missing argument, and the two transports of the same route disagreed because of it. In protocol 17 the call is REFUSED instead: neither `organizationId` nor `allTenants: true` answers 400 `TENANT_SCOPE_REQUIRED` and deletes nothing, as does supplying both (they are contradictory, not redundant). Whether a given caller meant \"this tenant\" or \"every tenant\" is an intent no transform can recover: `resolveActiveOrganizationId` is catch-wrapped, so an accidental org-less call and a deliberate environment-wide one are byte-identical at the call site — which is the whole reason the parameter had to become explicit rather than conventional. Nothing in authored metadata spells this: it is a runtime call-site contract, so it is one semantic TODO for operators and API callers rather than a stack conversion — the same disposition `rest-requireauth-default-flip` (#12) takes for its own default flip." + "rationale": "An uninstall that named no organization matched EVERY organization's rows — measured at 5 of 5 deleted, including a foreign org's, while uninstall's orphaned-row defect was being repaired. That width was never chosen; it fell out of a missing argument, and the two transports of the same route disagreed because of it. In protocol 17 the call is REFUSED instead: neither `organizationId` nor `allTenants: true` answers 400 `TENANT_SCOPE_REQUIRED` and deletes nothing, as does supplying both (they are contradictory, not redundant). Whether a given caller meant \"this tenant\" or \"every tenant\" is an intent no transform can recover: `resolveActiveOrganizationId` is catch-wrapped, so an accidental org-less call and a deliberate environment-wide one are byte-identical at the call site — which is the whole reason the parameter had to become explicit rather than conventional. Nothing in authored metadata spells this: it is a runtime call-site contract, so it is one semantic TODO for operators and API callers rather than a stack conversion — the same disposition `rest-requireauth-default-flip` (protocol 12) takes for its own default flip." }, { "surface": "kernel.dynamicLoadRequest.activationEvents / studio.studioPluginManifest.activationEvents", @@ -1683,7 +1683,7 @@ "replacement": "(removed — no replacement key exists. Delete the key; for a real outbound webhook use `Webhook` from `@objectstack/spec/automation`. Config-driven OpenAPI 3.1 webhooks/callbacks documentation returns, if ever, via the enforce route of ADR-0049 through a new ADR)", "migrationId": "rest-server-openapi31-block-removed", "toMajor": 17, - "rationale": "The `openApi31` block (`webhooks` / `callbacks` / `jsonSchemaDialect` / `pathItemReferences`, typed by `OpenApi31ExtensionsSchema` with `OpenApiWebhookEventSchema` and `CallbackSchema` under it) promised OpenAPI 3.1 document synthesis nothing delivered: the REST server's `normalizeConfig` forwards only `api`/`crud`/`metadata`/`batch`/`routes`, and the served /openapi.json is the pre-generated @objectstack/spec contract enriched with the live server URL and the registered objects — a webhook declared here never appeared in any served document (ADR-0049; the #3197 connector-webhook shape one layer up). There is no behaviour to preserve and nothing stored to rewrite: `RestServerConfig` is plugin TS configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), never a `sys_metadata` shape — the stack tree's `api` block declares only its four scoping/auth knobs. The three schemas are removed with the key (zero import-level consumers in objectstack / cloud / objectui); the key itself is tombstoned because the schema is not `.strict()` and a plain delete would strip it silently. #4579." + "rationale": "The `openApi31` block (`webhooks` / `callbacks` / `jsonSchemaDialect` / `pathItemReferences`, typed by `OpenApi31ExtensionsSchema` with `OpenApiWebhookEventSchema` and `CallbackSchema` under it) promised OpenAPI 3.1 document synthesis nothing delivered: the REST server's `normalizeConfig` forwards only `api`/`crud`/`metadata`/`batch`/`routes`, and the served /openapi.json is the pre-generated @objectstack/spec contract enriched with the live server URL and the registered objects — a webhook declared here never appeared in any served document (ADR-0049; the declared-but-unconsumed shape an earlier audit found in the connector webhook and event enums one layer up). There is no behaviour to preserve and nothing stored to rewrite: `RestServerConfig` is plugin TS configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), never a `sys_metadata` shape — the stack tree's `api` block declares only its four scoping/auth knobs. The three schemas are removed with the key (zero import-level consumers in objectstack / cloud / objectui); the key itself is tombstoned because the schema is not `.strict()` and a plain delete would strip it silently." }, { "surface": "runtime.HttpServer (the exported delegating wrapper class)", @@ -1694,17 +1694,17 @@ }, { "surface": "@objectstack/spec: the exported type `SharingExecutionContext` (`contracts/sharing-service`), and its re-export from @objectstack/plugin-sharing — the six-field context shape (`userId` / `tenantId` / `positions` / `permissions` / `systemPermissions` / `isSystem`) that sharing, approval and report enforcement signatures used to name", - "replacement": "`ExecutionContext` from `@objectstack/spec` — the complete `resolveAuthzContext` envelope the contracts have declared since #6523. Every one of the retired type's six fields exists on it under the same name and type, so a value that satisfied the old type already satisfies the envelope: only the annotation is rewritten, never the value", + "replacement": "`ExecutionContext` from `@objectstack/spec` — the complete `resolveAuthzContext` envelope the sharing, approval and report contracts have declared since they converged onto it. Every one of the retired type's six fields exists on it under the same name and type, so a value that satisfied the old type already satisfies the envelope: only the annotation is rewritten, never the value", "migrationId": "sharing-execution-context-retired", "toMajor": 17, - "rationale": "ADR-0049 enforce-or-remove, completing the #6206 ruling (2026-08-07: enforcement adjudicates on the WHOLE envelope, never a per-site subset). This type was the declared context parameter of 36 signatures across three contracts — `ISharingService` / `ISharingRuleService`, `IApprovalService`, `IReportService` — and it omitted four fields those gates need: `accessible_org_ids` (under the `group` tenancy posture this IS the Layer 0 wall, ADR-0105 D2), `org_user_ids`, `posture` (ADR-0095 D2) and `tabPermissions`. Its damage ran in the MIRROR direction of the share-link twin (#6430 / PR #6511): nothing trimmed the VALUES — the engine middleware always handed the whole context down — it was the declared TYPE that was narrow, so an implementation could not READ what it had been given without casting out of its own contract (`const posture = (context as any).posture` in plugin-approvals' privileged-override gate). #6523 / PR #7068 converged the contracts, PR #7140 and PR #7206 re-annotated the four implementations, and this card removes the now-unreferenced declaration (#7070, #7218). Why this needs a ledger entry despite nothing in-repo referencing it: it is the `export-field-meta-constraints-retired` / `hook-context-session-roles-retired` disposition — a PUBLISHED TypeScript surface with no spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry the prescription, and the ledger is the only channel that reaches an upgrader. Why D3 semantic and not a D2 conversion: nothing authored or stored changes shape. The name is only ever spelled inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach it, and no `sys_metadata` row carries it. ADR-0049 / ADR-0087, #7218." + "rationale": "ADR-0049 enforce-or-remove, completing the maintainer's ruling of 2026-08-07 on the share-link context (enforcement adjudicates on the WHOLE envelope, never a per-site subset). This type was the declared context parameter of 36 signatures across three contracts — `ISharingService` / `ISharingRuleService`, `IApprovalService`, `IReportService` — and it omitted four fields those gates need: `accessible_org_ids` (under the `group` tenancy posture this IS the Layer 0 wall, ADR-0105 D2), `org_user_ids`, `posture` (ADR-0095 D2) and `tabPermissions`. Its damage ran in the MIRROR direction of the share-link twin, which that same ruling moved onto the whole context: nothing trimmed the VALUES — the engine middleware always handed the whole context down — it was the declared TYPE that was narrow, so an implementation could not READ what it had been given without casting out of its own contract (`const posture = (context as any).posture` in plugin-approvals' privileged-override gate). One change converged the contracts, two more re-annotated the four implementations (sharing and audit, then approvals and reports), and this change removes the now-unreferenced declaration, the deletion that split had deferred. Why this needs a ledger entry despite nothing in-repo referencing it: it is the `export-field-meta-constraints-retired` / `hook-context-session-roles-retired` disposition — a PUBLISHED TypeScript surface with no spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry the prescription, and the ledger is the only channel that reaches an upgrader. Why D3 semantic and not a D2 conversion: nothing authored or stored changes shape. The name is only ever spelled inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach it, and no `sys_metadata` row carries it. ADR-0049 / ADR-0087." }, { "surface": "security.sharingRule.sharedWith.type `group` / `guest`, and owner-type rules (`type: owner` + `ownedBy`)", "replacement": "`group` → `team` (the enforced runtime vocabulary); `guest` → delete the rule and expose the records through a public form or a share link; `type: owner` → rewrite as a `type: criteria` rule. `business_unit` is newly authorable for the single-unit case", "migrationId": "sharing-rule-recipient-reconcile", "toMajor": 17, - "rationale": "The authoring `ShareRecipientType` enum had drifted behind both the ADR-0090 D3 rename and the enforced runtime, in both directions at once. It still offered the pre-rename `group`, which the seed path silently SKIPPED, while omitting two recipients the runtime and bootstrap already enforced (`team` via `sys_team` / `sys_team_member`, and `business_unit`). It also offered `guest`, which had no runtime recipient mapping at all. Each of those is a rule that validated and then materialised nothing — the ADR-0078 shape, and on a SECURITY surface, where the failure is silent under-sharing: the author sees a valid rule and believes a set of people can reach the records, and no error ever contradicts them. Owner-type rules go for a different and sharper reason: they depend on live team / position membership, which the static materialiser cannot track, so they could not be made to work by fixing a name. They return as an enforced form only if membership-reactive re-materialisation is designed. This is a semantic entry rather than a mechanical conversion because only one of the three rewrites is a rename: `group` → `team` is mechanical, but `guest` and `type: owner` have no target — the author has to decide who was actually meant to reach those records and say so in a form the runtime enforces, and a transform that guessed would be inventing an access grant. After this change every authorable recipient and rule type on the SharingRule surface is enforced; the `queue` recipient stays runtime-reserved and deliberately non-authorable (there is no `sys_queue` yet). Note the two neighbouring conversions cover DIFFERENT faces of this schema and not this one: `sharing-recipient-role-to-position` is the ADR-0090 role → position rename and `sharing-rule-access-level-full-to-edit` is the access-level vocabulary. Registered by the #6350 stock reconciliation. ADR-0078 / ADR-0090 D3 / ADR-0087, #1878 (backfilled #6350)." + "rationale": "The authoring `ShareRecipientType` enum had drifted behind both the ADR-0090 D3 rename and the enforced runtime, in both directions at once. It still offered the pre-rename `group`, which the seed path silently SKIPPED, while omitting two recipients the runtime and bootstrap already enforced (`team` via `sys_team` / `sys_team_member`, and `business_unit`). It also offered `guest`, which had no runtime recipient mapping at all. Each of those is a rule that validated and then materialised nothing — the ADR-0078 shape, and on a SECURITY surface, where the failure is silent under-sharing: the author sees a valid rule and believes a set of people can reach the records, and no error ever contradicts them. Owner-type rules go for a different and sharper reason: they depend on live team / position membership, which the static materialiser cannot track, so they could not be made to work by fixing a name. They return as an enforced form only if membership-reactive re-materialisation is designed. This is a semantic entry rather than a mechanical conversion because only one of the three rewrites is a rename: `group` → `team` is mechanical, but `guest` and `type: owner` have no target — the author has to decide who was actually meant to reach those records and say so in a form the runtime enforces, and a transform that guessed would be inventing an access grant. After this change every authorable recipient and rule type on the SharingRule surface is enforced; the `queue` recipient stays runtime-reserved and deliberately non-authorable (there is no `sys_queue` yet). Note the two neighbouring conversions cover DIFFERENT faces of this schema and not this one: `sharing-recipient-role-to-position` is the ADR-0090 role → position rename and `sharing-rule-access-level-full-to-edit` is the access-level vocabulary. The change came out of the metadata property liveness audit, which found security properties parsed but never enforced, and was registered late, by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger. ADR-0078 / ADR-0090 D3 / ADR-0087." }, { "surface": "data.query.orderBy[].direction (SortNode)", @@ -1767,14 +1767,14 @@ "replacement": "an ARRAY for in / not_in (a single value becomes a one-element list: value: \"won\" becomes value: [\"won\"]), and a two-element [min, max] array for between. The empty list [] stays legal for in / not_in and keeps its meaning. Nothing else moves: a scalar operator carrying an array, a string operator carrying a number, and a unary operator carrying an ignored value all still parse", "migrationId": "view-filter-rule-value-shaped-by-operator", "toMajor": 17, - "rationale": "A publish-time gate catching up to a query-time one, not a new rule. #5869 / PR #6209 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 #5685 already ruled on the opposite error: 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." + "rationale": "A publish-time gate catching up to a query-time one, not a new rule. An earlier fix closed the RUNTIME half: `assertListComparandShapes` (@objectstack/objectql, filter-comparand-shape.ts) refuses a lowered `{ stage: { $nin: \"won\" } }` with a named 400 INVALID_FILTER, and before that it was a 500. The authoring surface stayed silent, so the failure was two-stage: the view published cleanly and only broke when someone opened it. That file names this very schema as the reachable authoring source of the defect. The tightening MIRRORS that gate exactly — three constraints, one for one — and deliberately goes no further, because an earlier fix already settled the opposite error (the ordering operators' comparand widened to the strings the platform itself produces): a schema stricter than the runtime \"in ways the runtime deliberately allows\" was the WRONG side and was widened to match. So `in: []` is still accepted (a declared predicate both drivers implement), `equals: [\"a\",\"b\"]` is still accepted (it lowers to a deep-equality comparand), and `is_empty: \"\"` is still accepted (the null predicates take their direction from the operator NAME — convertComparison ignores the value position, and the ObjectUI client deliberately sends a truthy placeholder there). ⚠️ Metadata AT REST is deliberately NOT rewritten, and there is no D2 conversion. A D2 entry replays a shape the platform once WROTE and renamed; this shape was never written by any first-party producer (every in / not_in rule in this repo, in objectui and in the cloud repo already carries an array — measured) and has never EXECUTED, since it 400s on first render today. Coercing it at load would be the platform guessing intent rather than replaying a rename, and it cannot guess honestly: value: \"\" would become the predicate [\"\"] (a real filter on the empty string) rather than the \"not filled in yet\" a console row means, and between: 5 has no defensible second bound at all. The read path does not re-validate stored rows (applyConversionsToStoredItem never validates, by its own contract), so no stored view becomes unreadable; what changes is that RE-SAVING such a view is refused at the write gate naming `value`, instead of storing a filter that 400s. ADR-0049 / ADR-0078 / ADR-0112." }, { "surface": "api.listViews / api.getView / api.createView / api.updateView / api.deleteView (the ViewProtocol interface and its ten Request/Response schemas in api/protocol.zod.ts — 10 defs, 25 exported names)", "replacement": "the two view surfaces that are actually routed. For a view's STORED definition, the generic metadata methods with `type: 'view'` — `getMetaItem` / `getMetaItems` / `saveMetaItem` / `deleteMetaItem`, served at `/api/v1/meta/view/:name`. For the RESOLVED render-time view, `getUiView` (`GetUiViewRequest` / `GetUiViewResponse`), served at `/api/v1/ui/view/:object/:type`. Neither is addressed by a `viewId`, which is the one thing the retired surface offered and the one thing nothing implemented", "migrationId": "view-management-protocol-retired", "toMajor": 17, - "rationale": "A complete viewId-addressed CRUD surface — list (with a list/form filter), read, create, patch, delete — with none of the three things a protocol method needs. Measured on origin/main immediately before the removal: no implementation (`packages/metadata-protocol/src/protocol.ts` declares no `listViews` / `getView` / `createView` / `updateView` / `deleteView`; its only view resolver is `getUiView`), no route (`packages/rest/src/rest-server.ts` never mentions `viewId`, so nothing viewId-addressed is reachable over HTTP at all), and no caller (the only `ViewProtocol` mention outside its own file was the services checklist, which already recorded the five as declared-and-unrouted). The look-alike hits a bare-name grep turns up are all different contracts: `metadata-manager.ts`'s `getView(name: string)` is another class, and objectui's `getView(objectName, viewId)` resolves through `client.meta.getItem('view', …)`, i.e. the metadata route. What makes this worth a removal rather than a note is that the cost is already measured. A declared surface that is name-identical and semantics-adjacent to a real one is an attractive nuisance in every grep, and it mis-directed a decision once: #5948's issue body 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, #6239." + "rationale": "A complete viewId-addressed CRUD surface — list (with a list/form filter), read, create, patch, delete — with none of the three things a protocol method needs. Measured on origin/main immediately before the removal: no implementation (`packages/metadata-protocol/src/protocol.ts` declares no `listViews` / `getView` / `createView` / `updateView` / `deleteView`; its only view resolver is `getUiView`), no route (`packages/rest/src/rest-server.ts` never mentions `viewId`, so nothing viewId-addressed is reachable over HTTP at all), and no caller (the only `ViewProtocol` mention outside its own file was the services checklist, which already recorded the five as declared-and-unrouted). The look-alike hits a bare-name grep turns up are all different contracts: `metadata-manager.ts`'s `getView(name: string)` is another class, and objectui's `getView(objectName, viewId)` resolves through `client.meta.getItem('view', …)`, i.e. the metadata route. What makes this worth a removal rather than a note is that the cost is already measured. A declared surface that is name-identical and semantics-adjacent to a real one is an attractive nuisance in every grep, and it mis-directed a decision once: The issue asking what `GET /ui/view/:object/:type` answers AND its 2026-08-07 maintainer ruling both read `GetViewResponseSchema` (zero implementations) as the contract of `GET /ui/view/:object/:type`, whose declared response is `GetUiViewResponseSchema` — one word apart, 250 lines up. That ruling's reasoning happened to survive the mix-up (\"nobody can consume `{object, view}` successfully today\" was true, though not for the stated reason), which is the luck this removal stops relying on. Route 3: none of the ten was a key on an authorable shape, nothing parsed them, so there is no tombstone and no D2 conversion — RETIRED_DEFS_BY_MAJOR plus this entry are the declaration. If reading and writing ONE view by id becomes a real requirement it returns implementation-first. ADR-0049, ADR-0087, maintainer ruling 2026-08-07." }, { "surface": "CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / discovery routes.workflow / RestApiRouteCategory workflow", diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 317051cc49b..06c06741fe8 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -1436,7 +1436,9 @@ const step17: MigrationStep = { replacement: 'bare AnalyticsQuery body (top-level cube/measures/dimensions/where/...)', reason: 'The { cube, query: {...} } envelope was an HTTP-wire dialect of the retired degraded ' + - 'analytics shim (#3891), never stored in stack metadata — there is no source for the ' + + 'analytics shim (the fallback that answered /analytics/query when no analytics service ' + + 'was installed, and dropped the caller\'s identity and its `where` filter at the door), ' + + 'never stored in stack metadata — there is no source for the ' + 'chain to rewrite. Callers of POST /analytics/query and /analytics/sql must move the ' + 'query.* fields to the body top level themselves.', acceptanceCriteria: @@ -1620,8 +1622,9 @@ const step17: MigrationStep = { + 'on every deployment, and still is — what changed is that the contract no longer ' + 'promises otherwise', reason: - 'Maintainer ruling 2026-08-12 (#7675), the retirement half of a two-half verdict: the ' - + 'cheap writers get built (#8144 login/logout, #8145 config_change) and the enum ' + '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 ' @@ -1651,7 +1654,7 @@ const step17: MigrationStep = { + '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, #8147.', + + 'genuinely destructive reading of this change. ADR-0049 / ADR-0087.', acceptanceCriteria: 'No consumer filters `sys_audit_log` on `action = "export"` or ' + '`action = "permission_change"` expecting rows: both were empty everywhere before ' @@ -1693,10 +1696,11 @@ const step17: MigrationStep = { + '`sys_audit_log` on this value was reading an empty result set on every deployment, ' + 'and still is — what changed is that the contract no longer promises otherwise. If ' + 'you were counting on a restore trail, the capability itself is the missing piece ' - + '(#1883, #3146), not this enum row', + + '(an undelete / purge permission lifecycle and a soft-delete recycle bin, neither built ' + + 'yet), not this enum row', reason: 'The same maintainer ruling as `audit-log-action-enum-retired`, carried to the one ' - + "value #7675's own survey did not name (#8315, triage 2026-08-13). 原则记录:空 " + + "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 " @@ -1706,7 +1710,8 @@ const step17: MigrationStep = { + '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 (#8011) — a ' + + '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) ' @@ -1719,16 +1724,16 @@ const step17: MigrationStep = { + '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 (#1883 pm:on-hold, #3146 ' - + 'status:parked). If that capability lands, this value returns WITH its writer — the ' + + '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, #8315, ' - + '#7675, #8147.', + + 'the one genuinely destructive reading of this change. ADR-0049 / ADR-0087.', acceptanceCriteria: '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. ' @@ -3190,7 +3195,7 @@ const step17: MigrationStep = { reason: 'maxRetries had two defaults — FlowSchema `.default(0)` and the engine\'s ' + '`maxRetries ?? 3` — so an unstated count retried 0 times through the schema and 3 ' + - 'times through a hand-built definition (#4247). With the engine\'s copy removed the ' + + 'times through a hand-built definition. With the engine\'s copy removed the ' + 'unstated count is unambiguously 0, and retrying zero times is exactly ' + "`strategy: 'fail'`, so the schema now refuses the combination instead of it silently " + 'doing nothing. There is no lossless rewrite: 0 preserves the behaviour a parsed flow ' + @@ -3311,8 +3316,9 @@ const step17: MigrationStep = { + '`@objectstack/runtime`\'s `instrumentRouteHandler`, applied only by the dispatcher\'s ' + 'own route Proxy — so the series never saw auth\'s `getRawApp()` mount, the REST data ' + 'API via `RouteManager`, or any other inbound surface. Its two siblings in the same ' - + 'family were moved to the transport seam (#9650/#9835 for the counter, #9834/#10004 ' - + 'for the histogram) and this one could not follow: `HttpResponseObservation` carries ' + + 'family were moved to the transport seam (the request counter, then the latency ' + + 'histogram, both through the response-observing hook the transport was given) and this ' + + 'one could not follow: `HttpResponseObservation` carries ' + '`{method, routePattern, status, elapsedMs}` and NO throw signal of any kind, so every ' + 'transport-side shape would have counted a DIFFERENT population rather than the same ' + 'one more widely. The divergence was measured in both directions — the dispatcher ' @@ -3328,8 +3334,8 @@ const step17: MigrationStep = { + 'the series in its own dashboard or alert file, outside this repo. That is exactly why ' + 'this entry exists: for an operator whose Grafana keys on the string, the ledger is the ' + 'only notification channel there is. Same disposition, and the same reason, as ' - + '`runtime-httpserver-wrapper-retired` (#5122) and `enhanced-api-error-field-errors-renamed` ' - + '(#3977). ADR-0049 / ADR-0087, #9834.', + + '`runtime-httpserver-wrapper-retired` and `enhanced-api-error-field-errors-renamed`. ' + + 'ADR-0049 / ADR-0087.', acceptanceCriteria: 'No dashboard, alert rule or exporter config names `http_request_errors_total`: the ' + 'series stops receiving samples the moment 17.2.0 is deployed, so a panel keyed on it ' @@ -3361,8 +3367,8 @@ const step17: MigrationStep = { + 'record can only disagree with them. Server-level configuration that IS authorable ' + 'lives on `defineStack({ server })` / `StackServerConfigSchema`, which is unaffected)', reason: - 'The second and final ADR-0049 pass over `system/http-server.zod.ts`. #4938 removed the ' - + 'CONFIG half (`HttpServerConfigSchema`, nine keys, zero readers, zero authoring ' + 'The second and final ADR-0049 pass over `system/http-server.zod.ts`. The first removed ' + + 'the CONFIG half (`HttpServerConfigSchema`, nine keys, zero readers, zero authoring ' + 'entry); this removes the RUNTIME half — a 7-member lifecycle event union with a ' + 'timestamped envelope, an eight-boolean capability report, and a five-state status ' + 'record with connection and request counters. Nothing ever emitted, consumed or ' @@ -3382,10 +3388,12 @@ const step17: MigrationStep = { + 'this file when there was one. ' + 'With no carrier key there is nothing to tombstone, and with no author there is no ' + 'source or `sys_metadata` row for a D2 conversion to rewrite: RETIRED_DEFS_BY_MAJOR ' - + 'plus this entry are the declaration — route 3, the same shape as #4938 in this very ' - + 'file, #4834, #4988 and #5055. If host-implementer conformance becomes a real ' + + 'plus this entry are the declaration — route 3, the same shape as the config half\'s ' + + 'removal in this very file and the earlier removals of the dynamic plugin-loading family, ' + + 'the `ui/` interaction configs and the widget / i18n shapes. If host-implementer ' + + 'conformance becomes a real ' + 'requirement it returns through the ENFORCE route: an adapter contract with a checker ' - + 'behind it, vocabulary second. ADR-0049, #5295.', + + 'behind it, vocabulary second. ADR-0049.', acceptanceCriteria: 'No source imports `ServerEvent`, `ServerEventType`, `ServerEventSchema`, ' + '`ServerCapabilities`, `ServerCapabilitiesSchema`, `ServerCapabilitiesParsed`, ' @@ -3559,7 +3567,8 @@ const step17: MigrationStep = { replacement: 'explicit `allTenants: true` for a cross-tenant uninstall, or an `organizationId` to scope it', reason: 'An uninstall that named no organization matched EVERY organization\'s rows — measured ' - + 'at 5 of 5 deleted, including a foreign org\'s (#7705, #7780). That width was never ' + + 'at 5 of 5 deleted, including a foreign org\'s, while uninstall\'s orphaned-row defect was ' + + 'being repaired. That width was never ' + 'chosen; it fell out of a missing argument, and the two transports of the same route ' + 'disagreed because of it. In protocol 17 the call is REFUSED instead: neither ' + '`organizationId` nor `allTenants: true` answers 400 `TENANT_SCOPE_REQUIRED` and ' @@ -3570,7 +3579,7 @@ const step17: MigrationStep = { + 'at the call site — which is the whole reason the parameter had to become explicit ' + 'rather than conventional. Nothing in authored metadata spells this: it is a runtime ' + 'call-site contract, so it is one semantic TODO for operators and API callers rather ' - + 'than a stack conversion — the same disposition `rest-requireauth-default-flip` (#12) ' + + 'than a stack conversion — the same disposition `rest-requireauth-default-flip` (protocol 12) ' + 'takes for its own default flip.', acceptanceCriteria: 'Every caller of `deletePackage` states its tenant scope. A caller that intends an ' @@ -3582,7 +3591,7 @@ const step17: MigrationStep = { + 'that script was relying on the cross-tenant reading and must now say so on purpose. ' + 'The org-scoped path is unchanged — an uninstall carrying an `organizationId` still ' + 'removes that org\'s rows AND the environment-wide (`organization_id IS NULL`) rows, ' - + 'exactly as #7705 left it.', + + 'exactly as the orphaned-row repair left it.', }, { id: 'plugin-activation-events-retired', @@ -4026,13 +4035,14 @@ const step17: MigrationStep = { + 'only `api`/`crud`/`metadata`/`batch`/`routes`, and the served /openapi.json is the ' + 'pre-generated @objectstack/spec contract enriched with the live server URL and the ' + 'registered objects — a webhook declared here never appeared in any served document ' - + '(ADR-0049; the #3197 connector-webhook shape one layer up). There is no behaviour ' + + '(ADR-0049; the declared-but-unconsumed shape an earlier audit found in the connector ' + + 'webhook and event enums one layer up). There is no behaviour ' + 'to preserve and nothing stored to rewrite: `RestServerConfig` is plugin TS ' + 'configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), never ' + "a `sys_metadata` shape — the stack tree's `api` block declares only its four " + 'scoping/auth knobs. The three schemas are removed with the key (zero import-level ' + 'consumers in objectstack / cloud / objectui); the key itself is tombstoned because ' - + 'the schema is not `.strict()` and a plain delete would strip it silently. #4579.', + + 'the schema is not `.strict()` and a plain delete would strip it silently.', acceptanceCriteria: 'No `RestServerConfig` value passed to the REST plugin (or `plugin-hono-server` ' + '`restConfig`) carries `openApi31` — a config that includes it now fails the parse ' @@ -4092,25 +4102,29 @@ const step17: MigrationStep = { + '`isSystem`) that sharing, approval and report enforcement signatures used to name', replacement: '`ExecutionContext` from `@objectstack/spec` — the complete ' - + '`resolveAuthzContext` envelope the contracts have declared since #6523. Every one ' + + '`resolveAuthzContext` envelope the sharing, approval and report contracts have declared ' + + 'since they converged onto it. Every one ' + 'of the retired type\'s six fields exists on it under the same name and type, so a ' + 'value that satisfied the old type already satisfies the envelope: only the ' + 'annotation is rewritten, never the value', reason: - 'ADR-0049 enforce-or-remove, completing the #6206 ruling (2026-08-07: enforcement ' - + 'adjudicates on the WHOLE envelope, never a per-site subset). This type was the ' + 'ADR-0049 enforce-or-remove, completing the maintainer\'s ruling of 2026-08-07 on the ' + + 'share-link context (enforcement adjudicates on the WHOLE envelope, never a per-site ' + + 'subset). This type was the ' + 'declared context parameter of 36 signatures across three contracts — ' + '`ISharingService` / `ISharingRuleService`, `IApprovalService`, `IReportService` — ' + 'and it omitted four fields those gates need: `accessible_org_ids` (under the ' + '`group` tenancy posture this IS the Layer 0 wall, ADR-0105 D2), `org_user_ids`, ' + '`posture` (ADR-0095 D2) and `tabPermissions`. Its damage ran in the MIRROR ' - + 'direction of the share-link twin (#6430 / PR #6511): nothing trimmed the VALUES — ' + + 'direction of the share-link twin, which that same ruling moved onto the whole context: ' + + 'nothing trimmed the VALUES — ' + "the engine middleware always handed the whole context down — it was the declared " + 'TYPE that was narrow, so an implementation could not READ what it had been given ' + 'without casting out of its own contract (`const posture = (context as any).posture` ' - + "in plugin-approvals' privileged-override gate). #6523 / PR #7068 converged the " - + 'contracts, PR #7140 and PR #7206 re-annotated the four implementations, and this ' - + 'card removes the now-unreferenced declaration (#7070, #7218). ' + + "in plugin-approvals' privileged-override gate). One change converged the " + + 'contracts, two more re-annotated the four implementations (sharing and audit, then ' + + 'approvals and reports), and this change removes the now-unreferenced declaration, the ' + + 'deletion that split had deferred. ' + 'Why this needs a ledger entry despite nothing in-repo referencing it: it is the ' + '`export-field-meta-constraints-retired` / `hook-context-session-roles-retired` ' + 'disposition — a PUBLISHED TypeScript surface with no spec schema, so there is no ' @@ -4119,7 +4133,7 @@ const step17: MigrationStep = { + 'Why D3 semantic and not a D2 conversion: nothing authored or stored changes shape. ' + 'The name is only ever spelled inside a consumer\'s own TypeScript, so no ' + '`objectstack migrate meta` transform can reach it, and no `sys_metadata` row ' - + 'carries it. ADR-0049 / ADR-0087, #7218.', + + 'carries it. ADR-0049 / ADR-0087.', acceptanceCriteria: 'No source of yours imports `SharingExecutionContext` from `@objectstack/spec` or ' + '`@objectstack/plugin-sharing`; each such import becomes `ExecutionContext` from ' @@ -4164,9 +4178,11 @@ const step17: MigrationStep = { + 'runtime-reserved and deliberately non-authorable (there is no `sys_queue` yet). Note ' + 'the two neighbouring conversions cover DIFFERENT faces of this schema and not this ' + 'one: `sharing-recipient-role-to-position` is the ADR-0090 role → position rename and ' - + '`sharing-rule-access-level-full-to-edit` is the access-level vocabulary. Registered by ' - + 'the #6350 stock reconciliation. ADR-0078 / ADR-0090 D3 / ADR-0087, #1878 (backfilled ' - + '#6350).', + + '`sharing-rule-access-level-full-to-edit` is the access-level vocabulary. The change came ' + + 'out of the metadata property liveness audit, which found security properties parsed but ' + + 'never enforced, and was registered late, by the stock reconciliation that compared the ' + + 'breaking changesets already on the v17 release train against this ledger. ADR-0078 / ' + + 'ADR-0090 D3 / ADR-0087.', acceptanceCriteria: 'No sharing rule names `group` or `guest`, and none carries `type: owner`; stale ' + 'definitions now FAIL parse with the valid options listed, so the sweep is "fix until ' @@ -4735,15 +4751,17 @@ const step17: MigrationStep = { + 'scalar operator carrying an array, a string operator carrying a number, and a unary ' + 'operator carrying an ignored value all still parse', reason: - 'A publish-time gate catching up to a query-time one, not a new rule. #5869 / PR ' - + '#6209 closed the RUNTIME half: `assertListComparandShapes` ' + '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 #5685 already ruled on the opposite error: a schema stricter than ' + + '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 ' @@ -4773,7 +4791,7 @@ const step17: MigrationStep = { + '`operator: "in", value: ""` is an UNFINISHED row, not a filter — decide what it was ' + 'meant to select rather than mechanically rewriting it to [""], which is a real and ' + 'different predicate. And a view that already carried one of these shapes was never ' - + 'returning filtered rows: it answered 400 INVALID_FILTER on render (#5869), so ' + + 'returning filtered rows: it answered 400 INVALID_FILTER on render, so ' + 're-check what the view is supposed to show rather than assuming the old result set ' + 'was correct.', }, @@ -4807,7 +4825,8 @@ const step17: MigrationStep = { + '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: ' - + '#5948\'s issue body AND its 2026-08-07 maintainer ruling both read ' + + '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 ' @@ -4817,7 +4836,7 @@ const step17: MigrationStep = { + '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, #6239.', + + '2026-08-07.', acceptanceCriteria: 'No source imports `ListViewsRequest(Schema)`, `ListViewsResponse(Schema)`, ' + '`GetViewRequest(Schema)`, `GetViewResponse(Schema)`, `CreateViewRequest(Schema)`, ' @@ -4828,7 +4847,7 @@ const step17: MigrationStep = { + '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 #5948 meant.', + + 'still resolve — they are the shapes that ruling meant.', }, { id: 'workflow-service-slot-retired', @@ -5948,16 +5967,20 @@ const step18: MigrationStep = { + 'query gets the `where` prescription). A key that names no supported capability is simply ' + 'removed', reason: - 'The #4001 strictness campaign\'s data/ batch D. These shapes parsed `.strip` — an ' + 'The unknown-key strictness campaign (the sweep that ended silent stripping of undeclared ' + + 'keys as the default, one schema family at a time), its data/ batch. These shapes parsed ' + + '`.strip` — an ' + 'undeclared key on an authored cube was silently dropped, so a join authored with a ' + 'typo\'d `relationship` registered with the `many_to_one` default (a different join than ' + 'the author declared) and a metric\'s misspelled key vanished under a successful parse. ' - + 'The subtle half: `/analytics/query`\'s top level has been strict since #3878, but ' + + 'The subtle half: `/analytics/query`\'s top level has been strict since the degraded ' + + 'shim\'s envelope dialect was retired (one URL, one request body), but ' + 'top-level strictness does not recurse — `timeDimensions: [{ dimension, granuarity: ' + '\'day\' }]` rode through the strict wrapper with the typo stripped, bucketing the whole ' + 'range as one group under an ordinary 200. Undeclared keys on all eight sites are now ' + 'refused at parse time with a prescriptive message. (One of the eight — the nested metric ' - + '`filters[]` item — was itself removed later in this major: #10414, `metric-filters-removed`.)', + + '`filters[]` item — was itself removed later in this major, because nothing ever read it: ' + + '`metric-filters-removed`.)', acceptanceCriteria: 'Every cube in `defineStack({ analyticsCubes })` / `defineCube` parses with only declared ' + 'keys at every level (cube, refreshKey, measures, dimensions, joins); ' @@ -6021,8 +6044,9 @@ const step18: MigrationStep = { replacement: 'exactly two string bounds — `[start, end]`. A ONE-ELEMENT window is that day written as ' + 'BOTH bounds: `[\'2026-01-01\']` becomes `[\'2026-01-01\', \'2026-01-01\']`, the shape ' - + 'the shipped #16322 migration table already prescribes for a single day, and the shape ' - + 'all four analytics faces have selected that one day with since PR #17593. ⛔ The EMPTY ' + + 'the shipped migration table for the closed preset vocabulary already prescribes for a ' + + 'single day, and the shape all four analytics faces have selected that one day with since ' + + 'the fix that made them read the array arm one way. ⛔ The EMPTY ' + 'array and THREE-OR-MORE bounds have NO replacement that can be derived from what was ' + 'written: an empty array names no window at all, and a 3+ array names no pair — decide ' + 'the window the widget was meant to show and write its two bounds, or drop the ' @@ -6030,17 +6054,19 @@ const step18: MigrationStep = { + 'time-bounded). A relative window is a preset name from the closed vocabulary ' + '(`\'last_7_days\'`) or a date-macro pair (`[\'{7_days_ago}\', \'{today}\']`).', reason: - 'Maintainer ruling A on #17598 (decision batch #117 item 3, 2026-09-12, re-affirmed ' - + '2026-09-13): the array arm was a bare `z.array(z.string())` with NO length constraint, ' + 'Maintainer ruling A of 2026-09-12, re-affirmed 2026-09-13, which tightened the array arm ' + + 'to exactly two string bounds: the arm was a bare `z.array(z.string())` with NO length ' + + 'constraint, ' + 'while the refusal sentence in the same source file said verbatim that "an explicit ' - + 'window is the two-element array [start, end]" and #16322\'s shipped migration table ' - + 'told an author to write a single day as `[\'2026-01-20\', \'2026-01-20\']`. So only the ' - + 'TYPE was weaker than the prose beside it, and #17124 measured what that bought: one ' + + 'window is the two-element array [start, end]" and the shipped migration table for the ' + + 'closed preset vocabulary told an author to write a single day as ' + + '`[\'2026-01-20\', \'2026-01-20\']`. So only the TYPE was weaker than the prose beside it, ' + + 'and a measurement of one authored document on each face found what that bought: one ' + 'authored `[\'2026-01-01\']` meant a point window on ObjectQLStrategy, NO time clause at ' + 'all on NativeSQLStrategy (the whole of history), an unbounded-above window in the ' + 'draft-preview evaluator, and a shifted point window in DatasetExecutor.runCompare — the ' - + 'same document, four backends, four different numbers, no error on any of them. PR ' - + '#17593 made all four faces refuse it with the ADR-0112 envelope `400 ' + + 'same document, four backends, four different numbers, no error on any of them. The fix ' + + 'that followed made all four faces refuse it with the ADR-0112 envelope `400 ' + 'ANALYTICS_DATE_RANGE_UNRECOGNIZED`, which left the contract door LOOSER than every ' + 'reader behind it; this narrowing closes that gap at the door. ⚠️ No D2 conversion and ' + 'no stored-metadata rewrite, deliberately: rewriting `[\'2026-01-01\']` to the same day ' @@ -6048,7 +6074,7 @@ const step18: MigrationStep = { + 'rather than a window whose end they forgot — and for the empty array and 3+ bounds ' + 'there is nothing to decide FROM. The blast radius is the WIDGET, not the page: a stored ' + 'dashboard carrying a now-refused range loses that widget with the accurate refusal ' - + 'shown, and the dashboard still loads. Since PR #17593 every such stored range already ' + + 'shown, and the dashboard still loads. Since that fix every such stored range already ' + 'failed at QUERY time with the same code and status, so this adds no new class of ' + 'breakage — it moves the refusal to authoring time and states it accurately. ' + 'ADR-0049 / ADR-0087 / ADR-0112.', @@ -6086,9 +6112,10 @@ const step18: MigrationStep = { + '\'2026-01-20\']` for the single day a bare ISO string used to mean on SQL, ' + '`[\'2026-01-01\', \'2026-01-31\']`, or `[\'{7_days_ago}\', \'{today}\']` in date-macro tokens', reason: - 'Maintainer ruling on #16041 (decision batch #57, option A — contract first, 2026-09-06): ' - + 'the protocol is the baseline, so the vocabulary is declared once in the schema and the ' - + 'drivers align to it (#16322) instead of each guessing. The arm was a bare `z.string()` ' + 'Maintainer ruling of 2026-09-06 on the analytics date-range string (option A — contract ' + + 'first): the protocol is the baseline, so the vocabulary is declared once in the schema and ' + + 'the drivers align to it, in a driver change of their own, instead of each guessing. The ' + + 'arm was a bare `z.string()` ' + 'whose only documented example, `"Last 7 days"`, no driver could parse: driver-memory ' + 'recognised exactly `today` and a case-sensitive `last N ` and fell every other ' + 'string through to a `[range, range]` pseudo-window that — measured through mingo on ' @@ -6097,7 +6124,8 @@ const step18: MigrationStep = { + 'as a single ISO day. A dashboard asking for one week silently got all of history on one ' + 'backend and one day on the other, with no error on either. The string arm is now ' + '`z.enum(DATE_RANGE_PRESETS)` — derived from `data/date-range-presets.ts`, the vocabulary\'s ' - + 'single source of truth since #4614, so the two cannot drift — and any other string is ' + + 'single source of truth since the dashboard date filter\'s three copies of the list were ' + + 'folded into it, so the two cannot drift — and any other string is ' + 'refused at parse time with one prescriptive issue at the field\'s own path; the runtime ' + 'door answers the ADR-0112 envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` ' + '(`api/error-code-ledger.zod.ts`). ⚠️ No D2 conversion and no stored-metadata rewrite: ' @@ -6191,7 +6219,8 @@ const step18: MigrationStep = { replacement: 'retryAfterSeconds — rename the key; the value (seconds) is unchanged', reason: 'Maintainer ruling B of 2026-09-02 on duration-shaped keys: the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' - + 'BREAKING ON THE WIRE, and ruled in deliberately: the ruling puts the ~16 runtime-emitted ' + + 'BREAKING ON THE WIRE, and ruled in deliberately: its 2026-09-05 population ruling puts the ' + + '~16 runtime-emitted ' + 'measurements in scope because they are read by humans and agents even if nobody authors ' + 'them, and names ApiError.retryAfter explicitly, with its own BREAKING note. The ambiguity ' + 'here is sharper than the usual bare duration. A consumer meets TWO retry-after values on ' @@ -10741,7 +10770,7 @@ const step18: MigrationStep = { + 'with no `conditions` the node routes by its out-edges alone, so the out-edge that branch ' + 'labelled is no longer held back', reason: - 'Card #19961. `DecisionConditionSchema` declares a branch `{ label, expression }` with ' + '`DecisionConditionSchema` declares a branch `{ label, expression }` with ' + '`expression` a required `z.string()`, but nothing parses a decision node\'s open config ' + 'against it, and the expression-ledger resolver skipped an absent value as "not authored" — ' + 'so a branch with no predicate passed `FlowSchema.parse`, `AutomationEngine.registerFlow` and ' @@ -10797,7 +10826,8 @@ const step18: MigrationStep = { 'A DEFAULT FLIP of a shipped node type, ruled rather than patched: the schema, the docs ' + 'and the engine\'s own comment all called an edge-branched decision an exclusive gateway ' + 'while the traversal took EVERY out-edge whose condition held, one after another, and ' - + 'reported nothing — hotcrm#1555 rendered a refusal screen AND ran the conversion in one ' + + 'reported nothing — a CRM application\'s lead-conversion flow rendered a refusal screen AND ' + + 'ran the conversion in one ' + 'execution. The traversal now matches the declaration (BPMN exclusive gateway, ' + 'Salesforce Flow Decision, n8n Switch default), and the every-true-edge behaviour is the ' + 'BPMN inclusive gateway an author must write down. The KEY converts mechanically and ' @@ -10853,7 +10883,7 @@ const step18: MigrationStep = { + 'authored either as an expression envelope carrying only ast ({ dialect: \'cel\', ast: … } ' + 'with no source), or with a source that is blank after trimming, through the envelope key ' + '({ dialect: \'cel\', source: \' \' }) or the bare-string shorthand for it ' - + '(condition: \' \'). The node slot joined this entry with #17322 and #17495, which rebound ' + + '(condition: \' \'). The node slot joined this entry with the two later changes that rebound ' + 'AutomationEngine.registerFlow and objectstack validate to the edge door\'s own rule rather ' + 'than deriving a second one; it is the same decision reaching the second slot, which is why ' + 'it is named here instead of in an entry of its own. Reachable wherever a flow is authored ' @@ -10869,14 +10899,18 @@ const step18: MigrationStep = { + 'edge rather than preserving it. An `ast` BESIDE a string `source` is untouched and stays ' + 'admitted everywhere', reason: - 'Card #15807 (the #15430 / #15662 lineage): `FlowEdgeSchema.condition` now composes ' + 'The evaluated-slot rule, carried to the edge condition — the line that first refused an ' + + '`ast`-only envelope no engine can evaluate, and refused a non-string node predicate at ' + + 'registration instead of letting the evaluator answer it a silent `false`: ' + + '`FlowEdgeSchema.condition` now composes ' + '`EvaluatedExpressionInputSchema` instead of `ExpressionInputSchema`, so an evaluated slot ' + 'is held to what the engine can actually run. The engine reads `source` alone ' + '(`cel-engine.ts` `evaluate`: "AST-only evaluation not yet supported; persist `source`"), ' + 'so both refused spellings landed in its empty-source arm and answered a SILENT `false` on ' + 'every release that carried them — they parsed, registered, passed `objectstack validate`, ' - + 'and then produced a branch that quietly never fired (measured on #15430, comment ' - + '5550509137). The refusal is one rule with one sentence, ' + + 'and then produced a branch that quietly never fired (measured on 2026-09-05 by driving an ' + + '`ast`-only envelope through `AutomationEngine.evaluateCondition` directly). The refusal is ' + + 'one rule with one sentence, ' + '`EVALUATED_EXPRESSION_SOURCE_REQUIRED`. ' + '⚠️ No D2 conversion is possible, and this is exactly why the change needs a D3 entry ' + 'rather than none. An `ast`-only envelope carries no `source` to derive one from — ' @@ -11028,15 +11062,16 @@ const step18: MigrationStep = { + '`condition` turns a never-firing edge into an always-firing one ' + '(`flow-edge-condition-evaluated-slot-source-required`)', reason: - 'Card #17493, ruling A (5651023407). Both slots are declared bare CEL text (`z.string()`) ' + 'Maintainer ruling A of 2026-09-13: the two sibling predicate slots refuse a blank string ' + + 'at authoring. Both slots are declared bare CEL text (`z.string()`) ' + 'and both admitted a blank string at every door: the expression ledger resolver skipped it ' + 'as "not authored", and `AutomationEngine.evaluateCondition` answered it `false` — so a ' + 'decision branch carrying it was never taken, with nothing said at any layer, and a screen ' - + 'field carrying it was shown with its predicate ignored. #15572 had pinned that ' + + 'field carrying it was shown with its predicate ignored. An earlier fix had pinned that ' + 'admission as correct because the two sides agreed. The ruling is that self-consistency ' + 'between parser and evaluator is not a defence when the author\'s intent is silently ' - + 'dropped — the third instance of one rule, after #17322 (the structural `config.condition`) ' - + 'and #15811 (a blank evaluated `source`). The blank is now refused at `FlowSchema.parse`, ' + + 'dropped — the third instance of one rule, after the structural `config.condition` and a ' + + 'blank evaluated `source`. The blank is now refused at `FlowSchema.parse`, ' + 'at `AutomationEngine.registerFlow` (which parses first) and at `objectstack validate`, all ' + 'three through `predicateSlotRefusal`, leading with `PREDICATE_SLOT_STRING_REFUSAL`. ' + '⚠️ No D2 conversion, and the reason is the judgment this entry delegates: the blank is ' @@ -11492,9 +11527,9 @@ const step18: MigrationStep = { + 'computed amount is rounded to. ⛔ Nothing replaces the key: do not re-declare its value under ' + 'any other key.', reason: - 'Maintainer ruling 5791803339 (batch #215 item 1, letter B) retired `scale` from the ' - + '`currency` field type, and ruling 5805782503 (batch #218 item 2, letter 乙 — a currency\'s ' - + 'ISO 4217 minor unit decides its display) worded the remedy. Neither reached the inline grid ' + 'The maintainer\'s ruling of 2026-09-23 (option B) retired `scale` from the `currency` field ' + + 'type, and the ruling of 2026-09-24 (option 乙 — a currency\'s ISO 4217 minor unit decides its ' + + 'display) worded the remedy. Neither reached the inline grid ' + 'column, the strict mirror of the console grid\'s column, which still offered per-column ' + 'decimals on a `currency` column; triage read the column as inherited from both rulings, so ' + '`InlineGridColumnSchema` now refuses the key on a column declaring `type: \'currency\'` at ' @@ -12719,14 +12754,16 @@ const step18: MigrationStep = { + '`object-grid.defaultSort` is a different key, retired separately by the ' + '`ui__ObjectGridProps__defaultSort` entry.', reason: - 'One `sort` spelling platform-wide, the array (objectui#8221, decision batch #77, ' - + '2026-09-07, maintainer verbatim 「其他同意」, option B; the consumer half is ' - + 'objectui PR #8758, which drops the string arm from `convertSortToQueryParams`). ' - + 'Item 4 of that ruling is this entry\'s subject: 「`ComponentPropsMap` for ' + 'One `sort` spelling platform-wide, the array: the maintainer\'s ruling of 2026-09-07 ' + + '(option B) retired the legacy string `sort` clause, and its consumer half is the objectui ' + + 'change that drops the string arm from `convertSortToQueryParams`. One item of that ' + + 'ruling is this entry\'s subject: 「`ComponentPropsMap` for ' + '`object-calendar` and `object-grid` constrains the `sort` value to the array shape ' + '(today it accepts anything), so the spec, the registrations and the helper agree; ' + 'that is a pull-back to the declared contract, ordinary tier」. The `z.unknown()` at ' - + 'both doors was a read-point record (#7751), the same vintage as the `filter` doors ' + + 'both doors was a read-point record from the change that brought the `object-*` blocks ' + + 'into `ComponentPropsMap` (the maintainer\'s ruling of 2026-08-12), the same vintage as ' + + 'the `filter` doors ' + 'the `element-data-source-and-object-block-filter-rule-array` entry moved, and not an ' + 'exception to the ruling: measured on `@objectstack/spec` 17.2.0 an array, a string ' + 'and a bare NUMBER all returned `success: true` while `bogusProp` was refused by name ' @@ -12781,20 +12818,21 @@ const step18: MigrationStep = { + '`ViewDataSchema` semantics; `staticData` (the deprecated bare-array shortcut the ' + 'renderer still reads) keeps its shape but is not the prescription', reason: - 'Two entries of one contract disagreed on the KIND (objectui#6207, contract-vs-' - + "contract): `ComponentPropsMap['object-grid'].data` said bare array ('Static inline " + 'Two entries of one contract disagreed on the KIND (contract-vs-contract, found by ' + + "objectui's declared-arm parity gate): `ComponentPropsMap['object-grid'].data` said bare " + + "array ('Static inline " + "rows — bypasses the object query') while `ViewDataSchema` — the authority " - + 'objectui#5090 ruled the registry declaration against, pinned by ' + + 'objectui aligned the grid\'s registry declaration to, pinned by ' + '`gridDataInputContract.test.ts`, and what `ObjectGridSchema.data` resolves to — is ' + "an object discriminated on `provider`. Measured on @objectstack/spec@17.2.0: " + "`{ provider: 'value', items: [] }` — the pinned-legal form — was REFUSED by the " + 'props-map entry (`expected array, received object`) while the bare array parsed. ' + 'Whichever authority a value satisfied, the other refused it, and the objectui ' + 'parity gate had to carry the reasoned exemption `object-grid.data:object` to look ' - + 'away. The maintainer ruling (2026-08-25, batch adjudication batch 4; verbatim: ' - + '「同意」, Option A) converged the props-map entry onto `ViewDataSchema`; the ' - + 'bare-array form is the deprecated `staticData` shortcut the objectui#4648 ' - + 'carve-out already refuses to publish. The ruled migration check ran with the ' + + 'away. The maintainer\'s ruling of 2026-08-25 (option A) converged the props-map entry ' + + 'onto `ViewDataSchema`; the bare-array form is the deprecated `staticData` shortcut that ' + + 'objectui\'s deprecated-alias carve-out already refuses to publish as authoring surface. ' + + 'The ruled migration check ran with the ' + 'change: the sweep of generated artifacts, templates and first-party corpora ' + '(examples/, skills/, create-objectstack, spec fixtures) found ZERO bare-array ' + '`data` authors, so no rewrite ships — this entry carries the prescription for ' @@ -12806,8 +12844,8 @@ const step18: MigrationStep = { + "`data: [...]` writes `data: { provider: 'value', items: [...] }` — same rows, " + 'one wrapping object. Downstream (objectui, after a released spec version reaches ' + 'the pin): the `object-grid.data:object` exemption entry in ' - + '`registry-inputs-spec-parity.test.ts` becomes deletable, which is what closes ' - + 'objectui#6207.', + + '`registry-inputs-spec-parity.test.ts` becomes deletable, which is what closes the ' + + 'objectui finding that the two authorities disagreed.', }, // The key the one-filter-orthography convergence did not name. Its sibling // entry element-data-source-and-object-block-filter-rule-array says so in as @@ -12836,8 +12874,10 @@ const step18: MigrationStep = { + 'is read only when filter is absent, and its own description has prescribed filter all ' + 'along', reason: - '#19514, out of objectui#9050 ruling C-prime (maintainer 2026-09-20, verbatim, ' - + 'untranslated): 「the differences are the protocol\'s to close」. This is the SAME value ' + 'The protocol half of the maintainer\'s ruling C-prime of 2026-09-20 on objectui\'s ' + + 'render-time filter converter — the protocol is the only refusal set, so a document it ' + + 'accepts never throws at render time — verbatim, untranslated: 「the differences are the ' + + 'protocol\'s to close」. This is the SAME value ' + 'in the SAME role as filter — the key\'s own description says it is read only when ' + 'filter is absent — and the consumer reads it through the SAME lowering sink, so every ' + 'refusal that sink can give was reachable from a document the protocol had just ' @@ -12916,14 +12956,17 @@ const step18: MigrationStep = { surface: 'object `indexes[]` entries (`IndexSchema`) — undeclared keys', replacement: 'the declared surface: `name` / `fields` / `unique` (ADR-0120 scope). A key that ' + 'names no declared capability is simply removed. `where` — the console fallback editor\'s ' - + 'drifted spelling for a partial-index predicate, removed from the editor by objectui#4772 — ' + + 'drifted spelling for a partial-index predicate, removed when objectui converged that editor ' + + 'onto `IndexSchema` — ' + 'gets a curated prescription: partial indexes are built at the database layer ' + '(`CREATE [UNIQUE] INDEX … WHERE` from a runtime migration), never declared here', reason: - 'The #4001 strictness campaign\'s 批 20 held site 14 open on a measured #5114-class risk: ' + 'The unknown-key strictness campaign held this site open on a measured risk, the kind that ' + + 'had already made a console save answer 422 (a strict schema refusing a key the console ' + + 'itself writes): ' + 'objectui\'s embedded index editor shipped a drifted hand-copied schema offering `where` ' + 'and `brin`, spliced its output into `object.indexes[]` and PUT the whole object, so ' - + 'closing the shape would have 422\'d a control the console itself rendered. objectui#4772 ' + + 'closing the shape would have 422\'d a control the console itself rendered. objectui then ' + 'converged that editor to the declared surface, spending the hold\'s evidence. Before this ' + 'close an undeclared key on an index parsed clean and was silently dropped — an admin ' + 'filling the old "Partial-index predicate" control got a green save while no driver ever ' @@ -13095,14 +13138,14 @@ const step18: MigrationStep = { + 'platform later serves a package upgrade, dependency-resolution or upload route, its entry ' + 'arrives in the same change that mounts it.', reason: - 'Maintainer ruling 2026-09-23 on #19116 (director seat, decision batch #217 item 4, letter A, ' - + '「217 同意」). The contract map is the declaration SDKs, codegen and AI clients are entitled to ' + 'Maintainer ruling of 2026-09-23 (option A: retire the three contract-map entries that name paths ' + + 'nothing mounts). The contract map is the declaration SDKs, codegen and AI clients are entitled to ' + 'trust, and three of its seven entries named paths the composed runtime mounts nowhere: the ' + 'package dispatcher has no branch for a single-segment POST under /packages and ' + '`@objectstack/rest` mounts only /packages/publish there, so all three answered handled=false ' + 'while the four surviving entries answer 200/201 (measured on one HttpDispatcher over a real ' - + 'SchemaRegistry, #18604) — and the generated reference page printed ' - + 'all three as live endpoints. Unlike `installPackage` (#18058, rebound onto the serving ' + + 'SchemaRegistry) — and the generated reference page printed ' + + 'all three as live endpoints. Unlike `installPackage` (rebound by an earlier fix onto the serving ' + 'POST /api/v1/packages), no serving door existed to rebind them onto, and mounting three ' + 'capabilities with zero measured pull was ruled out (ADR-0049 enforce-or-remove). Zero ' + 'consumers measured at the retiring PR\'s base: across this repository the three paths occur ' @@ -13146,8 +13189,9 @@ const step18: MigrationStep = { + 'removed. The bare form (a manifest as the whole body) is unchanged: it was already ' + 'closed, and it still carries no install options.', reason: - 'One rule for the whole install contract (decision batch #227 item 3, letter A; ruling ' - + 'record 5856869656). The manifest and the bare form already refused an unknown key by ' + 'One rule for the whole install contract (the maintainer\'s ruling of 2026-09-27, option A: ' + + 'the wrapped form refuses an unknown top-level key by name). The manifest and the bare form ' + + 'already refused an unknown key by ' + 'name; the wrapped top level was the one position still declared strip mode, so ' + '`{ manifest, enabledOnInstall: false }` — a misspelled `enableOnInstall` — parsed green ' + 'with the key DROPPED, and the install door, which answers exactly what this declaration ' @@ -13230,18 +13274,21 @@ const step18: MigrationStep = { + '`PackageRollbackRequestSchema` stays published (ruled out of the ' + 'retirement), bound to no route.', reason: - 'Maintainer ruling 2026-08-27 on #12038, sub-question 3A (五问一批, ' - + '「其他接受」). The schema declared a version rollback — ' + 'Maintainer ruling of 2026-08-27 on the client SDK\'s unbound response ' + + 'contracts, sub-question 3A: retire this false declaration first, then ' + + 'author the true one. The schema declared a version rollback — ' + '`{ success, restoredVersion?, message? }`, matching its file header ' + '"Rollback a package" — while the live path it was contract-bound to ' + 'serves the ADR-0067 commit rollback: a different operation with a ' + 'different result. Binding it in the SDK would compile and be false ' - + '(#11925 left a compile-time guard against exactly that substitution). ' + + '(the change that typed the SDK\'s un-annotated return values left a ' + + 'compile-time guard against exactly that substitution). ' + 'Zero consumers measured across objectstack, objectui and cloud ' - + '(#12038 survey §5.2, re-verified at the retiring PR\'s base): only its ' - + 'own unit test and the #11925 negative guard. A published declaration ' - + 'that outran the implementation is the #3877 hazard realised in the ' - + 'opposite direction — not "no declaration" but a WRONG one — and it is ' + + '(the ruling\'s own survey, re-verified at the retiring PR\'s base): only its ' + + 'own unit test and that negative guard. A published declaration that ' + + 'outran the implementation is the hazard of response bodies never checked ' + + 'against the schemas that declare them, realised in the opposite direction ' + + '— not "no declaration" but a WRONG one — and it is ' + 'retired BEFORE the true schema is authored so no window exists in ' + 'which both claims are published.', acceptanceCriteria: @@ -14280,9 +14327,9 @@ const step18: MigrationStep = { + 'pull); if it is ever wanted it re-declares fresh under its own ruling, ' + 'executor first', reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-01 on #13823 ' - + '(director decision batch #27, verbatim 「同意」: remove; enforce ' - + 'excluded). The key was DOCUMENTED to cause a specific runtime behaviour ' + 'ADR-0049 enforce-or-remove; maintainer ruling of 2026-09-01 on this key: ' + + 'remove it with a tombstone; enforce excluded. The key was DOCUMENTED to ' + + 'cause a specific runtime behaviour ' + '— its docstring said a stub handler "returns 501 Not Implemented" — and ' + 'that behaviour has a different cause: every ' + 'DispatcherErrorCode.enum.NOT_IMPLEMENTED site (runtime/src/' @@ -14298,13 +14345,16 @@ const step18: MigrationStep = { + '(pinned sha) and cloud. So an author who wrote handlerStatus: \'stub\' ' + 'expecting the dispatcher to answer 501 got an ordinarily served route, ' + 'and the declaration reported progress to nobody — a declared ≠ enforced ' - + 'gap on the same endpoint vocabulary ApiEndpointSchema closed strictly in ' - + '#5384, and the surface a published skill had been teaching as working ' - + 'machinery (the sentence corrected in #13808 is where this card came ' - + 'from). Bookkeeping: the KEY is tombstoned with retiredKey() on the ' + + 'gap on the same endpoint vocabulary whose ApiEndpointSchema had already ' + + 'been closed strictly once `api` became a registered metadata type, and ' + + 'the surface a published skill had been teaching as working machinery ' + + '(this finding came out of correcting that skill sentence, in a factual ' + + 'sweep of the automation skill). Bookkeeping: the KEY is tombstoned with ' + + 'retiredKey() on the ' + 'non-strict RestApiEndpointSchema (api/RestApiEndpoint:handlerStatus in ' + 'RETIRED_KEYS_BY_MAJOR[18]); the DEFS leave whole — api/HandlerStatus ' - + '(orphan value enum once both carriers are gone, the #3950 rule), ' + + '(orphan value enum once both carriers are gone — an exported value schema ' + + 'with no consumer reads as a capability, so it leaves with its key), ' + 'api/RouteCoverageEntry and api/RouteCoverageReport (route 3: nobody ' + 'ever parsed or constructed one) — all three in RETIRED_DEFS_BY_MAJOR[18]. ' + 'It is a SEMANTIC entry rather than a D2 conversion because there is no ' @@ -14315,9 +14365,10 @@ const step18: MigrationStep = { + 'kernel/Manifest:loading disposition). ENFORCE was excluded by the ruling: ' + 'mounting a 501 stub for stub / planned endpoints is a zero-pull new ' + 'capability, not a repair. The same ruling records the class direction ' - + 'for the two sibling ADR-0049 cards (#13612 / #13613, not ruled by it): ' + + 'for two sibling ADR-0049 findings (the unbound branded identifier schemas ' + + 'and the event-name schema no runtime reads; not ruled by it): ' + 'a declared-but-unenforced key with no pull retires; enforce/bind only on ' - + 'a named consumer or measured pull. ADR-0049 / ADR-0087, #13823.', + + 'a named consumer or measured pull. ADR-0049 / ADR-0087.', acceptanceCriteria: 'No source writes handlerStatus on a RestApiEndpoint: authoring it is now a ' + 'tsc error at the site (the tombstone types the key never) and a parse ' @@ -14347,7 +14398,7 @@ const step18: MigrationStep = { replacement: 'timeoutMs (milliseconds), cacheTtlSeconds (seconds) and defaultCacheTtlSeconds ' + '(seconds, default 300) — rename each key; every 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 on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + 'RestApiEndpoint is this rule\'s clearest specimen after the founding one: `timeout` in ' + 'MILLISECONDS and `cacheTtl` in SECONDS sat three lines apart on one shape, each unit named ' + 'only in its describe, so the two numbers were indistinguishable at the authoring site and a ' @@ -14363,7 +14414,7 @@ const step18: MigrationStep = { + 'chain has no seam that ever runs on them. That is the disposition ' + 'api/RestApiEndpoint:handlerStatus already carries on this very shape ' + '(rest-api-endpoint-handler-status-retired), and what ruling B prescribes for a key that is ' - + 'not authorable metadata. #15677, #14478, ADR-0087.', + + 'not authorable metadata. ADR-0087.', acceptanceCriteria: 'Every RestApiEndpointSchema.parse(…) and RestApiPluginConfigSchema.parse(…) site spells ' + '`timeoutMs`, `cacheTtlSeconds` and `performance.defaultCacheTtlSeconds`; authoring any old ' @@ -14389,9 +14440,10 @@ const step18: MigrationStep = { + 'Batch atomicity is the per-request `options.atomic` (ADR-0119 D4); upsert is an operation type of ' + 'the generic `POST /data/:object/batch` endpoint, gated by `batch.enableBatchEndpoint`.)', reason: - 'The #14369 liveness census enrolled the four `RestServerConfig` sub-objects and found 15 of their ' + 'The liveness census that enrolled the four `RestServerConfig` sub-objects found 15 of their ' + '32 rows `dead`: parsed, defaulted and normalized into the REST server\'s config by `normalizeConfig` ' - + '(#11984) and never read back. `crud.patterns` and `routes.overrides` described route customization ' + + '(which parses them, rather than casting them, since an earlier fix) and never read back. ' + + '`crud.patterns` and `routes.overrides` described route customization ' + 'the server mounts from fixed pairs; `routes.includeObjects` / `excludeObjects` and `overrides.enabled` ' + '/ `operations` duplicated the object\'s own enforced exposure keys; `nameTransform` and ' + '`objectParamStyle` were enums validated and then ignored; `metadata.endpoints.schema` and ' @@ -14404,8 +14456,9 @@ const step18: MigrationStep = { + 'segment). All four schemas are non-strict `z.object()`s, so each key is a `retiredKey()` tombstone ' + 'and its ledger row stays `dead` with a REMOVED note; `api/CrudEndpointPattern`, the value def of ' + '`crud.patterns`, leaves with it. No D2 conversion: a `RestServerConfig` is plugin TS configuration, ' - + 'never a stack collection member or a `sys_metadata` row (the `openApi31` precedent, #4579). Cloud ' - + 'sweep #14796 @9b6abe0f2fd5: zero hits, structural — cloud never authors a `RestServerConfig`. #14691.', + + 'never a stack collection member or a `sys_metadata` row (the `openApi31` precedent). A closed-set ' + + 'sweep of the cloud repository at 9b6abe0f2fd5: zero hits, structural — cloud never authors a ' + + '`RestServerConfig`.', acceptanceCriteria: 'No `RestServerConfig` value passed to the REST plugin (or `plugin-hono-server` `restConfig`) carries ' + 'any of the ten keys — a config that does now fails `new RestServer(...)` / `createRestApiPlugin().start()` ' @@ -17044,7 +17097,8 @@ const step18: MigrationStep = { + 'row is deleted. The list operators (in / not_in) and the range operator (between) ' + 'refused an absent value before this change and still do, in their own words', reason: - '#19751. The value key\'s own published description has declared since #6227 that every ' + 'The value key\'s own published description has declared, since the value was first shaped ' + + 'by its operator, that every ' + 'operator outside the list, range and unary sets takes a scalar, and that only the unary ' + 'operators ignore the key; the refinement implementing the coupling returned early on an ' + 'absent value for every operator, so a rule with no value parsed green on all thirteen ' @@ -17146,12 +17200,14 @@ const step18: MigrationStep = { + 'discarded, so whatever sits there still parses, array included. An omitted value is ' + 'still an omitted value', reason: - '#19514, closing the protocol half of objectui#9050 ruling C-prime (maintainer ' - + '2026-09-20, verbatim, untranslated): 「the differences are the protocol\'s to close」. ' - + 'The value key\'s own published description has declared this rule since #6227 — ' + 'Closing the protocol half of the maintainer\'s ruling C-prime of 2026-09-20 on objectui\'s ' + + 'render-time filter converter — the protocol is the only refusal set, so a document it ' + + 'accepts never throws at render time — verbatim, untranslated: 「the differences are the ' + + 'protocol\'s to close」. The value key\'s own published description has declared this rule ' + + 'since the value was first shaped by its operator — ' + '「every other operator takes a scalar」 — and the refinement that implements the ' + 'coupling returned early for every operator that is neither a list operator nor ' - + 'between, so the entire scalar class was declared and, from #6227 until this change, ' + + 'between, so the entire scalar class was declared and, from then until this change, ' + 'not judged. ' + '⚠️ This REVERSES a reading recorded in the sibling entry ' + 'view-filter-rule-value-shaped-by-operator, which listed a scalar operator carrying an ' @@ -17295,7 +17351,7 @@ const step18: MigrationStep = { + 'view\'s `options` into the list renderer, which merges `options.KIND` under the top-level block — so a ' + 'key the strict block refuses by name (`timeline.metaFields`) was saved and rendered when spelled ' + '`options.timeline.metaFields`. Measured on `origin/main` @ `8d1f7ab` through the real save. Ruled ' - + 'direction A (maintainer 「其他同意」): judge each `options.KIND` with the kind\'s strict schema and ' + + 'direction A (the maintainer\'s ruling of 2026-09-24): judge each `options.KIND` with the kind\'s strict schema and ' + 'refuse an out-of-contract key by name, as the direct spelling is; refusing the bag whole was ruled ' + 'out because the legacy `options.map` path is live and pinned. Judged key by key, because the ' + 'renderer reads the bag as a per-key underlay of the top-level block: a bag that carries only the ' @@ -17379,13 +17435,14 @@ const step18: MigrationStep = { + 'per page on a view, write it: `pagination: { pageSize: 25 }`', reason: 'A RULED behaviour change on a default, so there is nothing to rewrite and nothing to ' - + 'refuse: the maintainer set the platform display page size to 50 (「9853 默认页大小改为50」, ' - + 'objectui#9853), and the declared default of `PaginationConfigSchema.pageSize` moved ' + + 'refuse: the maintainer\'s ruling of 2026-09-24 set the platform display page size to 50, ' + + 'declared once in the protocol, and the declared default of `PaginationConfigSchema.pageSize` moved ' + 'from 25 to 50. A `pagination` block that omits `pageSize` now parses to 50 — 50 rows ' + 'per page on a paged view, and a fetch ceiling of 50 on a view with no pager (kanban, ' + 'gallery, timeline). A view with no `pagination` block at all parses with none on either ' + 'side; its page size reaches it through the renderer, which is ruled to read the spec ' - + 'default rather than keep its own number (objectui#9853 ruling C′ item 1). Not losslessly ' + + 'default rather than keep its own number (an earlier ruling on the grid\'s page size, which ' + + 'the page-size ruling restated). Not losslessly ' + 'convertible because the question is intent, not text: a mechanical pass that wrote ' + '`pageSize: 25` into every silent view would preserve the old number and defeat the ' + 'ruling, and one that wrote 50 would add nothing the default does not already do. Only ' From 472ee29b8293041df4664be19fd38ab1a068c136 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 23:47:55 +0000 Subject: [PATCH 3/3] chore(changeset): stage-6 migration guidance, patch Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- ...ow-http-migration-guidance-tracker-free.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .changeset/20233-rest-analytics-view-package-object-sharing-audit-flow-http-migration-guidance-tracker-free.md diff --git a/.changeset/20233-rest-analytics-view-package-object-sharing-audit-flow-http-migration-guidance-tracker-free.md b/.changeset/20233-rest-analytics-view-package-object-sharing-audit-flow-http-migration-guidance-tracker-free.md new file mode 100644 index 00000000000..0d1896aee93 --- /dev/null +++ b/.changeset/20233-rest-analytics-view-package-object-sharing-audit-flow-http-migration-guidance-tracker-free.md @@ -0,0 +1,36 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): `os migrate meta` guidance for the `rest-*`, `analytics-*`, `view-*`, `package-*`, `object-*`, `sharing-*`, `audit-*`, `flow-*` and `http-*` migration entries states each lesson in words instead of citing tracker numbers + +Clause-②: no + +The ADR-0087 semantic entries of the `rest-*` family (the retired OpenAPI 3.1 block, the +endpoint `handlerStatus` marker, the REST server's dead config keys and the REST plugin +durations renamed with their unit), the `analytics-*` family (the retired query envelope, +the unknown-key refusals on cubes, and the closed date-range vocabulary and two-bound +window), the `view-*` family (the filter value shaped by its operator and its array and +absent-value refusals, the retired view-management protocol, the page-size default and the +judged overlay `options` bag), the `package-*` family (the explicit all-tenants uninstall, +the retired unmounted contract-map entries and rollback response, and the strict wrapped +install body), the `object-*` family (the array `sort` on object blocks, the converged grid +`data`, the rule-array `defaultFilters` and the index unknown-key refusal), the `sharing-*` +family (the retired `SharingExecutionContext` type and the reconciled rule recipients), the +`audit-*` family (the audit-log action values no writer produced), the `flow-*` family (the +retry count, the decision-branch and edge refusals, first-match edge branching and blank +predicate slots) and the `http-*` family (the retired error counter and server runtime +vocabulary) are printed by `os migrate meta` as the header, `why:` and `verify:` lines of a +manual change. Their text sent the reader to issue-tracker, decision-batch and ruling-record +numbers — some of which no longer resolve, and some in another repository — for what a +ruling, measurement or fix had decided; it now says what was decided, in the sentence being +read. ADR ids are kept. Two entries of other families are corrected the same way: +`api-error-retry-after-unit-in-key` now dates the population ruling its clause describes, +and `inline-grid-column-currency-scale-refused` names its two currency rulings by date +instead of by record number. + +Text only: no entry id, `from` / `to`, conversion or matching logic changes, and the chain +rewrites exactly what it rewrote before. One entry's `surface` (the header line of +`flow-edge-condition-evaluated-slot-source-required`) drops the two tracker numbers it +carried and names nothing else differently. The generated migration registry, +`spec-changes.json` and the protocol upgrade guide carry the same text.