diff --git a/.changeset/migration-registry-per-entry-files.md b/.changeset/migration-registry-per-entry-files.md
new file mode 100644
index 0000000000..ca044d51e5
--- /dev/null
+++ b/.changeset/migration-registry-per-entry-files.md
@@ -0,0 +1,41 @@
+---
+"@objectstack/spec": patch
+---
+
+refactor(spec): split the migration registry's three append tables into per-entry files (#7297)
+
+`packages/spec/src/migrations/registry.ts` carried three hand-authored **append**
+tables — each protocol step's `semantic` list, `RETIRED_KEYS_BY_MAJOR` and
+`RETIRED_DEFS_BY_MAJOR`. Every retirement card appended to the same tail line of the
+same two of them, so two cards in one window were a textual conflict by construction.
+Measured on #6957 over 2026-08-06..10: `step17`'s semantic list and
+`RETIRED_KEYS_BY_MAJOR[17]` conflicted in **6 of 11** contended re-merge laps, for 613
+hand-resolved lines of conflict markers in four days.
+
+Wall-clock was never the reason to fix it. **Both tables are consumed as sets**, so a
+conflict resolution that drops a sibling's entry produces **no error anywhere**: the
+tombstone `check:authorable-surface` was waiting for never arrives, and the D3
+prescription leaves the upgrade guide without a trace.
+
+Per the maintainer ruling on #6957 (2026-08-10, option (a)), the entries now live one
+file per entry under `packages/spec/src/migrations/entries/`, concatenated into
+`registry.ts`'s `` regions by `gen:migration-registry` and verified by
+`check:migration-registry` (wired into `check:generated`). The filename is a pure
+function of the entry id, so two cards registering different entries write different
+files and merge clean, while two cards editing one entry collide in git — which is
+correct and must stay true. Order is derived (sorted by id); there is deliberately no
+index file. `scripts/adr-anchors/` (#7301) is the pilot this mirrors.
+
+**No behaviour change and no acceptance movement.** Every exported value is identical
+entry-for-entry — proved before and after by deep-comparing `MIGRATIONS_BY_MAJOR`,
+`RETIRED_KEYS_BY_MAJOR` and `RETIRED_DEFS_BY_MAJOR` across the change. What moved is
+order: `spec-changes.json` and `docs/protocol-upgrade-guide.md` now list the 59
+semantic migrations sorted by id rather than in append order, a one-time reorder whose
+line multiset is byte-identical to before. Twelve prose cross-references that pointed
+at a neighbour by POSITION ("the entry above", "the trio at the top of this list") were
+rewritten to name the entry, since position is no longer stable.
+
+⚠️ Honest limit, carried from #6957: this removes the conflict **resolution**, not the
+regeneration **lap**. `spec-changes.json` and the upgrade guide are still committed
+projections (option B was rejected — the review diff is worth the laps it costs), so a
+retirement card is not faster, only much harder to lose.
diff --git a/.gitattributes b/.gitattributes
index 650e181131..c7563486a2 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -36,6 +36,15 @@
# WIDEN them), variant-docs.json and the migrations/conversions registries
# (hand-written). Those conflicts are for a human. See NOT_DRIVER_MANAGED.
#
+# The migrations registry is the interesting one since #7297: its three append
+# tables now come from `packages/spec/src/migrations/entries/` (one file per
+# entry, the `.changeset/*.md` shape), so the conflict two retirement cards used
+# to have is gone at the SOURCE — different entries are different files. The
+# file stays out of this list anyway, because it is now MIXED: a driver that
+# deferred it whole would resolve its still-hand-written prose by regenerating,
+# which loses an edit rather than a merge. `check:migration-registry` is what
+# guards the generated half instead.
+#
# The strictness ledger's COUNTS file joined at #5107 — the ledger's numbers were
# the repo's hottest conflict surface and merged in the one way that hides: two
# batches each decrement a header by their own correct delta, the rows merge
diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md
index b8b0424120..6e9e50931f 100644
--- a/docs/protocol-upgrade-guide.md
+++ b/docs/protocol-upgrade-guide.md
@@ -61,18 +61,18 @@ Protocol 13 (ADR-0090 P1) converged the permission model: Role became Position (
### Semantic (delegated to you, with acceptance criteria)
-- **`permission-set-profile-removed`** — `permissionSet.kind / permissionSet.isProfile` → position-based assignment + permission-set grants (ADR-0090 D2)
- - Why not automatic: The Profile concept was removed: `isProfile` is gone from `PermissionSetSchema` and the `profile` metadata kind folded into `position`. Mapping a profile onto positions and permission-set grants is an authorization-design decision, not a rename.
- - Done when: No permission set declares `isProfile` or kind `profile`; the intended assignees hold equivalent grants via positions/permission sets. The access matrix (`os compile` access-matrix gate, where enabled) is reviewed and `objectstack validate` passes.
-- **`position-hierarchy-flattened`** — `position.parent / sharingRule recipient role_and_subordinates` → business-unit tree + `unit_and_subordinates` (ADR-0090 D3)
- - Why not automatic: Positions are flat in v2 — `parent` was removed and the `role_and_subordinates` recipient with it; hierarchy lives on the business-unit tree, which expands a DIFFERENT structure than the retired role tree. Re-homing an org hierarchy is a judgment call.
- - Done when: No position declares `parent`; former `role_and_subordinates` rules are re-expressed with `unit_and_subordinates` over an equivalent business-unit tree. Row visibility is unchanged for a representative fixture set.
- **`cel-current-user-roles-to-positions`** — `CEL/formula: current_user.roles` → current_user.positions
- Why not automatic: The EvalUser/CEL contract renamed `current_user.roles` to `current_user.positions`. The token lives inside free-form expression strings, where a blind textual substitution could corrupt string literals or comments — so the rewrite is delegated to the author.
- Done when: No expression references `current_user.roles`; formula validation and `objectstack validate` report no unknown-identifier errors; predicate behavior is unchanged for representative users.
- **`owd-full-alias-removed`** — `object.sharingModel: 'full'` → 'public_read_write' or explicit sharing rules
- Why not automatic: The legacy `'full'` OWD alias implied full access (including transfer/ delete) — wider than any canonical OWD value, so it has no lossless target ('read'/'read_write' converted mechanically; this one did not). Choosing between `public_read_write` and explicit sharing rules is a security-posture decision.
- Done when: No object declares sharingModel 'full'; the chosen replacement posture is verified against the intended access (who can read/write/delete) for a representative fixture set.
+- **`permission-set-profile-removed`** — `permissionSet.kind / permissionSet.isProfile` → position-based assignment + permission-set grants (ADR-0090 D2)
+ - Why not automatic: The Profile concept was removed: `isProfile` is gone from `PermissionSetSchema` and the `profile` metadata kind folded into `position`. Mapping a profile onto positions and permission-set grants is an authorization-design decision, not a rename.
+ - Done when: No permission set declares `isProfile` or kind `profile`; the intended assignees hold equivalent grants via positions/permission sets. The access matrix (`os compile` access-matrix gate, where enabled) is reviewed and `objectstack validate` passes.
+- **`position-hierarchy-flattened`** — `position.parent / sharingRule recipient role_and_subordinates` → business-unit tree + `unit_and_subordinates` (ADR-0090 D3)
+ - Why not automatic: Positions are flat in v2 — `parent` was removed and the `role_and_subordinates` recipient with it; hierarchy lives on the business-unit tree, which expands a DIFFERENT structure than the retired role tree. Re-homing an org hierarchy is a judgment call.
+ - Done when: No position declares `parent`; former `role_and_subordinates` rules are re-expressed with `unit_and_subordinates` over an equivalent business-unit tree. Row visibility is unchanged for a representative fixture set.
- **`sharing-model-secure-default`** — `object.sharingModel (absent, custom object with owner field)` → an explicit `sharingModel` declaration
- Why not automatic: ADR-0090 D1 secure default: a custom object with an owner field and NO `sharingModel` now resolves `private` (it used to fall through to fully public). Restoring the old exposure must be a deliberate, visible declaration — the chain must not silently re-open data.
- Done when: Every custom object that relied on the implicit public posture declares an explicit `sharingModel`; row visibility is verified for a representative fixture set (owners, non-owners, admins).
@@ -301,144 +301,87 @@ The action LOCATION vocabulary loses `global_nav` in this step (#6888, ADR-0049,
### Semantic (delegated to you, with acceptance criteria)
-- **`dashboard-widget-compareto-offset`** — `dashboard.widgets[].compareTo: { offset: '7d' | '1M' | … } (every duration except '1y')` → compareTo: { kind: 'previousPeriod' } plus an explicit window on the widget's own `filter`
- - Why not automatic: The widget declared three comparison arms; the analytics executor implements one shape, `{ kind, dimension? }`, with no `offset` concept in it at all. On the ADR-0021 dataset path — the spec's single author-facing analytics shape — `{ offset }` was forwarded verbatim into that contract and threw `compareTo requires a timeDimension "undefined"`, taking the widget down; the arm ever only ran on the legacy inline chart path (#5011). The conversion rewrites `{ offset: '1y' }`, which IS `previousYear` by definition. Every other duration has NO faithful target: `previousPeriod` shifts by the length of whatever window the widget's filter resolves to, which equals `7d` only when that window happens to be seven days long. Rewriting mechanically would silently change which rows the comparison column counts — a wrong number rather than a missing one, which is strictly worse and exactly the class this convergence exists to end. Re-stating the intended window is a judgment about the presentation, not a transform.
- - Done when: No dashboard widget declares `compareTo.offset`. Each former offset comparison states its window on the widget's `filter` and compares with `compareTo: { kind: 'previousPeriod' }` (or `'previousYear'`), and `dimension` is named wherever the selection dates more than one time dimension. `objectstack validate` passes, and each affected widget renders a `__compare` column over the window its author intended.
-- **`job-retry-policy-constraints-tightened`** — `job.retryPolicy.maxRetries (> 10) / job.retryPolicy.backoffMultiplier (< 1)` → maxRetries <= 10, and backoffMultiplier >= 1
- - Why not automatic: The converged RetryPolicy (#4661) keeps the automation side's bounds, which the job side never had: `maxRetries` is capped at 10 and `backoffMultiplier` floored at 1. Neither has a lossless rewrite. Clamping `maxRetries: 20` to 10 would halve a retry budget its author chose, and a `backoffMultiplier` below 1 describes a delay that SHRINKS on each attempt — retrying a failing dependency ever faster, which is the opposite of backoff and was never a shape the engine meant to offer. Both now fail at parse time with the bound named, rather than being silently reinterpreted. Choosing the replacement count (or accepting the cap) is the author's call.
- - Done when: Every job declaring `retryPolicy` parses: no `maxRetries` above 10 and no `backoffMultiplier` below 1 remain, and each adjusted value was re-chosen knowing a retry re-runs the handler with its writes and callouts. No job fails to register with the retry-policy bound prescription.
-- **`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.
- - 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.
+- **`action-descriptor-is-async-retired`** — `ActionDescriptor.isAsync (the descriptor an executor publishes via `registerNodeExecutor` / `defineActionDescriptor`)` → nothing to re-declare — delete the key. Suspension is `execute()` RETURNING `suspend: true`, and permission to suspend is `supportsPause: true` on the same descriptor (with the `resumeAuthority` its pauses need)
+ - Why not automatic: ADR-0049 enforce-or-remove. `isAsync` declared "this action suspends the flow awaiting an external reply" and NOTHING read it: a fresh three-repo measurement (#6748, re-run at pickup) found zero property reads across objectstack, objectui and cloud — every hit was the declaration itself, a generated baseline, one of five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. So declaring it never made a node suspend and omitting it never stopped one, which is the silently-inert declaration ADR-0049 exists to end. It was always a second, weaker spelling of the capability `supportsPause` states, and the two diverged in exactly the way a duplicated declaration does: `screen` declared both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling in #6667 — `AutomationEngine` now refuses a suspension whose type does not declare `supportsPause: true` — so the capability this key gestured at is now a real, enforced fact under one name. This one had no consumer to grow into and takes the remove leg. Why D3 semantic and not a D2 conversion: an ActionDescriptor is published from an executor's TypeScript, never stored in stack metadata — no stack, example or template carries the key — so there is no source for the chain to rewrite and `os migrate meta` cannot reach it. The schema tombstones it via `retiredKey()` and descriptor authors delete the key themselves; that rejection (a `tsc` error at the authoring site, and a parse error inside `defineActionDescriptor`) is the channel a third-party plugin author actually meets. The `EnhancedApiError.fieldErrors` disposition, one layer down.
+ - Done when: No descriptor declares `isAsync` — not the five that shipped it (`screen`, `map`, `wait`, `approval`, `approval_revise`), not a plugin's. Every node type that returns `suspend: true` from `execute()` declares `supportsPause: true` on its descriptor together with a `resumeAuthority`, and its runs still pause and resume as before: the behaviour never depended on `isAsync`, so deleting the key changes no run. Authoring `isAsync` fails `tsc` at the descriptor literal and fails `defineActionDescriptor()` at runtime with the prescription, instead of parsing clean and being stripped.
+- **`action-descriptor-resume-authority-default-flip`** — `automation.ActionDescriptor.resumeAuthority — an OMITTED value on a pausing node descriptor (supportsPause: true, or any executor whose execute() returns suspend: true)` → an explicit resumeAuthority: 'any' on the descriptor, for a pausing node whose pauses really are meant to be continued through the generic resume route (POST /automation/:name/runs/:runId/resume) — a screen-style collected-input pause, or a signal wait an external producer resumes. Declare 'service' instead if continuing is the tail of a decision your own service must authorize and record first. Either value is a one-line addition; only the silence changed meaning
+ - Why not automatic: A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as protocol 12's `rest-requireauth-default-flip`, and it is registered here for the same reason: whether a given pause is genuinely open to the generic route is a trust judgment no transform can make. The #3801 resume gate keys on the SUSPENDED NODE, and `ActionDescriptor.resumeAuthority` used to default to `'any'`, so a pausing node type shipped raw-resumable unless its author remembered the field. It now resolves to `'service'` when absent: an unclaimed pause is refused on the generic route with `PERMISSION_DENIED` / 403 until its descriptor states who may continue it. #3823 is the incident that decided the direction — ADR-0044 pointed an approval's revise edge at a generic `wait`, `wait` is legitimately `'any'`, and the pause standing in a service-owned position inherited a fail-open value nobody chose; the demonstrated cost was an unaudited resubmit plus a destroyed remote run. The two possible mistakes are asymmetric, which is the whole argument: guessing `'any'` walks past a decision nothing recorded and is silent, while guessing `'service'` returns a refusal naming the missing field. ⚠️ The surface is a DESCRIPTOR FIELD set in plugin CODE, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) already carry. It differs from those in one way a reader should not have to infer: nothing is REMOVED, so tsc reports nothing at all — the field was already optional after step one and an omission still compiles. The enforced channels are all run-time: a registration warning naming the node type (once per type per engine), the refusal message on the resume itself, and `check:resume-authority-declared` for executors living in this repo. For a third-party plugin the generated upgrade guide is the only channel that arrives BEFORE a user hits a run that will not continue. In-tree the flip moves nothing: all six shipped pausing types (screen, wait, subflow, map, approval, approval_revise) declare their authority explicitly. ADR-0044 amendment (2026-07-28) and its 2026-08-08 landing section, ADR-0019 #3801 addendum, #5561.
+ - Done when: Every action descriptor your plugin registers for a node type that can suspend declares `resumeAuthority`. Booting the stack logs no `declares supportsPause but never declares resumeAuthority` warning naming one of your types, and a run parked on each of your pausing nodes can still be continued the way you intend: a resume through the generic route succeeds for the ones you declared `'any'`, and answers 403 (`PERMISSION_DENIED`) for the ones you declared `'service'`, which continue through your own service API instead. ⚠️ `supportsPause` is no longer the declaration nothing enforced (#5703, closed by #6667): an executor whose `execute()` returns `suspend: true` while leaving `supportsPause` false is still warned about by neither warning channel, but `AutomationEngine.refuseUndeclaredSuspension` now refuses that suspension at the one seam every suspension passes through — a guard-class failure no `fault` edge routes — so it needs no hand-check. The residue that does: an executor registering NO descriptor declares nothing for either warning or the refusal to read, so its pauses are still created and refused only later, on the resume route (#5561).
+- **`action-session-roles-to-positions`** — `ui.actionSession.roles` → ui.actionSession.positions (an action body reads `ctx.session.positions`)
+ - Why not automatic: The MIRROR-IMAGE sibling of `actor-user-roles-to-positions`, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright, #5050), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. #5613 ruled contract-first (maintainer, 2026-08-06: "C skeleton + A semantics"): phase 1 (#5697) declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it (#5779); the producer emits both for one deprecation window (#5613 runtime half), after which `roles` is removed on the path the v11 session-alias removal already walked (#3280 deprecated → #3290 removed). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087, #5613 / #5779.
+ - Done when: No action body reads `ctx.session.roles`; every such read is `ctx.session.positions` and observes the same array (the rename is a rename — the VALUE is `ExecutionContext.positions` on both sides, which the runtime pin `action-session-shape-contract.test.ts` asserts independently of the key name). Privilege is NOT re-derived from either spelling: a read that was `roles.includes('admin')` as an access check is rewritten to ask the security service (capability grants / placements / derived posture, ADR-0095), never renamed to `positions.includes('admin')` — renaming that read migrates the defect rather than the code. Verify against a real dispatch, not a fixture: invoke an action as a caller holding positions and assert the body observed them under the canonical key. During the window both keys are present and equal, so a reader can be migrated and verified before the alias is removed; after it, `roles` is absent and a body still reading it sees `undefined` — which is why the read must be moved inside the window rather than at its close.
+- **`actor-user-roles-to-positions`** — `action body / AI route: ctx.user.roles (req.user.roles)` → ctx.user.positions (an AI route handler reads `req.user.positions`) — the same array, under the one spelling ADR-0090 D3 sanctions
+ - Why not automatic: The THIRD face of the ADR-0090 `roles` → `positions` rename, and the only one whose surface the spec never declared. `ActorUser` (`packages/runtime/src/security/actor-user.ts`) is the ONE producer of the `user` envelope handed to an action body as `ctx.user` and to an AI route handler as `req.user`; it declared `positions` and `roles` side by side and filled them from a SINGLE assignment (`roles: core.positions`), so the two keys were verbatim identical on every dispatch — a second spelling of the vocabulary ADR-0090 D3 reserves and bans, published straight into author-written code. The maintainer ruled it closed IMMEDIATELY (2026-08-06 14:49Z, #6011): no deprecation window, no dual-emit, the alias simply gone in 17 (PR #6048). ⚠️ Do not read this entry across to its sibling `action-session-roles-to-positions`: `action-session-roles-to-positions` governs `ctx.session`, a DIFFERENT object reached through the same `ctx`, and that one KEEPS its one-window dual-emit (#5613). Same word, same dispatch, two faces, two schedules — `ctx.user.roles` is absent in 17 while `ctx.session.roles` still answers for the length of its window. What makes this entry different in KIND from both session-side siblings: `ctx.user` has no spec schema and never had one. It is a runtime TS interface, so unlike `HookContext.session.roles` (tombstoned on a deliberately non-strict `HookContextSchema`, #5050) and unlike `ActionSessionSchema` (declared contract-first at #5697 precisely so its key could be renamed), there is no schema key here to tombstone and no `retiredKey()` prescription that could reach anybody — nothing ever ran an `ActorUser` through a `.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it reports at the READ site inside the author's own body; for an untyped or sandboxed body there is no enforced channel at all, which is exactly why this ledger entry has to exist — `spec-changes.json` and the generated upgrade guide are the ONLY way such a reader learns of the rename. It is the `findStream` (#4484) / `IStorageService.list` (#5540) disposition — a TS/API contract, no stored source, no tombstone, tsc at the call site — applied to a surface that lives one layer further out than either: those two are at least DECLARED in `packages/spec/src/contracts`, this one only in `packages/runtime`. Why it is a D3 semantic TODO and not a D2 conversion, on the same two independent grounds as its session sibling: FIRST, there is no source to convert — an `ActorUser` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key (the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape). SECOND, the only place the key is ever SPELLED is inside an action body or an AI route handler: author-written JS/TS, or a sandboxed script. A declarative transform cannot safely rewrite an identifier inside free-form code — the same reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. The removal's hard precondition was met before it landed, and the result is recorded here because the ledger is where an upgrading consumer meets it: the declaration's own comment claimed the alias was "kept for the REST/AI shapes", and that claim was DISPROVEN face by face against `origin/main` — repo-wide `user.roles` was 4 hits, all of them in the pins PR #6048 flipped; the four `ActorUser` construction sites build server-side envelopes that never enter a response body; objectui's `.roles` reads belong to two unrelated producers (the better-auth session, and the `/auth/me/permissions` payload). The `cloud` repo was NOT reachable in that session and is the one consumer face left unverified — this entry, and the changeset's FROM/TO prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087, #6011 (PR #6048).
+ - Done when: No action body reads `ctx.user.roles` and no AI route handler reads `req.user.roles`; every such read is `.positions` and observes the SAME array — the value was `ExecutionContext.positions` on both sides, so this is a pure key rename and no value has to be re-derived. Privilege is NOT re-derived from either spelling: a read that was `roles.includes('admin')` as an access check is rewritten to ask the security service (capability grants / placements / derived posture, ADR-0095), never renamed to `positions.includes('admin')` — renaming that read migrates the defect rather than the code. Unlike `ctx.session` there is NO window to migrate inside: in 17 the key is already absent, so a typed body fails `tsc` at the read while an untyped or sandboxed one silently sees `undefined` — move the read AS you upgrade, not after it. Verify against a real dispatch rather than a fixture: invoke an action (and an AI route) as a caller holding positions, assert the body observed them under the canonical key, and assert the old key is ABSENT by key existence (`'roles' in ctx.user === false`) rather than by `undefined`, which cannot tell a removed key from one left behind holding nothing — the runtime pin `action-ctx-user-shape.test.ts` asserts both halves that way.
+- **`aggregation-node-distinct-retired`** — `data.query.aggregations[].distinct` → the `count_distinct` aggregation FUNCTION for a deduplicated count — the one deduplicating spelling every face computes, lowered to `COUNT(DISTINCT field)` on both SQL faces since #6409. `SUM(DISTINCT …)` / `AVG(DISTINCT …)` get no replacement: no backend ever computed them here, and a per-row measure that needs deduplicating before summing is a modelling problem to fix in the data
+ - 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 frozen under #5499 — 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.
- 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.
-- **`enhanced-api-error-field-errors-renamed`** — `api.enhancedApiError.fieldErrors` → fields
- - Why not automatic: The wire has always carried `fields` — the validators, import coercion, validation-failure.ts, @objectstack/client and the console's field-error extractor all say `fields`, and nothing ever emitted `fieldErrors`, so a reader keying on it was reading a field no server sent (ADR-0078's silently-inert declaration, on the error envelope). This is a RESPONSE surface: no stack, example or template carries the key, so there is no source for the chain to rewrite — the schema tombstones it via retiredKey() and consumers move their read themselves. ADR-0114 D4, #3977.
- - Done when: No consumer reads `error.fieldErrors`; per-field validation detail is read from `error.fields`, and constructing an EnhancedApiError with `fieldErrors` fails to parse with the rename prescription instead of silently losing the array.
- **`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.
- Done when: No /analytics/query or /analytics/sql call sends `format`; exports go through the export surface.
-- **`query-field-node-object-form-retired`** — `data.query.fields` → expand (`expand: { owner: { object: 'user', fields: ['name'] } }`), or a dotted path for a single related column (`fields: ['owner.name']`)
- - Why not automatic: The `FieldNode` union declared a nested-select object form `{ field, fields, alias }` that was inert end to end: no producer emitted it, and no consumer read `.fields` or `.alias` — objectql's formula projection and known-field filters, driver-sql's `select()` and driver-memory's projection all treat the list as `string[]`, driver-mongodb keyed its projection with the entry itself, and the REST ingress stringified it. Nested selection is `expand`, which the engine resolves via batch `$in` queries. This is a REQUEST surface — `QueryAST` is never stored in stack metadata (no view, dataset or report authors one), so there is no source for the chain to rewrite: the schema narrows to `z.string()` and callers move their own select lists. ADR-0049 / ADR-0078, #4196.
- - Done when: No caller puts an object in `fields[]`; related records are read through `expand` and single related columns through dotted paths. A `fields` entry that is not a string fails to parse with the removal prescription, and the list/query/export routes answer 400 INVALID_FIELD naming the retired form instead of the field `"[object Object]"`.
+- **`api-runtime-create-withdrawn`** — `PUT /api/v1/meta/api/{name} (runtime-authored `api` endpoints, draft and active alike)` → Declare the endpoint as a stack artifact (`**/*.api.ts`, or `defineStack({ apis })`) and ship it through `publishPackage`
+ - Why not automatic: The `api` registry entry declared `allowRuntimeCreate: true` and the runtime never honoured it. Measured on a real showcase boot (#5488): `PUT /api/v1/meta/api/e8_backdoor` answered 200 with `{"success":true,…,"message":"Saved …"}`, and the declared route then answered 404 forever — with NO `[EndpointMatcher] … EXCLUDED` line, because the endpoint was never in the index to be excluded from. The serving criterion belongs to `IMetadataService.matchEndpoint` -> `EndpointMatcher` -> `MetadataManager.listForIndex('api')`, which reads the manager's registry plus its registered loaders (`["filesystem","memory"]` on dev/serve); a runtime write lands in `sys_metadata`, which is in neither. A declared capability the runtime does not honour is ADR-0049 false compliance, and a write that answers "Saved" and then 404s forever is its most dangerous shape for the AI authors ADR-0033 targets. The maintainer ruled REMOVE on 2026-08-07 rather than converge the read path, because making the matcher read `sys_metadata` re-opens cache, invalidation, tenancy and the ADR-0110 D3 miss-vs-outage distinction on a new read path, and there is no business pull for Studio-authored endpoints today (zero `.api.*` artifacts author them at runtime; showcase uses the artifact route, #5040 E8 LIVE). There is NO D2 conversion, for the reason this list exists: nothing in an authored source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an authorable one, and the artifact route it points authors toward is untouched — a `**/*.api.ts` file valid before this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, so it is one semantic TODO for operators and Studio callers rather than a stack conversion — the same disposition `BatchOptions.validateOnly` (#4052) takes. Consequently `gateApiDraftsForPublish` (PR #5279) is retired with it: it gated a promotion into a state the matcher can never read, and with the inlet closed no `api` draft can exist for it to judge. Re-entry is recorded in the ruling: if #2657 Part B promotes `apis` to a registered type WITH A REAL CONSUMPTION PATH, the flag flips back then — implementation first, declaration second. ADR-0049 / ADR-0121, #5488 (subsumes #5311).
+ - Done when: No caller creates or updates an `api` item through the runtime metadata API. `PUT /api/v1/meta/api/{name}` answers 403 with `code: "NOT_CREATABLE"` and a body naming both flags (`allowRuntimeCreate=false, allowOrgOverride=false`) and the prescription `Declare it in source (**/*.api.ts) and redeploy` — in `?mode=draft` as well as direct-active, because the gate runs before the draft/publish branch and does not read `mode`. ⚠️ Verify the artifact route is UNAFFECTED, which is the whole point of the change: a stack declaring `apis:` still compiles, still passes `validateApiEndpointDeclarations` at publish (`publishPackage`, #5189) and at load (`buildEndpointIndex`, PR #5203), and its endpoints still SERVE — that route was always the only one that served. An operator who genuinely needs the runtime door back on one deployment sets `OS_METADATA_WRITABLE=api`, the same single escape hatch `job` / `agent` / `capability` use; note that this unlocks the WRITE only, and the endpoint still will not be served, which is why it is a diagnostic and not a workaround. Any `api` rows already sitting in `sys_metadata` from before this change were never served either; they can be deleted (`deleteMetaItem` is deliberately not gated by this refusal, so repair stays possible).
- **`batch-options-validate-only-retired`** — `api.batchOptions.validateOnly` → (removed — no dry-run today; open an issue to design a no-commit batch preview)
- Why not automatic: The `validateOnly` key promised a dry-run ("validate records without persisting") but no batch surface ever read it — updateManyData / deleteManyData / batchData persist regardless. There is no behaviour to preserve and nothing stored to rewrite (it only ever appeared in an HTTP request body). Callers must stop sending it.
- Done when: No /batch, /updateMany or /deleteMany call sends `options.validateOnly`; a request that includes it answers 400 VALIDATION_FAILED with the retirement prescription.
- **`batch-row-result-schema-shape`** — `api.batchOperationResult — the per-row `results` entries of BatchUpdateResponse (`POST /data/:object/batch`, `/updateMany`, `/deleteMany`)` → `errors: ApiError[]` (was `error: string` — read `row.errors?.[0]?.message`, branch on `row.errors?.[0]?.code`), `data` (was `record`), and `index` (new — the row's position in the request array)
- Why not automatic: The rows the three bulk-write endpoints emitted had drifted from the schema that declared them: `BatchOperationResultSchema`, the client SDK's exported `BatchOperationResult` type and the reference docs all said `errors: ApiError[]` / `data` / `index`, while the wire carried `error: string` / `record` and never sent `index` at all. A TypeScript consumer written against the published type compiled, validated and read `undefined` at runtime — the declared-but-not-delivered shape this registry exists to close, on the response envelope (ADR-0119 D4 deferred the reconciliation off a bug fix; this is that tracked change, shipped in the 17 major window). The ADR-0119/#4620 rollback marking is structured in the same move: the `ROLLED_BACK:` / `NOT_ATTEMPTED:` message-string prefixes become registered `ApiError.code` values (message keeps the human-readable cause and causal row index), so "attempted and undone" vs "never ran" is machine-readable instead of a regex convention. A RESPONSE surface — nothing stored in stack metadata carries a batch row, so there is no source for the chain to rewrite; consumers of the legacy keys move their reads themselves. Off-contract readers only: the legacy keys were never in the schema or the SDK types, so a typed consumer needs no change. #4793.
- Done when: No consumer reads `row.error` or `row.record` on a batch result row; failures are read from `row.errors` (message via `errors[0].message`, rollback state via `errors[0].code` — ROLLED_BACK / NOT_ATTEMPTED), records from `row.data`, and rows correlate to the request via `row.index`. Every row the three endpoints emit parses under `BatchOperationResultSchema` with those keys present.
-- **`query-joins-retired`** — `data.query.joins` → expand (`expand: { owner: { object: 'user', fields: ['name'] } }`), or a dotted `fields` path for a single related column (`fields: ['owner.name']`)
- - Why not automatic: The `joins` array was declared-but-inert: no engine or driver read `query.joins` anywhere on the query path, so a query carrying it behaved exactly as if the key were absent — while the name squatted on the reserved REST parameter set. Related-record retrieval already has a live spelling (`expand`, resolved by the engine via batch `$in` queries), so the removal deletes the second, broken spelling rather than the capability, and the orphaned `JoinNode`/`JoinType`/`JoinStrategy` cluster goes with the key. A REQUEST surface — `QueryAST` is never stored in stack metadata — so there is no source for the chain to rewrite; callers move their own queries. ADR-0049 / ADR-0078, #4286.
- - Done when: No caller sends `joins`; related records are read through `expand` and single related columns through dotted `fields` paths. A query that still carries `joins` fails to parse with the removal prescription (even as an empty array), and authoring it is a `tsc` error at the call site.
-- **`query-window-functions-retired`** — `data.query.windowFunctions` → `aggregations` + `groupBy` for request-level analytics; `SqlDriver.findWithWindowFunctions(object, query)` for embedders on a SQL datasource
- - Why not automatic: The `windowFunctions` array was declared-but-inert on the query path: `find()` never applied a window function, so every OVER clause a caller declared was silently dropped. The capability only ever ran behind `SqlDriver.findWithWindowFunctions()`, a driver-level door that is not on the `IDataDriver` contract and whose flat input shape (`{ function, alias, partitionBy?, orderBy? }`) the spec vocabulary never matched — `WindowFunctionNodeSchema` declared `field`/`over`/`frame` members the door never read, so that cluster is removed with the key rather than left as a false affordance. A REQUEST surface, never stored; no source to rewrite. ADR-0049 / ADR-0078, #4286.
- - Done when: No caller sends `windowFunctions` in a query; request-level analytics use `aggregations` + `groupBy`, and embedders needing OVER-clause SQL call the SQL driver's `findWithWindowFunctions` door directly. A query that still carries the key fails to parse with the removal prescription naming that door.
-- **`query-cursor-retired`** — `data.query.cursor` → a `where` predicate on the sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` (the documented manual-keyset pattern)
- - Why not automatic: The `cursor` key promised keyset pagination and no driver implemented it: the cursor was accepted and ignored, so every page came back identical — a caller looping "until hasMore is false" never terminates. Worse than inert, it had a shipped public producer (`QueryBuilder.cursor()`, removed with the key). The caller-built `Record` shape also leaks sort/storage detail and squats on the reserved REST parameter set; a first-class cursor, if ever designed, will be a response-minted opaque token — a different API, so keeping this one preserved a wrong design rather than a roadmap. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286.
- - Done when: No caller sends `cursor` and no SDK call site uses `QueryBuilder.cursor()`; deep pagination expresses the keyset as a `where` predicate on the sort key. A query still carrying `cursor` fails to parse with the removal prescription, and authoring it is a `tsc` error.
-- **`query-distinct-retired`** — `data.query.distinct` → `groupBy` for unique combinations; the `count_distinct` aggregation for deduplicated counts; the SQL/memory drivers' `distinct(object, field)` door for one column's values
- - Why not automatic: The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND worse pagination metadata, and a side effect that "confirmed" the flag was doing something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with the key). The count suppression is deleted in the same change — `total` is truthful for those queries again. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286.
- - Done when: No caller sends `distinct` and no SDK call site uses `QueryBuilder.distinct()`; deduplication goes through `groupBy` / `count_distinct` / the drivers' `distinct()` door. A query still carrying the key fails to parse with the removal prescription, and the REST list response reports a real `total` for queries that used to send it.
-- **`query-array-string-agg-retired`** — `data.query.aggregations[].function ('array_agg' / 'string_agg')` → an ordinary `fields` query, shaped in the caller — or a stored field that materialises the roll-up. For a deduplicated COUNT the live spelling is unchanged: `count_distinct` stays declared
- - Why not automatic: The stored half of this retirement is a conversion (`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. `QueryAST` is never stored in stack metadata — it is the client SDK builder's output and the `POST /data/:object/query` body — so there is no source for the chain to rewrite and callers move their own queries. Both values were declared-but-unlowered on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller following the schema against a SQL datasource got a refusal, not an array. They did run on `driver-mongodb` and on the engine's in-memory fallback, which is what makes this the one narrowing in the batch that removes reachable behaviour: an aggregation that worked on one backend and failed on another is exactly the unpredictability the ruling ended, and #5499 has both of those backends frozen. `count_distinct` was deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049's enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049, #6188.
- - Done when: No caller sends `array_agg` or `string_agg` in `aggregations[].function`; list-style roll-ups are assembled by the caller from an ordinary `fields` query, or materialised as a stored field. A query still carrying either value fails to parse with the removal prescription naming it, and authoring it is a `tsc` error at the call site; `count_distinct` continues to parse and is unaffected.
-- **`aggregation-node-distinct-retired`** — `data.query.aggregations[].distinct` → the `count_distinct` aggregation FUNCTION for a deduplicated count — the one deduplicating spelling every face computes, lowered to `COUNT(DISTINCT field)` on both SQL faces since #6409. `SUM(DISTINCT …)` / `AVG(DISTINCT …)` get no replacement: no backend ever computed them here, and a per-row measure that needs deduplicating before summing is a modelling problem to fix in the data
- - 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 frozen under #5499 — 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.
-- **`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).
+- **`dashboard-widget-compareto-offset`** — `dashboard.widgets[].compareTo: { offset: '7d' | '1M' | … } (every duration except '1y')` → compareTo: { kind: 'previousPeriod' } plus an explicit window on the widget's own `filter`
+ - Why not automatic: The widget declared three comparison arms; the analytics executor implements one shape, `{ kind, dimension? }`, with no `offset` concept in it at all. On the ADR-0021 dataset path — the spec's single author-facing analytics shape — `{ offset }` was forwarded verbatim into that contract and threw `compareTo requires a timeDimension "undefined"`, taking the widget down; the arm ever only ran on the legacy inline chart path (#5011). The conversion rewrites `{ offset: '1y' }`, which IS `previousYear` by definition. Every other duration has NO faithful target: `previousPeriod` shifts by the length of whatever window the widget's filter resolves to, which equals `7d` only when that window happens to be seven days long. Rewriting mechanically would silently change which rows the comparison column counts — a wrong number rather than a missing one, which is strictly worse and exactly the class this convergence exists to end. Re-stating the intended window is a judgment about the presentation, not a transform.
+ - Done when: No dashboard widget declares `compareTo.offset`. Each former offset comparison states its window on the widget's `filter` and compares with `compareTo: { kind: 'previousPeriod' }` (or `'previousYear'`), and `dimension` is named wherever the selection dates more than one time dimension. `objectstack validate` passes, and each affected widget renders a `__compare` column over the window its author intended.
- **`data-driver-find-stream-retired`** — `contracts.IDataDriver.findStream / data.DriverInterfaceSchema.findStream` → find() with limit/offset — the paged read whose determinism IS enforced (IDataDriver.find, data/pagination-conformance.ts)
- Why not automatic: `findStream` was a REQUIRED contract method documented as "optimized for large datasets to avoid memory overflow", and in two of its three implementations it delivered the opposite: `SqlDriver` and `InMemoryDriver` both awaited `find()` for the ENTIRE result set and then yielded it row by row, so the peak memory a caller was promised protection from was already reached before the first yield. The third (`MongoDBDriver._findStream`) did walk a cursor, but it was the one read path in that driver never routed through `buildFindOptions`, so it hardcoded `projection: { _id: 0 }` and silently discarded `query.fields`. None of it was ever observed, because the method had NO caller in either repository: the engine exposes no stream entry, and the REST export, import and bulk-read paths all go through `find()`. The ~20 driver test doubles that existed only to satisfy a required method almost all threw `not implemented`, and nothing ever noticed — which is the proof, not the anecdote. Being REQUIRED, it also taxed every new driver and every test double with an implementation of a capability the platform does not have. Rather than build a caller to justify three implementations, the method is retired; a real cursor-based read should return WITH the caller that needs it (ADR-0049 enforce-or-remove). This is a TS/API contract surface — a driver is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone either: nothing ever ran a driver object through `DriverInterfaceSchema.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it points at callers. ADR-0049 / ADR-0078, #4484.
- Done when: No code calls `driver.findStream(...)`; large reads page through `find()` with `limit`/`offset` (which guarantees a total order across the whole walk) or go through the export surface. Drivers and test doubles no longer implement the method — one left behind still compiles and is simply never reached, so removing it is cleanup rather than a break, while a CALLER of it no longer type-checks.
-- **`data-field-changed-event-retired`** — `api.DataEventType 'data.field.changed'` → the `data.record.updated` event, whose payload already carries the per-field detail: `changes` (the changed fields), plus `before` / `after`
- - Why not automatic: `data.field.changed` was declared in `DataEventType` and emitted by nothing — the engine's `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since #4639) `data.records.{updated,deleted}`, and no other producer exists in either repository. A subscriber that switched on it was waiting on an event no producer sends: the branch never ran, and because the surrounding `switch` still compiled, nothing anywhere reported the gap (ADR-0078's silently-inert declaration, on the event vocabulary). `DataEventSchema` could not have carried the semantics even if something had emitted it — the payload is record-shaped (`recordId`, `changes`, `before`, `after`) with no `field` / `oldValue` / `newValue` slot — so the member promised a granularity the contract has no room for. Per-field detail is therefore not lost: it has always ridden on `data.record.updated` as `changes`, which is one event per write rather than N events on a wide table. This is a runtime EVENT surface — no stack, example or template authors an event name (webhooks subscribe through the separate authorable `WebhookTriggerType`, whose vocabulary was already trimmed to producers that exist, #3196) — so there is no source for the chain to rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a retiredKey() fix-it error the way an authorable object key can (the same limit the sharing-rule `full` retirement hit above). The enforced channels are tsc, which fails any consumer still naming the value in a `DataEventType` position, and the enum parse, which now rejects the name instead of accepting an event that never arrives. A genuine per-field stream, if one is ever wanted, gets its own honest contract the way #4639 gave bulk writes theirs. ADR-0049 / ADR-0078, #4673.
- - Done when: No consumer subscribes to or switches on `data.field.changed`; per-field change detail is read from a `data.record.updated` event's `changes` map (with `before` / `after` for the surrounding state). Deleting the dead branch changes no observable behaviour — it never executed — so the migration is removing code that could not run, not rebuilding a capability.
- **`data-engine-batch-retired`** — `contracts.IDataEngine.batch / data.DataEngineBatchRequestSchema` → `IObjectQLEngine.transaction(cb)` for in-process multi-write atomicity; the metadata protocol's `batchData` with `options.atomic: true` for a batch over one object; `POST {basePath}/batch` on the wire
- Why not automatic: `batch?` was declared on `IDataEngine` for as long as that contract existed and was never implemented by any engine: `ObjectQL` has no `batch` method and there is no other engine in the tree. It also had no caller — `DataEngineRequest` was imported by exactly one file, the contract declaring the member. Its entire specification was a three-word doc comment ("Batch Operations (Transactional)"), which settles nothing about partial failure, ordering, cross-object references, rollback scope, or what `transaction: false` was supposed to mean — the questions a batch API exists to answer. Contrast its neighbours `getDefaultDriverName?` / `getDriverByName?`, whose optionality is evidenced: each names its implementer and its probing caller. The tell that nobody ever designed against it is in the schema: `DataEngineBatchRequestSchema.requests` nested the request union RECURSIVELY, so a batch could contain batches, with no statement anywhere about what that meant for ordering or rollback. The only test was a type pin — an ad-hoc object literal carrying a `batch` property, asserting the property was defined — which could not fail while the declaration existed and would have passed unchanged for the member's whole life with no engine implementing it. What it claimed is now covered by members that are real, so the removal deletes a false affordance rather than a capability: ADR-0119 D1 made `transaction` reachable through the contract and D4 made `batchData`'s `atomic` honest, while the wire batch has always validated with `CrossObjectBatchRequestSchema` / `BatchUpdateRequestSchema` from `api/batch.zod.ts` — a different schema entirely, untouched here. TS/API surfaces only: an engine is CODE, never stack metadata, so there is no source for the chain to rewrite. Deliberately no schema tombstone either — nothing ever parsed `DataEngineBatchRequestSchema`, so a `retiredKey()` prescription would have no one to reach; its three `authorable-surface.json` baseline lines and its `json-schema.manifest.json` entry are dropped in the same change, deliberately. The enforced channel is tsc. ADR-0049 / ADR-0078, #4618.
- Done when: No code calls `engine.batch(...)` and no type references `DataEngineBatchRequest`; in-process multi-write atomicity goes through `IObjectQLEngine.transaction(cb)`, a batch over one object through `batchData` with `options.atomic: true`, and a cross-object batch over the wire through `POST {basePath}/batch`. Because no engine implemented the member, an implementation left behind still compiles and is simply never reached; a CALLER of it no longer type-checks — and there were none.
-- **`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.
- - 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.
-- **`driver-capabilities-inert-bits-removed`** — `data.DriverCapabilities.create / data.DriverCapabilities.read / data.DriverCapabilities.update / data.DriverCapabilities.delete / data.DriverCapabilities.bulkCreate / data.DriverCapabilities.bulkUpdate / data.DriverCapabilities.bulkDelete / data.DriverCapabilities.transactions / data.DriverCapabilities.savepoints / data.DriverCapabilities.isolationLevels / data.DriverCapabilities.queryFilters / data.DriverCapabilities.queryAggregations / data.DriverCapabilities.querySorting / data.DriverCapabilities.queryPagination / data.DriverCapabilities.queryWindowFunctions / data.DriverCapabilities.querySubqueries / data.DriverCapabilities.queryCTE / data.DriverCapabilities.joins / data.DriverCapabilities.fullTextSearch / data.DriverCapabilities.jsonQuery / data.DriverCapabilities.geospatialQuery / data.DriverCapabilities.streaming / data.DriverCapabilities.jsonFields / data.DriverCapabilities.arrayFields / data.DriverCapabilities.vectorSearch / data.DriverCapabilities.schemaSync / data.DriverCapabilities.migrations / data.DriverCapabilities.indexes / data.DriverCapabilities.connectionPooling / data.DriverCapabilities.preparedStatements / data.DriverCapabilities.queryCache` → (removed — delete the keys. A driver advertises a capability by implementing the corresponding IDataDriver method; the three bits that survive because method presence cannot carry the signal are `queryDateGranularity`, `autonumber` and `batchSchemaSync`)
- - Why not automatic: The #4484 findStream close-out found `DriverCapabilities.streaming` pointing at a capability the contract no longer declares, and the follow-up audit (#4634) checked every bit in the record the same way, across objectstack and cloud (objectui confirmed clean): of 34 declared bits, THREE have a decision-making reader — `queryDateGranularity` (engine aggregate dispatch + checkDateBucketParity), `autonumber` (engine defers generation to the driver), `batchSchemaSync` (engine ANDs it with method presence, because a subclass can inherit `syncSchemasBatch` from a base whose transport batches while its own cannot) — and THIRTY-ONE were written by every driver and read by nothing. Their `.describe()` strings promised engine adaptation ("if false, ObjectQL will filter/sort/paginate in memory") that was never built, and zero readers let the values go WRONG unnoticed: SqlDriver declared `streaming: false` while implementing `findStream`; InMemoryDriver declared `streaming: true` over a full-table read (ADR-0078 false affordance, on the capability record itself). The real mechanism everywhere else is METHOD presence: transactions gate on `driver.beginTransaction`, aggregate pushdown on `typeof driver.aggregate`, schema sync on `typeof driver.syncSchema`, and the REQUIRED CRUD/bulk methods are called unconditionally. A driver is CODE, never stack metadata — `supports` literals live in driver classes and `DriverConfig.capabilities` is plugin TS configuration, neither ever a `sys_metadata` shape (the stack-tree neighbour, `datasource.capabilities`, was retired separately in #4583) — so there is no source for the D2 chain to rewrite and this entry is the D3 record. The keys are tombstoned rather than deleted because `DriverCapabilitiesSchema` is not `.strict()` and IS parsed (DriverConfigSchema / SQLDriverConfigSchema / NoSQLDriverConfigSchema embed it): a plain delete would silently strip a vendor's authored bit, replacing one silent no-op with another. `batchSchemaSync` also drops its `.default(false)` for `.optional()` — absence already meant false at both readers, and the default forced every capability object to spell out 30+ bits. ADR-0049 / ADR-0078, #4634.
- - Done when: No `supports` literal or `DriverConfig.capabilities` object authors any of the 31 retired bits — a driver class that still writes one fails tsc against `IDataDriver.supports` (the bit is `never`), and a parsed config fails with the per-key prescription. The three in-repo drivers (memory / mongodb / sql) declare only live bits; cloud's TursoDriver keeps compiling via its `...super.supports` spread (its stale explicit overrides are cleanup, tracked cloud-side). Engine behaviour is byte-identical: every removed bit had zero readers, and the three live bits keep their readers (engine.ts autonumber defer / aggregate dispatch, plugin.ts + engine.ts batched schema sync, verify date-bucket parity).
-- **`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` after the #4653 convergence) — 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 (#3950: an exported schema with no consumer is read as a capability). #4657. SUPERSEDED ON THE KERNEL SIDE by #4834 (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` below). 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 #4834 criterion 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.
-- **`plugin-runtime-family-retired`** — `kernel.dynamicLoadRequest / kernel.dynamicUnloadRequest / kernel.dynamicPluginResult / kernel.pluginSource / kernel.dynamicPluginOperation` → (removed — there is no replacement shape, because there is no operation to describe. Plugins are composed at boot: `defineStack` registers them and the kernel runs register → init → start; the set is fixed until the process restarts. Delete the import and the value. Runtime plugin loading, if it is ever built, returns via the enforce route of ADR-0049 through a new ADR — loader first, vocabulary second)
- - Why not automatic: The five schemas declared the "Dynamic Loading" capability — runtime load / unload / reload of plugins without a kernel restart, with sandboxing, integrity hashes, drain strategies and dependent-cascade policy — and NOTHING implemented it. A bare-name scan of objectstack, cloud and objectui found zero references outside this package's own declaration, its unit tests and the generated artifacts: no runtime ever received a `DynamicLoadRequest`, performed a load/unload, or produced a `DynamicPluginResult`. That is the ADR-0049 false-compliance shape at its most inviting to an AI author (ADR-0033), who reads `DynamicLoadRequestSchema` in the published IDE bundle as proof the platform hot-loads plugins and constructs a request that parses clean and is received by nobody (#3950: an exported schema with no consumer is read as a capability). The #3896 follow-up removed this module's discovery/sandbox config island and left these five in place explicitly — "operation contracts, not security promises; the enforce-or-remove call on them is a design decision rather than a correction" — but that suspension lived only in a changeset paragraph with no issue carrying it. #4834 is that decision, answered REMOVE. `experimental` was considered and rejected: it is only `.describe()` prose and cannot stop an import, the weakest of the three ADR-0049 channels. None of the five is stored metadata — they are root request/result payload shapes embedded in no parent schema and parsed against no metadata document — so no `sys_metadata` row can carry one and there is no source for the D2 chain to rewrite; this entry is the D3 record. The removal also subsumes the kernel half of `plugin-activation-events-retired` (#4657): that tombstone goes with the shape that carried it. ADR-0049, #4834.
- - Done when: No code imports `DynamicLoadRequestSchema`, `DynamicUnloadRequestSchema`, `DynamicPluginResultSchema`, `PluginSourceSchema`, `DynamicPluginOperationSchema` or any of their type aliases (`DynamicLoadRequest`, `DynamicUnloadRequest`, `DynamicPluginResult`, `PluginSource`, `DynamicPluginOperation`, `DynamicLoadRequestInput`, `DynamicUnloadRequestInput`) from `@objectstack/spec` or `@objectstack/spec/kernel` — every one is TS2305 after upgrade, on every public entry (pinned by symbol identity in `plugin-runtime-retirement.test.ts`). Nothing regresses at runtime, because nothing called anything: a caller that believed it was hot-loading a plugin was already only building an object. Boot-time composition through `defineStack` is unchanged.
+- **`data-field-changed-event-retired`** — `api.DataEventType 'data.field.changed'` → the `data.record.updated` event, whose payload already carries the per-field detail: `changes` (the changed fields), plus `before` / `after`
+ - Why not automatic: `data.field.changed` was declared in `DataEventType` and emitted by nothing — the engine's `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since #4639) `data.records.{updated,deleted}`, and no other producer exists in either repository. A subscriber that switched on it was waiting on an event no producer sends: the branch never ran, and because the surrounding `switch` still compiled, nothing anywhere reported the gap (ADR-0078's silently-inert declaration, on the event vocabulary). `DataEventSchema` could not have carried the semantics even if something had emitted it — the payload is record-shaped (`recordId`, `changes`, `before`, `after`) with no `field` / `oldValue` / `newValue` slot — so the member promised a granularity the contract has no room for. Per-field detail is therefore not lost: it has always ridden on `data.record.updated` as `changes`, which is one event per write rather than N events on a wide table. This is a runtime EVENT surface — no stack, example or template authors an event name (webhooks subscribe through the separate authorable `WebhookTriggerType`, whose vocabulary was already trimmed to producers that exist, #3196) — so there is no source for the chain to rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a retiredKey() fix-it error the way an authorable object key can (the same limit the sharing-rule `full` retirement `owd-full-alias-removed` hit). The enforced channels are tsc, which fails any consumer still naming the value in a `DataEventType` position, and the enum parse, which now rejects the name instead of accepting an event that never arrives. A genuine per-field stream, if one is ever wanted, gets its own honest contract the way #4639 gave bulk writes theirs. ADR-0049 / ADR-0078, #4673.
+ - Done when: No consumer subscribes to or switches on `data.field.changed`; per-field change detail is read from a `data.record.updated` event's `changes` map (with `before` / `after` for the surrounding state). Deleting the dead branch changes no observable behaviour — it never executed — so the migration is removing code that could not run, not rebuilding a capability.
- **`declarative-apis-endpoints-live`** — `stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)` → the same declarations, re-read as LIVE HTTP routes: `path` moved under `/api/v1/apps//`, and every entry that declares `authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying `rateLimit: { enabled: true, … }`
- Why not automatic: This is the one protocol-17 entry that turns metadata ON rather than off, so read it as a SECURITY review item and not as a rename. Before 17 the declarative endpoint surface executed NOTHING: no route was mounted for a declared `path`, no matcher existed, and every key — `authRequired` included — parsed green and gated nothing (#4936, which refused a non-empty `apis:` outright for exactly that reason). Protocol 17 ships the executor (#5040) and narrows that refusal to a per-endpoint publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as soon as the stack is published. So an `apis:` block written against an older major — or one restored from a pre-#4936 source, or authored from a doc that predates the refusal — changes meaning without changing a byte: what used to be inert documentation becomes an execution entry point into the data and automation pipelines. Nothing about that transition can be applied mechanically, because the judgment it needs is "did the author of this endpoint mean for the internet to reach it?" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT `authRequired: false` is the only thing that opens anonymous access, and under ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key defaults to `false`, so a budget written without it meters nothing) or the stack refuses to publish. ⚠️ If you author endpoints in TypeScript, annotate them with `ApiEndpoint` — the AUTHOR state — so that omitting `authRequired` compiles: `const e: ApiEndpoint = { name, path, method, type, target }` is legal and is the safe shape this paragraph prescribes. `ApiEndpointParsed` is the POST-parse type (defaults materialized, ADR-0122), where `authRequired` is required — annotating a declaration with it forces you to write the key out, and being made to think about a key whose only unrecoverable value is `false` is the one thing this entry is trying to avoid (#5227). Hold a parse RESULT with `ApiEndpointParsed`; write declarations as `ApiEndpoint`. Grep every `apis:` entry for `authRequired: false` before you upgrade, delete the ones that were never meant to be public, and arm a budget on the ones that were. The path move is the mechanical-looking half and is still yours: ADR-0121 D1/D2 confine a declared path to your own namespace carve-out (`/api/v1/apps//…`), the namespace comes from an explicit `manifest.namespace` with no derivation fallback, and the subpath is the only part you name — rewriting it for you would silently change a URL third parties call.
- Done when: You have READ every entry of every `apis:` block, not just the ones that fail to publish. Concretely: (1) each declared `path` is `/api/v1/apps//` and the stack declares that `manifest.namespace` explicitly; (2) every entry declaring `authRequired: false` is one you INTEND to be reachable without a session, and each carries `rateLimit: { enabled: true, windowMs, maxRequests }` — entries that were not intended to be anonymous have the key removed so the safe default (`true`) applies; (3) `objectstack validate` passes, which also proves no endpoint declares a shape 17.x cannot execute (`type: script` / `proxy`, mapping `transform`, an `object_operation` missing `objectParams`, `cacheTtl` on a non-GET method, `inputMapping` on find/get/delete, or two endpoints claiming one METHOD + path); and (4) after publishing, each endpoint answers as you expect — an anonymous request to a session-only endpoint returns 401 rather than data.
-- **`ui-widget-i18n-family-retired`** — `ui.widgetManifest / ui.widgetLifecycle / ui.widgetEvent / ui.widgetProperty / ui.widgetSource / ui.i18nObject / ui.pluralRule / ui.numberFormat / ui.dateFormat / ui.localeConfig (the widget-registration vocabulary of ui/widget.zod.ts, and the five doorless shapes of ui/i18n.zod.ts — 10 defs, 26 exported names)` → (removed — there is no replacement key, because there was never a key. A custom field widget is still named the same way it always was: `field.widget` is a plain string naming a component the RENDERER has registered, and objectui's registry has always carried its own runtime manifest for that (`RuntimeWidgetManifest` / `RuntimeWidgetSource` in `@object-ui/types`, objectui#3161 / #4115), which models different keys and never derived from these. For localisation: write the default-language string on `label` / `description` — the framework generates the translation key at registration time from the naming convention — and put translations in translation files, which is the LIVE `system/translation.zod.ts` surface. Widget registration and locale formatting as authorable protocol metadata return via the ENFORCE route of ADR-0049 through a new ADR — the registry / loader / formatter first, the vocabulary second)
- - Why not automatic: `ui/widget.zod.ts` published a complete widget-registration vocabulary — a manifest with lifecycle hooks, custom events, configurable properties and an npm/remote/inline implementation-source union — and `ui/i18n.zod.ts` published a structured-label, plural-rule and locale-formatting vocabulary. NOTHING in the protocol carried either. Three independent measurements, re-run on `origin/main` immediately before the removal with their controls passing in the SAME run: (1) no module under `packages/spec/src` imported `widget.zod` at all, and the only imports of `i18n.zod` anywhere name `I18nLabelSchema` / `AriaPropsSchema` (both KEPT), so no schema declared a carrier key — `field.widget` is a `z.string()` naming a registered component and has never referenced `WidgetManifest`; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` reached none of them, while `PageSchema` / `ObjectListViewSchema` resolved `direct` in the same run and a synthetic carrier flipped every one of them; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these files' own unit tests. `NumberFormat` / `DateFormat` DID have a carrier key (`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier was itself doorless, so the subtree was `no door` rather than `no gate` and goes whole — leaving the two leaves behind would strand exported schemas with no consumer (#3950). `I18nObjectSchema` was additionally superseded by its own file-neighbour: `I18nLabelSchema`'s documentation already says translation keys are generated at registration time and translations live in translation files, and the live translation surface is `system/translation.zod.ts`, which uses none of these shapes. The 2026-08-06 ruling weighed giving them a carrier (option B) and rejected it: that is a feature with a registry and a renderer behind it, not ledger clean-up. Tightening them to `strictObject` was rejected earlier and explicitly (#4001 批 16) — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave "a precisely validated dead slot, the more convincing lie" (#4583). With no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, route 3, the same shape as #4988 (the ui/ interaction config family), #4834 (kernel plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ `WidgetManifest.performance`'s own `retiredKey()` tombstone (#3896 close-out) is SUBSUMED here, the #4657/#4834 way: it goes with the shape that carried it, which is strictly stronger than the tombstone, because there is no longer a manifest to author the key INTO. ⚠️ One of the nine widget sites is deliberately NOT retired. `FieldWidgetPropsSchema` survives: it is a REACT PROPS CONTRACT rather than authorable metadata (it never appeared in `authorable-surface/` or `json-schema.manifest/` — its `onChange` is a `z.function()`), so "zero parse" is its design and not its defect, and it acquired a live cross-repo compile-time consumer one day before 批 16 measured: objectui PR #3289 (2026-08-03) renamed `@object-ui/fields`' validation slot onto the spec's `error` with no alias, the form renderer began producing it, and `packages/fields/src/__tests__/spec-symbol-batch7.test.ts` pins the shape against `import type { FieldWidgetProps } from '@objectstack/spec/ui'` as an intentional tripwire. Re-verified on objectui `origin/main` 2026-08-07. ADR-0049, #5055.
- - Done when: No code imports `WidgetManifest(Schema|Parsed)`, `WidgetLifecycle(Schema)`, `WidgetEvent(Schema|Parsed)`, `WidgetProperty(Schema|Parsed)`, `WidgetSource(Schema|Parsed)`, `I18nObject(Schema)`, `PluralRule(Schema)`, `NumberFormat(Schema|Parsed)`, `DateFormat(Schema)` or `LocaleConfig(Schema|Parsed)` from `@objectstack/spec` or `@objectstack/spec/ui` — every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in `ui/widget-i18n-retirement.test.ts`). No metadata document needs editing, because none could ever carry one of these shapes: a stack that parsed before parses byte-for-byte the same after, and a `field.widget: "my_picker"` string is untouched. `FieldWidgetProps` / `FieldWidgetPropsSchema` / `FieldWidgetPropsParsed`, `I18nLabel(Schema)` and `AriaProps(Schema)` all still resolve on `@objectstack/spec/ui` and are asserted to. ⚠️ objectui needs a companion PR in the same window: `packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts` asserts the spec STILL owns `WidgetManifest` / `WidgetSource` (it is the "a workaround should not outlive its reason" half of the objectui#3169 tripwire, designed to go red exactly here), and `packages/types/src/widget.ts`'s "Renamed off the spec's `WidgetManifest` name" comments now point at names that no longer exist. Both are prescribed responses to this removal, not collateral damage.
-- **`ui-interaction-config-family-retired`** — `ui.touchInteraction / ui.gestureConfig / ui.dndConfig / ui.keyboardNavigationConfig / ui.componentAnimation / ui.motionConfig / ui.pageTransition / ui.offlineConfig (the whole export surface of ui/touch.zod.ts, ui/dnd.zod.ts, ui/keyboard.zod.ts, ui/animation.zod.ts and ui/offline.zod.ts — 32 defs, 64 exported names)` → (removed — there is no replacement key, because there was never a key. Touch targets, drag-and-drop, focus management, keyboard shortcuts and motion are RENDERER BUILT-IN behaviour: the component library decides them, not a per-page metadata author. Offline is a platform capability, and its vocabulary belongs on the sync engine that owns the queue, the conflict policy and the cache — none of which exists yet. Delete the import and the value. Whichever of these earns real product pull returns WITH its own vocabulary and its executor, the #4910 way, not by un-retiring a declaration)
- - Why not automatic: Five `@objectstack/spec/ui` modules declared a full interaction-configuration vocabulary — 22 `z.object` sites across touch/gesture, drag-and-drop, focus/keyboard, animation/motion and offline/sync — and NOTHING in the protocol carried them. This is the ADR-0049 false-compliance shape in its most inviting form for an AI author (ADR-0033), and worse than the ordinary declared-but-unread defect: `authorable-surface.json` listed 109 keys under these defs and `content/docs/references/ui/{touch,dnd,keyboard,animation,offline}.mdx` rendered them as authoring tables, so the published documentation advertised a vocabulary with no carrier key anywhere. An author following `dnd.mdx` and writing a `dnd:` block onto a page component was rejected by `PageComponentSchema` for an unrecognized key — the docs and the schema disagreeing about the platform (Prime Directive #10). Three independent measurements, each with its controls passing in the same run: (1) no module under `packages/spec/src` imported any of the five except the `ui/index.ts` barrel, so no schema declared a carrier key; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` (25 roots, 4742 nodes) reached none of the 21 named object shapes, while `PageSchema`, `WebhookSchema` and `StateMachineSchema` all resolved `direct` and a synthetic carrier flipped all 21 — so unreachability was a fact about the graph, not a broken walker; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these modules' own unit tests. objectui holds TYPE re-exports and parity ratchets, never validators, and says so (#2561). The 2026-08-04 ruling weighed wiring a carrier key (option B) and rejected it: that is a feature with a renderer behind it, not ledger clean-up. It also weighed tightening the shapes to `strictObject` and rejected that explicitly — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave "a precisely validated dead slot, the more convincing lie" (#4583). Because there was no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, the same route 3 as #4834 (kernel plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ Not to be confused with #5021, which retired the THEME `animation` block — a different file, different defs, and that one did have a carrier key and therefore a tombstone. ADR-0049, #4988.
- - Done when: No code imports any of the 64 retired names from `@objectstack/spec` or `@objectstack/spec/ui` — `TouchTargetConfig(Schema)`, `GestureType(Schema)`, `SwipeDirection(Schema)`, `SwipeGestureConfig(Schema)`, `PinchGestureConfig(Schema)`, `LongPressGestureConfig(Schema)`, `GestureConfig(Schema)`, `TouchInteraction(Schema)`, `TransitionPreset(Schema)`, `EasingFunction(Schema)`, `TransitionConfig(Schema)`, `AnimationTrigger(Schema)`, `ComponentAnimation(Schema)`, `PageTransition(Schema)`, `MotionConfig(Schema)`, `DragHandle(Schema)`, `DropEffect(Schema)`, `DragConstraint(Schema)`, `DropZone(Schema)`, `DragItem(Schema)`, `DndConfig(Schema)`, `FocusTrapConfig(Schema)`, `KeyboardShortcut(Schema)`, `FocusManagement(Schema)`, `KeyboardNavigationConfig(Schema)`, `OfflineStrategy(Schema)`, `ConflictResolution(Schema)`, `SyncConfig(Schema)`, `PersistStorage(Schema)`, `EvictionPolicy(Schema)`, `OfflineCacheConfig(Schema)`, `OfflineConfig(Schema)` — every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in `ui/interaction-config-retirement.test.ts`). No metadata document needs editing, because none could ever carry one of these blocks: a stack that parsed before parses byte-for-byte the same after. If you consumed the bare `ConflictResolution` from `@objectstack/spec/ui` as a TYPE for your own offline code, declare that union locally — it is your client's policy, not the platform's. `@objectstack/spec/integration`'s `ConnectorConflictResolution` (connector sync) and `@objectstack/spec/api`'s `ConflictResolutionStrategy` (route merge policy) are different concepts and are untouched.
-- **`ui-notification-action-embed-config-retired`** — `ui.notificationAction / ui.embedConfig` → (removed — there is no replacement shape, because there was never a key to write either into. Delete the import and the value. Notification presentation is still described by the surviving `NotificationType` / `NotificationSeverity` / `NotificationPosition` vocabulary; public access to a form is granted by the LIVE `FormView.sharing` block (`SharingConfig`), which is untouched. Notification action buttons as metadata, and iframe embedding, return via the enforce route of ADR-0049 through a new ADR — carrier key and renderer first, vocabulary second)
- - Why not automatic: Both shapes were published `@objectstack/spec/ui` vocabulary with NO AUTHORING DOOR. #4001 批 14 measured them three ways on 2026-08-03 and this retirement re-ran all three against `origin/main` before removing anything, each with a positive control that passed in the same run: (1) CARRIER — no schema in `packages/spec/src` declared a key of either type (`ui/notification.zod`'s only non-test importer was the barrel; `ui/sharing.zod`'s were the barrel and `ui/view.zod.ts`, which names its SIBLING `SharingConfigSchema`), measured by resolving specifiers rather than substring-matching, because the repo holds two `sharing.zod` modules and a substring test miscredits `stack.zod.ts` to the UI one; (2) REACHABILITY — a BFS from the 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema`, over `build-schemas.ts`'s own walk including its derived-clone bridge, never reached either, while `Page` / `Action` / `DashboardWidget` / `Webhook` and `SharingConfig` itself all resolved `root-graph` in the same run and an injected synthetic carrier flipped both; (3) PARSE — zero `.parse()` in objectstack, cloud or objectui outside their own unit tests. So nobody could author one and nothing ever validated one: the #3950 shape, an exported schema with no consumer read as a capability, and the ADR-0033 trap where an AI author takes `EmbedConfigSchema` in the published bundle as proof the platform serves iframes. Neither is stored metadata and neither has a carrier, so no `sys_metadata` row can hold one and there is no source for the D2 chain to rewrite; this entry is the D3 record. 批 14 deliberately did NOT close them with `.strict()` — strictness is a property of a PARSE, and closing a shape nothing parses buys only "a precisely-validated dead slot, the more convincing lie" (#4583) — and filed the disposition as #5015, ruled REMOVE on 2026-08-04. Each was orphaned by an earlier retirement one level up: `NotificationAction` lost its wrappers at #4610 (`NotificationSchema` / `NotificationConfigSchema`, the #4535 C3 dual-source cleanup — that retirement's published "zero consumers" evidence was later falsified for objectui and is corrected on `ui/notification.zod`'s tombstone; the removal itself stands, #5781), and `EmbedConfig` lost its key at 17.0.0 when the 2026-06 liveness audit retired `App.embed` (no iframe route ever read it) — that key still stands as a `retiredKey()` tombstone in `app.zod.ts`, so an author who wrote the KEY already meets a prescription; this removes the value shape that outlived it. ⚠️ The retirement is per SCHEMA, not per file: `ui/sharing.zod` KEEPS `SharingConfigSchema`, a live door carried by `FormViewSchema.sharing` and read by `rest-server.ts` to mount the anonymous form routes, and `ui/notification.zod` keeps its three presentation enums. objectui consumed `NotificationActionSchema.shape.variant` as a VOCABULARY (never a parse) to pin its own hand-written `NotificationActionButton` interface — which is exactly why "has a consumer" never meant "has an authoring door" here; that pin is adapted objectui-side when it refreshes this dependency. ADR-0049, #5015.
- - Done when: No code imports `NotificationActionSchema`, `NotificationAction`, `EmbedConfigSchema` or `EmbedConfig` from `@objectstack/spec` or `@objectstack/spec/ui` — both are TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in `notification-embed-retirement.test.ts`). The same pin asserts the SURVIVORS in the same run, and that half is equally load-bearing: `NotificationTypeSchema` / `NotificationSeveritySchema` / `NotificationPositionSchema` and `SharingConfigSchema` must still be exported from `./ui`, and both modules must still load — a retirement that deleted either file would satisfy the absence half while destroying working surface. Nothing regresses at runtime, because nothing ever ran: no notification action was ever parsed from metadata and no iframe route ever read an embed config. Public form sharing is unaffected — `FormView.sharing` still gates the anonymous endpoints on `allowAnonymous` + `publicLink`.
-- **`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. #4839 (PR #5049) removed the readers; 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` (#4579) / `activationEvents` (#4657) 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, #3733 / ADR-0104), so a consumer that parses a context it was handed still meets the prescription. ADR-0049, #5050.
- - Done when: No hook reads `ctx.session.roles`; caller gating uses `ctx.session.userId` / `ctx.session.isSystem`, and privilege comes from the security service (`permissions` / `positions` / posture). Constructing a HookContext session with `roles` fails `tsc` (the input type is `never`) and fails `HookContextSchema.parse` with the retirement prescription instead of being silently stripped. Nothing regresses at runtime: the key had no producer, so no decision anywhere ever saw a value in it.
-- **`action-session-roles-to-positions`** — `ui.actionSession.roles` → ui.actionSession.positions (an action body reads `ctx.session.positions`)
- - Why not automatic: The MIRROR-IMAGE neighbour of the entry above, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright, #5050), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. #5613 ruled contract-first (maintainer, 2026-08-06: "C skeleton + A semantics"): phase 1 (#5697) declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it (#5779); the producer emits both for one deprecation window (#5613 runtime half), after which `roles` is removed on the path the v11 session-alias removal already walked (#3280 deprecated → #3290 removed). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087, #5613 / #5779.
- - Done when: No action body reads `ctx.session.roles`; every such read is `ctx.session.positions` and observes the same array (the rename is a rename — the VALUE is `ExecutionContext.positions` on both sides, which the runtime pin `action-session-shape-contract.test.ts` asserts independently of the key name). Privilege is NOT re-derived from either spelling: a read that was `roles.includes('admin')` as an access check is rewritten to ask the security service (capability grants / placements / derived posture, ADR-0095), never renamed to `positions.includes('admin')` — renaming that read migrates the defect rather than the code. Verify against a real dispatch, not a fixture: invoke an action as a caller holding positions and assert the body observed them under the canonical key. During the window both keys are present and equal, so a reader can be migrated and verified before the alias is removed; after it, `roles` is absent and a body still reading it sees `undefined` — which is why the read must be moved inside the window rather than at its close.
-- **`actor-user-roles-to-positions`** — `action body / AI route: ctx.user.roles (req.user.roles)` → ctx.user.positions (an AI route handler reads `req.user.positions`) — the same array, under the one spelling ADR-0090 D3 sanctions
- - Why not automatic: The THIRD face of the ADR-0090 `roles` → `positions` rename, and the only one whose surface the spec never declared. `ActorUser` (`packages/runtime/src/security/actor-user.ts`) is the ONE producer of the `user` envelope handed to an action body as `ctx.user` and to an AI route handler as `req.user`; it declared `positions` and `roles` side by side and filled them from a SINGLE assignment (`roles: core.positions`), so the two keys were verbatim identical on every dispatch — a second spelling of the vocabulary ADR-0090 D3 reserves and bans, published straight into author-written code. The maintainer ruled it closed IMMEDIATELY (2026-08-06 14:49Z, #6011): no deprecation window, no dual-emit, the alias simply gone in 17 (PR #6048). ⚠️ Do not read this entry across to its neighbour above: `action-session-roles-to-positions` governs `ctx.session`, a DIFFERENT object reached through the same `ctx`, and that one KEEPS its one-window dual-emit (#5613). Same word, same dispatch, two faces, two schedules — `ctx.user.roles` is absent in 17 while `ctx.session.roles` still answers for the length of its window. What makes this entry different in KIND from both session-side siblings: `ctx.user` has no spec schema and never had one. It is a runtime TS interface, so unlike `HookContext.session.roles` (tombstoned on a deliberately non-strict `HookContextSchema`, #5050) and unlike `ActionSessionSchema` (declared contract-first at #5697 precisely so its key could be renamed), there is no schema key here to tombstone and no `retiredKey()` prescription that could reach anybody — nothing ever ran an `ActorUser` through a `.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it reports at the READ site inside the author's own body; for an untyped or sandboxed body there is no enforced channel at all, which is exactly why this ledger entry has to exist — `spec-changes.json` and the generated upgrade guide are the ONLY way such a reader learns of the rename. It is the `findStream` (#4484) / `IStorageService.list` (#5540) disposition — a TS/API contract, no stored source, no tombstone, tsc at the call site — applied to a surface that lives one layer further out than either: those two are at least DECLARED in `packages/spec/src/contracts`, this one only in `packages/runtime`. Why it is a D3 semantic TODO and not a D2 conversion, on the same two independent grounds as its session sibling: FIRST, there is no source to convert — an `ActorUser` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key (the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape). SECOND, the only place the key is ever SPELLED is inside an action body or an AI route handler: author-written JS/TS, or a sandboxed script. A declarative transform cannot safely rewrite an identifier inside free-form code — the same reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. The removal's hard precondition was met before it landed, and the result is recorded here because the ledger is where an upgrading consumer meets it: the declaration's own comment claimed the alias was "kept for the REST/AI shapes", and that claim was DISPROVEN face by face against `origin/main` — repo-wide `user.roles` was 4 hits, all of them in the pins PR #6048 flipped; the four `ActorUser` construction sites build server-side envelopes that never enter a response body; objectui's `.roles` reads belong to two unrelated producers (the better-auth session, and the `/auth/me/permissions` payload). The `cloud` repo was NOT reachable in that session and is the one consumer face left unverified — this entry, and the changeset's FROM/TO prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087, #6011 (PR #6048).
- - Done when: No action body reads `ctx.user.roles` and no AI route handler reads `req.user.roles`; every such read is `.positions` and observes the SAME array — the value was `ExecutionContext.positions` on both sides, so this is a pure key rename and no value has to be re-derived. Privilege is NOT re-derived from either spelling: a read that was `roles.includes('admin')` as an access check is rewritten to ask the security service (capability grants / placements / derived posture, ADR-0095), never renamed to `positions.includes('admin')` — renaming that read migrates the defect rather than the code. Unlike `ctx.session` there is NO window to migrate inside: in 17 the key is already absent, so a typed body fails `tsc` at the read while an untyped or sandboxed one silently sees `undefined` — move the read AS you upgrade, not after it. Verify against a real dispatch rather than a fixture: invoke an action (and an AI route) as a caller holding positions, assert the body observed them under the canonical key, and assert the old key is ABSENT by key existence (`'roles' in ctx.user === false`) rather than by `undefined`, which cannot tell a removed key from one left behind holding nothing — the runtime pin `action-ctx-user-shape.test.ts` asserts both halves that way.
-- **`storage-service-list-retired`** — `contracts.IStorageService.list` → track the keys you wrote (sys_file / file-reference records, queryable through ObjectQL with real pagination) instead of enumerating the bucket — and where no such record exists, the cursor-shaped `list(prefix, { cursor, limit })` this entry reserved, restored in #6781
- - Why not automatic: `list(prefix)` was an OPTIONAL contract method documented as "List files in a directory/prefix", and the two shipped adapters answered the same call with two different semantics — both of them silently incomplete. `LocalStorageAdapter.list` was a single-level `readdir`, so a nested key `a/b/c` was invisible under `list('a')` (only `a/b` came back), and a subdirectory that `stat` succeeded on was pushed into the result as a file, yielding a `StorageFileInfo` whose `size` is a directory inode and which cannot be downloaded at all. `S3StorageAdapter.list` was RECURSIVE (`ListObjectsV2` matches the whole key) and read neither `IsTruncated` nor `ContinuationToken`, so past 1000 objects the "all files" a caller received was the first page, with no signal. One contract method, two dialects, both quietly incomplete — and the first feature that genuinely needed to enumerate a prefix (backup, orphan sweep, migration audit) would have got two different answers on two deployments without an error on either. #5172 was nearly that feature: it planned to drive attachment reclamation off `list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one level down, and switched to queue-driven deferred work instead. Nothing consumed it afterwards: the only in-repo call site was the `SwappableStorageService` pass-through (which itself rejects when the active adapter has no `list`), and REST, CLI and the storage routes never called it. Remove was chosen over align-and-tighten (maintainer ruling, 2026-08-05, #5266): aligning would grow a conformance surface nobody walks, while a prefix listing that cannot paginate is the wrong signature to inherit — when a real caller needs enumeration it returns cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases (nested keys, directory entries, >1000 objects) proving both backends agree. This is a TS/API contract surface — a storage adapter is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription there would reach no one. The enforced channel is tsc, and it reports at the call site. Same disposition, and the same reason, as `data-driver-find-stream-retired` (#4484). ADR-0049 / ADR-0087, #5540 (analysis #5266).
- - Done when: No code calls `storage.list(...)` on the `file-storage` service or on any `IStorageService` value. Code that needed "which files are under this prefix" reads the records it wrote — `sys_file` / file-reference rows carry the storage key and page deterministically through ObjectQL — rather than asking the bucket, which is also the only form that stays correct past 1000 objects and across both adapters. An adapter that still IMPLEMENTS `list` keeps compiling (an extra method is not an error on a class) and is simply unreachable through the contract, so deleting it is cleanup that can follow. The break is on the CALLER side: `storage.list(...)` no longer type-checks, and a PROXY typed against `IStorageService` that forwards to `inner.list` is exactly such a caller — the one in `@objectstack/service-storage` goes with the adapters (#5541). ⚠️ AMENDED 2026-08-09 (#6781, maintainer ruling on cloud#1203, option B): the RESERVED route in the paragraph above was taken. `list` exists again on the contract, cursor-shaped — `list(prefix, { cursor, limit })` returning `{ items, nextCursor }` — because cloud had two first-party callers this repo could not see when the measurement said "nothing calls it" (tenant attachment reclamation, marketplace snapshot GC). This does NOT un-retire anything and the acceptance criterion above is unchanged for what it actually governs: the single-argument `list(prefix): StorageFileInfo[]` is gone for good, a call written against it still fails to compile, and the two dialects it had are now pinned against each other in `storage-adapter-list.conformance.test.ts` rather than left to diverge. What changed for an upgrader is only the destination: prefer the records you wrote, and reach for the restored member when there are none.
- **`driver-aggregate-undeclared-key-aliases-removed`** — `driver aggregate() call argument — query.aggregate and aggregations[].func` → query.aggregations and aggregations[].function — the spellings QueryASTSchema and AggregationNodeSchema have always declared
- Why not automatic: `SqlDriver.aggregate` and `RemoteTransport.aggregate` each read two aliases the Query Protocol has never declared: `query.aggregations || query.aggregate` and `agg.function || agg.func`. "Never declared" is measured, not assumed — `git log -S` over `data/query.zod.ts` finds no commit that ever introduced either name, there is no `retiredKey()` tombstone and no alias-table entry for them (the file's only alias table is `SortNode`'s `direction` → `order`), and neither appears in any upgrade guide or release note. So this entry does not record a declared surface being withdrawn; it records a LENIENCY being withdrawn, which is why it is here rather than behind a tombstone. The only writers in this repository were the two driver packages' own fixtures — #4984's family, where a fixture spelling the alias keeps the tolerant limb green forever and no test in existence can go red on its deletion — so ADR-0049 enforce-or-remove applies once those are re-spelt. ⚠️ Do NOT read this across to `dashboard`/`page` measures: `aggregate` IS the canonical key there and `func` IS a declared, loudly-suggesting alias (`DatasetMeasureSchema`, ui/dataset.zod.ts). That neighbouring vocabulary is untouched, and it is the most likely reason an off-repo caller ever wrote these keys on a QUERY — one habit, two surfaces, only one of which declared it. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone: nothing ever ran a query through `QueryASTSchema.parse()` on this path. The enforced channel is tsc at the call site, once the parameter is `DriverQuery` — and for an untyped JS caller there is no enforced channel at all, which is exactly why this ledger entry has to exist: the generated upgrade guide is the only way such a reader learns of the rename. Same disposition, and the same reason, as `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011). ADR-0049 / ADR-0087, #6321 (PR #6404).
- Done when: No caller passes `aggregate:` to a driver's `aggregate()`, and no aggregation entry spells its function `func:`; both are written `aggregations:` / `function:`. An inline literal still using either old spelling no longer type-checks (TS2353 at the call site). An untyped JS caller that keeps writing `aggregate:` silently receives no aggregate column — the grouping still happens, the measure is simply absent — and one that keeps writing `func:` receives INVALID_QUERY / 400 naming the undeclared function, identically on the local driver and the Turso remote transport.
-- **`spec-type-alias-input-suffix-retired`** — `type alias: the 102 XInput names of @objectstack/spec (ConnectorInput, AppInput, PageInput, ActionInput, ServiceObjectInput, ExecutionContextInput, TaskInput, … — 52 files across api/ automation/ data/ identity/ integration/ kernel/ security/ system/ ui/)` → the BARE name. ADR-0122 phase 2 moved the author state onto `X`, which makes `XInput` a character-for-character synonym of it — the permanent synonym D3 forbids. Drop the `Input` suffix: `ConnectorInput` -> `Connector`. Symmetrically, a consumer that held a PARSE RESULT under the bare name moves to `XParsed`, which phase 1 (16.x) already declared for every schema whose two shapes differ, so the target name has existed for a release. NINE `*Input` names are NOT retired and need no edit: `ExpressionInput`, `CronExpressionInput`, `TemplateExpressionInput` and `PredicateInput` are the bare aliases of their own `…InputSchema`, and `FormFieldInput`, `QueryInput`, `FieldInput`, `ObjectStackDefinitionInput` and `NavigationItemInput` are composed (recursive or `Partial`-shaped) types no bare alias denotes.
- - Why not automatic: This entry exists for the reason `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) exist, and it is the same disposition: the surface is a TYPESCRIPT NAME, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — an `XInput` alias never had a carrier key, never emitted a def, and no `.parse()` ever saw it. Measured and verified rather than assumed: `json-schema/`, `json-schema.manifest/` and `authorable-surface/` are BYTE-IDENTICAL across this change, because those generators enumerate runtime `z.ZodType` exports and never read a type alias. So nothing left the published metadata surface and RETIRED_DEFS_BY_MAJOR is deliberately untouched — an entry there would falsely claim the metadata contract shrank. The enforced channel is tsc: the name is gone, so every consumer gets TS2724/TS2305 naming the import. That is loud but MUTE about the replacement — a compile error says `ConnectorInput` does not exist, not that `Connector` now means what it meant. The generated upgrade guide is the only channel that carries the second half, which is precisely the #6048 gap ADR-0087 registration exists to close. ⚠️ Deliberately NOT registered alongside it: the 1384 bare aliases the same change FLIPPED from `z.infer` to `z.input`. Those names all still exist and still resolve; what moved is which of a schema's two shapes they denote, and only where the two differ (663 of 1384 — the rest are isomorphic and the flip is a no-op there, pinned as such). A consumer holding an authored literal is made MORE correct by it, silently; one holding a parse result gets a tsc error at the first defaulted key it reads. Registering that as a rename would misdescribe it — no name was retired — and the changeset carries its own FROM -> TO for it. ADR-0122 D8/D9, #6083 (PR #6279).
- - Done when: No source imports a name ending `Input` from `@objectstack/spec` except the nine listed above: `rg "\b\w+Input\b" --type ts` over consumer code resolves only to those. A literal annotated with a bare spec type compiles while listing ONLY the keys the author means — `const c: Connector = { name, label, type }` type-checks, which it did not in 16.x — and a value read out of `XSchema.parse()` annotated with the bare name no longer compiles at the first defaulted key it reads (TS18048/TS2532), the signal that the annotation should be `XParsed`. `pnpm check:spec-parsed-alias` reports every bare alias as `z.input` and refuses both a bare `z.infer` alias and a reintroduced `XInput` synonym.
+- **`driver-capabilities-inert-bits-removed`** — `data.DriverCapabilities.create / data.DriverCapabilities.read / data.DriverCapabilities.update / data.DriverCapabilities.delete / data.DriverCapabilities.bulkCreate / data.DriverCapabilities.bulkUpdate / data.DriverCapabilities.bulkDelete / data.DriverCapabilities.transactions / data.DriverCapabilities.savepoints / data.DriverCapabilities.isolationLevels / data.DriverCapabilities.queryFilters / data.DriverCapabilities.queryAggregations / data.DriverCapabilities.querySorting / data.DriverCapabilities.queryPagination / data.DriverCapabilities.queryWindowFunctions / data.DriverCapabilities.querySubqueries / data.DriverCapabilities.queryCTE / data.DriverCapabilities.joins / data.DriverCapabilities.fullTextSearch / data.DriverCapabilities.jsonQuery / data.DriverCapabilities.geospatialQuery / data.DriverCapabilities.streaming / data.DriverCapabilities.jsonFields / data.DriverCapabilities.arrayFields / data.DriverCapabilities.vectorSearch / data.DriverCapabilities.schemaSync / data.DriverCapabilities.migrations / data.DriverCapabilities.indexes / data.DriverCapabilities.connectionPooling / data.DriverCapabilities.preparedStatements / data.DriverCapabilities.queryCache` → (removed — delete the keys. A driver advertises a capability by implementing the corresponding IDataDriver method; the three bits that survive because method presence cannot carry the signal are `queryDateGranularity`, `autonumber` and `batchSchemaSync`)
+ - Why not automatic: The #4484 findStream close-out found `DriverCapabilities.streaming` pointing at a capability the contract no longer declares, and the follow-up audit (#4634) checked every bit in the record the same way, across objectstack and cloud (objectui confirmed clean): of 34 declared bits, THREE have a decision-making reader — `queryDateGranularity` (engine aggregate dispatch + checkDateBucketParity), `autonumber` (engine defers generation to the driver), `batchSchemaSync` (engine ANDs it with method presence, because a subclass can inherit `syncSchemasBatch` from a base whose transport batches while its own cannot) — and THIRTY-ONE were written by every driver and read by nothing. Their `.describe()` strings promised engine adaptation ("if false, ObjectQL will filter/sort/paginate in memory") that was never built, and zero readers let the values go WRONG unnoticed: SqlDriver declared `streaming: false` while implementing `findStream`; InMemoryDriver declared `streaming: true` over a full-table read (ADR-0078 false affordance, on the capability record itself). The real mechanism everywhere else is METHOD presence: transactions gate on `driver.beginTransaction`, aggregate pushdown on `typeof driver.aggregate`, schema sync on `typeof driver.syncSchema`, and the REQUIRED CRUD/bulk methods are called unconditionally. A driver is CODE, never stack metadata — `supports` literals live in driver classes and `DriverConfig.capabilities` is plugin TS configuration, neither ever a `sys_metadata` shape (the stack-tree neighbour, `datasource.capabilities`, was retired separately in #4583) — so there is no source for the D2 chain to rewrite and this entry is the D3 record. The keys are tombstoned rather than deleted because `DriverCapabilitiesSchema` is not `.strict()` and IS parsed (DriverConfigSchema / SQLDriverConfigSchema / NoSQLDriverConfigSchema embed it): a plain delete would silently strip a vendor's authored bit, replacing one silent no-op with another. `batchSchemaSync` also drops its `.default(false)` for `.optional()` — absence already meant false at both readers, and the default forced every capability object to spell out 30+ bits. ADR-0049 / ADR-0078, #4634.
+ - Done when: No `supports` literal or `DriverConfig.capabilities` object authors any of the 31 retired bits — a driver class that still writes one fails tsc against `IDataDriver.supports` (the bit is `never`), and a parsed config fails with the per-key prescription. The three in-repo drivers (memory / mongodb / sql) declare only live bits; cloud's TursoDriver keeps compiling via its `...super.supports` spread (its stale explicit overrides are cleanup, tracked cloud-side). Engine behaviour is byte-identical: every removed bit had zero readers, and the three live bits keep their readers (engine.ts autonumber defer / aggregate dispatch, plugin.ts + engine.ts batched schema sync, verify date-bucket parity).
- **`driver-sql-distinct-bare-filter-typed`** — `SqlDriver.distinct() third argument — any value` → a bare FilterCondition (@objectstack/spec/data) — the same value find() carries under query.where, never a query envelope
- Why not automatic: This entry records a TYPE being added, not a surface being withdrawn, and it says so up front because the distinction decides who has to do anything. `distinct` is not declared on `IDataDriver`, so #5181 / #6075 never reached it and it kept `filters?: any` while its body said something far more specific — `applyFilters(builder, filters)` is handed the ARGUMENT ITSELF, never a `.where` off it. ⚠️ RUNTIME BEHAVIOUR IS UNCHANGED by this entry's change: not one statement moved, so no upgrade breaks at run time and nothing that answered correctly stops. What the annotation removes is a compile-time hole, measured rather than assumed: a truthy NON-OBJECT third argument — `distinct('orders', 'product', 'completed')` — used to type-check and resolve the UNFILTERED set, because `applyFilters` emits no predicate at all for a truthy non-object, non-array filter. A call meaning "which products among completed orders" answered with EVERY product, silently. That spelling is now TS2345 at the call site. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone, the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540), `actor-user-roles-to-positions` (#6011) and `driver-aggregate-undeclared-key-aliases-removed` (#6321) already carry. ⚠️ It differs from those four in ONE measured way a reader should not have to infer: because nothing changed at run time, an untyped JS caller is not affected BY THE UPGRADE at all. The entry is here for a different reason — such a caller is exactly the one tsc can never reach, and the silent widening above is a defect they may ALREADY be sitting on, before and after this major. The generated upgrade guide is the only channel that reaches them, which is why the fix is written down rather than left to the compiler. ⛔ The reverse mismatch is NOT closed and no type can close it: `FilterCondition` is an open map (`[key: string]: any`) because a filter key IS a field name, so a query envelope `{ object, where }` is structurally a valid filter — one constraining columns named `object` and `where` — and so is a FilterArray. Both reach `distinct` type-checked and are refused at run time, loudly, with INVALID_FILTER / 400. `driver-memory`'s opposite half — where the BARE spelling returns the unfiltered set in silence — stays open under the #5499 freeze (#6320). ADR-0087, #6320.
- Done when: No caller passes a non-object to `distinct()`'s third argument. A scalar there is now a compile error (`TS2345: Argument of type 'string' is not assignable to parameter of type 'FilterCondition'`); rewrite it as the bare filter it was always meant to be — `'completed'` becomes `{ status: 'completed' }`. ⚠️ That is NOT an equivalent rewrite: the old spelling returned the UNFILTERED set, so the answer changes once fixed, and the changed answer is the one the call always meant. An untyped JS caller gets no compile error and no behaviour change — for them this entry is the only notice that the spelling never filtered anything. A query envelope or a FilterArray in that slot still compiles and is rejected at run time with INVALID_FILTER / 400.
-- **`filter-regex-options-retired`** — `data.filter $regex / $options — in a STORED filter (dashboard widget filter and globalFilters, report runtimeFilter, page and component filter, solution-blueprint filter), and equally in the where clause of a query request` → $icontains for the case-insensitive substring match this was almost always used for, or $contains for a case-sensitive one — a pattern that genuinely needs a regular expression has no filter-level replacement
- - 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 #5701 itself, 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 #4706 ruling (B), not just the driver one: the contract half (#5701 — the `$icontains` declaration, the `$contains` family pinned case-sensitive, and the `RETIRED_FILTER_OPERATORS` prescriptions) landed before the ADR-0087 disposition gate (#6148) existed and so was never asked for a ledger entry; the driver half (#5702) is where the refusal became executable. One surface, one entry, registered from the half that made it observable. ADR-0049 / ADR-0087, #4706 / #5701 / #5702.
- - 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.
-- **`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.
- - 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.
-- **`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.
+- **`engine-find-formula-order-by-refused`** — `engine.find(object, { orderBy }) and engine.findOne(object, { orderBy }) naming a `formula` field — the direct engine path, not the REST ingress` → denormalise the value onto the object (a stored field, written when the source changes) and sort by that — the same remedy the REST ingress has prescribed since #6924 / #6994; a `summary` field is unaffected and still sorts, because it gets a real maintained column
+ - Why not automatic: #4226 / #4256 / #6994 closed the SORT axis at the REST ingress (`assertSortFieldsExist`, `400 INVALID_SORT`), which covers everything reaching `findData`: the list route, `POST /data/:object/query`, the export route and the RPC dispatcher. A caller reaching `engine.find()` / `engine.findOne()` DIRECTLY passed through none of it, and a `formula` ORDER BY there was dropped in silence. Measured on a real driver: `asc` and `desc` came back BYTE-IDENTICAL, in insertion order, under a success, with the rows carrying the very values they were asked to be ordered by. No column exists to order by (a formula is computed on read, so no driver materialises one), so the ORDER BY reached the driver, found nothing, and the unknown-column backstop returned the rows unordered.
+
+Ruled 2026-08-10 on #7095: an ORDER BY the engine cannot apply is a 4xx with guidance prose at the public boundary, never a silent drop — the same direction as the analytics dataset refusal envelope and the #6924 sort-hint prescription. The engine's documented internal-caller tolerance (`assertProjectionFieldsExist`'s docblock) was to survive only behind a pinned internal path, and only if a MEASURED internal call site relied on it. The #7095 sweep of every in-tree `orderBy` reaching the engine directly — hooks, flows, reports, queue/job adapters, sharing, metadata loaders, expand sub-reads — found NONE: every hardcoded internal sort names a real stored column (`created_at`, `updated_at`, `version`, `priority`, `scheduled_for`, `started_at`, `next_run_at`, `recorded_at`, `id`), and no shipped object in the repo declares a `formula` field at all. So no internal path shipped, and there is no flag to opt back into the drop.
+
+This is a CODE-path API, not stored metadata, so — like `hook-register-empty-object-target-refused` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and the ledger entry is the notification channel. No mechanical rewrite exists in either direction: the platform cannot invent the stored column the remedy prescribes, and it must not sort post-hoc instead — `driver.find` has already applied `limit` / `offset`, so re-sorting after the formulas are evaluated would reorder an ARBITRARY PAGE, which looks correct on small result sets and is wrong the moment pagination is involved.
+
+ONE AUTHOR-REACHABLE SURFACE reaches this indirectly and is why it is not purely a code-side note: a saved report's `query.orderBy` (`sys_saved_report`) is forwarded verbatim into `engine.find` by `plugin-reports`, bypassing the ingress gate. A report authored to sort by a formula field used to run and return rows in an arbitrary order; it now fails loudly, with the remedy in the message. One further path is deliberately NOT a refusal: a nested `expand` sort raises this refusal inside `expandRelatedRecords`, whose pre-existing graceful-degradation `catch` swallows every expand failure and retains the raw foreign keys — so that path moves from silent to OBSERVABLE (a warning naming the field and the fix) rather than refusing. Reversing that backstop is a separate decision on all expand failure modes. #7095, #6994, #6924, #4226, #4256, #3821, ADR-0112.
+ - Done when: No `engine.find` / `engine.findOne` call site sorts by a `formula` field, and no saved report's `query.orderBy` names one — grep your report definitions for an `orderBy` field whose object declares it as a `formula`, and denormalise it onto a stored column written when the source changes. A `summary` / rollup field needs no action: it has a real maintained column and sorts correctly. Reads complete with no `INVALID_SORT` naming a formula field, and no "Failed to expand relationship field" warning whose error text names one.
+- **`enhanced-api-error-field-errors-renamed`** — `api.enhancedApiError.fieldErrors` → fields
+ - Why not automatic: The wire has always carried `fields` — the validators, import coercion, validation-failure.ts, @objectstack/client and the console's field-error extractor all say `fields`, and nothing ever emitted `fieldErrors`, so a reader keying on it was reading a field no server sent (ADR-0078's silently-inert declaration, on the error envelope). This is a RESPONSE surface: no stack, example or template carries the key, so there is no source for the chain to rewrite — the schema tombstones it via retiredKey() and consumers move their read themselves. ADR-0114 D4, #3977.
+ - Done when: No consumer reads `error.fieldErrors`; per-field validation detail is read from `error.fields`, and constructing an EnhancedApiError with `fieldErrors` fails to parse with the rename prescription instead of silently losing the array.
- **`etl-pipeline-layer-retired`** — `automation.etlPipeline / automation.etlPipelineRun / automation.etlSource / automation.etlDestination / automation.etlTransformation (the whole L2 layer of automation/etl.zod.ts, its four enums and the `ETL` factory — 9 defs, 27 exported names)` → (removed — no protocol surface replaces it, deliberately. Layer by layer: connector-attached synchronisation is `ConnectorSchema.syncConfig` (`integration/connector.zod.ts`), which IS parsed and executed; per-field value transformation on import is `shared/mapping.zod.ts`, whose `transform` is applied row by row by the REST import path and recorded key by key in `packages/spec/liveness/mapping.json`; scheduling is `system/job.zod.ts`. What has NO replacement is multi-source, multi-stage movement with joins and aggregations — because it never had an implementation either. It returns through the ENFORCE route: the engine first, the vocabulary second)
- Why not automatic: The reading #4738 used to retire L1 `DataSyncConfig`, re-measured one layer up and identical: narrative-only. No engine ever parsed, scheduled or executed an `ETLPipeline`. Measured on origin/main immediately before the removal: the only non-spec references in this repo are two fumadocs-generated documentation sources (`apps/docs/.source/*.ts`), not executors; objectui has no reference at all; there is no `liveness/etl.json` or `pipeline.json`, so no ADR-0049 gate ever had a reading on it — while the same file family's EXECUTED half does have one (`liveness/mapping.json`), which is the contrast that makes the absence meaningful rather than an oversight. The `etl` string in this registry was the one untested link the finding named, and it is not a loader path: it was the id of the #4962 retry-vocabulary entry, absorbed here. The layer was ADR-0078's asymmetry in its purest form — an author could write a complete ten-stage pipeline, get no error, and get no execution. It was also advertised: `packages/spec/docs/SYNC_ARCHITECTURE.md` named `ETLPipeline` as the recommended destination for authors displaced by the L1 retirement (#4738) and listed ten transformation types with copyable examples down to `script | Custom JavaScript/Python`. That document is rewritten in the same change; a retirement whose own doc still recommends the retired layer is self-contradictory, and forwarding L1's authors to a second layer with no executor was the defect compounding rather than closing. ⚠️ `etl-retry-converged-onto-retry-policy` (#4962) is SUBSUMED here, the #4657/#4834/#5055 way: both land in the unreleased protocol 17, so composed, a rename of `retry.maxAttempts` on a shape that does not survive the major has no observable effect — and keeping both would tell an upgrader to rewrite a key on a schema the same upgrade deletes. The `maxAttempts` `retiredKey()` tombstone goes with the shape that carried it, which is strictly stronger than the tombstone: there is no longer a `retry` block to author the key into. Route 3 — no carrier key, no parse site, so no D2 conversion and no tombstone; RETIRED_DEFS_BY_MAJOR plus this entry are the declaration. ADR-0049, ADR-0078, #6414.
- Done when: No source imports `ETLPipeline`, `ETLPipelineParsed`, `ETLPipelineSchema`, `ETLPipelineRun(Schema)`, `ETLSource(Schema)`, `ETLDestination(Schema)`, `ETLTransformation(Schema)`, `ETLEndpointType(Schema)`, `ETLTransformationType(Schema)`, `ETLSyncMode(Schema)`, `ETLRunStatus(Schema)` or the `ETL` factory from `@objectstack/spec/automation`; `tsc` reports TS2724/TS2305 on any that survives. Every author who was pointed at L2 has been re-pointed by name: SYNC_ARCHITECTURE.md no longer lists an L2 row, no longer recommends `ETLPipeline` as L1's destination and no longer advertises a transformation-type table. The surviving layers still parse unchanged — a connector declaring `syncConfig` and an import declaring `mapping.transform` both behave exactly as they did in 16.x.
-- **`action-descriptor-resume-authority-default-flip`** — `automation.ActionDescriptor.resumeAuthority — an OMITTED value on a pausing node descriptor (supportsPause: true, or any executor whose execute() returns suspend: true)` → an explicit resumeAuthority: 'any' on the descriptor, for a pausing node whose pauses really are meant to be continued through the generic resume route (POST /automation/:name/runs/:runId/resume) — a screen-style collected-input pause, or a signal wait an external producer resumes. Declare 'service' instead if continuing is the tail of a decision your own service must authorize and record first. Either value is a one-line addition; only the silence changed meaning
- - Why not automatic: A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as protocol 12's `rest-requireauth-default-flip`, and it is registered here for the same reason: whether a given pause is genuinely open to the generic route is a trust judgment no transform can make. The #3801 resume gate keys on the SUSPENDED NODE, and `ActionDescriptor.resumeAuthority` used to default to `'any'`, so a pausing node type shipped raw-resumable unless its author remembered the field. It now resolves to `'service'` when absent: an unclaimed pause is refused on the generic route with `PERMISSION_DENIED` / 403 until its descriptor states who may continue it. #3823 is the incident that decided the direction — ADR-0044 pointed an approval's revise edge at a generic `wait`, `wait` is legitimately `'any'`, and the pause standing in a service-owned position inherited a fail-open value nobody chose; the demonstrated cost was an unaudited resubmit plus a destroyed remote run. The two possible mistakes are asymmetric, which is the whole argument: guessing `'any'` walks past a decision nothing recorded and is silent, while guessing `'service'` returns a refusal naming the missing field. ⚠️ The surface is a DESCRIPTOR FIELD set in plugin CODE, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) already carry. It differs from those in one way a reader should not have to infer: nothing is REMOVED, so tsc reports nothing at all — the field was already optional after step one and an omission still compiles. The enforced channels are all run-time: a registration warning naming the node type (once per type per engine), the refusal message on the resume itself, and `check:resume-authority-declared` for executors living in this repo. For a third-party plugin the generated upgrade guide is the only channel that arrives BEFORE a user hits a run that will not continue. In-tree the flip moves nothing: all six shipped pausing types (screen, wait, subflow, map, approval, approval_revise) declare their authority explicitly. ADR-0044 amendment (2026-07-28) and its 2026-08-08 landing section, ADR-0019 #3801 addendum, #5561.
- - Done when: Every action descriptor your plugin registers for a node type that can suspend declares `resumeAuthority`. Booting the stack logs no `declares supportsPause but never declares resumeAuthority` warning naming one of your types, and a run parked on each of your pausing nodes can still be continued the way you intend: a resume through the generic route succeeds for the ones you declared `'any'`, and answers 403 (`PERMISSION_DENIED`) for the ones you declared `'service'`, which continue through your own service API instead. ⚠️ `supportsPause` is no longer the declaration nothing enforced (#5703, closed by #6667): an executor whose `execute()` returns `suspend: true` while leaving `supportsPause` false is still warned about by neither warning channel, but `AutomationEngine.refuseUndeclaredSuspension` now refuses that suspension at the one seam every suspension passes through — a guard-class failure no `fault` edge routes — so it needs no hand-check. The residue that does: an executor registering NO descriptor declares nothing for either warning or the refusal to read, so its pauses are still created and refused only later, on the resume route (#5561).
- **`export-field-meta-constraints-retired`** — `@objectstack/rest: ExportFieldMeta.required / .system / .readonly / .hasDefault / .min / .max / .minLength / .maxLength (the map built by `buildFieldMetaMap`, reached as `PreparedImport.metaMap` from `prepareImportRequest`)` → the object schema you already hold — read `fields[name].required` / `.system` / `.readonly` / `.defaultValue` / `.min` / `.max` / `.minLength` / `.maxLength` off the same `ObjectSchema` you passed to `buildFieldMetaMap`, which is where the ENGINE reads them and therefore the only copy that cannot drift
- Why not automatic: ADR-0049 enforce-or-remove. These eight were never a source of truth: `buildFieldMetaMap(schema)` DERIVED each one from the very `schema` its caller passed in, so the map carried a second copy of facts the caller already held. They existed for exactly one consumer — the import dry run's hand-copied pre-check mirror (`firstMissingRequiredField` / `firstConstraintViolation`, framework#3956) — and #4633 ruling D retired that mirror (PR #6532): the dry run now asks `DataProtocol.validateData` for the engine's verdict, which reads the object's own schema. That left all eight computed on every import and read by NOTHING, which is the declared-and-unread shape ADR-0049 exists for; a constraint vocabulary standing next to the presentation one with no enforcer behind it is precisely the thing an AI-authored consumer mistakes for a contract. Verified zero-reader before removal, per key and by type, across this repo (`packages/rest` itself, and all five in-repo dependents of `@objectstack/rest`: runtime, cli, verify, plugin-auth, plugin-dev) and the `objectui` sibling; plugin-auth's identity import forwards `prepared.metaMap` into `runImport` but reads only the presentation keys through `coerceRow`. Why this needs a ledger entry despite that sweep: it is the `findStream` (#4484) / `IStorageService.list` (#5540) / `actor-user-roles-to-positions` (#6011) disposition — a published TS surface with NO spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry a prescription, and the ledger is the only channel that reaches an upgrader. It is if anything blinder than those three: the keys shipped in a FINAL release (`@objectstack/rest` 14.5.0) and have been published in every release since, and because they were OPTIONAL keys on an interface that itself survives, a JavaScript consumer reading `meta.required` after the upgrade gets `undefined` with no error at all — tsc reports at the read site only for a typed consumer. Why D3 semantic and not a D2 conversion: there is nothing to convert. No authored or stored metadata changes shape — `required` / `min` / `maxLength` and the rest remain fully authorable on a field definition and fully enforced by the engine, which is where they always lived. The only place these eight are ever spelled is inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach them. ADR-0049 / ADR-0087, #6536 (the sweep PR #6532 deliberately deferred).
- Done when: No code of yours reads any of the eight off a `buildFieldMetaMap` / `prepareImportRequest` result. Grep your sources for `.required` / `.hasDefault` / `.minLength` / `.maxLength` / `.min` / `.max` / `.system` / `.readonly` on an `ExportFieldMeta`-typed value; each hit moves to the object schema you already passed in. ⚠️ Prove it against a RUN, not against tsc: these were optional keys, so an untyped or `any`-typed read compiles clean and silently becomes `undefined` — assert that the constraint your code acts on is still observed on a real import, not merely that the build is green. Note `hasDefault` has no one-to-one replacement key: it was the derived predicate `defaultValue != null`, mirroring the engine's `applyFieldDefaults` gate, so read `fields[name].defaultValue` and apply that same `!= null` test yourself.
-- **`action-descriptor-is-async-retired`** — `ActionDescriptor.isAsync (the descriptor an executor publishes via `registerNodeExecutor` / `defineActionDescriptor`)` → nothing to re-declare — delete the key. Suspension is `execute()` RETURNING `suspend: true`, and permission to suspend is `supportsPause: true` on the same descriptor (with the `resumeAuthority` its pauses need)
- - Why not automatic: ADR-0049 enforce-or-remove. `isAsync` declared "this action suspends the flow awaiting an external reply" and NOTHING read it: a fresh three-repo measurement (#6748, re-run at pickup) found zero property reads across objectstack, objectui and cloud — every hit was the declaration itself, a generated baseline, one of five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. So declaring it never made a node suspend and omitting it never stopped one, which is the silently-inert declaration ADR-0049 exists to end. It was always a second, weaker spelling of the capability `supportsPause` states, and the two diverged in exactly the way a duplicated declaration does: `screen` declared both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling in #6667 — `AutomationEngine` now refuses a suspension whose type does not declare `supportsPause: true` — so the capability this key gestured at is now a real, enforced fact under one name. This one had no consumer to grow into and takes the remove leg. Why D3 semantic and not a D2 conversion: an ActionDescriptor is published from an executor's TypeScript, never stored in stack metadata — no stack, example or template carries the key — so there is no source for the chain to rewrite and `os migrate meta` cannot reach it. The schema tombstones it via `retiredKey()` and descriptor authors delete the key themselves; that rejection (a `tsc` error at the authoring site, and a parse error inside `defineActionDescriptor`) is the channel a third-party plugin author actually meets. The `EnhancedApiError.fieldErrors` disposition, one layer down.
- - Done when: No descriptor declares `isAsync` — not the five that shipped it (`screen`, `map`, `wait`, `approval`, `approval_revise`), not a plugin's. Every node type that returns `suspend: true` from `execute()` declares `supportsPause: true` on its descriptor together with a `resumeAuthority`, and its runs still pause and resume as before: the behaviour never depended on `isAsync`, so deleting the key changes no run. Authoring `isAsync` fails `tsc` at the descriptor literal and fails `defineActionDescriptor()` at runtime with the prescription, instead of parsing clean and being stripped.
-- **`notification-list-cursor-retired`** — `api.listNotifications cursor — the key on BOTH halves of GET /api/v1/notifications (ListNotificationsRequestSchema and ListNotificationsResponseSchema) and the cursor argument of the client SDK call client.notifications.list(). The same entry covers the limit default: the request schema no longer declares default(20)` → a larger `limit` — the route answers the newest N notifications and has no page 2. There is no replacement for `cursor`, deliberately: nothing ever minted one, so no caller holds a value to carry over. Callers that looped on it were re-reading the first window and should read one window sized to what they display (the Console bell polls exactly this way). For the removed `limit` default, send the number you want explicitly if you were relying on 20 — omitting it takes the server window, which is 50 on the platform inbox and clamped into 1..200, and has been since before the declaration existed
- - Why not automatic: One capability, both halves, never half-deleted (maintainer ruling 2026-08-07, Option A, ruled jointly with #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.
-- **`plugin-manifest-loading-retired`** — `manifest.loading (the whole block: strategy / preload / codeSplitting / dynamicImport / initialization / dependencyResolution / hotReload / caching / sandboxing / monitoring)` → nothing to re-declare — delete the key. Plugins are composed at boot: `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder` in `packages/core/src/plugin-order.ts`). For the isolation `loading.sandboxing` appeared to configure, use the plugin trust tier (`manifest.runtime`, ADR-0025 §3.6) and the manifest permission declarations, which are the surfaces the platform actually enforces
- - Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-08-04 on #4914. The block declared a complete plugin loading policy and NOTHING read it. A bare-name scan of all three repos — objectstack, cloud (measured 2026-08-09) and objectui (measured at pickup), each with a control probe proving the scan saw the tree — put every hit inside `packages/spec` itself: this module's own declaration, its own unit tests, the `Manifest.loading` embed and the generated artifacts. `manifest.loading.*` had zero readers in `packages/core`, `packages/runtime` and `packages/metadata`. So the key parsed, entered the manifest, and changed nothing — #3950, at the scale of a whole block. What made it outrank ordinary inert-key cleanup is `sandboxing`: it declared process / vm / iframe / web-worker isolation, IPC transports and an `allowedServices` ACL, so an AI author (ADR-0033) reading that vocabulary concluded the platform isolates plugins, wrote the config, and received a clean parse and zero isolation. An inert security control is worse than an absent one because it is believed. Hot reload was additionally a TWO-SOURCE defect: the docs pointed at this dead `PluginHotReloadSchema` while the only implementation body, `HotReloadManager` (`packages/core/src/hot-reload.ts`), reads a different vocabulary — `HotReloadConfigSchema` in `plugin-lifecycle-advanced.zod.ts`. Ruling §2 converges on the surviving side: that schema is KEPT as the starting point for a future enforce decision (it has an implementation body but no runtime composes it yet), and enforcing it is deliberately a separate decision, not this retirement. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections. A package manifest is neither — `PLURAL_TO_SINGULAR` has no `packages` / `plugins` entry, so a manifest is not a stack collection member and a stored manifest row passes that seam through unchanged. A conversion would be a transform with no seam that ever runs.
- - Done when: No `objectstack.plugin.json` and no stored package manifest carries a `loading` key. The enforced channel is the one place a manifest is parsed with an author present: `os plugin build` runs `ManifestSchema.safeParse` and exits non-zero, printing the tombstone prescription, so a manifest still declaring `loading` fails its build rather than shipping. TypeScript authors get it earlier still — `loading` is typed `never`, so assigning it is a `tsc` error. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ever read the block, so removing it removes no behaviour. A package ALREADY INSTALLED whose stored manifest carries `loading` keeps working — the registry's `validate()` is an explicit diagnostic and not a gate (it catches, logs `[metadata_spec_invalid]`, and registers the item anyway, deliberately, so bad metadata is never a data outage), so such a row degrades to one log line at registration rather than a boot failure. Clear it by deleting the key from the source manifest and reinstalling.
-- **`api-runtime-create-withdrawn`** — `PUT /api/v1/meta/api/{name} (runtime-authored `api` endpoints, draft and active alike)` → Declare the endpoint as a stack artifact (`**/*.api.ts`, or `defineStack({ apis })`) and ship it through `publishPackage`
- - Why not automatic: The `api` registry entry declared `allowRuntimeCreate: true` and the runtime never honoured it. Measured on a real showcase boot (#5488): `PUT /api/v1/meta/api/e8_backdoor` answered 200 with `{"success":true,…,"message":"Saved …"}`, and the declared route then answered 404 forever — with NO `[EndpointMatcher] … EXCLUDED` line, because the endpoint was never in the index to be excluded from. The serving criterion belongs to `IMetadataService.matchEndpoint` -> `EndpointMatcher` -> `MetadataManager.listForIndex('api')`, which reads the manager's registry plus its registered loaders (`["filesystem","memory"]` on dev/serve); a runtime write lands in `sys_metadata`, which is in neither. A declared capability the runtime does not honour is ADR-0049 false compliance, and a write that answers "Saved" and then 404s forever is its most dangerous shape for the AI authors ADR-0033 targets. The maintainer ruled REMOVE on 2026-08-07 rather than converge the read path, because making the matcher read `sys_metadata` re-opens cache, invalidation, tenancy and the ADR-0110 D3 miss-vs-outage distinction on a new read path, and there is no business pull for Studio-authored endpoints today (zero `.api.*` artifacts author them at runtime; showcase uses the artifact route, #5040 E8 LIVE). There is NO D2 conversion, for the reason this list exists: nothing in an authored source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an authorable one, and the artifact route it points authors toward is untouched — a `**/*.api.ts` file valid before this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, so it is one semantic TODO for operators and Studio callers rather than a stack conversion — the same disposition `BatchOptions.validateOnly` (#4052) takes. Consequently `gateApiDraftsForPublish` (PR #5279) is retired with it: it gated a promotion into a state the matcher can never read, and with the inlet closed no `api` draft can exist for it to judge. Re-entry is recorded in the ruling: if #2657 Part B promotes `apis` to a registered type WITH A REAL CONSUMPTION PATH, the flag flips back then — implementation first, declaration second. ADR-0049 / ADR-0121, #5488 (subsumes #5311).
- - Done when: No caller creates or updates an `api` item through the runtime metadata API. `PUT /api/v1/meta/api/{name}` answers 403 with `code: "NOT_CREATABLE"` and a body naming both flags (`allowRuntimeCreate=false, allowOrgOverride=false`) and the prescription `Declare it in source (**/*.api.ts) and redeploy` — in `?mode=draft` as well as direct-active, because the gate runs before the draft/publish branch and does not read `mode`. ⚠️ Verify the artifact route is UNAFFECTED, which is the whole point of the change: a stack declaring `apis:` still compiles, still passes `validateApiEndpointDeclarations` at publish (`publishPackage`, #5189) and at load (`buildEndpointIndex`, PR #5203), and its endpoints still SERVE — that route was always the only one that served. An operator who genuinely needs the runtime door back on one deployment sets `OS_METADATA_WRITABLE=api`, the same single escape hatch `job` / `agent` / `capability` use; note that this unlocks the WRITE only, and the endpoint still will not be served, which is why it is a diagnostic and not a workaround. Any `api` rows already sitting in `sys_metadata` from before this change were never served either; they can be deleted (`deleteMetaItem` is deliberately not gated by this refusal, so repair stays possible).
-- **`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.
- - Done when: Every import request of yours that must NOT fire triggers sends `runAutomations: false` explicitly, rather than omitting the key and trusting the old declared default. The check is worth doing precisely where it looks unnecessary: if you build the body by parsing it through `ImportRequestSchema` (or the published JSON Schema) and then send the PARSED object, your bulk loads were running with automations OFF and will now run with them ON — that is the only class whose behaviour changes, and it changes toward what an unvalidated caller always got. ⚠️ Behaviour on the wire is deliberately UNCHANGED and should be verified as such: a body that omits `runAutomations` fired triggers before this change and fires them after, and `runAutomations: false` turns them off before and after. Nothing starts being refused — the route never validated this body against the schema and does not begin to. `dryRun` is unaffected and still runs NO automations whatever the flag says (#6037).
-- **`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.
+- **`filter-regex-options-retired`** — `data.filter $regex / $options — in a STORED filter (dashboard widget filter and globalFilters, report runtimeFilter, page and component filter, solution-blueprint filter), and equally in the where clause of a query request` → $icontains for the case-insensitive substring match this was almost always used for, or $contains for a case-sensitive one — a pattern that genuinely needs a regular expression has no filter-level replacement
+ - 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 #5701 itself, 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 #4706 ruling (B), not just the driver one: the contract half (#5701 — the `$icontains` declaration, the `$contains` family pinned case-sensitive, and the `RETIRED_FILTER_OPERATORS` prescriptions) landed before the ADR-0087 disposition gate (#6148) existed and so was never asked for a ledger entry; the driver half (#5702) is where the refusal became executable. One surface, one entry, registered from the half that made it observable. ADR-0049 / ADR-0087, #4706 / #5701 / #5702.
+ - 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.
+ - 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. #4839 (PR #5049) removed the readers; 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` (#4579) / `activationEvents` (#4657) 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, #3733 / ADR-0104), so a consumer that parses a context it was handed still meets the prescription. ADR-0049, #5050.
+ - Done when: No hook reads `ctx.session.roles`; caller gating uses `ctx.session.userId` / `ctx.session.isSystem`, and privilege comes from the security service (`permissions` / `positions` / posture). Constructing a HookContext session with `roles` fails `tsc` (the input type is `never`) and fails `HookContextSchema.parse` with the retirement prescription instead of being silently stripped. Nothing regresses at runtime: the key had no producer, so no decision anywhere ever saw a value in it.
- **`hook-register-empty-object-target-refused`** — `engine.registerHook(event, handler, { object: '' | [] | [''] }), and a scope whose `excludeObjects` cancels its `object` entirely` → name the object(s) — `object: 'account'` / `object: ['account', 'contact']` — or, for a global hook, `object: '*'` or no `object` key at all; for a cancelled scope, widen `object` or drop the overlapping names from `excludeObjects`
- Why not automatic: #4281 ruled that an empty hook target is not "no target" and closed the shape at the two METADATA doors — `HookSchema.object`'s refine and `hook-binder.ts`'s `normalizeObjects`. `engine.registerHook`, the CODE door, goes through neither, so all three spellings still registered, each producing a defect the author did not write: `''` is FALSY, so the allow face was skipped entirely and the entry became a GLOBAL hook (#4281's headline failure mode — blank intent taking the broadest possible blast radius); `[]` and `['']` are truthy but admit no object name, so the entry could never fire. #5928 then added the `excludeObjects` face, which brought a fourth shape reached by arithmetic rather than by one bad name: an `object` list every member of which is also excluded admits nothing, so that entry can never fire either. All four are ADR-0078 silently-inert declarations, and all four are now refused at REGISTRATION.
@@ -446,18 +389,75 @@ 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. #6573, #4281, #4001, #5928, 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.
-- **`engine-find-formula-order-by-refused`** — `engine.find(object, { orderBy }) and engine.findOne(object, { orderBy }) naming a `formula` field — the direct engine path, not the REST ingress` → denormalise the value onto the object (a stored field, written when the source changes) and sort by that — the same remedy the REST ingress has prescribed since #6924 / #6994; a `summary` field is unaffected and still sorts, because it gets a real maintained column
- - Why not automatic: #4226 / #4256 / #6994 closed the SORT axis at the REST ingress (`assertSortFieldsExist`, `400 INVALID_SORT`), which covers everything reaching `findData`: the list route, `POST /data/:object/query`, the export route and the RPC dispatcher. A caller reaching `engine.find()` / `engine.findOne()` DIRECTLY passed through none of it, and a `formula` ORDER BY there was dropped in silence. Measured on a real driver: `asc` and `desc` came back BYTE-IDENTICAL, in insertion order, under a success, with the rows carrying the very values they were asked to be ordered by. No column exists to order by (a formula is computed on read, so no driver materialises one), so the ORDER BY reached the driver, found nothing, and the unknown-column backstop returned the rows unordered.
-
-Ruled 2026-08-10 on #7095: an ORDER BY the engine cannot apply is a 4xx with guidance prose at the public boundary, never a silent drop — the same direction as the analytics dataset refusal envelope and the #6924 sort-hint prescription. The engine's documented internal-caller tolerance (`assertProjectionFieldsExist`'s docblock) was to survive only behind a pinned internal path, and only if a MEASURED internal call site relied on it. The #7095 sweep of every in-tree `orderBy` reaching the engine directly — hooks, flows, reports, queue/job adapters, sharing, metadata loaders, expand sub-reads — found NONE: every hardcoded internal sort names a real stored column (`created_at`, `updated_at`, `version`, `priority`, `scheduled_for`, `started_at`, `next_run_at`, `recorded_at`, `id`), and no shipped object in the repo declares a `formula` field at all. So no internal path shipped, and there is no flag to opt back into the drop.
-
-This is a CODE-path API, not stored metadata, so — like `hook-register-empty-object-target-refused` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and the ledger entry is the notification channel. No mechanical rewrite exists in either direction: the platform cannot invent the stored column the remedy prescribes, and it must not sort post-hoc instead — `driver.find` has already applied `limit` / `offset`, so re-sorting after the formulas are evaluated would reorder an ARBITRARY PAGE, which looks correct on small result sets and is wrong the moment pagination is involved.
-
-ONE AUTHOR-REACHABLE SURFACE reaches this indirectly and is why it is not purely a code-side note: a saved report's `query.orderBy` (`sys_saved_report`) is forwarded verbatim into `engine.find` by `plugin-reports`, bypassing the ingress gate. A report authored to sort by a formula field used to run and return rows in an arbitrary order; it now fails loudly, with the remedy in the message. One further path is deliberately NOT a refusal: a nested `expand` sort raises this refusal inside `expandRelatedRecords`, whose pre-existing graceful-degradation `catch` swallows every expand failure and retains the raw foreign keys — so that path moves from silent to OBSERVABLE (a warning naming the field and the fix) rather than refusing. Reversing that backstop is a separate decision on all expand failure modes. #7095, #6994, #6924, #4226, #4256, #3821, ADR-0112.
- - Done when: No `engine.find` / `engine.findOne` call site sorts by a `formula` field, and no saved report's `query.orderBy` names one — grep your report definitions for an `orderBy` field whose object declares it as a `formula`, and denormalise it onto a stored column written when the source changes. A `summary` / rollup field needs no action: it has a real maintained column and sorts correctly. Reads complete with no `INVALID_SORT` naming a formula field, and no "Failed to expand relationship field" warning whose error text names one.
+- **`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.
+ - 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.
+ - Done when: Every import request of yours that must NOT fire triggers sends `runAutomations: false` explicitly, rather than omitting the key and trusting the old declared default. The check is worth doing precisely where it looks unnecessary: if you build the body by parsing it through `ImportRequestSchema` (or the published JSON Schema) and then send the PARSED object, your bulk loads were running with automations OFF and will now run with them ON — that is the only class whose behaviour changes, and it changes toward what an unvalidated caller always got. ⚠️ Behaviour on the wire is deliberately UNCHANGED and should be verified as such: a body that omits `runAutomations` fired triggers before this change and fires them after, and `runAutomations: false` turns them off before and after. Nothing starts being refused — the route never validated this body against the schema and does not begin to. `dryRun` is unaffected and still runs NO automations whatever the flag says (#6037).
+- **`job-retry-policy-constraints-tightened`** — `job.retryPolicy.maxRetries (> 10) / job.retryPolicy.backoffMultiplier (< 1)` → maxRetries <= 10, and backoffMultiplier >= 1
+ - Why not automatic: The converged RetryPolicy (#4661) keeps the automation side's bounds, which the job side never had: `maxRetries` is capped at 10 and `backoffMultiplier` floored at 1. Neither has a lossless rewrite. Clamping `maxRetries: 20` to 10 would halve a retry budget its author chose, and a `backoffMultiplier` below 1 describes a delay that SHRINKS on each attempt — retrying a failing dependency ever faster, which is the opposite of backoff and was never a shape the engine meant to offer. Both now fail at parse time with the bound named, rather than being silently reinterpreted. Choosing the replacement count (or accepting the cap) is the author's call.
+ - Done when: Every job declaring `retryPolicy` parses: no `maxRetries` above 10 and no `backoffMultiplier` below 1 remain, and each adjusted value was re-chosen knowing a retry re-runs the handler with its writes and callouts. No job fails to register with the retry-policy bound prescription.
+- **`notification-list-cursor-retired`** — `api.listNotifications cursor — the key on BOTH halves of GET /api/v1/notifications (ListNotificationsRequestSchema and ListNotificationsResponseSchema) and the cursor argument of the client SDK call client.notifications.list(). The same entry covers the limit default: the request schema no longer declares default(20)` → a larger `limit` — the route answers the newest N notifications and has no page 2. There is no replacement for `cursor`, deliberately: nothing ever minted one, so no caller holds a value to carry over. Callers that looped on it were re-reading the first window and should read one window sized to what they display (the Console bell polls exactly this way). For the removed `limit` default, send the number you want explicitly if you were relying on 20 — omitting it takes the server window, which is 50 on the platform inbox and clamped into 1..200, and has been since before the declaration existed
+ - Why not automatic: One capability, both halves, never half-deleted (maintainer ruling 2026-08-07, Option A, ruled jointly with #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.
+- **`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` after the #4653 convergence) — 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 (#3950: an exported schema with no consumer is read as a capability). #4657. SUPERSEDED ON THE KERNEL SIDE by #4834 (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 #4834 criterion 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.
+- **`plugin-manifest-loading-retired`** — `manifest.loading (the whole block: strategy / preload / codeSplitting / dynamicImport / initialization / dependencyResolution / hotReload / caching / sandboxing / monitoring)` → nothing to re-declare — delete the key. Plugins are composed at boot: `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder` in `packages/core/src/plugin-order.ts`). For the isolation `loading.sandboxing` appeared to configure, use the plugin trust tier (`manifest.runtime`, ADR-0025 §3.6) and the manifest permission declarations, which are the surfaces the platform actually enforces
+ - Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-08-04 on #4914. The block declared a complete plugin loading policy and NOTHING read it. A bare-name scan of all three repos — objectstack, cloud (measured 2026-08-09) and objectui (measured at pickup), each with a control probe proving the scan saw the tree — put every hit inside `packages/spec` itself: this module's own declaration, its own unit tests, the `Manifest.loading` embed and the generated artifacts. `manifest.loading.*` had zero readers in `packages/core`, `packages/runtime` and `packages/metadata`. So the key parsed, entered the manifest, and changed nothing — #3950, at the scale of a whole block. What made it outrank ordinary inert-key cleanup is `sandboxing`: it declared process / vm / iframe / web-worker isolation, IPC transports and an `allowedServices` ACL, so an AI author (ADR-0033) reading that vocabulary concluded the platform isolates plugins, wrote the config, and received a clean parse and zero isolation. An inert security control is worse than an absent one because it is believed. Hot reload was additionally a TWO-SOURCE defect: the docs pointed at this dead `PluginHotReloadSchema` while the only implementation body, `HotReloadManager` (`packages/core/src/hot-reload.ts`), reads a different vocabulary — `HotReloadConfigSchema` in `plugin-lifecycle-advanced.zod.ts`. Ruling §2 converges on the surviving side: that schema is KEPT as the starting point for a future enforce decision (it has an implementation body but no runtime composes it yet), and enforcing it is deliberately a separate decision, not this retirement. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections. A package manifest is neither — `PLURAL_TO_SINGULAR` has no `packages` / `plugins` entry, so a manifest is not a stack collection member and a stored manifest row passes that seam through unchanged. A conversion would be a transform with no seam that ever runs.
+ - Done when: No `objectstack.plugin.json` and no stored package manifest carries a `loading` key. The enforced channel is the one place a manifest is parsed with an author present: `os plugin build` runs `ManifestSchema.safeParse` and exits non-zero, printing the tombstone prescription, so a manifest still declaring `loading` fails its build rather than shipping. TypeScript authors get it earlier still — `loading` is typed `never`, so assigning it is a `tsc` error. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ever read the block, so removing it removes no behaviour. A package ALREADY INSTALLED whose stored manifest carries `loading` keeps working — the registry's `validate()` is an explicit diagnostic and not a gate (it catches, logs `[metadata_spec_invalid]`, and registers the item anyway, deliberately, so bad metadata is never a data outage), so such a row degrades to one log line at registration rather than a boot failure. Clear it by deleting the key from the source manifest and reinstalling.
+- **`plugin-runtime-family-retired`** — `kernel.dynamicLoadRequest / kernel.dynamicUnloadRequest / kernel.dynamicPluginResult / kernel.pluginSource / kernel.dynamicPluginOperation` → (removed — there is no replacement shape, because there is no operation to describe. Plugins are composed at boot: `defineStack` registers them and the kernel runs register → init → start; the set is fixed until the process restarts. Delete the import and the value. Runtime plugin loading, if it is ever built, returns via the enforce route of ADR-0049 through a new ADR — loader first, vocabulary second)
+ - Why not automatic: The five schemas declared the "Dynamic Loading" capability — runtime load / unload / reload of plugins without a kernel restart, with sandboxing, integrity hashes, drain strategies and dependent-cascade policy — and NOTHING implemented it. A bare-name scan of objectstack, cloud and objectui found zero references outside this package's own declaration, its unit tests and the generated artifacts: no runtime ever received a `DynamicLoadRequest`, performed a load/unload, or produced a `DynamicPluginResult`. That is the ADR-0049 false-compliance shape at its most inviting to an AI author (ADR-0033), who reads `DynamicLoadRequestSchema` in the published IDE bundle as proof the platform hot-loads plugins and constructs a request that parses clean and is received by nobody (#3950: an exported schema with no consumer is read as a capability). The #3896 follow-up removed this module's discovery/sandbox config island and left these five in place explicitly — "operation contracts, not security promises; the enforce-or-remove call on them is a design decision rather than a correction" — but that suspension lived only in a changeset paragraph with no issue carrying it. #4834 is that decision, answered REMOVE. `experimental` was considered and rejected: it is only `.describe()` prose and cannot stop an import, the weakest of the three ADR-0049 channels. None of the five is stored metadata — they are root request/result payload shapes embedded in no parent schema and parsed against no metadata document — so no `sys_metadata` row can carry one and there is no source for the D2 chain to rewrite; this entry is the D3 record. The removal also subsumes the kernel half of `plugin-activation-events-retired` (#4657): that tombstone goes with the shape that carried it. ADR-0049, #4834.
+ - Done when: No code imports `DynamicLoadRequestSchema`, `DynamicUnloadRequestSchema`, `DynamicPluginResultSchema`, `PluginSourceSchema`, `DynamicPluginOperationSchema` or any of their type aliases (`DynamicLoadRequest`, `DynamicUnloadRequest`, `DynamicPluginResult`, `PluginSource`, `DynamicPluginOperation`, `DynamicLoadRequestInput`, `DynamicUnloadRequestInput`) from `@objectstack/spec` or `@objectstack/spec/kernel` — every one is TS2305 after upgrade, on every public entry (pinned by symbol identity in `plugin-runtime-retirement.test.ts`). Nothing regresses at runtime, because nothing called anything: a caller that believed it was hot-loading a plugin was already only building an object. Boot-time composition through `defineStack` is unchanged.
+- **`query-array-string-agg-retired`** — `data.query.aggregations[].function ('array_agg' / 'string_agg')` → an ordinary `fields` query, shaped in the caller — or a stored field that materialises the roll-up. For a deduplicated COUNT the live spelling is unchanged: `count_distinct` stays declared
+ - Why not automatic: The stored half of this retirement is a conversion (`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. `QueryAST` is never stored in stack metadata — it is the client SDK builder's output and the `POST /data/:object/query` body — so there is no source for the chain to rewrite and callers move their own queries. Both values were declared-but-unlowered on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller following the schema against a SQL datasource got a refusal, not an array. They did run on `driver-mongodb` and on the engine's in-memory fallback, which is what makes this the one narrowing in the batch that removes reachable behaviour: an aggregation that worked on one backend and failed on another is exactly the unpredictability the ruling ended, and #5499 has both of those backends frozen. `count_distinct` was deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049's enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049, #6188.
+ - Done when: No caller sends `array_agg` or `string_agg` in `aggregations[].function`; list-style roll-ups are assembled by the caller from an ordinary `fields` query, or materialised as a stored field. A query still carrying either value fails to parse with the removal prescription naming it, and authoring it is a `tsc` error at the call site; `count_distinct` continues to parse and is unaffected.
+- **`query-cursor-retired`** — `data.query.cursor` → a `where` predicate on the sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` (the documented manual-keyset pattern)
+ - Why not automatic: The `cursor` key promised keyset pagination and no driver implemented it: the cursor was accepted and ignored, so every page came back identical — a caller looping "until hasMore is false" never terminates. Worse than inert, it had a shipped public producer (`QueryBuilder.cursor()`, removed with the key). The caller-built `Record` shape also leaks sort/storage detail and squats on the reserved REST parameter set; a first-class cursor, if ever designed, will be a response-minted opaque token — a different API, so keeping this one preserved a wrong design rather than a roadmap. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286.
+ - Done when: No caller sends `cursor` and no SDK call site uses `QueryBuilder.cursor()`; deep pagination expresses the keyset as a `where` predicate on the sort key. A query still carrying `cursor` fails to parse with the removal prescription, and authoring it is a `tsc` error.
+- **`query-distinct-retired`** — `data.query.distinct` → `groupBy` for unique combinations; the `count_distinct` aggregation for deduplicated counts; the SQL/memory drivers' `distinct(object, field)` door for one column's values
+ - Why not automatic: The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND worse pagination metadata, and a side effect that "confirmed" the flag was doing something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with the key). The count suppression is deleted in the same change — `total` is truthful for those queries again. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286.
+ - Done when: No caller sends `distinct` and no SDK call site uses `QueryBuilder.distinct()`; deduplication goes through `groupBy` / `count_distinct` / the drivers' `distinct()` door. A query still carrying the key fails to parse with the removal prescription, and the REST list response reports a real `total` for queries that used to send it.
+- **`query-field-node-object-form-retired`** — `data.query.fields` → expand (`expand: { owner: { object: 'user', fields: ['name'] } }`), or a dotted path for a single related column (`fields: ['owner.name']`)
+ - Why not automatic: The `FieldNode` union declared a nested-select object form `{ field, fields, alias }` that was inert end to end: no producer emitted it, and no consumer read `.fields` or `.alias` — objectql's formula projection and known-field filters, driver-sql's `select()` and driver-memory's projection all treat the list as `string[]`, driver-mongodb keyed its projection with the entry itself, and the REST ingress stringified it. Nested selection is `expand`, which the engine resolves via batch `$in` queries. This is a REQUEST surface — `QueryAST` is never stored in stack metadata (no view, dataset or report authors one), so there is no source for the chain to rewrite: the schema narrows to `z.string()` and callers move their own select lists. ADR-0049 / ADR-0078, #4196.
+ - Done when: No caller puts an object in `fields[]`; related records are read through `expand` and single related columns through dotted paths. A `fields` entry that is not a string fails to parse with the removal prescription, and the list/query/export routes answer 400 INVALID_FIELD naming the retired form instead of the field `"[object Object]"`.
+- **`query-joins-retired`** — `data.query.joins` → expand (`expand: { owner: { object: 'user', fields: ['name'] } }`), or a dotted `fields` path for a single related column (`fields: ['owner.name']`)
+ - Why not automatic: The `joins` array was declared-but-inert: no engine or driver read `query.joins` anywhere on the query path, so a query carrying it behaved exactly as if the key were absent — while the name squatted on the reserved REST parameter set. Related-record retrieval already has a live spelling (`expand`, resolved by the engine via batch `$in` queries), so the removal deletes the second, broken spelling rather than the capability, and the orphaned `JoinNode`/`JoinType`/`JoinStrategy` cluster goes with the key. A REQUEST surface — `QueryAST` is never stored in stack metadata — so there is no source for the chain to rewrite; callers move their own queries. ADR-0049 / ADR-0078, #4286.
+ - Done when: No caller sends `joins`; related records are read through `expand` and single related columns through dotted `fields` paths. A query that still carries `joins` fails to parse with the removal prescription (even as an empty array), and authoring it is a `tsc` error at the call site.
+- **`query-window-functions-retired`** — `data.query.windowFunctions` → `aggregations` + `groupBy` for request-level analytics; `SqlDriver.findWithWindowFunctions(object, query)` for embedders on a SQL datasource
+ - Why not automatic: The `windowFunctions` array was declared-but-inert on the query path: `find()` never applied a window function, so every OVER clause a caller declared was silently dropped. The capability only ever ran behind `SqlDriver.findWithWindowFunctions()`, a driver-level door that is not on the `IDataDriver` contract and whose flat input shape (`{ function, alias, partitionBy?, orderBy? }`) the spec vocabulary never matched — `WindowFunctionNodeSchema` declared `field`/`over`/`frame` members the door never read, so that cluster is removed with the key rather than left as a false affordance. A REQUEST surface, never stored; no source to rewrite. ADR-0049 / ADR-0078, #4286.
+ - Done when: No caller sends `windowFunctions` in a query; request-level analytics use `aggregations` + `groupBy`, and embedders needing OVER-clause SQL call the SQL driver's `findWithWindowFunctions` door directly. A query that still carries the key fails to parse with the removal prescription naming that door.
+- **`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.
+ - 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.
- **`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.
- 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.
+- **`spec-type-alias-input-suffix-retired`** — `type alias: the 102 XInput names of @objectstack/spec (ConnectorInput, AppInput, PageInput, ActionInput, ServiceObjectInput, ExecutionContextInput, TaskInput, … — 52 files across api/ automation/ data/ identity/ integration/ kernel/ security/ system/ ui/)` → the BARE name. ADR-0122 phase 2 moved the author state onto `X`, which makes `XInput` a character-for-character synonym of it — the permanent synonym D3 forbids. Drop the `Input` suffix: `ConnectorInput` -> `Connector`. Symmetrically, a consumer that held a PARSE RESULT under the bare name moves to `XParsed`, which phase 1 (16.x) already declared for every schema whose two shapes differ, so the target name has existed for a release. NINE `*Input` names are NOT retired and need no edit: `ExpressionInput`, `CronExpressionInput`, `TemplateExpressionInput` and `PredicateInput` are the bare aliases of their own `…InputSchema`, and `FormFieldInput`, `QueryInput`, `FieldInput`, `ObjectStackDefinitionInput` and `NavigationItemInput` are composed (recursive or `Partial`-shaped) types no bare alias denotes.
+ - Why not automatic: This entry exists for the reason `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) exist, and it is the same disposition: the surface is a TYPESCRIPT NAME, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — an `XInput` alias never had a carrier key, never emitted a def, and no `.parse()` ever saw it. Measured and verified rather than assumed: `json-schema/`, `json-schema.manifest/` and `authorable-surface/` are BYTE-IDENTICAL across this change, because those generators enumerate runtime `z.ZodType` exports and never read a type alias. So nothing left the published metadata surface and RETIRED_DEFS_BY_MAJOR is deliberately untouched — an entry there would falsely claim the metadata contract shrank. The enforced channel is tsc: the name is gone, so every consumer gets TS2724/TS2305 naming the import. That is loud but MUTE about the replacement — a compile error says `ConnectorInput` does not exist, not that `Connector` now means what it meant. The generated upgrade guide is the only channel that carries the second half, which is precisely the #6048 gap ADR-0087 registration exists to close. ⚠️ Deliberately NOT registered alongside it: the 1384 bare aliases the same change FLIPPED from `z.infer` to `z.input`. Those names all still exist and still resolve; what moved is which of a schema's two shapes they denote, and only where the two differ (663 of 1384 — the rest are isomorphic and the flip is a no-op there, pinned as such). A consumer holding an authored literal is made MORE correct by it, silently; one holding a parse result gets a tsc error at the first defaulted key it reads. Registering that as a rename would misdescribe it — no name was retired — and the changeset carries its own FROM -> TO for it. ADR-0122 D8/D9, #6083 (PR #6279).
+ - Done when: No source imports a name ending `Input` from `@objectstack/spec` except the nine listed above: `rg "\b\w+Input\b" --type ts` over consumer code resolves only to those. A literal annotated with a bare spec type compiles while listing ONLY the keys the author means — `const c: Connector = { name, label, type }` type-checks, which it did not in 16.x — and a value read out of `XSchema.parse()` annotated with the bare name no longer compiles at the first defaulted key it reads (TS18048/TS2532), the signal that the annotation should be `XParsed`. `pnpm check:spec-parsed-alias` reports every bare alias as `z.input` and refuses both a bare `z.infer` alias and a reintroduced `XInput` synonym.
+- **`storage-service-list-retired`** — `contracts.IStorageService.list` → track the keys you wrote (sys_file / file-reference records, queryable through ObjectQL with real pagination) instead of enumerating the bucket — and where no such record exists, the cursor-shaped `list(prefix, { cursor, limit })` this entry reserved, restored in #6781
+ - Why not automatic: `list(prefix)` was an OPTIONAL contract method documented as "List files in a directory/prefix", and the two shipped adapters answered the same call with two different semantics — both of them silently incomplete. `LocalStorageAdapter.list` was a single-level `readdir`, so a nested key `a/b/c` was invisible under `list('a')` (only `a/b` came back), and a subdirectory that `stat` succeeded on was pushed into the result as a file, yielding a `StorageFileInfo` whose `size` is a directory inode and which cannot be downloaded at all. `S3StorageAdapter.list` was RECURSIVE (`ListObjectsV2` matches the whole key) and read neither `IsTruncated` nor `ContinuationToken`, so past 1000 objects the "all files" a caller received was the first page, with no signal. One contract method, two dialects, both quietly incomplete — and the first feature that genuinely needed to enumerate a prefix (backup, orphan sweep, migration audit) would have got two different answers on two deployments without an error on either. #5172 was nearly that feature: it planned to drive attachment reclamation off `list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one level down, and switched to queue-driven deferred work instead. Nothing consumed it afterwards: the only in-repo call site was the `SwappableStorageService` pass-through (which itself rejects when the active adapter has no `list`), and REST, CLI and the storage routes never called it. Remove was chosen over align-and-tighten (maintainer ruling, 2026-08-05, #5266): aligning would grow a conformance surface nobody walks, while a prefix listing that cannot paginate is the wrong signature to inherit — when a real caller needs enumeration it returns cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases (nested keys, directory entries, >1000 objects) proving both backends agree. This is a TS/API contract surface — a storage adapter is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription there would reach no one. The enforced channel is tsc, and it reports at the call site. Same disposition, and the same reason, as `data-driver-find-stream-retired` (#4484). ADR-0049 / ADR-0087, #5540 (analysis #5266).
+ - Done when: No code calls `storage.list(...)` on the `file-storage` service or on any `IStorageService` value. Code that needed "which files are under this prefix" reads the records it wrote — `sys_file` / file-reference rows carry the storage key and page deterministically through ObjectQL — rather than asking the bucket, which is also the only form that stays correct past 1000 objects and across both adapters. An adapter that still IMPLEMENTS `list` keeps compiling (an extra method is not an error on a class) and is simply unreachable through the contract, so deleting it is cleanup that can follow. The break is on the CALLER side: `storage.list(...)` no longer type-checks, and a PROXY typed against `IStorageService` that forwards to `inner.list` is exactly such a caller — the one in `@objectstack/service-storage` goes with the adapters (#5541). ⚠️ AMENDED 2026-08-09 (#6781, maintainer ruling on cloud#1203, option B): the RESERVED route in the paragraph above was taken. `list` exists again on the contract, cursor-shaped — `list(prefix, { cursor, limit })` returning `{ items, nextCursor }` — because cloud had two first-party callers this repo could not see when the measurement said "nothing calls it" (tenant attachment reclamation, marketplace snapshot GC). This does NOT un-retire anything and the acceptance criterion above is unchanged for what it actually governs: the single-argument `list(prefix): StorageFileInfo[]` is gone for good, a call written against it still fails to compile, and the two dialects it had are now pinned against each other in `storage-adapter-list.conformance.test.ts` rather than left to diverge. What changed for an upgrader is only the destination: prefer the records you wrote, and reach for the restored member when there are none.
+- **`ui-interaction-config-family-retired`** — `ui.touchInteraction / ui.gestureConfig / ui.dndConfig / ui.keyboardNavigationConfig / ui.componentAnimation / ui.motionConfig / ui.pageTransition / ui.offlineConfig (the whole export surface of ui/touch.zod.ts, ui/dnd.zod.ts, ui/keyboard.zod.ts, ui/animation.zod.ts and ui/offline.zod.ts — 32 defs, 64 exported names)` → (removed — there is no replacement key, because there was never a key. Touch targets, drag-and-drop, focus management, keyboard shortcuts and motion are RENDERER BUILT-IN behaviour: the component library decides them, not a per-page metadata author. Offline is a platform capability, and its vocabulary belongs on the sync engine that owns the queue, the conflict policy and the cache — none of which exists yet. Delete the import and the value. Whichever of these earns real product pull returns WITH its own vocabulary and its executor, the #4910 way, not by un-retiring a declaration)
+ - Why not automatic: Five `@objectstack/spec/ui` modules declared a full interaction-configuration vocabulary — 22 `z.object` sites across touch/gesture, drag-and-drop, focus/keyboard, animation/motion and offline/sync — and NOTHING in the protocol carried them. This is the ADR-0049 false-compliance shape in its most inviting form for an AI author (ADR-0033), and worse than the ordinary declared-but-unread defect: `authorable-surface.json` listed 109 keys under these defs and `content/docs/references/ui/{touch,dnd,keyboard,animation,offline}.mdx` rendered them as authoring tables, so the published documentation advertised a vocabulary with no carrier key anywhere. An author following `dnd.mdx` and writing a `dnd:` block onto a page component was rejected by `PageComponentSchema` for an unrecognized key — the docs and the schema disagreeing about the platform (Prime Directive #10). Three independent measurements, each with its controls passing in the same run: (1) no module under `packages/spec/src` imported any of the five except the `ui/index.ts` barrel, so no schema declared a carrier key; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` (25 roots, 4742 nodes) reached none of the 21 named object shapes, while `PageSchema`, `WebhookSchema` and `StateMachineSchema` all resolved `direct` and a synthetic carrier flipped all 21 — so unreachability was a fact about the graph, not a broken walker; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these modules' own unit tests. objectui holds TYPE re-exports and parity ratchets, never validators, and says so (#2561). The 2026-08-04 ruling weighed wiring a carrier key (option B) and rejected it: that is a feature with a renderer behind it, not ledger clean-up. It also weighed tightening the shapes to `strictObject` and rejected that explicitly — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave "a precisely validated dead slot, the more convincing lie" (#4583). Because there was no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, the same route 3 as #4834 (kernel plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ Not to be confused with #5021, which retired the THEME `animation` block — a different file, different defs, and that one did have a carrier key and therefore a tombstone. ADR-0049, #4988.
+ - Done when: No code imports any of the 64 retired names from `@objectstack/spec` or `@objectstack/spec/ui` — `TouchTargetConfig(Schema)`, `GestureType(Schema)`, `SwipeDirection(Schema)`, `SwipeGestureConfig(Schema)`, `PinchGestureConfig(Schema)`, `LongPressGestureConfig(Schema)`, `GestureConfig(Schema)`, `TouchInteraction(Schema)`, `TransitionPreset(Schema)`, `EasingFunction(Schema)`, `TransitionConfig(Schema)`, `AnimationTrigger(Schema)`, `ComponentAnimation(Schema)`, `PageTransition(Schema)`, `MotionConfig(Schema)`, `DragHandle(Schema)`, `DropEffect(Schema)`, `DragConstraint(Schema)`, `DropZone(Schema)`, `DragItem(Schema)`, `DndConfig(Schema)`, `FocusTrapConfig(Schema)`, `KeyboardShortcut(Schema)`, `FocusManagement(Schema)`, `KeyboardNavigationConfig(Schema)`, `OfflineStrategy(Schema)`, `ConflictResolution(Schema)`, `SyncConfig(Schema)`, `PersistStorage(Schema)`, `EvictionPolicy(Schema)`, `OfflineCacheConfig(Schema)`, `OfflineConfig(Schema)` — every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in `ui/interaction-config-retirement.test.ts`). No metadata document needs editing, because none could ever carry one of these blocks: a stack that parsed before parses byte-for-byte the same after. If you consumed the bare `ConflictResolution` from `@objectstack/spec/ui` as a TYPE for your own offline code, declare that union locally — it is your client's policy, not the platform's. `@objectstack/spec/integration`'s `ConnectorConflictResolution` (connector sync) and `@objectstack/spec/api`'s `ConflictResolutionStrategy` (route merge policy) are different concepts and are untouched.
+- **`ui-notification-action-embed-config-retired`** — `ui.notificationAction / ui.embedConfig` → (removed — there is no replacement shape, because there was never a key to write either into. Delete the import and the value. Notification presentation is still described by the surviving `NotificationType` / `NotificationSeverity` / `NotificationPosition` vocabulary; public access to a form is granted by the LIVE `FormView.sharing` block (`SharingConfig`), which is untouched. Notification action buttons as metadata, and iframe embedding, return via the enforce route of ADR-0049 through a new ADR — carrier key and renderer first, vocabulary second)
+ - Why not automatic: Both shapes were published `@objectstack/spec/ui` vocabulary with NO AUTHORING DOOR. #4001 批 14 measured them three ways on 2026-08-03 and this retirement re-ran all three against `origin/main` before removing anything, each with a positive control that passed in the same run: (1) CARRIER — no schema in `packages/spec/src` declared a key of either type (`ui/notification.zod`'s only non-test importer was the barrel; `ui/sharing.zod`'s were the barrel and `ui/view.zod.ts`, which names its SIBLING `SharingConfigSchema`), measured by resolving specifiers rather than substring-matching, because the repo holds two `sharing.zod` modules and a substring test miscredits `stack.zod.ts` to the UI one; (2) REACHABILITY — a BFS from the 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema`, over `build-schemas.ts`'s own walk including its derived-clone bridge, never reached either, while `Page` / `Action` / `DashboardWidget` / `Webhook` and `SharingConfig` itself all resolved `root-graph` in the same run and an injected synthetic carrier flipped both; (3) PARSE — zero `.parse()` in objectstack, cloud or objectui outside their own unit tests. So nobody could author one and nothing ever validated one: the #3950 shape, an exported schema with no consumer read as a capability, and the ADR-0033 trap where an AI author takes `EmbedConfigSchema` in the published bundle as proof the platform serves iframes. Neither is stored metadata and neither has a carrier, so no `sys_metadata` row can hold one and there is no source for the D2 chain to rewrite; this entry is the D3 record. 批 14 deliberately did NOT close them with `.strict()` — strictness is a property of a PARSE, and closing a shape nothing parses buys only "a precisely-validated dead slot, the more convincing lie" (#4583) — and filed the disposition as #5015, ruled REMOVE on 2026-08-04. Each was orphaned by an earlier retirement one level up: `NotificationAction` lost its wrappers at #4610 (`NotificationSchema` / `NotificationConfigSchema`, the #4535 C3 dual-source cleanup — that retirement's published "zero consumers" evidence was later falsified for objectui and is corrected on `ui/notification.zod`'s tombstone; the removal itself stands, #5781), and `EmbedConfig` lost its key at 17.0.0 when the 2026-06 liveness audit retired `App.embed` (no iframe route ever read it) — that key still stands as a `retiredKey()` tombstone in `app.zod.ts`, so an author who wrote the KEY already meets a prescription; this removes the value shape that outlived it. ⚠️ The retirement is per SCHEMA, not per file: `ui/sharing.zod` KEEPS `SharingConfigSchema`, a live door carried by `FormViewSchema.sharing` and read by `rest-server.ts` to mount the anonymous form routes, and `ui/notification.zod` keeps its three presentation enums. objectui consumed `NotificationActionSchema.shape.variant` as a VOCABULARY (never a parse) to pin its own hand-written `NotificationActionButton` interface — which is exactly why "has a consumer" never meant "has an authoring door" here; that pin is adapted objectui-side when it refreshes this dependency. ADR-0049, #5015.
+ - Done when: No code imports `NotificationActionSchema`, `NotificationAction`, `EmbedConfigSchema` or `EmbedConfig` from `@objectstack/spec` or `@objectstack/spec/ui` — both are TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in `notification-embed-retirement.test.ts`). The same pin asserts the SURVIVORS in the same run, and that half is equally load-bearing: `NotificationTypeSchema` / `NotificationSeveritySchema` / `NotificationPositionSchema` and `SharingConfigSchema` must still be exported from `./ui`, and both modules must still load — a retirement that deleted either file would satisfy the absence half while destroying working surface. Nothing regresses at runtime, because nothing ever ran: no notification action was ever parsed from metadata and no iframe route ever read an embed config. Public form sharing is unaffected — `FormView.sharing` still gates the anonymous endpoints on `allowAnonymous` + `publicLink`.
+- **`ui-widget-i18n-family-retired`** — `ui.widgetManifest / ui.widgetLifecycle / ui.widgetEvent / ui.widgetProperty / ui.widgetSource / ui.i18nObject / ui.pluralRule / ui.numberFormat / ui.dateFormat / ui.localeConfig (the widget-registration vocabulary of ui/widget.zod.ts, and the five doorless shapes of ui/i18n.zod.ts — 10 defs, 26 exported names)` → (removed — there is no replacement key, because there was never a key. A custom field widget is still named the same way it always was: `field.widget` is a plain string naming a component the RENDERER has registered, and objectui's registry has always carried its own runtime manifest for that (`RuntimeWidgetManifest` / `RuntimeWidgetSource` in `@object-ui/types`, objectui#3161 / #4115), which models different keys and never derived from these. For localisation: write the default-language string on `label` / `description` — the framework generates the translation key at registration time from the naming convention — and put translations in translation files, which is the LIVE `system/translation.zod.ts` surface. Widget registration and locale formatting as authorable protocol metadata return via the ENFORCE route of ADR-0049 through a new ADR — the registry / loader / formatter first, the vocabulary second)
+ - Why not automatic: `ui/widget.zod.ts` published a complete widget-registration vocabulary — a manifest with lifecycle hooks, custom events, configurable properties and an npm/remote/inline implementation-source union — and `ui/i18n.zod.ts` published a structured-label, plural-rule and locale-formatting vocabulary. NOTHING in the protocol carried either. Three independent measurements, re-run on `origin/main` immediately before the removal with their controls passing in the SAME run: (1) no module under `packages/spec/src` imported `widget.zod` at all, and the only imports of `i18n.zod` anywhere name `I18nLabelSchema` / `AriaPropsSchema` (both KEPT), so no schema declared a carrier key — `field.widget` is a `z.string()` naming a registered component and has never referenced `WidgetManifest`; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` reached none of them, while `PageSchema` / `ObjectListViewSchema` resolved `direct` in the same run and a synthetic carrier flipped every one of them; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these files' own unit tests. `NumberFormat` / `DateFormat` DID have a carrier key (`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier was itself doorless, so the subtree was `no door` rather than `no gate` and goes whole — leaving the two leaves behind would strand exported schemas with no consumer (#3950). `I18nObjectSchema` was additionally superseded by its own file-neighbour: `I18nLabelSchema`'s documentation already says translation keys are generated at registration time and translations live in translation files, and the live translation surface is `system/translation.zod.ts`, which uses none of these shapes. The 2026-08-06 ruling weighed giving them a carrier (option B) and rejected it: that is a feature with a registry and a renderer behind it, not ledger clean-up. Tightening them to `strictObject` was rejected earlier and explicitly (#4001 批 16) — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave "a precisely validated dead slot, the more convincing lie" (#4583). With no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, route 3, the same shape as #4988 (the ui/ interaction config family), #4834 (kernel plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ `WidgetManifest.performance`'s own `retiredKey()` tombstone (#3896 close-out) is SUBSUMED here, the #4657/#4834 way: it goes with the shape that carried it, which is strictly stronger than the tombstone, because there is no longer a manifest to author the key INTO. ⚠️ One of the nine widget sites is deliberately NOT retired. `FieldWidgetPropsSchema` survives: it is a REACT PROPS CONTRACT rather than authorable metadata (it never appeared in `authorable-surface/` or `json-schema.manifest/` — its `onChange` is a `z.function()`), so "zero parse" is its design and not its defect, and it acquired a live cross-repo compile-time consumer one day before 批 16 measured: objectui PR #3289 (2026-08-03) renamed `@object-ui/fields`' validation slot onto the spec's `error` with no alias, the form renderer began producing it, and `packages/fields/src/__tests__/spec-symbol-batch7.test.ts` pins the shape against `import type { FieldWidgetProps } from '@objectstack/spec/ui'` as an intentional tripwire. Re-verified on objectui `origin/main` 2026-08-07. ADR-0049, #5055.
+ - Done when: No code imports `WidgetManifest(Schema|Parsed)`, `WidgetLifecycle(Schema)`, `WidgetEvent(Schema|Parsed)`, `WidgetProperty(Schema|Parsed)`, `WidgetSource(Schema|Parsed)`, `I18nObject(Schema)`, `PluralRule(Schema)`, `NumberFormat(Schema|Parsed)`, `DateFormat(Schema)` or `LocaleConfig(Schema|Parsed)` from `@objectstack/spec` or `@objectstack/spec/ui` — every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in `ui/widget-i18n-retirement.test.ts`). No metadata document needs editing, because none could ever carry one of these shapes: a stack that parsed before parses byte-for-byte the same after, and a `field.widget: "my_picker"` string is untouched. `FieldWidgetProps` / `FieldWidgetPropsSchema` / `FieldWidgetPropsParsed`, `I18nLabel(Schema)` and `AriaProps(Schema)` all still resolve on `@objectstack/spec/ui` and are asserted to. ⚠️ objectui needs a companion PR in the same window: `packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts` asserts the spec STILL owns `WidgetManifest` / `WidgetSource` (it is the "a workaround should not outlive its reason" half of the objectui#3169 tripwire, designed to go red exactly here), and `packages/types/src/widget.ts`'s "Renamed off the spec's `WidgetManifest` name" comments now point at names that no longer exist. Both are prescribed responses to this removal, not collateral damage.
+- **`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.
+- **`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.
+- **`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/spec/package.json b/packages/spec/package.json
index 8f0035c419..564ffe1a78 100644
--- a/packages/spec/package.json
+++ b/packages/spec/package.json
@@ -204,6 +204,8 @@
"check:exported-any": "tsx scripts/check-exported-any.ts --self-test && tsx scripts/check-exported-any.ts",
"check:dual-source-exports": "tsx scripts/check-dual-source-exports.ts --self-test && tsx scripts/check-dual-source-exports.ts",
"check:authorable-surface": "OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts --check",
+ "gen:migration-registry": "tsx scripts/build-migration-registry.ts",
+ "check:migration-registry": "tsx scripts/build-migration-registry.ts --self-test --check",
"gen:spec-changes": "tsx scripts/build-spec-changes.ts",
"check:spec-changes": "tsx scripts/build-spec-changes.ts --check",
"gen:upgrade-guide": "tsx scripts/build-upgrade-guide.ts",
diff --git a/packages/spec/scripts/build-migration-registry.ts b/packages/spec/scripts/build-migration-registry.ts
new file mode 100644
index 0000000000..5c2dfcecab
--- /dev/null
+++ b/packages/spec/scripts/build-migration-registry.ts
@@ -0,0 +1,478 @@
+#!/usr/bin/env tsx
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * build-migration-registry — concatenate `src/migrations/entries/` into the
+ * marked regions of `src/migrations/registry.ts` (#7297, the registry half of
+ * #6957's ruling).
+ *
+ * ## The failure this exists for
+ *
+ * `registry.ts` carried three hand-authored APPEND tables — each protocol
+ * step's `semantic` list, `RETIRED_KEYS_BY_MAJOR` and `RETIRED_DEFS_BY_MAJOR`.
+ * Every retirement card appended to the SAME tail line of the same two of them,
+ * so two cards in one window were a textual conflict by construction: measured
+ * on #6957 across 2026-08-06..10, `step17`'s semantic list and
+ * `RETIRED_KEYS_BY_MAJOR[17]` conflicted in **6 of 11** contended re-merge laps,
+ * for 613 hand-resolved lines of conflict markers in four days.
+ *
+ * Wall-clock was never the reason to fix it. **Both tables are consumed as
+ * SETS** — `registeredRetiredKeys()` (`scripts/build-schemas.ts`) folds them
+ * into a `Map`, and the chain walks the semantic list — so a conflict
+ * resolution that drops a sibling's entry produces **no error anywhere**. The
+ * retirement simply stops being declared: check (b) never sees the tombstone it
+ * was meant to answer, and the D3 prescription silently leaves the upgrade
+ * guide. Conflict-free-by-construction beats "resolve carefully" precisely when
+ * careless is undetectable.
+ *
+ * Maintainer ruling, 2026-08-10 (#6957), verbatim and untranslated:
+ *
+ * > **hybrid — batch now, split the append surface as the durable fix.**
+ * > Now: the PM landing relay batches same-window retirement cards into one PR …
+ * > Queued: implement per-card registry entry files concatenated by a generator,
+ * > the `.changeset/*.md` shape that already de-conflicted this repo's other
+ * > hottest file; `scripts/adr-anchors.json` is in the same fix space and may
+ * > ride the same mechanism. Option B (uncommitted build-time artifacts) is
+ * > rejected — the review diff of `spec-changes.json` / the upgrade guide is
+ * > worth the laps it costs.
+ *
+ * `scripts/adr-anchors/` (#7301) is the pilot this mirrors. It differs in one
+ * forced way, and the difference is worth naming because it bounds what this
+ * generator can promise:
+ *
+ * ## Why the concatenation is COMMITTED here and was in-memory there
+ *
+ * `scripts/adr-anchors.mjs` assembles its shards with `readdirSync` at read
+ * time, so no aggregate is checked in at all. That option does not exist for
+ * this registry: `MIGRATIONS_BY_MAJOR` / `RETIRED_*_BY_MAJOR` are re-exported
+ * from `@objectstack/spec`'s ROOT barrel and reach browser bundles through it.
+ * A `node:fs` read anywhere in that graph breaks every consumer that bundles
+ * the package — spec's `src/` is deliberately free of node builtins today. A
+ * bundled library needs a STATIC module graph, and a static graph over N
+ * entries needs one file that names all N.
+ *
+ * So the concatenation lands where it already was: inside `registry.ts`,
+ * between markers. That choice is what keeps this change's blast radius at
+ * zero for every existing consumer — `check-adr-0087-registration.mjs` still
+ * reads every `id:` line out of `LEDGER_SOURCES`, `composeSpecChanges` still
+ * folds the same objects, the public API is byte-identical, and the review diff
+ * the ruling explicitly paid for is exactly the diff it was before.
+ *
+ * ⚠️ **What this therefore does NOT claim.** The AUTHORED surface is now
+ * conflict-free by construction: two cards retiring different things write
+ * different files and merge clean; two cards editing the SAME entry write the
+ * same filename and git reports an add/add conflict, which is correct and must
+ * stay true. The generated region is a different story — two entries whose ids
+ * sort ADJACENTLY insert at the same anchor and still conflict textually. What
+ * changed there is the class of the failure, not its existence: the region is
+ * generated from files git merged as a set, `--check` fails if it does not
+ * match them, and the only correct resolution is `gen:migration-registry`. A
+ * resolution that DROPS an entry is now caught by this gate instead of landing
+ * silently, which is the defect #6957 measured.
+ *
+ * ## Two structural properties, both enforced below
+ *
+ * **1. The filename is a pure function of the entry id** (`shardNameFor`), the
+ * pilot's rule verbatim: it is what makes two cards touching different entries
+ * merge clean while two cards touching one entry collide in git rather than
+ * landing as two entries. {@link loadEntries} verifies the correspondence, so a
+ * hand-renamed file cannot quietly opt out of the second half.
+ *
+ * **2. Order is DERIVED, never declared.** Entries are concatenated sorted by
+ * id. There is deliberately **no index file** listing them: an index is itself
+ * a single append-only file every card must edit, which reintroduces the exact
+ * conflict being removed (PM decision on #6957). The directory listing is the
+ * index.
+ *
+ * Usage:
+ * pnpm --filter @objectstack/spec gen:migration-registry
+ * pnpm --filter @objectstack/spec check:migration-registry # --self-test && --check
+ */
+
+import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
+
+/** `src/migrations/registry.ts`, the file whose marked regions this writes. */
+export const REGISTRY_PATH = 'src/migrations/registry.ts';
+
+/** The per-entry source root. Repo-relative to the package. */
+export const ENTRIES_DIR = 'src/migrations/entries';
+
+/**
+ * Non-entry files an entry directory is allowed to carry. Explicit for the same
+ * reason the pilot's `NON_ENTRY_FILES` is: the directory IS the registry, so
+ * anything in it that is not an entry has to say so once, here, rather than be
+ * skipped by a pattern that also skips a typo.
+ */
+const NON_ENTRY_FILES = new Set(['README.md']);
+
+/** The three append surfaces, each a directory and a marked region per major. */
+export const KINDS = [
+ { kind: 'semantic', dir: 'semantic' },
+ { kind: 'retired-key', dir: 'retired-keys' },
+ { kind: 'retired-def', dir: 'retired-defs' },
+] as const;
+
+export type Kind = (typeof KINDS)[number]['kind'];
+
+/** One parsed entry file. */
+export interface Entry {
+ /** The entry's id — the semantic migration's `id`, or the retired key/def string. */
+ id: string;
+ /** The protocol major whose region it belongs to. */
+ major: number;
+ /** Leading `//` comment lines, verbatim and un-indented. */
+ comment: string[];
+ /** The literal the region carries: an object literal, or a quoted string. */
+ literal: string[];
+ /** Its filename, for error messages. */
+ file: string;
+}
+
+/**
+ * The shard filename for an entry — the whole naming rule, in one place.
+ *
+ * `17` + `data/AggregationNode:distinct` → `17.data__AggregationNode__distinct.ts`
+ *
+ * The major leads so a directory listing groups by region and sorts, within a
+ * region, exactly as the emitted order does. `/` and `:` both become `__`
+ * because neither is portable in a filename; the mapping only has to be a
+ * FUNCTION (one id → one name), never reversible — the id is written inside the
+ * file, and two ids that collided on a name would collide as an add/add
+ * conflict, which is the behaviour duplicates should get anyway.
+ */
+export function shardNameFor(major: number, id: string): string {
+ return `${major}.${id.replaceAll('/', '__').replaceAll(':', '__')}.ts`;
+}
+
+/**
+ * Parse one entry file — pure, so `--self-test` can drive every red path with
+ * synthetic text instead of an imitation of it.
+ *
+ * @param name the filename, used to derive the major and to report errors
+ * @param text the file contents
+ */
+export function parseEntry(
+ kind: Kind,
+ name: string,
+ text: string,
+): { entry?: Entry; error?: string } {
+ const where = `${ENTRIES_DIR}/${KINDS.find((k) => k.kind === kind)!.dir}/${name}`;
+
+ const majorMatch = /^(\d+)\./.exec(name);
+ if (!majorMatch) {
+ return {
+ error: `${where}: filename must start with the protocol major it registers under, e.g. \`17..ts\`.`,
+ };
+ }
+ const major = Number(majorMatch[1]);
+
+ const lines = text.split('\n');
+ const declAt = lines.findIndex((l) => l.startsWith('export const entry'));
+ if (declAt < 0) {
+ return { error: `${where}: no \`export const entry\` — every entry file declares exactly one.` };
+ }
+
+ // The leading comment is the unbroken run of `//` lines immediately above the
+ // declaration. Anything higher up (the copyright header, the type import) is
+ // file scaffolding and is deliberately NOT carried into the registry.
+ const comment: string[] = [];
+ for (let i = declAt - 1; i >= 0 && lines[i].startsWith('//'); i--) comment.unshift(lines[i]);
+
+ const eq = lines[declAt].indexOf('= ');
+ if (eq < 0) return { error: `${where}: \`export const entry\` has no initializer.` };
+ const literal = [lines[declAt].slice(eq + 2), ...lines.slice(declAt + 1)];
+ while (literal.length && literal[literal.length - 1].trim() === '') literal.pop();
+ const last = literal[literal.length - 1];
+ if (last === undefined || !last.endsWith(';')) {
+ return { error: `${where}: the entry initializer must end with \`;\` on its own last line.` };
+ }
+ literal[literal.length - 1] = last.slice(0, -1);
+
+ let id: string;
+ if (kind === 'semantic') {
+ const m = /^ {2}id: '([^']+)',$/m.exec(literal.join('\n'));
+ if (!m) return { error: `${where}: no \`id: '…'\` property — the id is the entry's identity.` };
+ id = m[1];
+ } else {
+ const m = /^'([^']+)'$/.exec(literal.join('\n'));
+ if (!m) {
+ return { error: `${where}: a ${kind} entry is a single quoted string, e.g. \`export const entry = 'ui/PageCardProps:body';\`.` };
+ }
+ id = m[1];
+ }
+
+ // The correspondence is what makes two cards touching the SAME entry collide
+ // on one filename while two cards touching different entries never share a
+ // file. Unenforced, a rename re-opens the losing half of that.
+ const expected = shardNameFor(major, id);
+ if (name !== expected) {
+ return {
+ error:
+ `${where}: filename does not match its entry id (${id}) — expected \`${expected}\`.\n` +
+ ' The name is derived from the id on purpose: it is what makes two cards editing the same\n' +
+ ' entry conflict in git instead of landing as two entries, and two cards editing different\n' +
+ ' entries never share a file at all (#7297).',
+ };
+ }
+
+ return { entry: { id, major, comment, literal, file: where } };
+}
+
+/** Every entry of one kind, sorted by id within each major. */
+export function loadEntries(kind: Kind, root = pkgRoot): { entries: Entry[]; errors: string[] } {
+ const dir = join(root, ENTRIES_DIR, KINDS.find((k) => k.kind === kind)!.dir);
+ const errors: string[] = [];
+ const entries: Entry[] = [];
+ let names: string[];
+ try {
+ names = readdirSync(dir);
+ } catch (e) {
+ return { entries: [], errors: [`cannot read ${ENTRIES_DIR}/${kind} — ${(e as Error).message}`] };
+ }
+ for (const name of [...names].sort()) {
+ if (NON_ENTRY_FILES.has(name)) continue;
+ if (!name.endsWith('.ts')) {
+ errors.push(
+ `${ENTRIES_DIR}/${kind}/${name}: not an entry. Entries are one TypeScript file per entry, named ` +
+ '`..ts`. Documentation must be named in NON_ENTRY_FILES ' +
+ 'in scripts/build-migration-registry.ts — the directory is the registry, so a non-entry says so once.',
+ );
+ continue;
+ }
+ const { entry, error } = parseEntry(kind, name, readFileSync(join(dir, name), 'utf8'));
+ if (error) errors.push(error);
+ else entries.push(entry!);
+ }
+ // Sorted by id. Duplicates cannot occur — the name is a function of the id and
+ // a directory cannot hold two files with one name — which is why nothing here
+ // checks for them.
+ entries.sort((a, b) => (a.major - b.major) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
+ return { entries, errors };
+}
+
+/** The emitted body of one marked region, indented for its site (4 spaces). */
+export function renderRegion(entries: Entry[]): string[] {
+ const out: string[] = [];
+ for (const e of entries) {
+ for (const c of e.comment) out.push(` ${c}`);
+ const lit = e.literal.map((l) => (l === '' ? '' : ` ${l}`));
+ lit[lit.length - 1] += ',';
+ out.push(...lit);
+ }
+ return out;
+}
+
+const closeMarker = (kind: Kind, major: number) => ` // `;
+
+/**
+ * Splice every marked region of `registry.ts`. Pure over the file text, so the
+ * self-test drives it without touching the tree.
+ *
+ * A region present in the file with no entries on disk is emitted EMPTY rather
+ * than removed — a major whose semantic residue is genuinely nil (protocol 14)
+ * is a real state, and the marker is where its first entry will land.
+ */
+export function renderRegistry(source: string, byKind: Record): { text: string; errors: string[] } {
+ const errors: string[] = [];
+ const lines = source.split('\n');
+ const out: string[] = [];
+ const seen = new Set();
+
+ for (let i = 0; i < lines.length; i++) {
+ const m = /^ {4}\/\/ $/.exec(lines[i]);
+ if (!m) {
+ out.push(lines[i]);
+ continue;
+ }
+ const kind = m[1] as Kind;
+ const major = Number(m[2]);
+ if (!KINDS.some((k) => k.kind === kind)) {
+ errors.push(`${REGISTRY_PATH}:${i + 1}: unknown generated region kind \`${kind}\`.`);
+ out.push(lines[i]);
+ continue;
+ }
+ const close = lines.indexOf(closeMarker(kind, major), i + 1);
+ if (close < 0) {
+ errors.push(`${REGISTRY_PATH}:${i + 1}: region \`${kind}:${major}\` is never closed.`);
+ out.push(lines[i]);
+ continue;
+ }
+ seen.add(`${kind}:${major}`);
+ out.push(lines[i], ...renderRegion(byKind[kind].filter((e) => e.major === major)), lines[close]);
+ i = close;
+ }
+
+ // An entry whose major has no region would be silently dropped — the exact
+ // class of failure this whole change exists to end, so it is a hard error.
+ for (const { kind } of KINDS) {
+ for (const e of byKind[kind]) {
+ if (!seen.has(`${kind}:${e.major}`)) {
+ errors.push(
+ `${e.file}: registers under protocol major ${e.major}, but ${REGISTRY_PATH} has no ` +
+ `\`\` region. Add the region (and, for a semantic entry, ` +
+ 'the migration step that owns it) before the entry can be concatenated.',
+ );
+ }
+ }
+ }
+
+ return { text: out.join('\n'), errors };
+}
+
+// ---------------------------------------------------------------------------
+// self-test — every red path above, driven with synthetic input
+// ---------------------------------------------------------------------------
+
+function selfTest(): string[] {
+ const failures: string[] = [];
+ const eq = (what: string, actual: unknown, expected: unknown) => {
+ const a = JSON.stringify(actual);
+ const b = JSON.stringify(expected);
+ if (a !== b) failures.push(`${what}: got ${a}, expected ${b}`);
+ };
+ const red = (what: string, r: { error?: string }, needle: string) => {
+ if (!r.error) failures.push(`${what}: expected an error, got none`);
+ else if (!r.error.includes(needle)) failures.push(`${what}: error did not mention "${needle}" — ${r.error}`);
+ };
+
+ eq('shardNameFor slashes+colons', shardNameFor(17, 'data/AggregationNode:distinct'), '17.data__AggregationNode__distinct.ts');
+ eq('shardNameFor plain id', shardNameFor(11, 'rls-sql-predicate-to-cel'), '11.rls-sql-predicate-to-cel.ts');
+
+ const key = "export const entry = 'ui/PageCardProps:body';\n";
+ const okKey = parseEntry('retired-key', '17.ui__PageCardProps__body.ts', `// note\n${key}`);
+ eq('retired-key id', okKey.entry?.id, 'ui/PageCardProps:body');
+ eq('retired-key comment', okKey.entry?.comment, ['// note']);
+ eq('retired-key literal', okKey.entry?.literal, ["'ui/PageCardProps:body'"]);
+
+ // The correspondence rule, in both directions.
+ red('renamed file', parseEntry('retired-key', '17.wrong-name.ts', key), 'filename does not match');
+ red('no major prefix', parseEntry('retired-key', 'ui__PageCardProps__body.ts', key), 'must start with the protocol major');
+ red('no declaration', parseEntry('retired-key', '17.x.ts', '// just a comment\n'), 'no `export const entry`');
+ red('unterminated', parseEntry('retired-key', '17.x.ts', "export const entry = 'x'\n"), 'must end with `;`');
+ red('not a string', parseEntry('retired-key', '17.x.ts', 'export const entry = { id: 1 };\n'), 'a single quoted string');
+ red('semantic without id', parseEntry('semantic', '17.x.ts', 'export const entry = {\n surface: 1,\n};\n'), "no `id: '…'`");
+
+ const sem = parseEntry(
+ 'semantic',
+ '17.a-b.ts',
+ "// lead\nexport const entry: SemanticMigration = {\n id: 'a-b',\n surface: 'x',\n};\n",
+ );
+ eq('semantic id', sem.entry?.id, 'a-b');
+ eq('semantic render', renderRegion([sem.entry!]), [
+ ' // lead',
+ ' {',
+ " id: 'a-b',",
+ " surface: 'x',",
+ ' },',
+ ]);
+
+ // Region splicing replaces only what is between the markers, and leaves a
+ // region with no entries empty rather than dropping the markers.
+ const doc = [
+ 'before',
+ ' // ',
+ ' // stale content that must be replaced',
+ ' // ',
+ ' // ',
+ ' // ',
+ 'after',
+ ].join('\n');
+ const rendered = renderRegistry(doc, {
+ semantic: [],
+ 'retired-key': [okKey.entry!],
+ 'retired-def': [],
+ });
+ eq('spliced', rendered.text.split('\n'), [
+ 'before',
+ ' // ',
+ ' // note',
+ " 'ui/PageCardProps:body',",
+ ' // ',
+ ' // ',
+ ' // ',
+ 'after',
+ ]);
+ eq('splice errors', rendered.errors, []);
+
+ // An entry with no region is a hard error, never a silent drop.
+ const orphan = renderRegistry(' // \n // ', {
+ semantic: [],
+ 'retired-key': [okKey.entry!],
+ 'retired-def': [],
+ });
+ if (!orphan.errors.some((e) => e.includes('has no'))) {
+ failures.push('orphan entry: expected a "no region" error, got ' + JSON.stringify(orphan.errors));
+ }
+
+ const unclosed = renderRegistry(' // ', {
+ semantic: [],
+ 'retired-key': [],
+ 'retired-def': [],
+ });
+ if (!unclosed.errors.some((e) => e.includes('never closed'))) {
+ failures.push('unclosed region: expected an error, got ' + JSON.stringify(unclosed.errors));
+ }
+
+ return failures;
+}
+
+// ---------------------------------------------------------------------------
+// main
+// ---------------------------------------------------------------------------
+
+const argv = process.argv.slice(2);
+
+if (argv.includes('--self-test')) {
+ const failures = selfTest();
+ if (failures.length) {
+ console.error('build-migration-registry --self-test FAILED:\n' + failures.map((f) => ` - ${f}`).join('\n'));
+ process.exit(1);
+ }
+ console.log('build-migration-registry --self-test: ok');
+ if (argv.length === 1) process.exit(0);
+}
+
+const check = argv.includes('--check');
+const registryPath = join(pkgRoot, REGISTRY_PATH);
+const source = readFileSync(registryPath, 'utf8');
+
+const byKind = {} as Record;
+const errors: string[] = [];
+for (const { kind } of KINDS) {
+ const loaded = loadEntries(kind);
+ byKind[kind] = loaded.entries;
+ errors.push(...loaded.errors);
+}
+
+const { text, errors: spliceErrors } = renderRegistry(source, byKind);
+errors.push(...spliceErrors);
+
+if (errors.length) {
+ console.error('build-migration-registry: the entry directories are not readable as a registry.\n');
+ for (const e of errors) console.error(` ✗ ${e}`);
+ process.exit(1);
+}
+
+const counts = KINDS.map(({ kind }) => `${byKind[kind].length} ${kind}`).join(', ');
+
+if (check) {
+ if (text !== source) {
+ console.error(
+ `✗ ${REGISTRY_PATH} is stale — its generated regions do not match ${ENTRIES_DIR}/.\n\n` +
+ ' Run: pnpm --filter @objectstack/spec gen:migration-registry\n\n' +
+ ' If you reached this after resolving a merge conflict INSIDE a marked region: do not\n' +
+ ' hand-merge it. The entry files are the source and git merged them as a set; the only\n' +
+ ' correct resolution is to regenerate. This gate exists because a resolution that drops\n' +
+ " one side's entry produces no other error anywhere (#6957).",
+ );
+ process.exit(1);
+ }
+ console.log(`✓ ${REGISTRY_PATH} is current (${counts})`);
+} else {
+ if (text !== source) writeFileSync(registryPath, text);
+ console.log(`✓ wrote ${REGISTRY_PATH} (${counts})`);
+}
diff --git a/packages/spec/scripts/check-generated.ts b/packages/spec/scripts/check-generated.ts
index 5ee6175ca3..2f7e6543e4 100644
--- a/packages/spec/scripts/check-generated.ts
+++ b/packages/spec/scripts/check-generated.ts
@@ -76,6 +76,16 @@ const GATED: ReadonlyArray<{
readsSchemaTree?: string;
ratchet?: true;
}> = [
+ // First, because it is UPSTREAM of the two below and the cheapest thing in the
+ // list: it reads a directory and splices text, with no schema build. Both
+ // `spec-changes.json` and the upgrade guide are projections of the migration
+ // registry (#7297), so a registry left stale after an entry file was added
+ // reports as THREE stale artifacts, of which only this one names the cause.
+ {
+ check: 'check:migration-registry',
+ gen: 'gen:migration-registry',
+ artifact: 'src/migrations/registry.ts — its generated regions, from src/migrations/entries/',
+ },
{ check: 'check:spec-changes', gen: 'gen:spec-changes', artifact: 'spec-changes.json' },
{ check: 'check:upgrade-guide', gen: 'gen:upgrade-guide', artifact: 'docs/protocol-upgrade-guide.md' },
{ check: 'check:skill-docs', gen: 'gen:skill-docs', artifact: 'skill docs (from SKILL.md frontmatter)' },
diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json
index c7a19279da..92921c6058 100644
--- a/packages/spec/spec-changes.json
+++ b/packages/spec/spec-changes.json
@@ -427,20 +427,6 @@
"toMajor": 12,
"rationale": "The global default flipped from `false` to `true` in protocol 12: anonymous requests to the `/data/*` CRUD and batch endpoints are rejected with 401 unless the stack opts out. Whether anonymous access was intentional (demo / kiosk) or an accident is a security judgment no transform can make."
},
- {
- "surface": "permissionSet.kind / permissionSet.isProfile",
- "replacement": "position-based assignment + permission-set grants (ADR-0090 D2)",
- "migrationId": "permission-set-profile-removed",
- "toMajor": 13,
- "rationale": "The Profile concept was removed: `isProfile` is gone from `PermissionSetSchema` and the `profile` metadata kind folded into `position`. Mapping a profile onto positions and permission-set grants is an authorization-design decision, not a rename."
- },
- {
- "surface": "position.parent / sharingRule recipient role_and_subordinates",
- "replacement": "business-unit tree + `unit_and_subordinates` (ADR-0090 D3)",
- "migrationId": "position-hierarchy-flattened",
- "toMajor": 13,
- "rationale": "Positions are flat in v2 — `parent` was removed and the `role_and_subordinates` recipient with it; hierarchy lives on the business-unit tree, which expands a DIFFERENT structure than the retired role tree. Re-homing an org hierarchy is a judgment call."
- },
{
"surface": "CEL/formula: current_user.roles",
"replacement": "current_user.positions",
@@ -455,6 +441,20 @@
"toMajor": 13,
"rationale": "The legacy `'full'` OWD alias implied full access (including transfer/ delete) — wider than any canonical OWD value, so it has no lossless target ('read'/'read_write' converted mechanically; this one did not). Choosing between `public_read_write` and explicit sharing rules is a security-posture decision."
},
+ {
+ "surface": "permissionSet.kind / permissionSet.isProfile",
+ "replacement": "position-based assignment + permission-set grants (ADR-0090 D2)",
+ "migrationId": "permission-set-profile-removed",
+ "toMajor": 13,
+ "rationale": "The Profile concept was removed: `isProfile` is gone from `PermissionSetSchema` and the `profile` metadata kind folded into `position`. Mapping a profile onto positions and permission-set grants is an authorization-design decision, not a rename."
+ },
+ {
+ "surface": "position.parent / sharingRule recipient role_and_subordinates",
+ "replacement": "business-unit tree + `unit_and_subordinates` (ADR-0090 D3)",
+ "migrationId": "position-hierarchy-flattened",
+ "toMajor": 13,
+ "rationale": "Positions are flat in v2 — `parent` was removed and the `role_and_subordinates` recipient with it; hierarchy lives on the business-unit tree, which expands a DIFFERENT structure than the retired role tree. Re-homing an org hierarchy is a judgment call."
+ },
{
"surface": "object.sharingModel (absent, custom object with owner field)",
"replacement": "an explicit `sharingModel` declaration",
@@ -477,25 +477,39 @@
"rationale": "The `.strict()` flip turns a previously silently-stripped unknown key into a parse error. There is no mapping target for an arbitrary unknown key — auto-deleting it would be exactly the silent data loss ADR-0078 bans — so each occurrence needs the author to decide: bind a `dataset` and select `dimensions`/`values`, move a renderer setting under `options`, or delete the dead key."
},
{
- "surface": "dashboard.widgets[].compareTo: { offset: '7d' | '1M' | … } (every duration except '1y')",
- "replacement": "compareTo: { kind: 'previousPeriod' } plus an explicit window on the widget's own `filter`",
- "migrationId": "dashboard-widget-compareto-offset",
+ "surface": "ActionDescriptor.isAsync (the descriptor an executor publishes via `registerNodeExecutor` / `defineActionDescriptor`)",
+ "replacement": "nothing to re-declare — delete the key. Suspension is `execute()` RETURNING `suspend: true`, and permission to suspend is `supportsPause: true` on the same descriptor (with the `resumeAuthority` its pauses need)",
+ "migrationId": "action-descriptor-is-async-retired",
"toMajor": 17,
- "rationale": "The widget declared three comparison arms; the analytics executor implements one shape, `{ kind, dimension? }`, with no `offset` concept in it at all. On the ADR-0021 dataset path — the spec's single author-facing analytics shape — `{ offset }` was forwarded verbatim into that contract and threw `compareTo requires a timeDimension \"undefined\"`, taking the widget down; the arm ever only ran on the legacy inline chart path (#5011). The conversion rewrites `{ offset: '1y' }`, which IS `previousYear` by definition. Every other duration has NO faithful target: `previousPeriod` shifts by the length of whatever window the widget's filter resolves to, which equals `7d` only when that window happens to be seven days long. Rewriting mechanically would silently change which rows the comparison column counts — a wrong number rather than a missing one, which is strictly worse and exactly the class this convergence exists to end. Re-stating the intended window is a judgment about the presentation, not a transform."
+ "rationale": "ADR-0049 enforce-or-remove. `isAsync` declared \"this action suspends the flow awaiting an external reply\" and NOTHING read it: a fresh three-repo measurement (#6748, re-run at pickup) found zero property reads across objectstack, objectui and cloud — every hit was the declaration itself, a generated baseline, one of five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. So declaring it never made a node suspend and omitting it never stopped one, which is the silently-inert declaration ADR-0049 exists to end. It was always a second, weaker spelling of the capability `supportsPause` states, and the two diverged in exactly the way a duplicated declaration does: `screen` declared both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling in #6667 — `AutomationEngine` now refuses a suspension whose type does not declare `supportsPause: true` — so the capability this key gestured at is now a real, enforced fact under one name. This one had no consumer to grow into and takes the remove leg. Why D3 semantic and not a D2 conversion: an ActionDescriptor is published from an executor's TypeScript, never stored in stack metadata — no stack, example or template carries the key — so there is no source for the chain to rewrite and `os migrate meta` cannot reach it. The schema tombstones it via `retiredKey()` and descriptor authors delete the key themselves; that rejection (a `tsc` error at the authoring site, and a parse error inside `defineActionDescriptor`) is the channel a third-party plugin author actually meets. The `EnhancedApiError.fieldErrors` disposition, one layer down."
},
{
- "surface": "job.retryPolicy.maxRetries (> 10) / job.retryPolicy.backoffMultiplier (< 1)",
- "replacement": "maxRetries <= 10, and backoffMultiplier >= 1",
- "migrationId": "job-retry-policy-constraints-tightened",
+ "surface": "automation.ActionDescriptor.resumeAuthority — an OMITTED value on a pausing node descriptor (supportsPause: true, or any executor whose execute() returns suspend: true)",
+ "replacement": "an explicit resumeAuthority: 'any' on the descriptor, for a pausing node whose pauses really are meant to be continued through the generic resume route (POST /automation/:name/runs/:runId/resume) — a screen-style collected-input pause, or a signal wait an external producer resumes. Declare 'service' instead if continuing is the tail of a decision your own service must authorize and record first. Either value is a one-line addition; only the silence changed meaning",
+ "migrationId": "action-descriptor-resume-authority-default-flip",
"toMajor": 17,
- "rationale": "The converged RetryPolicy (#4661) keeps the automation side's bounds, which the job side never had: `maxRetries` is capped at 10 and `backoffMultiplier` floored at 1. Neither has a lossless rewrite. Clamping `maxRetries: 20` to 10 would halve a retry budget its author chose, and a `backoffMultiplier` below 1 describes a delay that SHRINKS on each attempt — retrying a failing dependency ever faster, which is the opposite of backoff and was never a shape the engine meant to offer. Both now fail at parse time with the bound named, rather than being silently reinterpreted. Choosing the replacement count (or accepting the cap) is the author's call."
+ "rationale": "A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as protocol 12's `rest-requireauth-default-flip`, and it is registered here for the same reason: whether a given pause is genuinely open to the generic route is a trust judgment no transform can make. The #3801 resume gate keys on the SUSPENDED NODE, and `ActionDescriptor.resumeAuthority` used to default to `'any'`, so a pausing node type shipped raw-resumable unless its author remembered the field. It now resolves to `'service'` when absent: an unclaimed pause is refused on the generic route with `PERMISSION_DENIED` / 403 until its descriptor states who may continue it. #3823 is the incident that decided the direction — ADR-0044 pointed an approval's revise edge at a generic `wait`, `wait` is legitimately `'any'`, and the pause standing in a service-owned position inherited a fail-open value nobody chose; the demonstrated cost was an unaudited resubmit plus a destroyed remote run. The two possible mistakes are asymmetric, which is the whole argument: guessing `'any'` walks past a decision nothing recorded and is silent, while guessing `'service'` returns a refusal naming the missing field. ⚠️ The surface is a DESCRIPTOR FIELD set in plugin CODE, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) already carry. It differs from those in one way a reader should not have to infer: nothing is REMOVED, so tsc reports nothing at all — the field was already optional after step one and an omission still compiles. The enforced channels are all run-time: a registration warning naming the node type (once per type per engine), the refusal message on the resume itself, and `check:resume-authority-declared` for executors living in this repo. For a third-party plugin the generated upgrade guide is the only channel that arrives BEFORE a user hits a run that will not continue. In-tree the flip moves nothing: all six shipped pausing types (screen, wait, subflow, map, approval, approval_revise) declare their authority explicitly. ADR-0044 amendment (2026-07-28) and its 2026-08-08 landing section, ADR-0019 #3801 addendum, #5561."
},
{
- "surface": "flow.errorHandling.maxRetries (under strategy: 'retry')",
- "replacement": "an explicit count >= 1 (e.g. maxRetries: 3), or strategy: 'fail'",
- "migrationId": "flow-retry-max-retries-required",
+ "surface": "ui.actionSession.roles",
+ "replacement": "ui.actionSession.positions (an action body reads `ctx.session.positions`)",
+ "migrationId": "action-session-roles-to-positions",
"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": "The MIRROR-IMAGE sibling of `actor-user-roles-to-positions`, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright, #5050), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. #5613 ruled contract-first (maintainer, 2026-08-06: \"C skeleton + A semantics\"): phase 1 (#5697) declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it (#5779); the producer emits both for one deprecation window (#5613 runtime half), after which `roles` is removed on the path the v11 session-alias removal already walked (#3280 deprecated → #3290 removed). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087, #5613 / #5779."
+ },
+ {
+ "surface": "action body / AI route: ctx.user.roles (req.user.roles)",
+ "replacement": "ctx.user.positions (an AI route handler reads `req.user.positions`) — the same array, under the one spelling ADR-0090 D3 sanctions",
+ "migrationId": "actor-user-roles-to-positions",
+ "toMajor": 17,
+ "rationale": "The THIRD face of the ADR-0090 `roles` → `positions` rename, and the only one whose surface the spec never declared. `ActorUser` (`packages/runtime/src/security/actor-user.ts`) is the ONE producer of the `user` envelope handed to an action body as `ctx.user` and to an AI route handler as `req.user`; it declared `positions` and `roles` side by side and filled them from a SINGLE assignment (`roles: core.positions`), so the two keys were verbatim identical on every dispatch — a second spelling of the vocabulary ADR-0090 D3 reserves and bans, published straight into author-written code. The maintainer ruled it closed IMMEDIATELY (2026-08-06 14:49Z, #6011): no deprecation window, no dual-emit, the alias simply gone in 17 (PR #6048). ⚠️ Do not read this entry across to its sibling `action-session-roles-to-positions`: `action-session-roles-to-positions` governs `ctx.session`, a DIFFERENT object reached through the same `ctx`, and that one KEEPS its one-window dual-emit (#5613). Same word, same dispatch, two faces, two schedules — `ctx.user.roles` is absent in 17 while `ctx.session.roles` still answers for the length of its window. What makes this entry different in KIND from both session-side siblings: `ctx.user` has no spec schema and never had one. It is a runtime TS interface, so unlike `HookContext.session.roles` (tombstoned on a deliberately non-strict `HookContextSchema`, #5050) and unlike `ActionSessionSchema` (declared contract-first at #5697 precisely so its key could be renamed), there is no schema key here to tombstone and no `retiredKey()` prescription that could reach anybody — nothing ever ran an `ActorUser` through a `.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it reports at the READ site inside the author's own body; for an untyped or sandboxed body there is no enforced channel at all, which is exactly why this ledger entry has to exist — `spec-changes.json` and the generated upgrade guide are the ONLY way such a reader learns of the rename. It is the `findStream` (#4484) / `IStorageService.list` (#5540) disposition — a TS/API contract, no stored source, no tombstone, tsc at the call site — applied to a surface that lives one layer further out than either: those two are at least DECLARED in `packages/spec/src/contracts`, this one only in `packages/runtime`. Why it is a D3 semantic TODO and not a D2 conversion, on the same two independent grounds as its session sibling: FIRST, there is no source to convert — an `ActorUser` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key (the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape). SECOND, the only place the key is ever SPELLED is inside an action body or an AI route handler: author-written JS/TS, or a sandboxed script. A declarative transform cannot safely rewrite an identifier inside free-form code — the same reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. The removal's hard precondition was met before it landed, and the result is recorded here because the ledger is where an upgrading consumer meets it: the declaration's own comment claimed the alias was \"kept for the REST/AI shapes\", and that claim was DISPROVEN face by face against `origin/main` — repo-wide `user.roles` was 4 hits, all of them in the pins PR #6048 flipped; the four `ActorUser` construction sites build server-side envelopes that never enter a response body; objectui's `.roles` reads belong to two unrelated producers (the better-auth session, and the `/auth/me/permissions` payload). The `cloud` repo was NOT reachable in that session and is the one consumer face left unverified — this entry, and the changeset's FROM/TO prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087, #6011 (PR #6048)."
+ },
+ {
+ "surface": "data.query.aggregations[].distinct",
+ "replacement": "the `count_distinct` aggregation FUNCTION for a deduplicated count — the one deduplicating spelling every face computes, lowered to `COUNT(DISTINCT field)` on both SQL faces since #6409. `SUM(DISTINCT …)` / `AVG(DISTINCT …)` get no replacement: no backend ever computed them here, and a per-row measure that needs deduplicating before summing is a modelling problem to fix in the data",
+ "migrationId": "aggregation-node-distinct-retired",
+ "toMajor": 17,
+ "rationale": "A DIVERGENCE, not an inert declaration — which is why it outlived the #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 frozen under #5499 — 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."
},
{
"surface": "api.analyticsQueryRequest.query",
@@ -504,13 +518,6 @@
"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."
},
- {
- "surface": "api.enhancedApiError.fieldErrors",
- "replacement": "fields",
- "migrationId": "enhanced-api-error-field-errors-renamed",
- "toMajor": 17,
- "rationale": "The wire has always carried `fields` — the validators, import coercion, validation-failure.ts, @objectstack/client and the console's field-error extractor all say `fields`, and nothing ever emitted `fieldErrors`, so a reader keying on it was reading a field no server sent (ADR-0078's silently-inert declaration, on the error envelope). This is a RESPONSE surface: no stack, example or template carries the key, so there is no source for the chain to rewrite — the schema tombstones it via retiredKey() and consumers move their read themselves. ADR-0114 D4, #3977."
- },
{
"surface": "api.analyticsQueryRequest.format",
"replacement": "(removed — responses are always the JSON envelope; use the export surface for CSV/XLSX)",
@@ -519,11 +526,11 @@
"rationale": "The `format` key was declared but never implemented (declared ≠ enforced): every response is the JSON envelope regardless of the requested value, so there is no behaviour to preserve and nothing stored to rewrite."
},
{
- "surface": "data.query.fields",
- "replacement": "expand (`expand: { owner: { object: 'user', fields: ['name'] } }`), or a dotted path for a single related column (`fields: ['owner.name']`)",
- "migrationId": "query-field-node-object-form-retired",
+ "surface": "PUT /api/v1/meta/api/{name} (runtime-authored `api` endpoints, draft and active alike)",
+ "replacement": "Declare the endpoint as a stack artifact (`**/*.api.ts`, or `defineStack({ apis })`) and ship it through `publishPackage`",
+ "migrationId": "api-runtime-create-withdrawn",
"toMajor": 17,
- "rationale": "The `FieldNode` union declared a nested-select object form `{ field, fields, alias }` that was inert end to end: no producer emitted it, and no consumer read `.fields` or `.alias` — objectql's formula projection and known-field filters, driver-sql's `select()` and driver-memory's projection all treat the list as `string[]`, driver-mongodb keyed its projection with the entry itself, and the REST ingress stringified it. Nested selection is `expand`, which the engine resolves via batch `$in` queries. This is a REQUEST surface — `QueryAST` is never stored in stack metadata (no view, dataset or report authors one), so there is no source for the chain to rewrite: the schema narrows to `z.string()` and callers move their own select lists. ADR-0049 / ADR-0078, #4196."
+ "rationale": "The `api` registry entry declared `allowRuntimeCreate: true` and the runtime never honoured it. Measured on a real showcase boot (#5488): `PUT /api/v1/meta/api/e8_backdoor` answered 200 with `{\"success\":true,…,\"message\":\"Saved …\"}`, and the declared route then answered 404 forever — with NO `[EndpointMatcher] … EXCLUDED` line, because the endpoint was never in the index to be excluded from. The serving criterion belongs to `IMetadataService.matchEndpoint` -> `EndpointMatcher` -> `MetadataManager.listForIndex('api')`, which reads the manager's registry plus its registered loaders (`[\"filesystem\",\"memory\"]` on dev/serve); a runtime write lands in `sys_metadata`, which is in neither. A declared capability the runtime does not honour is ADR-0049 false compliance, and a write that answers \"Saved\" and then 404s forever is its most dangerous shape for the AI authors ADR-0033 targets. The maintainer ruled REMOVE on 2026-08-07 rather than converge the read path, because making the matcher read `sys_metadata` re-opens cache, invalidation, tenancy and the ADR-0110 D3 miss-vs-outage distinction on a new read path, and there is no business pull for Studio-authored endpoints today (zero `.api.*` artifacts author them at runtime; showcase uses the artifact route, #5040 E8 LIVE). There is NO D2 conversion, for the reason this list exists: nothing in an authored source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an authorable one, and the artifact route it points authors toward is untouched — a `**/*.api.ts` file valid before this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, so it is one semantic TODO for operators and Studio callers rather than a stack conversion — the same disposition `BatchOptions.validateOnly` (#4052) takes. Consequently `gateApiDraftsForPublish` (PR #5279) is retired with it: it gated a promotion into a state the matcher can never read, and with the inlet closed no `api` draft can exist for it to judge. Re-entry is recorded in the ruling: if #2657 Part B promotes `apis` to a registered type WITH A REAL CONSUMPTION PATH, the flag flips back then — implementation first, declaration second. ADR-0049 / ADR-0121, #5488 (subsumes #5311)."
},
{
"surface": "api.batchOptions.validateOnly",
@@ -540,53 +547,11 @@
"rationale": "The rows the three bulk-write endpoints emitted had drifted from the schema that declared them: `BatchOperationResultSchema`, the client SDK's exported `BatchOperationResult` type and the reference docs all said `errors: ApiError[]` / `data` / `index`, while the wire carried `error: string` / `record` and never sent `index` at all. A TypeScript consumer written against the published type compiled, validated and read `undefined` at runtime — the declared-but-not-delivered shape this registry exists to close, on the response envelope (ADR-0119 D4 deferred the reconciliation off a bug fix; this is that tracked change, shipped in the 17 major window). The ADR-0119/#4620 rollback marking is structured in the same move: the `ROLLED_BACK:` / `NOT_ATTEMPTED:` message-string prefixes become registered `ApiError.code` values (message keeps the human-readable cause and causal row index), so \"attempted and undone\" vs \"never ran\" is machine-readable instead of a regex convention. A RESPONSE surface — nothing stored in stack metadata carries a batch row, so there is no source for the chain to rewrite; consumers of the legacy keys move their reads themselves. Off-contract readers only: the legacy keys were never in the schema or the SDK types, so a typed consumer needs no change. #4793."
},
{
- "surface": "data.query.joins",
- "replacement": "expand (`expand: { owner: { object: 'user', fields: ['name'] } }`), or a dotted `fields` path for a single related column (`fields: ['owner.name']`)",
- "migrationId": "query-joins-retired",
- "toMajor": 17,
- "rationale": "The `joins` array was declared-but-inert: no engine or driver read `query.joins` anywhere on the query path, so a query carrying it behaved exactly as if the key were absent — while the name squatted on the reserved REST parameter set. Related-record retrieval already has a live spelling (`expand`, resolved by the engine via batch `$in` queries), so the removal deletes the second, broken spelling rather than the capability, and the orphaned `JoinNode`/`JoinType`/`JoinStrategy` cluster goes with the key. A REQUEST surface — `QueryAST` is never stored in stack metadata — so there is no source for the chain to rewrite; callers move their own queries. ADR-0049 / ADR-0078, #4286."
- },
- {
- "surface": "data.query.windowFunctions",
- "replacement": "`aggregations` + `groupBy` for request-level analytics; `SqlDriver.findWithWindowFunctions(object, query)` for embedders on a SQL datasource",
- "migrationId": "query-window-functions-retired",
- "toMajor": 17,
- "rationale": "The `windowFunctions` array was declared-but-inert on the query path: `find()` never applied a window function, so every OVER clause a caller declared was silently dropped. The capability only ever ran behind `SqlDriver.findWithWindowFunctions()`, a driver-level door that is not on the `IDataDriver` contract and whose flat input shape (`{ function, alias, partitionBy?, orderBy? }`) the spec vocabulary never matched — `WindowFunctionNodeSchema` declared `field`/`over`/`frame` members the door never read, so that cluster is removed with the key rather than left as a false affordance. A REQUEST surface, never stored; no source to rewrite. ADR-0049 / ADR-0078, #4286."
- },
- {
- "surface": "data.query.cursor",
- "replacement": "a `where` predicate on the sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` (the documented manual-keyset pattern)",
- "migrationId": "query-cursor-retired",
- "toMajor": 17,
- "rationale": "The `cursor` key promised keyset pagination and no driver implemented it: the cursor was accepted and ignored, so every page came back identical — a caller looping \"until hasMore is false\" never terminates. Worse than inert, it had a shipped public producer (`QueryBuilder.cursor()`, removed with the key). The caller-built `Record` shape also leaks sort/storage detail and squats on the reserved REST parameter set; a first-class cursor, if ever designed, will be a response-minted opaque token — a different API, so keeping this one preserved a wrong design rather than a roadmap. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286."
- },
- {
- "surface": "data.query.distinct",
- "replacement": "`groupBy` for unique combinations; the `count_distinct` aggregation for deduplicated counts; the SQL/memory drivers' `distinct(object, field)` door for one column's values",
- "migrationId": "query-distinct-retired",
- "toMajor": 17,
- "rationale": "The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND worse pagination metadata, and a side effect that \"confirmed\" the flag was doing something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with the key). The count suppression is deleted in the same change — `total` is truthful for those queries again. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286."
- },
- {
- "surface": "data.query.aggregations[].function ('array_agg' / 'string_agg')",
- "replacement": "an ordinary `fields` query, shaped in the caller — or a stored field that materialises the roll-up. For a deduplicated COUNT the live spelling is unchanged: `count_distinct` stays declared",
- "migrationId": "query-array-string-agg-retired",
- "toMajor": 17,
- "rationale": "The stored half of this retirement is a conversion (`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. `QueryAST` is never stored in stack metadata — it is the client SDK builder's output and the `POST /data/:object/query` body — so there is no source for the chain to rewrite and callers move their own queries. Both values were declared-but-unlowered on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller following the schema against a SQL datasource got a refusal, not an array. They did run on `driver-mongodb` and on the engine's in-memory fallback, which is what makes this the one narrowing in the batch that removes reachable behaviour: an aggregation that worked on one backend and failed on another is exactly the unpredictability the ruling ended, and #5499 has both of those backends frozen. `count_distinct` was deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049's enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049, #6188."
- },
- {
- "surface": "data.query.aggregations[].distinct",
- "replacement": "the `count_distinct` aggregation FUNCTION for a deduplicated count — the one deduplicating spelling every face computes, lowered to `COUNT(DISTINCT field)` on both SQL faces since #6409. `SUM(DISTINCT …)` / `AVG(DISTINCT …)` get no replacement: no backend ever computed them here, and a per-row measure that needs deduplicating before summing is a modelling problem to fix in the data",
- "migrationId": "aggregation-node-distinct-retired",
- "toMajor": 17,
- "rationale": "A DIVERGENCE, not an inert declaration — which is why it outlived the #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 frozen under #5499 — 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."
- },
- {
- "surface": "CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / discovery routes.workflow / RestApiRouteCategory workflow",
- "replacement": "the live mechanisms the slot only ever pointed at: `state_machine` validation rules for record state machines, approval flow nodes on the approvals runtime (ADR-0019) for approvals, lifecycle hooks + `record_change` flows (service-automation) for record-triggered automation",
- "migrationId": "workflow-service-slot-retired",
+ "surface": "dashboard.widgets[].compareTo: { offset: '7d' | '1M' | … } (every duration except '1y')",
+ "replacement": "compareTo: { kind: 'previousPeriod' } plus an explicit window on the widget's own `filter`",
+ "migrationId": "dashboard-widget-compareto-offset",
"toMajor": 17,
- "rationale": "The workflow slot was declared end to end and implemented nowhere: no code in either repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches were plugin-dev's retired stub probe and the generic discovery walk), no implementation of any WorkflowProtocol method ever existed, and no host ever mounted `/api/v1/workflow` (the pre-#3586 DEFAULT_DISPATCHER_ROUTES listed it among routes that never existed). Every part of it was ADR-0078's silently-inert declaration: a CoreServiceName nothing filled, a contract nothing implemented, a protocol nothing served, a discovery route field no builder could truthfully populate. These are TS/API surfaces and a discovery RESPONSE field — never stored in stack metadata, so there is no source for the chain to rewrite; consumers of the deleted types move their imports themselves. ADR-0049 / ADR-0078, #4451."
+ "rationale": "The widget declared three comparison arms; the analytics executor implements one shape, `{ kind, dimension? }`, with no `offset` concept in it at all. On the ADR-0021 dataset path — the spec's single author-facing analytics shape — `{ offset }` was forwarded verbatim into that contract and threw `compareTo requires a timeDimension \"undefined\"`, taking the widget down; the arm ever only ran on the legacy inline chart path (#5011). The conversion rewrites `{ offset: '1y' }`, which IS `previousYear` by definition. Every other duration has NO faithful target: `previousPeriod` shifts by the length of whatever window the widget's filter resolves to, which equals `7d` only when that window happens to be seven days long. Rewriting mechanically would silently change which rows the comparison column counts — a wrong number rather than a missing one, which is strictly worse and exactly the class this convergence exists to end. Re-stating the intended window is a judgment about the presentation, not a transform."
},
{
"surface": "contracts.IDataDriver.findStream / data.DriverInterfaceSchema.findStream",
@@ -595,26 +560,33 @@
"toMajor": 17,
"rationale": "`findStream` was a REQUIRED contract method documented as \"optimized for large datasets to avoid memory overflow\", and in two of its three implementations it delivered the opposite: `SqlDriver` and `InMemoryDriver` both awaited `find()` for the ENTIRE result set and then yielded it row by row, so the peak memory a caller was promised protection from was already reached before the first yield. The third (`MongoDBDriver._findStream`) did walk a cursor, but it was the one read path in that driver never routed through `buildFindOptions`, so it hardcoded `projection: { _id: 0 }` and silently discarded `query.fields`. None of it was ever observed, because the method had NO caller in either repository: the engine exposes no stream entry, and the REST export, import and bulk-read paths all go through `find()`. The ~20 driver test doubles that existed only to satisfy a required method almost all threw `not implemented`, and nothing ever noticed — which is the proof, not the anecdote. Being REQUIRED, it also taxed every new driver and every test double with an implementation of a capability the platform does not have. Rather than build a caller to justify three implementations, the method is retired; a real cursor-based read should return WITH the caller that needs it (ADR-0049 enforce-or-remove). This is a TS/API contract surface — a driver is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone either: nothing ever ran a driver object through `DriverInterfaceSchema.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it points at callers. ADR-0049 / ADR-0078, #4484."
},
+ {
+ "surface": "contracts.IDataEngine.batch / data.DataEngineBatchRequestSchema",
+ "replacement": "`IObjectQLEngine.transaction(cb)` for in-process multi-write atomicity; the metadata protocol's `batchData` with `options.atomic: true` for a batch over one object; `POST {basePath}/batch` on the wire",
+ "migrationId": "data-engine-batch-retired",
+ "toMajor": 17,
+ "rationale": "`batch?` was declared on `IDataEngine` for as long as that contract existed and was never implemented by any engine: `ObjectQL` has no `batch` method and there is no other engine in the tree. It also had no caller — `DataEngineRequest` was imported by exactly one file, the contract declaring the member. Its entire specification was a three-word doc comment (\"Batch Operations (Transactional)\"), which settles nothing about partial failure, ordering, cross-object references, rollback scope, or what `transaction: false` was supposed to mean — the questions a batch API exists to answer. Contrast its neighbours `getDefaultDriverName?` / `getDriverByName?`, whose optionality is evidenced: each names its implementer and its probing caller. The tell that nobody ever designed against it is in the schema: `DataEngineBatchRequestSchema.requests` nested the request union RECURSIVELY, so a batch could contain batches, with no statement anywhere about what that meant for ordering or rollback. The only test was a type pin — an ad-hoc object literal carrying a `batch` property, asserting the property was defined — which could not fail while the declaration existed and would have passed unchanged for the member's whole life with no engine implementing it. What it claimed is now covered by members that are real, so the removal deletes a false affordance rather than a capability: ADR-0119 D1 made `transaction` reachable through the contract and D4 made `batchData`'s `atomic` honest, while the wire batch has always validated with `CrossObjectBatchRequestSchema` / `BatchUpdateRequestSchema` from `api/batch.zod.ts` — a different schema entirely, untouched here. TS/API surfaces only: an engine is CODE, never stack metadata, so there is no source for the chain to rewrite. Deliberately no schema tombstone either — nothing ever parsed `DataEngineBatchRequestSchema`, so a `retiredKey()` prescription would have no one to reach; its three `authorable-surface.json` baseline lines and its `json-schema.manifest.json` entry are dropped in the same change, deliberately. The enforced channel is tsc. ADR-0049 / ADR-0078, #4618."
+ },
{
"surface": "api.DataEventType 'data.field.changed'",
"replacement": "the `data.record.updated` event, whose payload already carries the per-field detail: `changes` (the changed fields), plus `before` / `after`",
"migrationId": "data-field-changed-event-retired",
"toMajor": 17,
- "rationale": "`data.field.changed` was declared in `DataEventType` and emitted by nothing — the engine's `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since #4639) `data.records.{updated,deleted}`, and no other producer exists in either repository. A subscriber that switched on it was waiting on an event no producer sends: the branch never ran, and because the surrounding `switch` still compiled, nothing anywhere reported the gap (ADR-0078's silently-inert declaration, on the event vocabulary). `DataEventSchema` could not have carried the semantics even if something had emitted it — the payload is record-shaped (`recordId`, `changes`, `before`, `after`) with no `field` / `oldValue` / `newValue` slot — so the member promised a granularity the contract has no room for. Per-field detail is therefore not lost: it has always ridden on `data.record.updated` as `changes`, which is one event per write rather than N events on a wide table. This is a runtime EVENT surface — no stack, example or template authors an event name (webhooks subscribe through the separate authorable `WebhookTriggerType`, whose vocabulary was already trimmed to producers that exist, #3196) — so there is no source for the chain to rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a retiredKey() fix-it error the way an authorable object key can (the same limit the sharing-rule `full` retirement hit above). The enforced channels are tsc, which fails any consumer still naming the value in a `DataEventType` position, and the enum parse, which now rejects the name instead of accepting an event that never arrives. A genuine per-field stream, if one is ever wanted, gets its own honest contract the way #4639 gave bulk writes theirs. ADR-0049 / ADR-0078, #4673."
+ "rationale": "`data.field.changed` was declared in `DataEventType` and emitted by nothing — the engine's `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since #4639) `data.records.{updated,deleted}`, and no other producer exists in either repository. A subscriber that switched on it was waiting on an event no producer sends: the branch never ran, and because the surrounding `switch` still compiled, nothing anywhere reported the gap (ADR-0078's silently-inert declaration, on the event vocabulary). `DataEventSchema` could not have carried the semantics even if something had emitted it — the payload is record-shaped (`recordId`, `changes`, `before`, `after`) with no `field` / `oldValue` / `newValue` slot — so the member promised a granularity the contract has no room for. Per-field detail is therefore not lost: it has always ridden on `data.record.updated` as `changes`, which is one event per write rather than N events on a wide table. This is a runtime EVENT surface — no stack, example or template authors an event name (webhooks subscribe through the separate authorable `WebhookTriggerType`, whose vocabulary was already trimmed to producers that exist, #3196) — so there is no source for the chain to rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a retiredKey() fix-it error the way an authorable object key can (the same limit the sharing-rule `full` retirement `owd-full-alias-removed` hit). The enforced channels are tsc, which fails any consumer still naming the value in a `DataEventType` position, and the enum parse, which now rejects the name instead of accepting an event that never arrives. A genuine per-field stream, if one is ever wanted, gets its own honest contract the way #4639 gave bulk writes theirs. ADR-0049 / ADR-0078, #4673."
},
{
- "surface": "contracts.IDataEngine.batch / data.DataEngineBatchRequestSchema",
- "replacement": "`IObjectQLEngine.transaction(cb)` for in-process multi-write atomicity; the metadata protocol's `batchData` with `options.atomic: true` for a batch over one object; `POST {basePath}/batch` on the wire",
- "migrationId": "data-engine-batch-retired",
+ "surface": "stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)",
+ "replacement": "the same declarations, re-read as LIVE HTTP routes: `path` moved under `/api/v1/apps//`, and every entry that declares `authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying `rateLimit: { enabled: true, … }`",
+ "migrationId": "declarative-apis-endpoints-live",
"toMajor": 17,
- "rationale": "`batch?` was declared on `IDataEngine` for as long as that contract existed and was never implemented by any engine: `ObjectQL` has no `batch` method and there is no other engine in the tree. It also had no caller — `DataEngineRequest` was imported by exactly one file, the contract declaring the member. Its entire specification was a three-word doc comment (\"Batch Operations (Transactional)\"), which settles nothing about partial failure, ordering, cross-object references, rollback scope, or what `transaction: false` was supposed to mean — the questions a batch API exists to answer. Contrast its neighbours `getDefaultDriverName?` / `getDriverByName?`, whose optionality is evidenced: each names its implementer and its probing caller. The tell that nobody ever designed against it is in the schema: `DataEngineBatchRequestSchema.requests` nested the request union RECURSIVELY, so a batch could contain batches, with no statement anywhere about what that meant for ordering or rollback. The only test was a type pin — an ad-hoc object literal carrying a `batch` property, asserting the property was defined — which could not fail while the declaration existed and would have passed unchanged for the member's whole life with no engine implementing it. What it claimed is now covered by members that are real, so the removal deletes a false affordance rather than a capability: ADR-0119 D1 made `transaction` reachable through the contract and D4 made `batchData`'s `atomic` honest, while the wire batch has always validated with `CrossObjectBatchRequestSchema` / `BatchUpdateRequestSchema` from `api/batch.zod.ts` — a different schema entirely, untouched here. TS/API surfaces only: an engine is CODE, never stack metadata, so there is no source for the chain to rewrite. Deliberately no schema tombstone either — nothing ever parsed `DataEngineBatchRequestSchema`, so a `retiredKey()` prescription would have no one to reach; its three `authorable-surface.json` baseline lines and its `json-schema.manifest.json` entry are dropped in the same change, deliberately. The enforced channel is tsc. ADR-0049 / ADR-0078, #4618."
+ "rationale": "This is the one protocol-17 entry that turns metadata ON rather than off, so read it as a SECURITY review item and not as a rename. Before 17 the declarative endpoint surface executed NOTHING: no route was mounted for a declared `path`, no matcher existed, and every key — `authRequired` included — parsed green and gated nothing (#4936, which refused a non-empty `apis:` outright for exactly that reason). Protocol 17 ships the executor (#5040) and narrows that refusal to a per-endpoint publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as soon as the stack is published. So an `apis:` block written against an older major — or one restored from a pre-#4936 source, or authored from a doc that predates the refusal — changes meaning without changing a byte: what used to be inert documentation becomes an execution entry point into the data and automation pipelines. Nothing about that transition can be applied mechanically, because the judgment it needs is \"did the author of this endpoint mean for the internet to reach it?\" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT `authRequired: false` is the only thing that opens anonymous access, and under ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key defaults to `false`, so a budget written without it meters nothing) or the stack refuses to publish. ⚠️ If you author endpoints in TypeScript, annotate them with `ApiEndpoint` — the AUTHOR state — so that omitting `authRequired` compiles: `const e: ApiEndpoint = { name, path, method, type, target }` is legal and is the safe shape this paragraph prescribes. `ApiEndpointParsed` is the POST-parse type (defaults materialized, ADR-0122), where `authRequired` is required — annotating a declaration with it forces you to write the key out, and being made to think about a key whose only unrecoverable value is `false` is the one thing this entry is trying to avoid (#5227). Hold a parse RESULT with `ApiEndpointParsed`; write declarations as `ApiEndpoint`. Grep every `apis:` entry for `authRequired: false` before you upgrade, delete the ones that were never meant to be public, and arm a budget on the ones that were. The path move is the mechanical-looking half and is still yours: ADR-0121 D1/D2 confine a declared path to your own namespace carve-out (`/api/v1/apps//…`), the namespace comes from an explicit `manifest.namespace` with no derivation fallback, and the subpath is the only part you name — rewriting it for you would silently change a URL third parties call."
},
{
- "surface": "restServer.openApi31",
- "replacement": "(removed — no replacement key exists. Delete the key; for a real outbound webhook use `Webhook` from `@objectstack/spec/automation`. Config-driven OpenAPI 3.1 webhooks/callbacks documentation returns, if ever, via the enforce route of ADR-0049 through a new ADR)",
- "migrationId": "rest-server-openapi31-block-removed",
+ "surface": "driver aggregate() call argument — query.aggregate and aggregations[].func",
+ "replacement": "query.aggregations and aggregations[].function — the spellings QueryASTSchema and AggregationNodeSchema have always declared",
+ "migrationId": "driver-aggregate-undeclared-key-aliases-removed",
"toMajor": 17,
- "rationale": "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": "`SqlDriver.aggregate` and `RemoteTransport.aggregate` each read two aliases the Query Protocol has never declared: `query.aggregations || query.aggregate` and `agg.function || agg.func`. \"Never declared\" is measured, not assumed — `git log -S` over `data/query.zod.ts` finds no commit that ever introduced either name, there is no `retiredKey()` tombstone and no alias-table entry for them (the file's only alias table is `SortNode`'s `direction` → `order`), and neither appears in any upgrade guide or release note. So this entry does not record a declared surface being withdrawn; it records a LENIENCY being withdrawn, which is why it is here rather than behind a tombstone. The only writers in this repository were the two driver packages' own fixtures — #4984's family, where a fixture spelling the alias keeps the tolerant limb green forever and no test in existence can go red on its deletion — so ADR-0049 enforce-or-remove applies once those are re-spelt. ⚠️ Do NOT read this across to `dashboard`/`page` measures: `aggregate` IS the canonical key there and `func` IS a declared, loudly-suggesting alias (`DatasetMeasureSchema`, ui/dataset.zod.ts). That neighbouring vocabulary is untouched, and it is the most likely reason an off-repo caller ever wrote these keys on a QUERY — one habit, two surfaces, only one of which declared it. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone: nothing ever ran a query through `QueryASTSchema.parse()` on this path. The enforced channel is tsc at the call site, once the parameter is `DriverQuery` — and for an untyped JS caller there is no enforced channel at all, which is exactly why this ledger entry has to exist: the generated upgrade guide is the only way such a reader learns of the rename. Same disposition, and the same reason, as `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011). ADR-0049 / ADR-0087, #6321 (PR #6404)."
},
{
"surface": "data.DriverCapabilities.create / data.DriverCapabilities.read / data.DriverCapabilities.update / data.DriverCapabilities.delete / data.DriverCapabilities.bulkCreate / data.DriverCapabilities.bulkUpdate / data.DriverCapabilities.bulkDelete / data.DriverCapabilities.transactions / data.DriverCapabilities.savepoints / data.DriverCapabilities.isolationLevels / data.DriverCapabilities.queryFilters / data.DriverCapabilities.queryAggregations / data.DriverCapabilities.querySorting / data.DriverCapabilities.queryPagination / data.DriverCapabilities.queryWindowFunctions / data.DriverCapabilities.querySubqueries / data.DriverCapabilities.queryCTE / data.DriverCapabilities.joins / data.DriverCapabilities.fullTextSearch / data.DriverCapabilities.jsonQuery / data.DriverCapabilities.geospatialQuery / data.DriverCapabilities.streaming / data.DriverCapabilities.jsonFields / data.DriverCapabilities.arrayFields / data.DriverCapabilities.vectorSearch / data.DriverCapabilities.schemaSync / data.DriverCapabilities.migrations / data.DriverCapabilities.indexes / data.DriverCapabilities.connectionPooling / data.DriverCapabilities.preparedStatements / data.DriverCapabilities.queryCache",
@@ -624,46 +596,53 @@
"rationale": "The #4484 findStream close-out found `DriverCapabilities.streaming` pointing at a capability the contract no longer declares, and the follow-up audit (#4634) checked every bit in the record the same way, across objectstack and cloud (objectui confirmed clean): of 34 declared bits, THREE have a decision-making reader — `queryDateGranularity` (engine aggregate dispatch + checkDateBucketParity), `autonumber` (engine defers generation to the driver), `batchSchemaSync` (engine ANDs it with method presence, because a subclass can inherit `syncSchemasBatch` from a base whose transport batches while its own cannot) — and THIRTY-ONE were written by every driver and read by nothing. Their `.describe()` strings promised engine adaptation (\"if false, ObjectQL will filter/sort/paginate in memory\") that was never built, and zero readers let the values go WRONG unnoticed: SqlDriver declared `streaming: false` while implementing `findStream`; InMemoryDriver declared `streaming: true` over a full-table read (ADR-0078 false affordance, on the capability record itself). The real mechanism everywhere else is METHOD presence: transactions gate on `driver.beginTransaction`, aggregate pushdown on `typeof driver.aggregate`, schema sync on `typeof driver.syncSchema`, and the REQUIRED CRUD/bulk methods are called unconditionally. A driver is CODE, never stack metadata — `supports` literals live in driver classes and `DriverConfig.capabilities` is plugin TS configuration, neither ever a `sys_metadata` shape (the stack-tree neighbour, `datasource.capabilities`, was retired separately in #4583) — so there is no source for the D2 chain to rewrite and this entry is the D3 record. The keys are tombstoned rather than deleted because `DriverCapabilitiesSchema` is not `.strict()` and IS parsed (DriverConfigSchema / SQLDriverConfigSchema / NoSQLDriverConfigSchema embed it): a plain delete would silently strip a vendor's authored bit, replacing one silent no-op with another. `batchSchemaSync` also drops its `.default(false)` for `.optional()` — absence already meant false at both readers, and the default forced every capability object to spell out 30+ bits. ADR-0049 / ADR-0078, #4634."
},
{
- "surface": "kernel.dynamicLoadRequest.activationEvents / studio.studioPluginManifest.activationEvents",
- "replacement": "(removed — delete the key. Every plugin activates immediately on load/registration, which is the only behaviour that has ever existed; `activate()` still runs at registration time. Lazy activation, if built, returns via the enforce route of ADR-0049 through a new ADR, with a vocabulary its executor actually honours)",
- "migrationId": "plugin-activation-events-retired",
+ "surface": "SqlDriver.distinct() third argument — any value",
+ "replacement": "a bare FilterCondition (@objectstack/spec/data) — the same value find() carries under query.where, never a query envelope",
+ "migrationId": "driver-sql-distinct-bare-filter-typed",
"toMajor": 17,
- "rationale": "Both `activationEvents` keys — and the `ActivationEventSchema` trigger vocabulary they embedded (`onCommand` / `onRoute` / … / `onView` after the #4653 convergence) — 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 (#3950: an exported schema with no consumer is read as a capability). #4657. SUPERSEDED ON THE KERNEL SIDE by #4834 (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` below). The studio half of this entry is unaffected and still enforced by the strict manifest parse."
+ "rationale": "This entry records a TYPE being added, not a surface being withdrawn, and it says so up front because the distinction decides who has to do anything. `distinct` is not declared on `IDataDriver`, so #5181 / #6075 never reached it and it kept `filters?: any` while its body said something far more specific — `applyFilters(builder, filters)` is handed the ARGUMENT ITSELF, never a `.where` off it. ⚠️ RUNTIME BEHAVIOUR IS UNCHANGED by this entry's change: not one statement moved, so no upgrade breaks at run time and nothing that answered correctly stops. What the annotation removes is a compile-time hole, measured rather than assumed: a truthy NON-OBJECT third argument — `distinct('orders', 'product', 'completed')` — used to type-check and resolve the UNFILTERED set, because `applyFilters` emits no predicate at all for a truthy non-object, non-array filter. A call meaning \"which products among completed orders\" answered with EVERY product, silently. That spelling is now TS2345 at the call site. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone, the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540), `actor-user-roles-to-positions` (#6011) and `driver-aggregate-undeclared-key-aliases-removed` (#6321) already carry. ⚠️ It differs from those four in ONE measured way a reader should not have to infer: because nothing changed at run time, an untyped JS caller is not affected BY THE UPGRADE at all. The entry is here for a different reason — such a caller is exactly the one tsc can never reach, and the silent widening above is a defect they may ALREADY be sitting on, before and after this major. The generated upgrade guide is the only channel that reaches them, which is why the fix is written down rather than left to the compiler. ⛔ The reverse mismatch is NOT closed and no type can close it: `FilterCondition` is an open map (`[key: string]: any`) because a filter key IS a field name, so a query envelope `{ object, where }` is structurally a valid filter — one constraining columns named `object` and `where` — and so is a FilterArray. Both reach `distinct` type-checked and are refused at run time, loudly, with INVALID_FILTER / 400. `driver-memory`'s opposite half — where the BARE spelling returns the unfiltered set in silence — stays open under the #5499 freeze (#6320). ADR-0087, #6320."
},
{
- "surface": "kernel.dynamicLoadRequest / kernel.dynamicUnloadRequest / kernel.dynamicPluginResult / kernel.pluginSource / kernel.dynamicPluginOperation",
- "replacement": "(removed — there is no replacement shape, because there is no operation to describe. Plugins are composed at boot: `defineStack` registers them and the kernel runs register → init → start; the set is fixed until the process restarts. Delete the import and the value. Runtime plugin loading, if it is ever built, returns via the enforce route of ADR-0049 through a new ADR — loader first, vocabulary second)",
- "migrationId": "plugin-runtime-family-retired",
+ "surface": "engine.find(object, { orderBy }) and engine.findOne(object, { orderBy }) naming a `formula` field — the direct engine path, not the REST ingress",
+ "replacement": "denormalise the value onto the object (a stored field, written when the source changes) and sort by that — the same remedy the REST ingress has prescribed since #6924 / #6994; a `summary` field is unaffected and still sorts, because it gets a real maintained column",
+ "migrationId": "engine-find-formula-order-by-refused",
"toMajor": 17,
- "rationale": "The five schemas declared the \"Dynamic Loading\" capability — runtime load / unload / reload of plugins without a kernel restart, with sandboxing, integrity hashes, drain strategies and dependent-cascade policy — and NOTHING implemented it. A bare-name scan of objectstack, cloud and objectui found zero references outside this package's own declaration, its unit tests and the generated artifacts: no runtime ever received a `DynamicLoadRequest`, performed a load/unload, or produced a `DynamicPluginResult`. That is the ADR-0049 false-compliance shape at its most inviting to an AI author (ADR-0033), who reads `DynamicLoadRequestSchema` in the published IDE bundle as proof the platform hot-loads plugins and constructs a request that parses clean and is received by nobody (#3950: an exported schema with no consumer is read as a capability). The #3896 follow-up removed this module's discovery/sandbox config island and left these five in place explicitly — \"operation contracts, not security promises; the enforce-or-remove call on them is a design decision rather than a correction\" — but that suspension lived only in a changeset paragraph with no issue carrying it. #4834 is that decision, answered REMOVE. `experimental` was considered and rejected: it is only `.describe()` prose and cannot stop an import, the weakest of the three ADR-0049 channels. None of the five is stored metadata — they are root request/result payload shapes embedded in no parent schema and parsed against no metadata document — so no `sys_metadata` row can carry one and there is no source for the D2 chain to rewrite; this entry is the D3 record. The removal also subsumes the kernel half of `plugin-activation-events-retired` (#4657): that tombstone goes with the shape that carried it. ADR-0049, #4834."
+ "rationale": "#4226 / #4256 / #6994 closed the SORT axis at the REST ingress (`assertSortFieldsExist`, `400 INVALID_SORT`), which covers everything reaching `findData`: the list route, `POST /data/:object/query`, the export route and the RPC dispatcher. A caller reaching `engine.find()` / `engine.findOne()` DIRECTLY passed through none of it, and a `formula` ORDER BY there was dropped in silence. Measured on a real driver: `asc` and `desc` came back BYTE-IDENTICAL, in insertion order, under a success, with the rows carrying the very values they were asked to be ordered by. No column exists to order by (a formula is computed on read, so no driver materialises one), so the ORDER BY reached the driver, found nothing, and the unknown-column backstop returned the rows unordered.\n\nRuled 2026-08-10 on #7095: an ORDER BY the engine cannot apply is a 4xx with guidance prose at the public boundary, never a silent drop — the same direction as the analytics dataset refusal envelope and the #6924 sort-hint prescription. The engine's documented internal-caller tolerance (`assertProjectionFieldsExist`'s docblock) was to survive only behind a pinned internal path, and only if a MEASURED internal call site relied on it. The #7095 sweep of every in-tree `orderBy` reaching the engine directly — hooks, flows, reports, queue/job adapters, sharing, metadata loaders, expand sub-reads — found NONE: every hardcoded internal sort names a real stored column (`created_at`, `updated_at`, `version`, `priority`, `scheduled_for`, `started_at`, `next_run_at`, `recorded_at`, `id`), and no shipped object in the repo declares a `formula` field at all. So no internal path shipped, and there is no flag to opt back into the drop.\n\nThis is a CODE-path API, not stored metadata, so — like `hook-register-empty-object-target-refused` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and the ledger entry is the notification channel. No mechanical rewrite exists in either direction: the platform cannot invent the stored column the remedy prescribes, and it must not sort post-hoc instead — `driver.find` has already applied `limit` / `offset`, so re-sorting after the formulas are evaluated would reorder an ARBITRARY PAGE, which looks correct on small result sets and is wrong the moment pagination is involved.\n\nONE AUTHOR-REACHABLE SURFACE reaches this indirectly and is why it is not purely a code-side note: a saved report's `query.orderBy` (`sys_saved_report`) is forwarded verbatim into `engine.find` by `plugin-reports`, bypassing the ingress gate. A report authored to sort by a formula field used to run and return rows in an arbitrary order; it now fails loudly, with the remedy in the message. One further path is deliberately NOT a refusal: a nested `expand` sort raises this refusal inside `expandRelatedRecords`, whose pre-existing graceful-degradation `catch` swallows every expand failure and retains the raw foreign keys — so that path moves from silent to OBSERVABLE (a warning naming the field and the fix) rather than refusing. Reversing that backstop is a separate decision on all expand failure modes. #7095, #6994, #6924, #4226, #4256, #3821, ADR-0112."
},
{
- "surface": "stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)",
- "replacement": "the same declarations, re-read as LIVE HTTP routes: `path` moved under `/api/v1/apps//`, and every entry that declares `authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying `rateLimit: { enabled: true, … }`",
- "migrationId": "declarative-apis-endpoints-live",
+ "surface": "api.enhancedApiError.fieldErrors",
+ "replacement": "fields",
+ "migrationId": "enhanced-api-error-field-errors-renamed",
"toMajor": 17,
- "rationale": "This is the one protocol-17 entry that turns metadata ON rather than off, so read it as a SECURITY review item and not as a rename. Before 17 the declarative endpoint surface executed NOTHING: no route was mounted for a declared `path`, no matcher existed, and every key — `authRequired` included — parsed green and gated nothing (#4936, which refused a non-empty `apis:` outright for exactly that reason). Protocol 17 ships the executor (#5040) and narrows that refusal to a per-endpoint publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as soon as the stack is published. So an `apis:` block written against an older major — or one restored from a pre-#4936 source, or authored from a doc that predates the refusal — changes meaning without changing a byte: what used to be inert documentation becomes an execution entry point into the data and automation pipelines. Nothing about that transition can be applied mechanically, because the judgment it needs is \"did the author of this endpoint mean for the internet to reach it?\" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT `authRequired: false` is the only thing that opens anonymous access, and under ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key defaults to `false`, so a budget written without it meters nothing) or the stack refuses to publish. ⚠️ If you author endpoints in TypeScript, annotate them with `ApiEndpoint` — the AUTHOR state — so that omitting `authRequired` compiles: `const e: ApiEndpoint = { name, path, method, type, target }` is legal and is the safe shape this paragraph prescribes. `ApiEndpointParsed` is the POST-parse type (defaults materialized, ADR-0122), where `authRequired` is required — annotating a declaration with it forces you to write the key out, and being made to think about a key whose only unrecoverable value is `false` is the one thing this entry is trying to avoid (#5227). Hold a parse RESULT with `ApiEndpointParsed`; write declarations as `ApiEndpoint`. Grep every `apis:` entry for `authRequired: false` before you upgrade, delete the ones that were never meant to be public, and arm a budget on the ones that were. The path move is the mechanical-looking half and is still yours: ADR-0121 D1/D2 confine a declared path to your own namespace carve-out (`/api/v1/apps//…`), the namespace comes from an explicit `manifest.namespace` with no derivation fallback, and the subpath is the only part you name — rewriting it for you would silently change a URL third parties call."
+ "rationale": "The wire has always carried `fields` — the validators, import coercion, validation-failure.ts, @objectstack/client and the console's field-error extractor all say `fields`, and nothing ever emitted `fieldErrors`, so a reader keying on it was reading a field no server sent (ADR-0078's silently-inert declaration, on the error envelope). This is a RESPONSE surface: no stack, example or template carries the key, so there is no source for the chain to rewrite — the schema tombstones it via retiredKey() and consumers move their read themselves. ADR-0114 D4, #3977."
},
{
- "surface": "ui.widgetManifest / ui.widgetLifecycle / ui.widgetEvent / ui.widgetProperty / ui.widgetSource / ui.i18nObject / ui.pluralRule / ui.numberFormat / ui.dateFormat / ui.localeConfig (the widget-registration vocabulary of ui/widget.zod.ts, and the five doorless shapes of ui/i18n.zod.ts — 10 defs, 26 exported names)",
- "replacement": "(removed — there is no replacement key, because there was never a key. A custom field widget is still named the same way it always was: `field.widget` is a plain string naming a component the RENDERER has registered, and objectui's registry has always carried its own runtime manifest for that (`RuntimeWidgetManifest` / `RuntimeWidgetSource` in `@object-ui/types`, objectui#3161 / #4115), which models different keys and never derived from these. For localisation: write the default-language string on `label` / `description` — the framework generates the translation key at registration time from the naming convention — and put translations in translation files, which is the LIVE `system/translation.zod.ts` surface. Widget registration and locale formatting as authorable protocol metadata return via the ENFORCE route of ADR-0049 through a new ADR — the registry / loader / formatter first, the vocabulary second)",
- "migrationId": "ui-widget-i18n-family-retired",
+ "surface": "automation.etlPipeline / automation.etlPipelineRun / automation.etlSource / automation.etlDestination / automation.etlTransformation (the whole L2 layer of automation/etl.zod.ts, its four enums and the `ETL` factory — 9 defs, 27 exported names)",
+ "replacement": "(removed — no protocol surface replaces it, deliberately. Layer by layer: connector-attached synchronisation is `ConnectorSchema.syncConfig` (`integration/connector.zod.ts`), which IS parsed and executed; per-field value transformation on import is `shared/mapping.zod.ts`, whose `transform` is applied row by row by the REST import path and recorded key by key in `packages/spec/liveness/mapping.json`; scheduling is `system/job.zod.ts`. What has NO replacement is multi-source, multi-stage movement with joins and aggregations — because it never had an implementation either. It returns through the ENFORCE route: the engine first, the vocabulary second)",
+ "migrationId": "etl-pipeline-layer-retired",
"toMajor": 17,
- "rationale": "`ui/widget.zod.ts` published a complete widget-registration vocabulary — a manifest with lifecycle hooks, custom events, configurable properties and an npm/remote/inline implementation-source union — and `ui/i18n.zod.ts` published a structured-label, plural-rule and locale-formatting vocabulary. NOTHING in the protocol carried either. Three independent measurements, re-run on `origin/main` immediately before the removal with their controls passing in the SAME run: (1) no module under `packages/spec/src` imported `widget.zod` at all, and the only imports of `i18n.zod` anywhere name `I18nLabelSchema` / `AriaPropsSchema` (both KEPT), so no schema declared a carrier key — `field.widget` is a `z.string()` naming a registered component and has never referenced `WidgetManifest`; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` reached none of them, while `PageSchema` / `ObjectListViewSchema` resolved `direct` in the same run and a synthetic carrier flipped every one of them; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these files' own unit tests. `NumberFormat` / `DateFormat` DID have a carrier key (`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier was itself doorless, so the subtree was `no door` rather than `no gate` and goes whole — leaving the two leaves behind would strand exported schemas with no consumer (#3950). `I18nObjectSchema` was additionally superseded by its own file-neighbour: `I18nLabelSchema`'s documentation already says translation keys are generated at registration time and translations live in translation files, and the live translation surface is `system/translation.zod.ts`, which uses none of these shapes. The 2026-08-06 ruling weighed giving them a carrier (option B) and rejected it: that is a feature with a registry and a renderer behind it, not ledger clean-up. Tightening them to `strictObject` was rejected earlier and explicitly (#4001 批 16) — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave \"a precisely validated dead slot, the more convincing lie\" (#4583). With no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, route 3, the same shape as #4988 (the ui/ interaction config family), #4834 (kernel plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ `WidgetManifest.performance`'s own `retiredKey()` tombstone (#3896 close-out) is SUBSUMED here, the #4657/#4834 way: it goes with the shape that carried it, which is strictly stronger than the tombstone, because there is no longer a manifest to author the key INTO. ⚠️ One of the nine widget sites is deliberately NOT retired. `FieldWidgetPropsSchema` survives: it is a REACT PROPS CONTRACT rather than authorable metadata (it never appeared in `authorable-surface/` or `json-schema.manifest/` — its `onChange` is a `z.function()`), so \"zero parse\" is its design and not its defect, and it acquired a live cross-repo compile-time consumer one day before 批 16 measured: objectui PR #3289 (2026-08-03) renamed `@object-ui/fields`' validation slot onto the spec's `error` with no alias, the form renderer began producing it, and `packages/fields/src/__tests__/spec-symbol-batch7.test.ts` pins the shape against `import type { FieldWidgetProps } from '@objectstack/spec/ui'` as an intentional tripwire. Re-verified on objectui `origin/main` 2026-08-07. ADR-0049, #5055."
+ "rationale": "The reading #4738 used to retire L1 `DataSyncConfig`, re-measured one layer up and identical: narrative-only. No engine ever parsed, scheduled or executed an `ETLPipeline`. Measured on origin/main immediately before the removal: the only non-spec references in this repo are two fumadocs-generated documentation sources (`apps/docs/.source/*.ts`), not executors; objectui has no reference at all; there is no `liveness/etl.json` or `pipeline.json`, so no ADR-0049 gate ever had a reading on it — while the same file family's EXECUTED half does have one (`liveness/mapping.json`), which is the contrast that makes the absence meaningful rather than an oversight. The `etl` string in this registry was the one untested link the finding named, and it is not a loader path: it was the id of the #4962 retry-vocabulary entry, absorbed here. The layer was ADR-0078's asymmetry in its purest form — an author could write a complete ten-stage pipeline, get no error, and get no execution. It was also advertised: `packages/spec/docs/SYNC_ARCHITECTURE.md` named `ETLPipeline` as the recommended destination for authors displaced by the L1 retirement (#4738) and listed ten transformation types with copyable examples down to `script | Custom JavaScript/Python`. That document is rewritten in the same change; a retirement whose own doc still recommends the retired layer is self-contradictory, and forwarding L1's authors to a second layer with no executor was the defect compounding rather than closing. ⚠️ `etl-retry-converged-onto-retry-policy` (#4962) is SUBSUMED here, the #4657/#4834/#5055 way: both land in the unreleased protocol 17, so composed, a rename of `retry.maxAttempts` on a shape that does not survive the major has no observable effect — and keeping both would tell an upgrader to rewrite a key on a schema the same upgrade deletes. The `maxAttempts` `retiredKey()` tombstone goes with the shape that carried it, which is strictly stronger than the tombstone: there is no longer a `retry` block to author the key into. Route 3 — no carrier key, no parse site, so no D2 conversion and no tombstone; RETIRED_DEFS_BY_MAJOR plus this entry are the declaration. ADR-0049, ADR-0078, #6414."
},
{
- "surface": "ui.touchInteraction / ui.gestureConfig / ui.dndConfig / ui.keyboardNavigationConfig / ui.componentAnimation / ui.motionConfig / ui.pageTransition / ui.offlineConfig (the whole export surface of ui/touch.zod.ts, ui/dnd.zod.ts, ui/keyboard.zod.ts, ui/animation.zod.ts and ui/offline.zod.ts — 32 defs, 64 exported names)",
- "replacement": "(removed — there is no replacement key, because there was never a key. Touch targets, drag-and-drop, focus management, keyboard shortcuts and motion are RENDERER BUILT-IN behaviour: the component library decides them, not a per-page metadata author. Offline is a platform capability, and its vocabulary belongs on the sync engine that owns the queue, the conflict policy and the cache — none of which exists yet. Delete the import and the value. Whichever of these earns real product pull returns WITH its own vocabulary and its executor, the #4910 way, not by un-retiring a declaration)",
- "migrationId": "ui-interaction-config-family-retired",
+ "surface": "@objectstack/rest: ExportFieldMeta.required / .system / .readonly / .hasDefault / .min / .max / .minLength / .maxLength (the map built by `buildFieldMetaMap`, reached as `PreparedImport.metaMap` from `prepareImportRequest`)",
+ "replacement": "the object schema you already hold — read `fields[name].required` / `.system` / `.readonly` / `.defaultValue` / `.min` / `.max` / `.minLength` / `.maxLength` off the same `ObjectSchema` you passed to `buildFieldMetaMap`, which is where the ENGINE reads them and therefore the only copy that cannot drift",
+ "migrationId": "export-field-meta-constraints-retired",
"toMajor": 17,
- "rationale": "Five `@objectstack/spec/ui` modules declared a full interaction-configuration vocabulary — 22 `z.object` sites across touch/gesture, drag-and-drop, focus/keyboard, animation/motion and offline/sync — and NOTHING in the protocol carried them. This is the ADR-0049 false-compliance shape in its most inviting form for an AI author (ADR-0033), and worse than the ordinary declared-but-unread defect: `authorable-surface.json` listed 109 keys under these defs and `content/docs/references/ui/{touch,dnd,keyboard,animation,offline}.mdx` rendered them as authoring tables, so the published documentation advertised a vocabulary with no carrier key anywhere. An author following `dnd.mdx` and writing a `dnd:` block onto a page component was rejected by `PageComponentSchema` for an unrecognized key — the docs and the schema disagreeing about the platform (Prime Directive #10). Three independent measurements, each with its controls passing in the same run: (1) no module under `packages/spec/src` imported any of the five except the `ui/index.ts` barrel, so no schema declared a carrier key; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` (25 roots, 4742 nodes) reached none of the 21 named object shapes, while `PageSchema`, `WebhookSchema` and `StateMachineSchema` all resolved `direct` and a synthetic carrier flipped all 21 — so unreachability was a fact about the graph, not a broken walker; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these modules' own unit tests. objectui holds TYPE re-exports and parity ratchets, never validators, and says so (#2561). The 2026-08-04 ruling weighed wiring a carrier key (option B) and rejected it: that is a feature with a renderer behind it, not ledger clean-up. It also weighed tightening the shapes to `strictObject` and rejected that explicitly — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave \"a precisely validated dead slot, the more convincing lie\" (#4583). Because there was no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, the same route 3 as #4834 (kernel plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ Not to be confused with #5021, which retired the THEME `animation` block — a different file, different defs, and that one did have a carrier key and therefore a tombstone. ADR-0049, #4988."
+ "rationale": "ADR-0049 enforce-or-remove. These eight were never a source of truth: `buildFieldMetaMap(schema)` DERIVED each one from the very `schema` its caller passed in, so the map carried a second copy of facts the caller already held. They existed for exactly one consumer — the import dry run's hand-copied pre-check mirror (`firstMissingRequiredField` / `firstConstraintViolation`, framework#3956) — and #4633 ruling D retired that mirror (PR #6532): the dry run now asks `DataProtocol.validateData` for the engine's verdict, which reads the object's own schema. That left all eight computed on every import and read by NOTHING, which is the declared-and-unread shape ADR-0049 exists for; a constraint vocabulary standing next to the presentation one with no enforcer behind it is precisely the thing an AI-authored consumer mistakes for a contract. Verified zero-reader before removal, per key and by type, across this repo (`packages/rest` itself, and all five in-repo dependents of `@objectstack/rest`: runtime, cli, verify, plugin-auth, plugin-dev) and the `objectui` sibling; plugin-auth's identity import forwards `prepared.metaMap` into `runImport` but reads only the presentation keys through `coerceRow`. Why this needs a ledger entry despite that sweep: it is the `findStream` (#4484) / `IStorageService.list` (#5540) / `actor-user-roles-to-positions` (#6011) disposition — a published TS surface with NO spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry a prescription, and the ledger is the only channel that reaches an upgrader. It is if anything blinder than those three: the keys shipped in a FINAL release (`@objectstack/rest` 14.5.0) and have been published in every release since, and because they were OPTIONAL keys on an interface that itself survives, a JavaScript consumer reading `meta.required` after the upgrade gets `undefined` with no error at all — tsc reports at the read site only for a typed consumer. Why D3 semantic and not a D2 conversion: there is nothing to convert. No authored or stored metadata changes shape — `required` / `min` / `maxLength` and the rest remain fully authorable on a field definition and fully enforced by the engine, which is where they always lived. The only place these eight are ever spelled is inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach them. ADR-0049 / ADR-0087, #6536 (the sweep PR #6532 deliberately deferred)."
},
{
- "surface": "ui.notificationAction / ui.embedConfig",
- "replacement": "(removed — there is no replacement shape, because there was never a key to write either into. Delete the import and the value. Notification presentation is still described by the surviving `NotificationType` / `NotificationSeverity` / `NotificationPosition` vocabulary; public access to a form is granted by the LIVE `FormView.sharing` block (`SharingConfig`), which is untouched. Notification action buttons as metadata, and iframe embedding, return via the enforce route of ADR-0049 through a new ADR — carrier key and renderer first, vocabulary second)",
- "migrationId": "ui-notification-action-embed-config-retired",
+ "surface": "data.filter $regex / $options — in a STORED filter (dashboard widget filter and globalFilters, report runtimeFilter, page and component filter, solution-blueprint filter), and equally in the where clause of a query request",
+ "replacement": "$icontains for the case-insensitive substring match this was almost always used for, or $contains for a case-sensitive one — a pattern that genuinely needs a regular expression has no filter-level replacement",
+ "migrationId": "filter-regex-options-retired",
"toMajor": 17,
- "rationale": "Both shapes were published `@objectstack/spec/ui` vocabulary with NO AUTHORING DOOR. #4001 批 14 measured them three ways on 2026-08-03 and this retirement re-ran all three against `origin/main` before removing anything, each with a positive control that passed in the same run: (1) CARRIER — no schema in `packages/spec/src` declared a key of either type (`ui/notification.zod`'s only non-test importer was the barrel; `ui/sharing.zod`'s were the barrel and `ui/view.zod.ts`, which names its SIBLING `SharingConfigSchema`), measured by resolving specifiers rather than substring-matching, because the repo holds two `sharing.zod` modules and a substring test miscredits `stack.zod.ts` to the UI one; (2) REACHABILITY — a BFS from the 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema`, over `build-schemas.ts`'s own walk including its derived-clone bridge, never reached either, while `Page` / `Action` / `DashboardWidget` / `Webhook` and `SharingConfig` itself all resolved `root-graph` in the same run and an injected synthetic carrier flipped both; (3) PARSE — zero `.parse()` in objectstack, cloud or objectui outside their own unit tests. So nobody could author one and nothing ever validated one: the #3950 shape, an exported schema with no consumer read as a capability, and the ADR-0033 trap where an AI author takes `EmbedConfigSchema` in the published bundle as proof the platform serves iframes. Neither is stored metadata and neither has a carrier, so no `sys_metadata` row can hold one and there is no source for the D2 chain to rewrite; this entry is the D3 record. 批 14 deliberately did NOT close them with `.strict()` — strictness is a property of a PARSE, and closing a shape nothing parses buys only \"a precisely-validated dead slot, the more convincing lie\" (#4583) — and filed the disposition as #5015, ruled REMOVE on 2026-08-04. Each was orphaned by an earlier retirement one level up: `NotificationAction` lost its wrappers at #4610 (`NotificationSchema` / `NotificationConfigSchema`, the #4535 C3 dual-source cleanup — that retirement's published \"zero consumers\" evidence was later falsified for objectui and is corrected on `ui/notification.zod`'s tombstone; the removal itself stands, #5781), and `EmbedConfig` lost its key at 17.0.0 when the 2026-06 liveness audit retired `App.embed` (no iframe route ever read it) — that key still stands as a `retiredKey()` tombstone in `app.zod.ts`, so an author who wrote the KEY already meets a prescription; this removes the value shape that outlived it. ⚠️ The retirement is per SCHEMA, not per file: `ui/sharing.zod` KEEPS `SharingConfigSchema`, a live door carried by `FormViewSchema.sharing` and read by `rest-server.ts` to mount the anonymous form routes, and `ui/notification.zod` keeps its three presentation enums. objectui consumed `NotificationActionSchema.shape.variant` as a VOCABULARY (never a parse) to pin its own hand-written `NotificationActionButton` interface — which is exactly why \"has a consumer\" never meant \"has an authoring door\" here; that pin is adapted objectui-side when it refreshes this dependency. ADR-0049, #5015."
+ "rationale": "Like `driver-aggregate-undeclared-key-aliases-removed` and `driver-sql-distinct-bare-filter-typed`, this entry records a LENIENCY being withdrawn rather than a declared surface: `$regex` was never in `FILTER_OPERATORS` and never a key on `StringOperatorSchema`. That is measured, not assumed — `git log -S'$regex'` over `packages/spec/src` returns only doc comments describing how `$contains` LOWERS to MongoDB (`Contains substring - SQL: LIKE %?% | MongoDB: $regex`), plus #5701 itself, 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 #4706 ruling (B), not just the driver one: the contract half (#5701 — the `$icontains` declaration, the `$contains` family pinned case-sensitive, and the `RETIRED_FILTER_OPERATORS` prescriptions) landed before the ADR-0087 disposition gate (#6148) existed and so was never asked for a ledger entry; the driver half (#5702) is where the refusal became executable. One surface, one entry, registered from the half that made it observable. ADR-0049 / ADR-0087, #4706 / #5701 / #5702."
+ },
+ {
+ "surface": "flow.errorHandling.maxRetries (under strategy: 'retry')",
+ "replacement": "an explicit count >= 1 (e.g. maxRetries: 3), or strategy: 'fail'",
+ "migrationId": "flow-retry-max-retries-required",
+ "toMajor": 17,
+ "rationale": "maxRetries had two defaults — FlowSchema `.default(0)` and the engine's `maxRetries ?? 3` — so an unstated count retried 0 times through the schema and 3 times through a hand-built definition (#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."
},
{
"surface": "data.hookContext.session.roles",
@@ -673,123 +652,151 @@
"rationale": "Declared on the runtime hook context, read by exactly two consumers, produced by nobody. The two readers were the approvals record lock and the delegation write guard, each opening with `session.roles?.includes('admin')`; ObjectQL's `buildSession()` builds the session field by field and has never written `roles`, and nothing else feeds a HookContext in objectstack, cloud or objectui (cloud's hook consumers read `hookContext?.session?.userId`; objectui's `roles` are the `/auth/me` user payload, a different surface; an ACTION body's `ctx.session` is a different untyped object that does carry `roles`, tracked apart and unaffected). Both branches were therefore dead on every real engine path — an authorization decision in shape only, and a second admin dialect competing with the one ADR-0090 D3 / ADR-0095 D3 sanction. #4839 (PR #5049) removed the readers; 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` (#4579) / `activationEvents` (#4657) 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, #3733 / ADR-0104), so a consumer that parses a context it was handed still meets the prescription. ADR-0049, #5050."
},
{
- "surface": "ui.actionSession.roles",
- "replacement": "ui.actionSession.positions (an action body reads `ctx.session.positions`)",
- "migrationId": "action-session-roles-to-positions",
+ "surface": "engine.registerHook(event, handler, { object: '' | [] | [''] }), and a scope whose `excludeObjects` cancels its `object` entirely",
+ "replacement": "name the object(s) — `object: 'account'` / `object: ['account', 'contact']` — or, for a global hook, `object: '*'` or no `object` key at all; for a cancelled scope, widen `object` or drop the overlapping names from `excludeObjects`",
+ "migrationId": "hook-register-empty-object-target-refused",
"toMajor": 17,
- "rationale": "The MIRROR-IMAGE neighbour of the entry above, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright, #5050), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. #5613 ruled contract-first (maintainer, 2026-08-06: \"C skeleton + A semantics\"): phase 1 (#5697) declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it (#5779); the producer emits both for one deprecation window (#5613 runtime half), after which `roles` is removed on the path the v11 session-alias removal already walked (#3280 deprecated → #3290 removed). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087, #5613 / #5779."
+ "rationale": "#4281 ruled that an empty hook target is not \"no target\" and closed the shape at the two METADATA doors — `HookSchema.object`'s refine and `hook-binder.ts`'s `normalizeObjects`. `engine.registerHook`, the CODE door, goes through neither, so all three spellings still registered, each producing a defect the author did not write: `''` is FALSY, so the allow face was skipped entirely and the entry became a GLOBAL hook (#4281's headline failure mode — blank intent taking the broadest possible blast radius); `[]` and `['']` are truthy but admit no object name, so the entry could never fire. #5928 then added the `excludeObjects` face, which brought a fourth shape reached by arithmetic rather than by one bad name: an `object` list every member of which is also excluded admits nothing, so that entry can never fire either. All four are ADR-0078 silently-inert declarations, and all four are now refused at REGISTRATION.\n\nNo mechanical rewrite exists, in either direction. The refused values carry no recoverable intent — `object: ''` could have meant `'*'` (what it actually did) or a specific object name the author forgot to fill in, and those are opposite registrations; choosing between them is a judgment the chain cannot make. Nor could the MATCHING read be changed instead: teaching the matcher that `''` is an unmatchable name would silently convert a hook firing on every object into one firing on none — the same class of defect pointing the other way, which is why #5928 declined to do it in passing.\n\nThis is a RUNTIME registration API, not stored metadata, so — like `hook-context-session-roles-retired` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and the ledger entry is the notification channel. One metadata surface reaches it INDIRECTLY and is the reason this is not purely a code-side note: a `record-change` flow's start node forwards `config.objectName` verbatim into `registerHook` (`RecordChangeTrigger.start`), so a flow authored with a blank `objectName` used to bind a trigger to EVERY object in the tenant. It now fails to bind instead, loudly — the automation engine's per-flow bind guard warns and the `kernel:bootstrapped` binding audit re-reports it — which is the correct end state, but it is an observable change for that flow. #6573, #4281, #4001, #5928, ADR-0078."
},
{
- "surface": "action body / AI route: ctx.user.roles (req.user.roles)",
- "replacement": "ctx.user.positions (an AI route handler reads `req.user.positions`) — the same array, under the one spelling ADR-0090 D3 sanctions",
- "migrationId": "actor-user-roles-to-positions",
+ "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 THIRD face of the ADR-0090 `roles` → `positions` rename, and the only one whose surface the spec never declared. `ActorUser` (`packages/runtime/src/security/actor-user.ts`) is the ONE producer of the `user` envelope handed to an action body as `ctx.user` and to an AI route handler as `req.user`; it declared `positions` and `roles` side by side and filled them from a SINGLE assignment (`roles: core.positions`), so the two keys were verbatim identical on every dispatch — a second spelling of the vocabulary ADR-0090 D3 reserves and bans, published straight into author-written code. The maintainer ruled it closed IMMEDIATELY (2026-08-06 14:49Z, #6011): no deprecation window, no dual-emit, the alias simply gone in 17 (PR #6048). ⚠️ Do not read this entry across to its neighbour above: `action-session-roles-to-positions` governs `ctx.session`, a DIFFERENT object reached through the same `ctx`, and that one KEEPS its one-window dual-emit (#5613). Same word, same dispatch, two faces, two schedules — `ctx.user.roles` is absent in 17 while `ctx.session.roles` still answers for the length of its window. What makes this entry different in KIND from both session-side siblings: `ctx.user` has no spec schema and never had one. It is a runtime TS interface, so unlike `HookContext.session.roles` (tombstoned on a deliberately non-strict `HookContextSchema`, #5050) and unlike `ActionSessionSchema` (declared contract-first at #5697 precisely so its key could be renamed), there is no schema key here to tombstone and no `retiredKey()` prescription that could reach anybody — nothing ever ran an `ActorUser` through a `.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it reports at the READ site inside the author's own body; for an untyped or sandboxed body there is no enforced channel at all, which is exactly why this ledger entry has to exist — `spec-changes.json` and the generated upgrade guide are the ONLY way such a reader learns of the rename. It is the `findStream` (#4484) / `IStorageService.list` (#5540) disposition — a TS/API contract, no stored source, no tombstone, tsc at the call site — applied to a surface that lives one layer further out than either: those two are at least DECLARED in `packages/spec/src/contracts`, this one only in `packages/runtime`. Why it is a D3 semantic TODO and not a D2 conversion, on the same two independent grounds as its session sibling: FIRST, there is no source to convert — an `ActorUser` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key (the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape). SECOND, the only place the key is ever SPELLED is inside an action body or an AI route handler: author-written JS/TS, or a sandboxed script. A declarative transform cannot safely rewrite an identifier inside free-form code — the same reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. The removal's hard precondition was met before it landed, and the result is recorded here because the ledger is where an upgrading consumer meets it: the declaration's own comment claimed the alias was \"kept for the REST/AI shapes\", and that claim was DISPROVEN face by face against `origin/main` — repo-wide `user.roles` was 4 hits, all of them in the pins PR #6048 flipped; the four `ActorUser` construction sites build server-side envelopes that never enter a response body; objectui's `.roles` reads belong to two unrelated producers (the better-auth session, and the `/auth/me/permissions` payload). The `cloud` repo was NOT reachable in that session and is the one consumer face left unverified — this entry, and the changeset's FROM/TO prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087, #6011 (PR #6048)."
+ "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."
},
{
- "surface": "contracts.IStorageService.list",
- "replacement": "track the keys you wrote (sys_file / file-reference records, queryable through ObjectQL with real pagination) instead of enumerating the bucket — and where no such record exists, the cursor-shaped `list(prefix, { cursor, limit })` this entry reserved, restored in #6781",
- "migrationId": "storage-service-list-retired",
+ "surface": "api.ImportRequest runAutomations — the declared default of the key on BOTH import bodies, POST /api/v1/data/:object/import (ImportRequest) and its async twin POST /api/v1/data/:object/import/jobs (CreateImportJobRequest, which IS the same schema object). It was declared default(false) and described as \"off by default for bulk\"; it is now default(true), which is what the server has always done",
+ "replacement": "an explicit runAutomations: false on any import request that is meant to load rows without firing triggers/hooks. That spelling is unchanged and has always been the only one the server read — what changes is that omitting the key now DECLARES what it already DID. Callers who want automations on need write nothing",
+ "migrationId": "import-run-automations-declared-default-corrected",
"toMajor": 17,
- "rationale": "`list(prefix)` was an OPTIONAL contract method documented as \"List files in a directory/prefix\", and the two shipped adapters answered the same call with two different semantics — both of them silently incomplete. `LocalStorageAdapter.list` was a single-level `readdir`, so a nested key `a/b/c` was invisible under `list('a')` (only `a/b` came back), and a subdirectory that `stat` succeeded on was pushed into the result as a file, yielding a `StorageFileInfo` whose `size` is a directory inode and which cannot be downloaded at all. `S3StorageAdapter.list` was RECURSIVE (`ListObjectsV2` matches the whole key) and read neither `IsTruncated` nor `ContinuationToken`, so past 1000 objects the \"all files\" a caller received was the first page, with no signal. One contract method, two dialects, both quietly incomplete — and the first feature that genuinely needed to enumerate a prefix (backup, orphan sweep, migration audit) would have got two different answers on two deployments without an error on either. #5172 was nearly that feature: it planned to drive attachment reclamation off `list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one level down, and switched to queue-driven deferred work instead. Nothing consumed it afterwards: the only in-repo call site was the `SwappableStorageService` pass-through (which itself rejects when the active adapter has no `list`), and REST, CLI and the storage routes never called it. Remove was chosen over align-and-tighten (maintainer ruling, 2026-08-05, #5266): aligning would grow a conformance surface nobody walks, while a prefix listing that cannot paginate is the wrong signature to inherit — when a real caller needs enumeration it returns cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases (nested keys, directory entries, >1000 objects) proving both backends agree. This is a TS/API contract surface — a storage adapter is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription there would reach no one. The enforced channel is tsc, and it reports at the call site. Same disposition, and the same reason, as `data-driver-find-stream-retired` (#4484). ADR-0049 / ADR-0087, #5540 (analysis #5266)."
+ "rationale": "A DECLARATION corrected to match a runtime that did not move — the inverse of a behaviour flip, and registered here for the reason protocol 12's `rest-requireauth-default-flip` and this major's `action-descriptor-resume-authority-default-flip` are: whether a given import was meant to fire triggers is a judgment no transform can make, so the prescription is a TODO rather than a rewrite. The server decides in import-prepare.ts with `body?.runAutomations !== false`, i.e. an omitted flag runs automations, and has since #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."
},
{
- "surface": "driver aggregate() call argument — query.aggregate and aggregations[].func",
- "replacement": "query.aggregations and aggregations[].function — the spellings QueryASTSchema and AggregationNodeSchema have always declared",
- "migrationId": "driver-aggregate-undeclared-key-aliases-removed",
+ "surface": "job.retryPolicy.maxRetries (> 10) / job.retryPolicy.backoffMultiplier (< 1)",
+ "replacement": "maxRetries <= 10, and backoffMultiplier >= 1",
+ "migrationId": "job-retry-policy-constraints-tightened",
"toMajor": 17,
- "rationale": "`SqlDriver.aggregate` and `RemoteTransport.aggregate` each read two aliases the Query Protocol has never declared: `query.aggregations || query.aggregate` and `agg.function || agg.func`. \"Never declared\" is measured, not assumed — `git log -S` over `data/query.zod.ts` finds no commit that ever introduced either name, there is no `retiredKey()` tombstone and no alias-table entry for them (the file's only alias table is `SortNode`'s `direction` → `order`), and neither appears in any upgrade guide or release note. So this entry does not record a declared surface being withdrawn; it records a LENIENCY being withdrawn, which is why it is here rather than behind a tombstone. The only writers in this repository were the two driver packages' own fixtures — #4984's family, where a fixture spelling the alias keeps the tolerant limb green forever and no test in existence can go red on its deletion — so ADR-0049 enforce-or-remove applies once those are re-spelt. ⚠️ Do NOT read this across to `dashboard`/`page` measures: `aggregate` IS the canonical key there and `func` IS a declared, loudly-suggesting alias (`DatasetMeasureSchema`, ui/dataset.zod.ts). That neighbouring vocabulary is untouched, and it is the most likely reason an off-repo caller ever wrote these keys on a QUERY — one habit, two surfaces, only one of which declared it. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone: nothing ever ran a query through `QueryASTSchema.parse()` on this path. The enforced channel is tsc at the call site, once the parameter is `DriverQuery` — and for an untyped JS caller there is no enforced channel at all, which is exactly why this ledger entry has to exist: the generated upgrade guide is the only way such a reader learns of the rename. Same disposition, and the same reason, as `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011). ADR-0049 / ADR-0087, #6321 (PR #6404)."
+ "rationale": "The converged RetryPolicy (#4661) keeps the automation side's bounds, which the job side never had: `maxRetries` is capped at 10 and `backoffMultiplier` floored at 1. Neither has a lossless rewrite. Clamping `maxRetries: 20` to 10 would halve a retry budget its author chose, and a `backoffMultiplier` below 1 describes a delay that SHRINKS on each attempt — retrying a failing dependency ever faster, which is the opposite of backoff and was never a shape the engine meant to offer. Both now fail at parse time with the bound named, rather than being silently reinterpreted. Choosing the replacement count (or accepting the cap) is the author's call."
},
{
- "surface": "type alias: the 102 XInput names of @objectstack/spec (ConnectorInput, AppInput, PageInput, ActionInput, ServiceObjectInput, ExecutionContextInput, TaskInput, … — 52 files across api/ automation/ data/ identity/ integration/ kernel/ security/ system/ ui/)",
- "replacement": "the BARE name. ADR-0122 phase 2 moved the author state onto `X`, which makes `XInput` a character-for-character synonym of it — the permanent synonym D3 forbids. Drop the `Input` suffix: `ConnectorInput` -> `Connector`. Symmetrically, a consumer that held a PARSE RESULT under the bare name moves to `XParsed`, which phase 1 (16.x) already declared for every schema whose two shapes differ, so the target name has existed for a release. NINE `*Input` names are NOT retired and need no edit: `ExpressionInput`, `CronExpressionInput`, `TemplateExpressionInput` and `PredicateInput` are the bare aliases of their own `…InputSchema`, and `FormFieldInput`, `QueryInput`, `FieldInput`, `ObjectStackDefinitionInput` and `NavigationItemInput` are composed (recursive or `Partial`-shaped) types no bare alias denotes.",
- "migrationId": "spec-type-alias-input-suffix-retired",
+ "surface": "api.listNotifications cursor — the key on BOTH halves of GET /api/v1/notifications (ListNotificationsRequestSchema and ListNotificationsResponseSchema) and the cursor argument of the client SDK call client.notifications.list(). The same entry covers the limit default: the request schema no longer declares default(20)",
+ "replacement": "a larger `limit` — the route answers the newest N notifications and has no page 2. There is no replacement for `cursor`, deliberately: nothing ever minted one, so no caller holds a value to carry over. Callers that looped on it were re-reading the first window and should read one window sized to what they display (the Console bell polls exactly this way). For the removed `limit` default, send the number you want explicitly if you were relying on 20 — omitting it takes the server window, which is 50 on the platform inbox and clamped into 1..200, and has been since before the declaration existed",
+ "migrationId": "notification-list-cursor-retired",
"toMajor": 17,
- "rationale": "This entry exists for the reason `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) exist, and it is the same disposition: the surface is a TYPESCRIPT NAME, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — an `XInput` alias never had a carrier key, never emitted a def, and no `.parse()` ever saw it. Measured and verified rather than assumed: `json-schema/`, `json-schema.manifest/` and `authorable-surface/` are BYTE-IDENTICAL across this change, because those generators enumerate runtime `z.ZodType` exports and never read a type alias. So nothing left the published metadata surface and RETIRED_DEFS_BY_MAJOR is deliberately untouched — an entry there would falsely claim the metadata contract shrank. The enforced channel is tsc: the name is gone, so every consumer gets TS2724/TS2305 naming the import. That is loud but MUTE about the replacement — a compile error says `ConnectorInput` does not exist, not that `Connector` now means what it meant. The generated upgrade guide is the only channel that carries the second half, which is precisely the #6048 gap ADR-0087 registration exists to close. ⚠️ Deliberately NOT registered alongside it: the 1384 bare aliases the same change FLIPPED from `z.infer` to `z.input`. Those names all still exist and still resolve; what moved is which of a schema's two shapes they denote, and only where the two differ (663 of 1384 — the rest are isomorphic and the flip is a no-op there, pinned as such). A consumer holding an authored literal is made MORE correct by it, silently; one holding a parse result gets a tsc error at the first defaulted key it reads. Registering that as a rename would misdescribe it — no name was retired — and the changeset carries its own FROM -> TO for it. ADR-0122 D8/D9, #6083 (PR #6279)."
+ "rationale": "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."
},
{
- "surface": "SqlDriver.distinct() third argument — any value",
- "replacement": "a bare FilterCondition (@objectstack/spec/data) — the same value find() carries under query.where, never a query envelope",
- "migrationId": "driver-sql-distinct-bare-filter-typed",
+ "surface": "kernel.dynamicLoadRequest.activationEvents / studio.studioPluginManifest.activationEvents",
+ "replacement": "(removed — delete the key. Every plugin activates immediately on load/registration, which is the only behaviour that has ever existed; `activate()` still runs at registration time. Lazy activation, if built, returns via the enforce route of ADR-0049 through a new ADR, with a vocabulary its executor actually honours)",
+ "migrationId": "plugin-activation-events-retired",
"toMajor": 17,
- "rationale": "This entry records a TYPE being added, not a surface being withdrawn, and it says so up front because the distinction decides who has to do anything. `distinct` is not declared on `IDataDriver`, so #5181 / #6075 never reached it and it kept `filters?: any` while its body said something far more specific — `applyFilters(builder, filters)` is handed the ARGUMENT ITSELF, never a `.where` off it. ⚠️ RUNTIME BEHAVIOUR IS UNCHANGED by this entry's change: not one statement moved, so no upgrade breaks at run time and nothing that answered correctly stops. What the annotation removes is a compile-time hole, measured rather than assumed: a truthy NON-OBJECT third argument — `distinct('orders', 'product', 'completed')` — used to type-check and resolve the UNFILTERED set, because `applyFilters` emits no predicate at all for a truthy non-object, non-array filter. A call meaning \"which products among completed orders\" answered with EVERY product, silently. That spelling is now TS2345 at the call site. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone, the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540), `actor-user-roles-to-positions` (#6011) and `driver-aggregate-undeclared-key-aliases-removed` (#6321) already carry. ⚠️ It differs from those four in ONE measured way a reader should not have to infer: because nothing changed at run time, an untyped JS caller is not affected BY THE UPGRADE at all. The entry is here for a different reason — such a caller is exactly the one tsc can never reach, and the silent widening above is a defect they may ALREADY be sitting on, before and after this major. The generated upgrade guide is the only channel that reaches them, which is why the fix is written down rather than left to the compiler. ⛔ The reverse mismatch is NOT closed and no type can close it: `FilterCondition` is an open map (`[key: string]: any`) because a filter key IS a field name, so a query envelope `{ object, where }` is structurally a valid filter — one constraining columns named `object` and `where` — and so is a FilterArray. Both reach `distinct` type-checked and are refused at run time, loudly, with INVALID_FILTER / 400. `driver-memory`'s opposite half — where the BARE spelling returns the unfiltered set in silence — stays open under the #5499 freeze (#6320). ADR-0087, #6320."
+ "rationale": "Both `activationEvents` keys — and the `ActivationEventSchema` trigger vocabulary they embedded (`onCommand` / `onRoute` / … / `onView` after the #4653 convergence) — 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 (#3950: an exported schema with no consumer is read as a capability). #4657. SUPERSEDED ON THE KERNEL SIDE by #4834 (same unreleased major): the whole `DynamicLoadRequest` shape — and the rest of the plugin-runtime family with it — was removed, which took this key's `retiredKey()` tombstone with it. That is strictly stronger than the tombstone, not weaker: there is no longer a `DynamicLoadRequest` to author the key INTO, so the prescription an author needs is no longer \"delete this key\" but \"this request shape does not exist\" (see `plugin-runtime-family-retired`). The studio half of this entry is unaffected and still enforced by the strict manifest parse."
},
{
- "surface": "data.filter $regex / $options — in a STORED filter (dashboard widget filter and globalFilters, report runtimeFilter, page and component filter, solution-blueprint filter), and equally in the where clause of a query request",
- "replacement": "$icontains for the case-insensitive substring match this was almost always used for, or $contains for a case-sensitive one — a pattern that genuinely needs a regular expression has no filter-level replacement",
- "migrationId": "filter-regex-options-retired",
- "toMajor": 17,
- "rationale": "Like `driver-aggregate-undeclared-key-aliases-removed` and `driver-sql-distinct-bare-filter-typed`, this entry records a LENIENCY being withdrawn rather than a declared surface: `$regex` was never in `FILTER_OPERATORS` and never a key on `StringOperatorSchema`. That is measured, not assumed — `git log -S'$regex'` over `packages/spec/src` returns only doc comments describing how `$contains` LOWERS to MongoDB (`Contains substring - SQL: LIKE %?% | MongoDB: $regex`), plus #5701 itself, 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 #4706 ruling (B), not just the driver one: the contract half (#5701 — the `$icontains` declaration, the `$contains` family pinned case-sensitive, and the `RETIRED_FILTER_OPERATORS` prescriptions) landed before the ADR-0087 disposition gate (#6148) existed and so was never asked for a ledger entry; the driver half (#5702) is where the refusal became executable. One surface, one entry, registered from the half that made it observable. ADR-0049 / ADR-0087, #4706 / #5701 / #5702."
+ "surface": "manifest.loading (the whole block: strategy / preload / codeSplitting / dynamicImport / initialization / dependencyResolution / hotReload / caching / sandboxing / monitoring)",
+ "replacement": "nothing to re-declare — delete the key. Plugins are composed at boot: `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder` in `packages/core/src/plugin-order.ts`). For the isolation `loading.sandboxing` appeared to configure, use the plugin trust tier (`manifest.runtime`, ADR-0025 §3.6) and the manifest permission declarations, which are the surfaces the platform actually enforces",
+ "migrationId": "plugin-manifest-loading-retired",
+ "toMajor": 17,
+ "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-08-04 on #4914. The block declared a complete plugin loading policy and NOTHING read it. A bare-name scan of all three repos — objectstack, cloud (measured 2026-08-09) and objectui (measured at pickup), each with a control probe proving the scan saw the tree — put every hit inside `packages/spec` itself: this module's own declaration, its own unit tests, the `Manifest.loading` embed and the generated artifacts. `manifest.loading.*` had zero readers in `packages/core`, `packages/runtime` and `packages/metadata`. So the key parsed, entered the manifest, and changed nothing — #3950, at the scale of a whole block. What made it outrank ordinary inert-key cleanup is `sandboxing`: it declared process / vm / iframe / web-worker isolation, IPC transports and an `allowedServices` ACL, so an AI author (ADR-0033) reading that vocabulary concluded the platform isolates plugins, wrote the config, and received a clean parse and zero isolation. An inert security control is worse than an absent one because it is believed. Hot reload was additionally a TWO-SOURCE defect: the docs pointed at this dead `PluginHotReloadSchema` while the only implementation body, `HotReloadManager` (`packages/core/src/hot-reload.ts`), reads a different vocabulary — `HotReloadConfigSchema` in `plugin-lifecycle-advanced.zod.ts`. Ruling §2 converges on the surviving side: that schema is KEPT as the starting point for a future enforce decision (it has an implementation body but no runtime composes it yet), and enforcing it is deliberately a separate decision, not this retirement. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections. A package manifest is neither — `PLURAL_TO_SINGULAR` has no `packages` / `plugins` entry, so a manifest is not a stack collection member and a stored manifest row passes that seam through unchanged. A conversion would be a transform with no seam that ever runs."
},
{
- "surface": "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",
+ "surface": "kernel.dynamicLoadRequest / kernel.dynamicUnloadRequest / kernel.dynamicPluginResult / kernel.pluginSource / kernel.dynamicPluginOperation",
+ "replacement": "(removed — there is no replacement shape, because there is no operation to describe. Plugins are composed at boot: `defineStack` registers them and the kernel runs register → init → start; the set is fixed until the process restarts. Delete the import and the value. Runtime plugin loading, if it is ever built, returns via the enforce route of ADR-0049 through a new ADR — loader first, vocabulary second)",
+ "migrationId": "plugin-runtime-family-retired",
"toMajor": 17,
- "rationale": "The 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 five schemas declared the \"Dynamic Loading\" capability — runtime load / unload / reload of plugins without a kernel restart, with sandboxing, integrity hashes, drain strategies and dependent-cascade policy — and NOTHING implemented it. A bare-name scan of objectstack, cloud and objectui found zero references outside this package's own declaration, its unit tests and the generated artifacts: no runtime ever received a `DynamicLoadRequest`, performed a load/unload, or produced a `DynamicPluginResult`. That is the ADR-0049 false-compliance shape at its most inviting to an AI author (ADR-0033), who reads `DynamicLoadRequestSchema` in the published IDE bundle as proof the platform hot-loads plugins and constructs a request that parses clean and is received by nobody (#3950: an exported schema with no consumer is read as a capability). The #3896 follow-up removed this module's discovery/sandbox config island and left these five in place explicitly — \"operation contracts, not security promises; the enforce-or-remove call on them is a design decision rather than a correction\" — but that suspension lived only in a changeset paragraph with no issue carrying it. #4834 is that decision, answered REMOVE. `experimental` was considered and rejected: it is only `.describe()` prose and cannot stop an import, the weakest of the three ADR-0049 channels. None of the five is stored metadata — they are root request/result payload shapes embedded in no parent schema and parsed against no metadata document — so no `sys_metadata` row can carry one and there is no source for the D2 chain to rewrite; this entry is the D3 record. The removal also subsumes the kernel half of `plugin-activation-events-retired` (#4657): that tombstone goes with the shape that carried it. ADR-0049, #4834."
},
{
- "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",
+ "surface": "data.query.aggregations[].function ('array_agg' / 'string_agg')",
+ "replacement": "an ordinary `fields` query, shaped in the caller — or a stored field that materialises the roll-up. For a deduplicated COUNT the live spelling is unchanged: `count_distinct` stays declared",
+ "migrationId": "query-array-string-agg-retired",
"toMajor": 17,
- "rationale": "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": "The stored half of this retirement is a conversion (`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. `QueryAST` is never stored in stack metadata — it is the client SDK builder's output and the `POST /data/:object/query` body — so there is no source for the chain to rewrite and callers move their own queries. Both values were declared-but-unlowered on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller following the schema against a SQL datasource got a refusal, not an array. They did run on `driver-mongodb` and on the engine's in-memory fallback, which is what makes this the one narrowing in the batch that removes reachable behaviour: an aggregation that worked on one backend and failed on another is exactly the unpredictability the ruling ended, and #5499 has both of those backends frozen. `count_distinct` was deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049's enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049, #6188."
},
{
- "surface": "automation.etlPipeline / automation.etlPipelineRun / automation.etlSource / automation.etlDestination / automation.etlTransformation (the whole L2 layer of automation/etl.zod.ts, its four enums and the `ETL` factory — 9 defs, 27 exported names)",
- "replacement": "(removed — no protocol surface replaces it, deliberately. Layer by layer: connector-attached synchronisation is `ConnectorSchema.syncConfig` (`integration/connector.zod.ts`), which IS parsed and executed; per-field value transformation on import is `shared/mapping.zod.ts`, whose `transform` is applied row by row by the REST import path and recorded key by key in `packages/spec/liveness/mapping.json`; scheduling is `system/job.zod.ts`. What has NO replacement is multi-source, multi-stage movement with joins and aggregations — because it never had an implementation either. It returns through the ENFORCE route: the engine first, the vocabulary second)",
- "migrationId": "etl-pipeline-layer-retired",
+ "surface": "data.query.cursor",
+ "replacement": "a `where` predicate on the sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` (the documented manual-keyset pattern)",
+ "migrationId": "query-cursor-retired",
"toMajor": 17,
- "rationale": "The reading #4738 used to retire L1 `DataSyncConfig`, re-measured one layer up and identical: narrative-only. No engine ever parsed, scheduled or executed an `ETLPipeline`. Measured on origin/main immediately before the removal: the only non-spec references in this repo are two fumadocs-generated documentation sources (`apps/docs/.source/*.ts`), not executors; objectui has no reference at all; there is no `liveness/etl.json` or `pipeline.json`, so no ADR-0049 gate ever had a reading on it — while the same file family's EXECUTED half does have one (`liveness/mapping.json`), which is the contrast that makes the absence meaningful rather than an oversight. The `etl` string in this registry was the one untested link the finding named, and it is not a loader path: it was the id of the #4962 retry-vocabulary entry, absorbed here. The layer was ADR-0078's asymmetry in its purest form — an author could write a complete ten-stage pipeline, get no error, and get no execution. It was also advertised: `packages/spec/docs/SYNC_ARCHITECTURE.md` named `ETLPipeline` as the recommended destination for authors displaced by the L1 retirement (#4738) and listed ten transformation types with copyable examples down to `script | Custom JavaScript/Python`. That document is rewritten in the same change; a retirement whose own doc still recommends the retired layer is self-contradictory, and forwarding L1's authors to a second layer with no executor was the defect compounding rather than closing. ⚠️ `etl-retry-converged-onto-retry-policy` (#4962) is SUBSUMED here, the #4657/#4834/#5055 way: both land in the unreleased protocol 17, so composed, a rename of `retry.maxAttempts` on a shape that does not survive the major has no observable effect — and keeping both would tell an upgrader to rewrite a key on a schema the same upgrade deletes. The `maxAttempts` `retiredKey()` tombstone goes with the shape that carried it, which is strictly stronger than the tombstone: there is no longer a `retry` block to author the key into. Route 3 — no carrier key, no parse site, so no D2 conversion and no tombstone; RETIRED_DEFS_BY_MAJOR plus this entry are the declaration. ADR-0049, ADR-0078, #6414."
+ "rationale": "The `cursor` key promised keyset pagination and no driver implemented it: the cursor was accepted and ignored, so every page came back identical — a caller looping \"until hasMore is false\" never terminates. Worse than inert, it had a shipped public producer (`QueryBuilder.cursor()`, removed with the key). The caller-built `Record` shape also leaks sort/storage detail and squats on the reserved REST parameter set; a first-class cursor, if ever designed, will be a response-minted opaque token — a different API, so keeping this one preserved a wrong design rather than a roadmap. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286."
},
{
- "surface": "automation.ActionDescriptor.resumeAuthority — an OMITTED value on a pausing node descriptor (supportsPause: true, or any executor whose execute() returns suspend: true)",
- "replacement": "an explicit resumeAuthority: 'any' on the descriptor, for a pausing node whose pauses really are meant to be continued through the generic resume route (POST /automation/:name/runs/:runId/resume) — a screen-style collected-input pause, or a signal wait an external producer resumes. Declare 'service' instead if continuing is the tail of a decision your own service must authorize and record first. Either value is a one-line addition; only the silence changed meaning",
- "migrationId": "action-descriptor-resume-authority-default-flip",
+ "surface": "data.query.distinct",
+ "replacement": "`groupBy` for unique combinations; the `count_distinct` aggregation for deduplicated counts; the SQL/memory drivers' `distinct(object, field)` door for one column's values",
+ "migrationId": "query-distinct-retired",
"toMajor": 17,
- "rationale": "A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as protocol 12's `rest-requireauth-default-flip`, and it is registered here for the same reason: whether a given pause is genuinely open to the generic route is a trust judgment no transform can make. The #3801 resume gate keys on the SUSPENDED NODE, and `ActionDescriptor.resumeAuthority` used to default to `'any'`, so a pausing node type shipped raw-resumable unless its author remembered the field. It now resolves to `'service'` when absent: an unclaimed pause is refused on the generic route with `PERMISSION_DENIED` / 403 until its descriptor states who may continue it. #3823 is the incident that decided the direction — ADR-0044 pointed an approval's revise edge at a generic `wait`, `wait` is legitimately `'any'`, and the pause standing in a service-owned position inherited a fail-open value nobody chose; the demonstrated cost was an unaudited resubmit plus a destroyed remote run. The two possible mistakes are asymmetric, which is the whole argument: guessing `'any'` walks past a decision nothing recorded and is silent, while guessing `'service'` returns a refusal naming the missing field. ⚠️ The surface is a DESCRIPTOR FIELD set in plugin CODE, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) already carry. It differs from those in one way a reader should not have to infer: nothing is REMOVED, so tsc reports nothing at all — the field was already optional after step one and an omission still compiles. The enforced channels are all run-time: a registration warning naming the node type (once per type per engine), the refusal message on the resume itself, and `check:resume-authority-declared` for executors living in this repo. For a third-party plugin the generated upgrade guide is the only channel that arrives BEFORE a user hits a run that will not continue. In-tree the flip moves nothing: all six shipped pausing types (screen, wait, subflow, map, approval, approval_revise) declare their authority explicitly. ADR-0044 amendment (2026-07-28) and its 2026-08-08 landing section, ADR-0019 #3801 addendum, #5561."
+ "rationale": "The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND worse pagination metadata, and a side effect that \"confirmed\" the flag was doing something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with the key). The count suppression is deleted in the same change — `total` is truthful for those queries again. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286."
},
{
- "surface": "@objectstack/rest: ExportFieldMeta.required / .system / .readonly / .hasDefault / .min / .max / .minLength / .maxLength (the map built by `buildFieldMetaMap`, reached as `PreparedImport.metaMap` from `prepareImportRequest`)",
- "replacement": "the object schema you already hold — read `fields[name].required` / `.system` / `.readonly` / `.defaultValue` / `.min` / `.max` / `.minLength` / `.maxLength` off the same `ObjectSchema` you passed to `buildFieldMetaMap`, which is where the ENGINE reads them and therefore the only copy that cannot drift",
- "migrationId": "export-field-meta-constraints-retired",
+ "surface": "data.query.fields",
+ "replacement": "expand (`expand: { owner: { object: 'user', fields: ['name'] } }`), or a dotted path for a single related column (`fields: ['owner.name']`)",
+ "migrationId": "query-field-node-object-form-retired",
"toMajor": 17,
- "rationale": "ADR-0049 enforce-or-remove. These eight were never a source of truth: `buildFieldMetaMap(schema)` DERIVED each one from the very `schema` its caller passed in, so the map carried a second copy of facts the caller already held. They existed for exactly one consumer — the import dry run's hand-copied pre-check mirror (`firstMissingRequiredField` / `firstConstraintViolation`, framework#3956) — and #4633 ruling D retired that mirror (PR #6532): the dry run now asks `DataProtocol.validateData` for the engine's verdict, which reads the object's own schema. That left all eight computed on every import and read by NOTHING, which is the declared-and-unread shape ADR-0049 exists for; a constraint vocabulary standing next to the presentation one with no enforcer behind it is precisely the thing an AI-authored consumer mistakes for a contract. Verified zero-reader before removal, per key and by type, across this repo (`packages/rest` itself, and all five in-repo dependents of `@objectstack/rest`: runtime, cli, verify, plugin-auth, plugin-dev) and the `objectui` sibling; plugin-auth's identity import forwards `prepared.metaMap` into `runImport` but reads only the presentation keys through `coerceRow`. Why this needs a ledger entry despite that sweep: it is the `findStream` (#4484) / `IStorageService.list` (#5540) / `actor-user-roles-to-positions` (#6011) disposition — a published TS surface with NO spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry a prescription, and the ledger is the only channel that reaches an upgrader. It is if anything blinder than those three: the keys shipped in a FINAL release (`@objectstack/rest` 14.5.0) and have been published in every release since, and because they were OPTIONAL keys on an interface that itself survives, a JavaScript consumer reading `meta.required` after the upgrade gets `undefined` with no error at all — tsc reports at the read site only for a typed consumer. Why D3 semantic and not a D2 conversion: there is nothing to convert. No authored or stored metadata changes shape — `required` / `min` / `maxLength` and the rest remain fully authorable on a field definition and fully enforced by the engine, which is where they always lived. The only place these eight are ever spelled is inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach them. ADR-0049 / ADR-0087, #6536 (the sweep PR #6532 deliberately deferred)."
+ "rationale": "The `FieldNode` union declared a nested-select object form `{ field, fields, alias }` that was inert end to end: no producer emitted it, and no consumer read `.fields` or `.alias` — objectql's formula projection and known-field filters, driver-sql's `select()` and driver-memory's projection all treat the list as `string[]`, driver-mongodb keyed its projection with the entry itself, and the REST ingress stringified it. Nested selection is `expand`, which the engine resolves via batch `$in` queries. This is a REQUEST surface — `QueryAST` is never stored in stack metadata (no view, dataset or report authors one), so there is no source for the chain to rewrite: the schema narrows to `z.string()` and callers move their own select lists. ADR-0049 / ADR-0078, #4196."
},
{
- "surface": "ActionDescriptor.isAsync (the descriptor an executor publishes via `registerNodeExecutor` / `defineActionDescriptor`)",
- "replacement": "nothing to re-declare — delete the key. Suspension is `execute()` RETURNING `suspend: true`, and permission to suspend is `supportsPause: true` on the same descriptor (with the `resumeAuthority` its pauses need)",
- "migrationId": "action-descriptor-is-async-retired",
+ "surface": "data.query.joins",
+ "replacement": "expand (`expand: { owner: { object: 'user', fields: ['name'] } }`), or a dotted `fields` path for a single related column (`fields: ['owner.name']`)",
+ "migrationId": "query-joins-retired",
"toMajor": 17,
- "rationale": "ADR-0049 enforce-or-remove. `isAsync` declared \"this action suspends the flow awaiting an external reply\" and NOTHING read it: a fresh three-repo measurement (#6748, re-run at pickup) found zero property reads across objectstack, objectui and cloud — every hit was the declaration itself, a generated baseline, one of five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. So declaring it never made a node suspend and omitting it never stopped one, which is the silently-inert declaration ADR-0049 exists to end. It was always a second, weaker spelling of the capability `supportsPause` states, and the two diverged in exactly the way a duplicated declaration does: `screen` declared both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling in #6667 — `AutomationEngine` now refuses a suspension whose type does not declare `supportsPause: true` — so the capability this key gestured at is now a real, enforced fact under one name. This one had no consumer to grow into and takes the remove leg. Why D3 semantic and not a D2 conversion: an ActionDescriptor is published from an executor's TypeScript, never stored in stack metadata — no stack, example or template carries the key — so there is no source for the chain to rewrite and `os migrate meta` cannot reach it. The schema tombstones it via `retiredKey()` and descriptor authors delete the key themselves; that rejection (a `tsc` error at the authoring site, and a parse error inside `defineActionDescriptor`) is the channel a third-party plugin author actually meets. The `EnhancedApiError.fieldErrors` disposition, one layer down."
+ "rationale": "The `joins` array was declared-but-inert: no engine or driver read `query.joins` anywhere on the query path, so a query carrying it behaved exactly as if the key were absent — while the name squatted on the reserved REST parameter set. Related-record retrieval already has a live spelling (`expand`, resolved by the engine via batch `$in` queries), so the removal deletes the second, broken spelling rather than the capability, and the orphaned `JoinNode`/`JoinType`/`JoinStrategy` cluster goes with the key. A REQUEST surface — `QueryAST` is never stored in stack metadata — so there is no source for the chain to rewrite; callers move their own queries. ADR-0049 / ADR-0078, #4286."
},
{
- "surface": "api.listNotifications cursor — the key on BOTH halves of GET /api/v1/notifications (ListNotificationsRequestSchema and ListNotificationsResponseSchema) and the cursor argument of the client SDK call client.notifications.list(). The same entry covers the limit default: the request schema no longer declares default(20)",
- "replacement": "a larger `limit` — the route answers the newest N notifications and has no page 2. There is no replacement for `cursor`, deliberately: nothing ever minted one, so no caller holds a value to carry over. Callers that looped on it were re-reading the first window and should read one window sized to what they display (the Console bell polls exactly this way). For the removed `limit` default, send the number you want explicitly if you were relying on 20 — omitting it takes the server window, which is 50 on the platform inbox and clamped into 1..200, and has been since before the declaration existed",
- "migrationId": "notification-list-cursor-retired",
+ "surface": "data.query.windowFunctions",
+ "replacement": "`aggregations` + `groupBy` for request-level analytics; `SqlDriver.findWithWindowFunctions(object, query)` for embedders on a SQL datasource",
+ "migrationId": "query-window-functions-retired",
"toMajor": 17,
- "rationale": "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."
+ "rationale": "The `windowFunctions` array was declared-but-inert on the query path: `find()` never applied a window function, so every OVER clause a caller declared was silently dropped. The capability only ever ran behind `SqlDriver.findWithWindowFunctions()`, a driver-level door that is not on the `IDataDriver` contract and whose flat input shape (`{ function, alias, partitionBy?, orderBy? }`) the spec vocabulary never matched — `WindowFunctionNodeSchema` declared `field`/`over`/`frame` members the door never read, so that cluster is removed with the key rather than left as a false affordance. A REQUEST surface, never stored; no source to rewrite. ADR-0049 / ADR-0078, #4286."
},
{
- "surface": "manifest.loading (the whole block: strategy / preload / codeSplitting / dynamicImport / initialization / dependencyResolution / hotReload / caching / sandboxing / monitoring)",
- "replacement": "nothing to re-declare — delete the key. Plugins are composed at boot: `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder` in `packages/core/src/plugin-order.ts`). For the isolation `loading.sandboxing` appeared to configure, use the plugin trust tier (`manifest.runtime`, ADR-0025 §3.6) and the manifest permission declarations, which are the surfaces the platform actually enforces",
- "migrationId": "plugin-manifest-loading-retired",
+ "surface": "restServer.openApi31",
+ "replacement": "(removed — no replacement key exists. Delete the key; for a real outbound webhook use `Webhook` from `@objectstack/spec/automation`. Config-driven OpenAPI 3.1 webhooks/callbacks documentation returns, if ever, via the enforce route of ADR-0049 through a new ADR)",
+ "migrationId": "rest-server-openapi31-block-removed",
"toMajor": 17,
- "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-08-04 on #4914. The block declared a complete plugin loading policy and NOTHING read it. A bare-name scan of all three repos — objectstack, cloud (measured 2026-08-09) and objectui (measured at pickup), each with a control probe proving the scan saw the tree — put every hit inside `packages/spec` itself: this module's own declaration, its own unit tests, the `Manifest.loading` embed and the generated artifacts. `manifest.loading.*` had zero readers in `packages/core`, `packages/runtime` and `packages/metadata`. So the key parsed, entered the manifest, and changed nothing — #3950, at the scale of a whole block. What made it outrank ordinary inert-key cleanup is `sandboxing`: it declared process / vm / iframe / web-worker isolation, IPC transports and an `allowedServices` ACL, so an AI author (ADR-0033) reading that vocabulary concluded the platform isolates plugins, wrote the config, and received a clean parse and zero isolation. An inert security control is worse than an absent one because it is believed. Hot reload was additionally a TWO-SOURCE defect: the docs pointed at this dead `PluginHotReloadSchema` while the only implementation body, `HotReloadManager` (`packages/core/src/hot-reload.ts`), reads a different vocabulary — `HotReloadConfigSchema` in `plugin-lifecycle-advanced.zod.ts`. Ruling §2 converges on the surviving side: that schema is KEPT as the starting point for a future enforce decision (it has an implementation body but no runtime composes it yet), and enforcing it is deliberately a separate decision, not this retirement. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections. A package manifest is neither — `PLURAL_TO_SINGULAR` has no `packages` / `plugins` entry, so a manifest is not a stack collection member and a stored manifest row passes that seam through unchanged. A conversion would be a transform with no seam that ever runs."
+ "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."
},
{
- "surface": "PUT /api/v1/meta/api/{name} (runtime-authored `api` endpoints, draft and active alike)",
- "replacement": "Declare the endpoint as a stack artifact (`**/*.api.ts`, or `defineStack({ apis })`) and ship it through `publishPackage`",
- "migrationId": "api-runtime-create-withdrawn",
+ "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",
+ "migrationId": "sharing-execution-context-retired",
"toMajor": 17,
- "rationale": "The `api` registry entry declared `allowRuntimeCreate: true` and the runtime never honoured it. Measured on a real showcase boot (#5488): `PUT /api/v1/meta/api/e8_backdoor` answered 200 with `{\"success\":true,…,\"message\":\"Saved …\"}`, and the declared route then answered 404 forever — with NO `[EndpointMatcher] … EXCLUDED` line, because the endpoint was never in the index to be excluded from. The serving criterion belongs to `IMetadataService.matchEndpoint` -> `EndpointMatcher` -> `MetadataManager.listForIndex('api')`, which reads the manager's registry plus its registered loaders (`[\"filesystem\",\"memory\"]` on dev/serve); a runtime write lands in `sys_metadata`, which is in neither. A declared capability the runtime does not honour is ADR-0049 false compliance, and a write that answers \"Saved\" and then 404s forever is its most dangerous shape for the AI authors ADR-0033 targets. The maintainer ruled REMOVE on 2026-08-07 rather than converge the read path, because making the matcher read `sys_metadata` re-opens cache, invalidation, tenancy and the ADR-0110 D3 miss-vs-outage distinction on a new read path, and there is no business pull for Studio-authored endpoints today (zero `.api.*` artifacts author them at runtime; showcase uses the artifact route, #5040 E8 LIVE). There is NO D2 conversion, for the reason this list exists: nothing in an authored source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an authorable one, and the artifact route it points authors toward is untouched — a `**/*.api.ts` file valid before this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, so it is one semantic TODO for operators and Studio callers rather than a stack conversion — the same disposition `BatchOptions.validateOnly` (#4052) takes. Consequently `gateApiDraftsForPublish` (PR #5279) is retired with it: it gated a promotion into a state the matcher can never read, and with the inlet closed no `api` draft can exist for it to judge. Re-entry is recorded in the ruling: if #2657 Part B promotes `apis` to a registered type WITH A REAL CONSUMPTION PATH, the flag flips back then — implementation first, declaration second. ADR-0049 / ADR-0121, #5488 (subsumes #5311)."
+ "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."
},
{
- "surface": "api.ImportRequest runAutomations — the declared default of the key on BOTH import bodies, POST /api/v1/data/:object/import (ImportRequest) and its async twin POST /api/v1/data/:object/import/jobs (CreateImportJobRequest, which IS the same schema object). It was declared default(false) and described as \"off by default for bulk\"; it is now default(true), which is what the server has always done",
- "replacement": "an explicit runAutomations: false on any import request that is meant to load rows without firing triggers/hooks. That spelling is unchanged and has always been the only one the server read — what changes is that omitting the key now DECLARES what it already DID. Callers who want automations on need write nothing",
- "migrationId": "import-run-automations-declared-default-corrected",
+ "surface": "type alias: the 102 XInput names of @objectstack/spec (ConnectorInput, AppInput, PageInput, ActionInput, ServiceObjectInput, ExecutionContextInput, TaskInput, … — 52 files across api/ automation/ data/ identity/ integration/ kernel/ security/ system/ ui/)",
+ "replacement": "the BARE name. ADR-0122 phase 2 moved the author state onto `X`, which makes `XInput` a character-for-character synonym of it — the permanent synonym D3 forbids. Drop the `Input` suffix: `ConnectorInput` -> `Connector`. Symmetrically, a consumer that held a PARSE RESULT under the bare name moves to `XParsed`, which phase 1 (16.x) already declared for every schema whose two shapes differ, so the target name has existed for a release. NINE `*Input` names are NOT retired and need no edit: `ExpressionInput`, `CronExpressionInput`, `TemplateExpressionInput` and `PredicateInput` are the bare aliases of their own `…InputSchema`, and `FormFieldInput`, `QueryInput`, `FieldInput`, `ObjectStackDefinitionInput` and `NavigationItemInput` are composed (recursive or `Partial`-shaped) types no bare alias denotes.",
+ "migrationId": "spec-type-alias-input-suffix-retired",
"toMajor": 17,
- "rationale": "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."
+ "rationale": "This entry exists for the reason `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) exist, and it is the same disposition: the surface is a TYPESCRIPT NAME, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — an `XInput` alias never had a carrier key, never emitted a def, and no `.parse()` ever saw it. Measured and verified rather than assumed: `json-schema/`, `json-schema.manifest/` and `authorable-surface/` are BYTE-IDENTICAL across this change, because those generators enumerate runtime `z.ZodType` exports and never read a type alias. So nothing left the published metadata surface and RETIRED_DEFS_BY_MAJOR is deliberately untouched — an entry there would falsely claim the metadata contract shrank. The enforced channel is tsc: the name is gone, so every consumer gets TS2724/TS2305 naming the import. That is loud but MUTE about the replacement — a compile error says `ConnectorInput` does not exist, not that `Connector` now means what it meant. The generated upgrade guide is the only channel that carries the second half, which is precisely the #6048 gap ADR-0087 registration exists to close. ⚠️ Deliberately NOT registered alongside it: the 1384 bare aliases the same change FLIPPED from `z.infer` to `z.input`. Those names all still exist and still resolve; what moved is which of a schema's two shapes they denote, and only where the two differ (663 of 1384 — the rest are isomorphic and the flip is a no-op there, pinned as such). A consumer holding an authored literal is made MORE correct by it, silently; one holding a parse result gets a tsc error at the first defaulted key it reads. Registering that as a rename would misdescribe it — no name was retired — and the changeset carries its own FROM -> TO for it. ADR-0122 D8/D9, #6083 (PR #6279)."
+ },
+ {
+ "surface": "contracts.IStorageService.list",
+ "replacement": "track the keys you wrote (sys_file / file-reference records, queryable through ObjectQL with real pagination) instead of enumerating the bucket — and where no such record exists, the cursor-shaped `list(prefix, { cursor, limit })` this entry reserved, restored in #6781",
+ "migrationId": "storage-service-list-retired",
+ "toMajor": 17,
+ "rationale": "`list(prefix)` was an OPTIONAL contract method documented as \"List files in a directory/prefix\", and the two shipped adapters answered the same call with two different semantics — both of them silently incomplete. `LocalStorageAdapter.list` was a single-level `readdir`, so a nested key `a/b/c` was invisible under `list('a')` (only `a/b` came back), and a subdirectory that `stat` succeeded on was pushed into the result as a file, yielding a `StorageFileInfo` whose `size` is a directory inode and which cannot be downloaded at all. `S3StorageAdapter.list` was RECURSIVE (`ListObjectsV2` matches the whole key) and read neither `IsTruncated` nor `ContinuationToken`, so past 1000 objects the \"all files\" a caller received was the first page, with no signal. One contract method, two dialects, both quietly incomplete — and the first feature that genuinely needed to enumerate a prefix (backup, orphan sweep, migration audit) would have got two different answers on two deployments without an error on either. #5172 was nearly that feature: it planned to drive attachment reclamation off `list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one level down, and switched to queue-driven deferred work instead. Nothing consumed it afterwards: the only in-repo call site was the `SwappableStorageService` pass-through (which itself rejects when the active adapter has no `list`), and REST, CLI and the storage routes never called it. Remove was chosen over align-and-tighten (maintainer ruling, 2026-08-05, #5266): aligning would grow a conformance surface nobody walks, while a prefix listing that cannot paginate is the wrong signature to inherit — when a real caller needs enumeration it returns cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases (nested keys, directory entries, >1000 objects) proving both backends agree. This is a TS/API contract surface — a storage adapter is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription there would reach no one. The enforced channel is tsc, and it reports at the call site. Same disposition, and the same reason, as `data-driver-find-stream-retired` (#4484). ADR-0049 / ADR-0087, #5540 (analysis #5266)."
+ },
+ {
+ "surface": "ui.touchInteraction / ui.gestureConfig / ui.dndConfig / ui.keyboardNavigationConfig / ui.componentAnimation / ui.motionConfig / ui.pageTransition / ui.offlineConfig (the whole export surface of ui/touch.zod.ts, ui/dnd.zod.ts, ui/keyboard.zod.ts, ui/animation.zod.ts and ui/offline.zod.ts — 32 defs, 64 exported names)",
+ "replacement": "(removed — there is no replacement key, because there was never a key. Touch targets, drag-and-drop, focus management, keyboard shortcuts and motion are RENDERER BUILT-IN behaviour: the component library decides them, not a per-page metadata author. Offline is a platform capability, and its vocabulary belongs on the sync engine that owns the queue, the conflict policy and the cache — none of which exists yet. Delete the import and the value. Whichever of these earns real product pull returns WITH its own vocabulary and its executor, the #4910 way, not by un-retiring a declaration)",
+ "migrationId": "ui-interaction-config-family-retired",
+ "toMajor": 17,
+ "rationale": "Five `@objectstack/spec/ui` modules declared a full interaction-configuration vocabulary — 22 `z.object` sites across touch/gesture, drag-and-drop, focus/keyboard, animation/motion and offline/sync — and NOTHING in the protocol carried them. This is the ADR-0049 false-compliance shape in its most inviting form for an AI author (ADR-0033), and worse than the ordinary declared-but-unread defect: `authorable-surface.json` listed 109 keys under these defs and `content/docs/references/ui/{touch,dnd,keyboard,animation,offline}.mdx` rendered them as authoring tables, so the published documentation advertised a vocabulary with no carrier key anywhere. An author following `dnd.mdx` and writing a `dnd:` block onto a page component was rejected by `PageComponentSchema` for an unrecognized key — the docs and the schema disagreeing about the platform (Prime Directive #10). Three independent measurements, each with its controls passing in the same run: (1) no module under `packages/spec/src` imported any of the five except the `ui/index.ts` barrel, so no schema declared a carrier key; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` (25 roots, 4742 nodes) reached none of the 21 named object shapes, while `PageSchema`, `WebhookSchema` and `StateMachineSchema` all resolved `direct` and a synthetic carrier flipped all 21 — so unreachability was a fact about the graph, not a broken walker; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these modules' own unit tests. objectui holds TYPE re-exports and parity ratchets, never validators, and says so (#2561). The 2026-08-04 ruling weighed wiring a carrier key (option B) and rejected it: that is a feature with a renderer behind it, not ledger clean-up. It also weighed tightening the shapes to `strictObject` and rejected that explicitly — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave \"a precisely validated dead slot, the more convincing lie\" (#4583). Because there was no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, the same route 3 as #4834 (kernel plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ Not to be confused with #5021, which retired the THEME `animation` block — a different file, different defs, and that one did have a carrier key and therefore a tombstone. ADR-0049, #4988."
+ },
+ {
+ "surface": "ui.notificationAction / ui.embedConfig",
+ "replacement": "(removed — there is no replacement shape, because there was never a key to write either into. Delete the import and the value. Notification presentation is still described by the surviving `NotificationType` / `NotificationSeverity` / `NotificationPosition` vocabulary; public access to a form is granted by the LIVE `FormView.sharing` block (`SharingConfig`), which is untouched. Notification action buttons as metadata, and iframe embedding, return via the enforce route of ADR-0049 through a new ADR — carrier key and renderer first, vocabulary second)",
+ "migrationId": "ui-notification-action-embed-config-retired",
+ "toMajor": 17,
+ "rationale": "Both shapes were published `@objectstack/spec/ui` vocabulary with NO AUTHORING DOOR. #4001 批 14 measured them three ways on 2026-08-03 and this retirement re-ran all three against `origin/main` before removing anything, each with a positive control that passed in the same run: (1) CARRIER — no schema in `packages/spec/src` declared a key of either type (`ui/notification.zod`'s only non-test importer was the barrel; `ui/sharing.zod`'s were the barrel and `ui/view.zod.ts`, which names its SIBLING `SharingConfigSchema`), measured by resolving specifiers rather than substring-matching, because the repo holds two `sharing.zod` modules and a substring test miscredits `stack.zod.ts` to the UI one; (2) REACHABILITY — a BFS from the 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema`, over `build-schemas.ts`'s own walk including its derived-clone bridge, never reached either, while `Page` / `Action` / `DashboardWidget` / `Webhook` and `SharingConfig` itself all resolved `root-graph` in the same run and an injected synthetic carrier flipped both; (3) PARSE — zero `.parse()` in objectstack, cloud or objectui outside their own unit tests. So nobody could author one and nothing ever validated one: the #3950 shape, an exported schema with no consumer read as a capability, and the ADR-0033 trap where an AI author takes `EmbedConfigSchema` in the published bundle as proof the platform serves iframes. Neither is stored metadata and neither has a carrier, so no `sys_metadata` row can hold one and there is no source for the D2 chain to rewrite; this entry is the D3 record. 批 14 deliberately did NOT close them with `.strict()` — strictness is a property of a PARSE, and closing a shape nothing parses buys only \"a precisely-validated dead slot, the more convincing lie\" (#4583) — and filed the disposition as #5015, ruled REMOVE on 2026-08-04. Each was orphaned by an earlier retirement one level up: `NotificationAction` lost its wrappers at #4610 (`NotificationSchema` / `NotificationConfigSchema`, the #4535 C3 dual-source cleanup — that retirement's published \"zero consumers\" evidence was later falsified for objectui and is corrected on `ui/notification.zod`'s tombstone; the removal itself stands, #5781), and `EmbedConfig` lost its key at 17.0.0 when the 2026-06 liveness audit retired `App.embed` (no iframe route ever read it) — that key still stands as a `retiredKey()` tombstone in `app.zod.ts`, so an author who wrote the KEY already meets a prescription; this removes the value shape that outlived it. ⚠️ The retirement is per SCHEMA, not per file: `ui/sharing.zod` KEEPS `SharingConfigSchema`, a live door carried by `FormViewSchema.sharing` and read by `rest-server.ts` to mount the anonymous form routes, and `ui/notification.zod` keeps its three presentation enums. objectui consumed `NotificationActionSchema.shape.variant` as a VOCABULARY (never a parse) to pin its own hand-written `NotificationActionButton` interface — which is exactly why \"has a consumer\" never meant \"has an authoring door\" here; that pin is adapted objectui-side when it refreshes this dependency. ADR-0049, #5015."
+ },
+ {
+ "surface": "ui.widgetManifest / ui.widgetLifecycle / ui.widgetEvent / ui.widgetProperty / ui.widgetSource / ui.i18nObject / ui.pluralRule / ui.numberFormat / ui.dateFormat / ui.localeConfig (the widget-registration vocabulary of ui/widget.zod.ts, and the five doorless shapes of ui/i18n.zod.ts — 10 defs, 26 exported names)",
+ "replacement": "(removed — there is no replacement key, because there was never a key. A custom field widget is still named the same way it always was: `field.widget` is a plain string naming a component the RENDERER has registered, and objectui's registry has always carried its own runtime manifest for that (`RuntimeWidgetManifest` / `RuntimeWidgetSource` in `@object-ui/types`, objectui#3161 / #4115), which models different keys and never derived from these. For localisation: write the default-language string on `label` / `description` — the framework generates the translation key at registration time from the naming convention — and put translations in translation files, which is the LIVE `system/translation.zod.ts` surface. Widget registration and locale formatting as authorable protocol metadata return via the ENFORCE route of ADR-0049 through a new ADR — the registry / loader / formatter first, the vocabulary second)",
+ "migrationId": "ui-widget-i18n-family-retired",
+ "toMajor": 17,
+ "rationale": "`ui/widget.zod.ts` published a complete widget-registration vocabulary — a manifest with lifecycle hooks, custom events, configurable properties and an npm/remote/inline implementation-source union — and `ui/i18n.zod.ts` published a structured-label, plural-rule and locale-formatting vocabulary. NOTHING in the protocol carried either. Three independent measurements, re-run on `origin/main` immediately before the removal with their controls passing in the SAME run: (1) no module under `packages/spec/src` imported `widget.zod` at all, and the only imports of `i18n.zod` anywhere name `I18nLabelSchema` / `AriaPropsSchema` (both KEPT), so no schema declared a carrier key — `field.widget` is a `z.string()` naming a registered component and has never referenced `WidgetManifest`; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` reached none of them, while `PageSchema` / `ObjectListViewSchema` resolved `direct` in the same run and a synthetic carrier flipped every one of them; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these files' own unit tests. `NumberFormat` / `DateFormat` DID have a carrier key (`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier was itself doorless, so the subtree was `no door` rather than `no gate` and goes whole — leaving the two leaves behind would strand exported schemas with no consumer (#3950). `I18nObjectSchema` was additionally superseded by its own file-neighbour: `I18nLabelSchema`'s documentation already says translation keys are generated at registration time and translations live in translation files, and the live translation surface is `system/translation.zod.ts`, which uses none of these shapes. The 2026-08-06 ruling weighed giving them a carrier (option B) and rejected it: that is a feature with a registry and a renderer behind it, not ledger clean-up. Tightening them to `strictObject` was rejected earlier and explicitly (#4001 批 16) — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave \"a precisely validated dead slot, the more convincing lie\" (#4583). With no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, route 3, the same shape as #4988 (the ui/ interaction config family), #4834 (kernel plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ `WidgetManifest.performance`'s own `retiredKey()` tombstone (#3896 close-out) is SUBSUMED here, the #4657/#4834 way: it goes with the shape that carried it, which is strictly stronger than the tombstone, because there is no longer a manifest to author the key INTO. ⚠️ One of the nine widget sites is deliberately NOT retired. `FieldWidgetPropsSchema` survives: it is a REACT PROPS CONTRACT rather than authorable metadata (it never appeared in `authorable-surface/` or `json-schema.manifest/` — its `onChange` is a `z.function()`), so \"zero parse\" is its design and not its defect, and it acquired a live cross-repo compile-time consumer one day before 批 16 measured: objectui PR #3289 (2026-08-03) renamed `@object-ui/fields`' validation slot onto the spec's `error` with no alias, the form renderer began producing it, and `packages/fields/src/__tests__/spec-symbol-batch7.test.ts` pins the shape against `import type { FieldWidgetProps } from '@objectstack/spec/ui'` as an intentional tripwire. Re-verified on objectui `origin/main` 2026-08-07. ADR-0049, #5055."
},
{
"surface": "ui.ViewFilterRule value — the third key of a view filter rule, on every carrier of ViewFilterRuleSchema: ListView.filter, a list view tab filter, Page.filterBy, a related-list component filter and a lookup picker filter. It accepted any declared scalar or array for EVERY operator; the accepted shape is now decided by the rule operator — in / not_in require an array, between requires exactly two bounds, and every other operator is unchanged",
@@ -799,25 +806,18 @@
"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."
},
{
- "surface": "engine.registerHook(event, handler, { object: '' | [] | [''] }), and a scope whose `excludeObjects` cancels its `object` entirely",
- "replacement": "name the object(s) — `object: 'account'` / `object: ['account', 'contact']` — or, for a global hook, `object: '*'` or no `object` key at all; for a cancelled scope, widen `object` or drop the overlapping names from `excludeObjects`",
- "migrationId": "hook-register-empty-object-target-refused",
- "toMajor": 17,
- "rationale": "#4281 ruled that an empty hook target is not \"no target\" and closed the shape at the two METADATA doors — `HookSchema.object`'s refine and `hook-binder.ts`'s `normalizeObjects`. `engine.registerHook`, the CODE door, goes through neither, so all three spellings still registered, each producing a defect the author did not write: `''` is FALSY, so the allow face was skipped entirely and the entry became a GLOBAL hook (#4281's headline failure mode — blank intent taking the broadest possible blast radius); `[]` and `['']` are truthy but admit no object name, so the entry could never fire. #5928 then added the `excludeObjects` face, which brought a fourth shape reached by arithmetic rather than by one bad name: an `object` list every member of which is also excluded admits nothing, so that entry can never fire either. All four are ADR-0078 silently-inert declarations, and all four are now refused at REGISTRATION.\n\nNo mechanical rewrite exists, in either direction. The refused values carry no recoverable intent — `object: ''` could have meant `'*'` (what it actually did) or a specific object name the author forgot to fill in, and those are opposite registrations; choosing between them is a judgment the chain cannot make. Nor could the MATCHING read be changed instead: teaching the matcher that `''` is an unmatchable name would silently convert a hook firing on every object into one firing on none — the same class of defect pointing the other way, which is why #5928 declined to do it in passing.\n\nThis is a RUNTIME registration API, not stored metadata, so — like `hook-context-session-roles-retired` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and the ledger entry is the notification channel. One metadata surface reaches it INDIRECTLY and is the reason this is not purely a code-side note: a `record-change` flow's start node forwards `config.objectName` verbatim into `registerHook` (`RecordChangeTrigger.start`), so a flow authored with a blank `objectName` used to bind a trigger to EVERY object in the tenant. It now fails to bind instead, loudly — the automation engine's per-flow bind guard warns and the `kernel:bootstrapped` binding audit re-reports it — which is the correct end state, but it is an observable change for that flow. #6573, #4281, #4001, #5928, ADR-0078."
- },
- {
- "surface": "engine.find(object, { orderBy }) and engine.findOne(object, { orderBy }) naming a `formula` field — the direct engine path, not the REST ingress",
- "replacement": "denormalise the value onto the object (a stored field, written when the source changes) and sort by that — the same remedy the REST ingress has prescribed since #6924 / #6994; a `summary` field is unaffected and still sorts, because it gets a real maintained column",
- "migrationId": "engine-find-formula-order-by-refused",
+ "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": "#4226 / #4256 / #6994 closed the SORT axis at the REST ingress (`assertSortFieldsExist`, `400 INVALID_SORT`), which covers everything reaching `findData`: the list route, `POST /data/:object/query`, the export route and the RPC dispatcher. A caller reaching `engine.find()` / `engine.findOne()` DIRECTLY passed through none of it, and a `formula` ORDER BY there was dropped in silence. Measured on a real driver: `asc` and `desc` came back BYTE-IDENTICAL, in insertion order, under a success, with the rows carrying the very values they were asked to be ordered by. No column exists to order by (a formula is computed on read, so no driver materialises one), so the ORDER BY reached the driver, found nothing, and the unknown-column backstop returned the rows unordered.\n\nRuled 2026-08-10 on #7095: an ORDER BY the engine cannot apply is a 4xx with guidance prose at the public boundary, never a silent drop — the same direction as the analytics dataset refusal envelope and the #6924 sort-hint prescription. The engine's documented internal-caller tolerance (`assertProjectionFieldsExist`'s docblock) was to survive only behind a pinned internal path, and only if a MEASURED internal call site relied on it. The #7095 sweep of every in-tree `orderBy` reaching the engine directly — hooks, flows, reports, queue/job adapters, sharing, metadata loaders, expand sub-reads — found NONE: every hardcoded internal sort names a real stored column (`created_at`, `updated_at`, `version`, `priority`, `scheduled_for`, `started_at`, `next_run_at`, `recorded_at`, `id`), and no shipped object in the repo declares a `formula` field at all. So no internal path shipped, and there is no flag to opt back into the drop.\n\nThis is a CODE-path API, not stored metadata, so — like `hook-register-empty-object-target-refused` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and the ledger entry is the notification channel. No mechanical rewrite exists in either direction: the platform cannot invent the stored column the remedy prescribes, and it must not sort post-hoc instead — `driver.find` has already applied `limit` / `offset`, so re-sorting after the formulas are evaluated would reorder an ARBITRARY PAGE, which looks correct on small result sets and is wrong the moment pagination is involved.\n\nONE AUTHOR-REACHABLE SURFACE reaches this indirectly and is why it is not purely a code-side note: a saved report's `query.orderBy` (`sys_saved_report`) is forwarded verbatim into `engine.find` by `plugin-reports`, bypassing the ingress gate. A report authored to sort by a formula field used to run and return rows in an arbitrary order; it now fails loudly, with the remedy in the message. One further path is deliberately NOT a refusal: a nested `expand` sort raises this refusal inside `expandRelatedRecords`, whose pre-existing graceful-degradation `catch` swallows every expand failure and retains the raw foreign keys — so that path moves from silent to OBSERVABLE (a warning naming the field and the fix) rather than refusing. Reversing that backstop is a separate decision on all expand failure modes. #7095, #6994, #6924, #4226, #4256, #3821, ADR-0112."
+ "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."
},
{
- "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",
- "migrationId": "sharing-execution-context-retired",
+ "surface": "CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / discovery routes.workflow / RestApiRouteCategory workflow",
+ "replacement": "the live mechanisms the slot only ever pointed at: `state_machine` validation rules for record state machines, approval flow nodes on the approvals runtime (ADR-0019) for approvals, lifecycle hooks + `record_change` flows (service-automation) for record-triggered automation",
+ "migrationId": "workflow-service-slot-retired",
"toMajor": 17,
- "rationale": "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": "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."
}
],
"removed": []
@@ -912,20 +912,6 @@
}
],
"migrated": [
- {
- "surface": "permissionSet.kind / permissionSet.isProfile",
- "replacement": "position-based assignment + permission-set grants (ADR-0090 D2)",
- "migrationId": "permission-set-profile-removed",
- "toMajor": 13,
- "rationale": "The Profile concept was removed: `isProfile` is gone from `PermissionSetSchema` and the `profile` metadata kind folded into `position`. Mapping a profile onto positions and permission-set grants is an authorization-design decision, not a rename."
- },
- {
- "surface": "position.parent / sharingRule recipient role_and_subordinates",
- "replacement": "business-unit tree + `unit_and_subordinates` (ADR-0090 D3)",
- "migrationId": "position-hierarchy-flattened",
- "toMajor": 13,
- "rationale": "Positions are flat in v2 — `parent` was removed and the `role_and_subordinates` recipient with it; hierarchy lives on the business-unit tree, which expands a DIFFERENT structure than the retired role tree. Re-homing an org hierarchy is a judgment call."
- },
{
"surface": "CEL/formula: current_user.roles",
"replacement": "current_user.positions",
@@ -940,6 +926,20 @@
"toMajor": 13,
"rationale": "The legacy `'full'` OWD alias implied full access (including transfer/ delete) — wider than any canonical OWD value, so it has no lossless target ('read'/'read_write' converted mechanically; this one did not). Choosing between `public_read_write` and explicit sharing rules is a security-posture decision."
},
+ {
+ "surface": "permissionSet.kind / permissionSet.isProfile",
+ "replacement": "position-based assignment + permission-set grants (ADR-0090 D2)",
+ "migrationId": "permission-set-profile-removed",
+ "toMajor": 13,
+ "rationale": "The Profile concept was removed: `isProfile` is gone from `PermissionSetSchema` and the `profile` metadata kind folded into `position`. Mapping a profile onto positions and permission-set grants is an authorization-design decision, not a rename."
+ },
+ {
+ "surface": "position.parent / sharingRule recipient role_and_subordinates",
+ "replacement": "business-unit tree + `unit_and_subordinates` (ADR-0090 D3)",
+ "migrationId": "position-hierarchy-flattened",
+ "toMajor": 13,
+ "rationale": "Positions are flat in v2 — `parent` was removed and the `role_and_subordinates` recipient with it; hierarchy lives on the business-unit tree, which expands a DIFFERENT structure than the retired role tree. Re-homing an org hierarchy is a judgment call."
+ },
{
"surface": "object.sharingModel (absent, custom object with owner field)",
"replacement": "an explicit `sharingModel` declaration",
@@ -1354,25 +1354,39 @@
],
"migrated": [
{
- "surface": "dashboard.widgets[].compareTo: { offset: '7d' | '1M' | … } (every duration except '1y')",
- "replacement": "compareTo: { kind: 'previousPeriod' } plus an explicit window on the widget's own `filter`",
- "migrationId": "dashboard-widget-compareto-offset",
+ "surface": "ActionDescriptor.isAsync (the descriptor an executor publishes via `registerNodeExecutor` / `defineActionDescriptor`)",
+ "replacement": "nothing to re-declare — delete the key. Suspension is `execute()` RETURNING `suspend: true`, and permission to suspend is `supportsPause: true` on the same descriptor (with the `resumeAuthority` its pauses need)",
+ "migrationId": "action-descriptor-is-async-retired",
"toMajor": 17,
- "rationale": "The widget declared three comparison arms; the analytics executor implements one shape, `{ kind, dimension? }`, with no `offset` concept in it at all. On the ADR-0021 dataset path — the spec's single author-facing analytics shape — `{ offset }` was forwarded verbatim into that contract and threw `compareTo requires a timeDimension \"undefined\"`, taking the widget down; the arm ever only ran on the legacy inline chart path (#5011). The conversion rewrites `{ offset: '1y' }`, which IS `previousYear` by definition. Every other duration has NO faithful target: `previousPeriod` shifts by the length of whatever window the widget's filter resolves to, which equals `7d` only when that window happens to be seven days long. Rewriting mechanically would silently change which rows the comparison column counts — a wrong number rather than a missing one, which is strictly worse and exactly the class this convergence exists to end. Re-stating the intended window is a judgment about the presentation, not a transform."
+ "rationale": "ADR-0049 enforce-or-remove. `isAsync` declared \"this action suspends the flow awaiting an external reply\" and NOTHING read it: a fresh three-repo measurement (#6748, re-run at pickup) found zero property reads across objectstack, objectui and cloud — every hit was the declaration itself, a generated baseline, one of five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. So declaring it never made a node suspend and omitting it never stopped one, which is the silently-inert declaration ADR-0049 exists to end. It was always a second, weaker spelling of the capability `supportsPause` states, and the two diverged in exactly the way a duplicated declaration does: `screen` declared both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling in #6667 — `AutomationEngine` now refuses a suspension whose type does not declare `supportsPause: true` — so the capability this key gestured at is now a real, enforced fact under one name. This one had no consumer to grow into and takes the remove leg. Why D3 semantic and not a D2 conversion: an ActionDescriptor is published from an executor's TypeScript, never stored in stack metadata — no stack, example or template carries the key — so there is no source for the chain to rewrite and `os migrate meta` cannot reach it. The schema tombstones it via `retiredKey()` and descriptor authors delete the key themselves; that rejection (a `tsc` error at the authoring site, and a parse error inside `defineActionDescriptor`) is the channel a third-party plugin author actually meets. The `EnhancedApiError.fieldErrors` disposition, one layer down."
},
{
- "surface": "job.retryPolicy.maxRetries (> 10) / job.retryPolicy.backoffMultiplier (< 1)",
- "replacement": "maxRetries <= 10, and backoffMultiplier >= 1",
- "migrationId": "job-retry-policy-constraints-tightened",
+ "surface": "automation.ActionDescriptor.resumeAuthority — an OMITTED value on a pausing node descriptor (supportsPause: true, or any executor whose execute() returns suspend: true)",
+ "replacement": "an explicit resumeAuthority: 'any' on the descriptor, for a pausing node whose pauses really are meant to be continued through the generic resume route (POST /automation/:name/runs/:runId/resume) — a screen-style collected-input pause, or a signal wait an external producer resumes. Declare 'service' instead if continuing is the tail of a decision your own service must authorize and record first. Either value is a one-line addition; only the silence changed meaning",
+ "migrationId": "action-descriptor-resume-authority-default-flip",
"toMajor": 17,
- "rationale": "The converged RetryPolicy (#4661) keeps the automation side's bounds, which the job side never had: `maxRetries` is capped at 10 and `backoffMultiplier` floored at 1. Neither has a lossless rewrite. Clamping `maxRetries: 20` to 10 would halve a retry budget its author chose, and a `backoffMultiplier` below 1 describes a delay that SHRINKS on each attempt — retrying a failing dependency ever faster, which is the opposite of backoff and was never a shape the engine meant to offer. Both now fail at parse time with the bound named, rather than being silently reinterpreted. Choosing the replacement count (or accepting the cap) is the author's call."
+ "rationale": "A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as protocol 12's `rest-requireauth-default-flip`, and it is registered here for the same reason: whether a given pause is genuinely open to the generic route is a trust judgment no transform can make. The #3801 resume gate keys on the SUSPENDED NODE, and `ActionDescriptor.resumeAuthority` used to default to `'any'`, so a pausing node type shipped raw-resumable unless its author remembered the field. It now resolves to `'service'` when absent: an unclaimed pause is refused on the generic route with `PERMISSION_DENIED` / 403 until its descriptor states who may continue it. #3823 is the incident that decided the direction — ADR-0044 pointed an approval's revise edge at a generic `wait`, `wait` is legitimately `'any'`, and the pause standing in a service-owned position inherited a fail-open value nobody chose; the demonstrated cost was an unaudited resubmit plus a destroyed remote run. The two possible mistakes are asymmetric, which is the whole argument: guessing `'any'` walks past a decision nothing recorded and is silent, while guessing `'service'` returns a refusal naming the missing field. ⚠️ The surface is a DESCRIPTOR FIELD set in plugin CODE, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) already carry. It differs from those in one way a reader should not have to infer: nothing is REMOVED, so tsc reports nothing at all — the field was already optional after step one and an omission still compiles. The enforced channels are all run-time: a registration warning naming the node type (once per type per engine), the refusal message on the resume itself, and `check:resume-authority-declared` for executors living in this repo. For a third-party plugin the generated upgrade guide is the only channel that arrives BEFORE a user hits a run that will not continue. In-tree the flip moves nothing: all six shipped pausing types (screen, wait, subflow, map, approval, approval_revise) declare their authority explicitly. ADR-0044 amendment (2026-07-28) and its 2026-08-08 landing section, ADR-0019 #3801 addendum, #5561."
},
{
- "surface": "flow.errorHandling.maxRetries (under strategy: 'retry')",
- "replacement": "an explicit count >= 1 (e.g. maxRetries: 3), or strategy: 'fail'",
- "migrationId": "flow-retry-max-retries-required",
+ "surface": "ui.actionSession.roles",
+ "replacement": "ui.actionSession.positions (an action body reads `ctx.session.positions`)",
+ "migrationId": "action-session-roles-to-positions",
"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": "The MIRROR-IMAGE sibling of `actor-user-roles-to-positions`, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright, #5050), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. #5613 ruled contract-first (maintainer, 2026-08-06: \"C skeleton + A semantics\"): phase 1 (#5697) declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it (#5779); the producer emits both for one deprecation window (#5613 runtime half), after which `roles` is removed on the path the v11 session-alias removal already walked (#3280 deprecated → #3290 removed). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087, #5613 / #5779."
+ },
+ {
+ "surface": "action body / AI route: ctx.user.roles (req.user.roles)",
+ "replacement": "ctx.user.positions (an AI route handler reads `req.user.positions`) — the same array, under the one spelling ADR-0090 D3 sanctions",
+ "migrationId": "actor-user-roles-to-positions",
+ "toMajor": 17,
+ "rationale": "The THIRD face of the ADR-0090 `roles` → `positions` rename, and the only one whose surface the spec never declared. `ActorUser` (`packages/runtime/src/security/actor-user.ts`) is the ONE producer of the `user` envelope handed to an action body as `ctx.user` and to an AI route handler as `req.user`; it declared `positions` and `roles` side by side and filled them from a SINGLE assignment (`roles: core.positions`), so the two keys were verbatim identical on every dispatch — a second spelling of the vocabulary ADR-0090 D3 reserves and bans, published straight into author-written code. The maintainer ruled it closed IMMEDIATELY (2026-08-06 14:49Z, #6011): no deprecation window, no dual-emit, the alias simply gone in 17 (PR #6048). ⚠️ Do not read this entry across to its sibling `action-session-roles-to-positions`: `action-session-roles-to-positions` governs `ctx.session`, a DIFFERENT object reached through the same `ctx`, and that one KEEPS its one-window dual-emit (#5613). Same word, same dispatch, two faces, two schedules — `ctx.user.roles` is absent in 17 while `ctx.session.roles` still answers for the length of its window. What makes this entry different in KIND from both session-side siblings: `ctx.user` has no spec schema and never had one. It is a runtime TS interface, so unlike `HookContext.session.roles` (tombstoned on a deliberately non-strict `HookContextSchema`, #5050) and unlike `ActionSessionSchema` (declared contract-first at #5697 precisely so its key could be renamed), there is no schema key here to tombstone and no `retiredKey()` prescription that could reach anybody — nothing ever ran an `ActorUser` through a `.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it reports at the READ site inside the author's own body; for an untyped or sandboxed body there is no enforced channel at all, which is exactly why this ledger entry has to exist — `spec-changes.json` and the generated upgrade guide are the ONLY way such a reader learns of the rename. It is the `findStream` (#4484) / `IStorageService.list` (#5540) disposition — a TS/API contract, no stored source, no tombstone, tsc at the call site — applied to a surface that lives one layer further out than either: those two are at least DECLARED in `packages/spec/src/contracts`, this one only in `packages/runtime`. Why it is a D3 semantic TODO and not a D2 conversion, on the same two independent grounds as its session sibling: FIRST, there is no source to convert — an `ActorUser` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key (the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape). SECOND, the only place the key is ever SPELLED is inside an action body or an AI route handler: author-written JS/TS, or a sandboxed script. A declarative transform cannot safely rewrite an identifier inside free-form code — the same reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. The removal's hard precondition was met before it landed, and the result is recorded here because the ledger is where an upgrading consumer meets it: the declaration's own comment claimed the alias was \"kept for the REST/AI shapes\", and that claim was DISPROVEN face by face against `origin/main` — repo-wide `user.roles` was 4 hits, all of them in the pins PR #6048 flipped; the four `ActorUser` construction sites build server-side envelopes that never enter a response body; objectui's `.roles` reads belong to two unrelated producers (the better-auth session, and the `/auth/me/permissions` payload). The `cloud` repo was NOT reachable in that session and is the one consumer face left unverified — this entry, and the changeset's FROM/TO prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087, #6011 (PR #6048)."
+ },
+ {
+ "surface": "data.query.aggregations[].distinct",
+ "replacement": "the `count_distinct` aggregation FUNCTION for a deduplicated count — the one deduplicating spelling every face computes, lowered to `COUNT(DISTINCT field)` on both SQL faces since #6409. `SUM(DISTINCT …)` / `AVG(DISTINCT …)` get no replacement: no backend ever computed them here, and a per-row measure that needs deduplicating before summing is a modelling problem to fix in the data",
+ "migrationId": "aggregation-node-distinct-retired",
+ "toMajor": 17,
+ "rationale": "A DIVERGENCE, not an inert declaration — which is why it outlived the #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 frozen under #5499 — 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."
},
{
"surface": "api.analyticsQueryRequest.query",
@@ -1381,13 +1395,6 @@
"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."
},
- {
- "surface": "api.enhancedApiError.fieldErrors",
- "replacement": "fields",
- "migrationId": "enhanced-api-error-field-errors-renamed",
- "toMajor": 17,
- "rationale": "The wire has always carried `fields` — the validators, import coercion, validation-failure.ts, @objectstack/client and the console's field-error extractor all say `fields`, and nothing ever emitted `fieldErrors`, so a reader keying on it was reading a field no server sent (ADR-0078's silently-inert declaration, on the error envelope). This is a RESPONSE surface: no stack, example or template carries the key, so there is no source for the chain to rewrite — the schema tombstones it via retiredKey() and consumers move their read themselves. ADR-0114 D4, #3977."
- },
{
"surface": "api.analyticsQueryRequest.format",
"replacement": "(removed — responses are always the JSON envelope; use the export surface for CSV/XLSX)",
@@ -1396,11 +1403,11 @@
"rationale": "The `format` key was declared but never implemented (declared ≠ enforced): every response is the JSON envelope regardless of the requested value, so there is no behaviour to preserve and nothing stored to rewrite."
},
{
- "surface": "data.query.fields",
- "replacement": "expand (`expand: { owner: { object: 'user', fields: ['name'] } }`), or a dotted path for a single related column (`fields: ['owner.name']`)",
- "migrationId": "query-field-node-object-form-retired",
+ "surface": "PUT /api/v1/meta/api/{name} (runtime-authored `api` endpoints, draft and active alike)",
+ "replacement": "Declare the endpoint as a stack artifact (`**/*.api.ts`, or `defineStack({ apis })`) and ship it through `publishPackage`",
+ "migrationId": "api-runtime-create-withdrawn",
"toMajor": 17,
- "rationale": "The `FieldNode` union declared a nested-select object form `{ field, fields, alias }` that was inert end to end: no producer emitted it, and no consumer read `.fields` or `.alias` — objectql's formula projection and known-field filters, driver-sql's `select()` and driver-memory's projection all treat the list as `string[]`, driver-mongodb keyed its projection with the entry itself, and the REST ingress stringified it. Nested selection is `expand`, which the engine resolves via batch `$in` queries. This is a REQUEST surface — `QueryAST` is never stored in stack metadata (no view, dataset or report authors one), so there is no source for the chain to rewrite: the schema narrows to `z.string()` and callers move their own select lists. ADR-0049 / ADR-0078, #4196."
+ "rationale": "The `api` registry entry declared `allowRuntimeCreate: true` and the runtime never honoured it. Measured on a real showcase boot (#5488): `PUT /api/v1/meta/api/e8_backdoor` answered 200 with `{\"success\":true,…,\"message\":\"Saved …\"}`, and the declared route then answered 404 forever — with NO `[EndpointMatcher] … EXCLUDED` line, because the endpoint was never in the index to be excluded from. The serving criterion belongs to `IMetadataService.matchEndpoint` -> `EndpointMatcher` -> `MetadataManager.listForIndex('api')`, which reads the manager's registry plus its registered loaders (`[\"filesystem\",\"memory\"]` on dev/serve); a runtime write lands in `sys_metadata`, which is in neither. A declared capability the runtime does not honour is ADR-0049 false compliance, and a write that answers \"Saved\" and then 404s forever is its most dangerous shape for the AI authors ADR-0033 targets. The maintainer ruled REMOVE on 2026-08-07 rather than converge the read path, because making the matcher read `sys_metadata` re-opens cache, invalidation, tenancy and the ADR-0110 D3 miss-vs-outage distinction on a new read path, and there is no business pull for Studio-authored endpoints today (zero `.api.*` artifacts author them at runtime; showcase uses the artifact route, #5040 E8 LIVE). There is NO D2 conversion, for the reason this list exists: nothing in an authored source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an authorable one, and the artifact route it points authors toward is untouched — a `**/*.api.ts` file valid before this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, so it is one semantic TODO for operators and Studio callers rather than a stack conversion — the same disposition `BatchOptions.validateOnly` (#4052) takes. Consequently `gateApiDraftsForPublish` (PR #5279) is retired with it: it gated a promotion into a state the matcher can never read, and with the inlet closed no `api` draft can exist for it to judge. Re-entry is recorded in the ruling: if #2657 Part B promotes `apis` to a registered type WITH A REAL CONSUMPTION PATH, the flag flips back then — implementation first, declaration second. ADR-0049 / ADR-0121, #5488 (subsumes #5311)."
},
{
"surface": "api.batchOptions.validateOnly",
@@ -1417,256 +1424,256 @@
"rationale": "The rows the three bulk-write endpoints emitted had drifted from the schema that declared them: `BatchOperationResultSchema`, the client SDK's exported `BatchOperationResult` type and the reference docs all said `errors: ApiError[]` / `data` / `index`, while the wire carried `error: string` / `record` and never sent `index` at all. A TypeScript consumer written against the published type compiled, validated and read `undefined` at runtime — the declared-but-not-delivered shape this registry exists to close, on the response envelope (ADR-0119 D4 deferred the reconciliation off a bug fix; this is that tracked change, shipped in the 17 major window). The ADR-0119/#4620 rollback marking is structured in the same move: the `ROLLED_BACK:` / `NOT_ATTEMPTED:` message-string prefixes become registered `ApiError.code` values (message keeps the human-readable cause and causal row index), so \"attempted and undone\" vs \"never ran\" is machine-readable instead of a regex convention. A RESPONSE surface — nothing stored in stack metadata carries a batch row, so there is no source for the chain to rewrite; consumers of the legacy keys move their reads themselves. Off-contract readers only: the legacy keys were never in the schema or the SDK types, so a typed consumer needs no change. #4793."
},
{
- "surface": "data.query.joins",
- "replacement": "expand (`expand: { owner: { object: 'user', fields: ['name'] } }`), or a dotted `fields` path for a single related column (`fields: ['owner.name']`)",
- "migrationId": "query-joins-retired",
+ "surface": "dashboard.widgets[].compareTo: { offset: '7d' | '1M' | … } (every duration except '1y')",
+ "replacement": "compareTo: { kind: 'previousPeriod' } plus an explicit window on the widget's own `filter`",
+ "migrationId": "dashboard-widget-compareto-offset",
"toMajor": 17,
- "rationale": "The `joins` array was declared-but-inert: no engine or driver read `query.joins` anywhere on the query path, so a query carrying it behaved exactly as if the key were absent — while the name squatted on the reserved REST parameter set. Related-record retrieval already has a live spelling (`expand`, resolved by the engine via batch `$in` queries), so the removal deletes the second, broken spelling rather than the capability, and the orphaned `JoinNode`/`JoinType`/`JoinStrategy` cluster goes with the key. A REQUEST surface — `QueryAST` is never stored in stack metadata — so there is no source for the chain to rewrite; callers move their own queries. ADR-0049 / ADR-0078, #4286."
+ "rationale": "The widget declared three comparison arms; the analytics executor implements one shape, `{ kind, dimension? }`, with no `offset` concept in it at all. On the ADR-0021 dataset path — the spec's single author-facing analytics shape — `{ offset }` was forwarded verbatim into that contract and threw `compareTo requires a timeDimension \"undefined\"`, taking the widget down; the arm ever only ran on the legacy inline chart path (#5011). The conversion rewrites `{ offset: '1y' }`, which IS `previousYear` by definition. Every other duration has NO faithful target: `previousPeriod` shifts by the length of whatever window the widget's filter resolves to, which equals `7d` only when that window happens to be seven days long. Rewriting mechanically would silently change which rows the comparison column counts — a wrong number rather than a missing one, which is strictly worse and exactly the class this convergence exists to end. Re-stating the intended window is a judgment about the presentation, not a transform."
},
{
- "surface": "data.query.windowFunctions",
- "replacement": "`aggregations` + `groupBy` for request-level analytics; `SqlDriver.findWithWindowFunctions(object, query)` for embedders on a SQL datasource",
- "migrationId": "query-window-functions-retired",
+ "surface": "contracts.IDataDriver.findStream / data.DriverInterfaceSchema.findStream",
+ "replacement": "find() with limit/offset — the paged read whose determinism IS enforced (IDataDriver.find, data/pagination-conformance.ts)",
+ "migrationId": "data-driver-find-stream-retired",
"toMajor": 17,
- "rationale": "The `windowFunctions` array was declared-but-inert on the query path: `find()` never applied a window function, so every OVER clause a caller declared was silently dropped. The capability only ever ran behind `SqlDriver.findWithWindowFunctions()`, a driver-level door that is not on the `IDataDriver` contract and whose flat input shape (`{ function, alias, partitionBy?, orderBy? }`) the spec vocabulary never matched — `WindowFunctionNodeSchema` declared `field`/`over`/`frame` members the door never read, so that cluster is removed with the key rather than left as a false affordance. A REQUEST surface, never stored; no source to rewrite. ADR-0049 / ADR-0078, #4286."
+ "rationale": "`findStream` was a REQUIRED contract method documented as \"optimized for large datasets to avoid memory overflow\", and in two of its three implementations it delivered the opposite: `SqlDriver` and `InMemoryDriver` both awaited `find()` for the ENTIRE result set and then yielded it row by row, so the peak memory a caller was promised protection from was already reached before the first yield. The third (`MongoDBDriver._findStream`) did walk a cursor, but it was the one read path in that driver never routed through `buildFindOptions`, so it hardcoded `projection: { _id: 0 }` and silently discarded `query.fields`. None of it was ever observed, because the method had NO caller in either repository: the engine exposes no stream entry, and the REST export, import and bulk-read paths all go through `find()`. The ~20 driver test doubles that existed only to satisfy a required method almost all threw `not implemented`, and nothing ever noticed — which is the proof, not the anecdote. Being REQUIRED, it also taxed every new driver and every test double with an implementation of a capability the platform does not have. Rather than build a caller to justify three implementations, the method is retired; a real cursor-based read should return WITH the caller that needs it (ADR-0049 enforce-or-remove). This is a TS/API contract surface — a driver is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone either: nothing ever ran a driver object through `DriverInterfaceSchema.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it points at callers. ADR-0049 / ADR-0078, #4484."
},
{
- "surface": "data.query.cursor",
- "replacement": "a `where` predicate on the sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` (the documented manual-keyset pattern)",
- "migrationId": "query-cursor-retired",
+ "surface": "contracts.IDataEngine.batch / data.DataEngineBatchRequestSchema",
+ "replacement": "`IObjectQLEngine.transaction(cb)` for in-process multi-write atomicity; the metadata protocol's `batchData` with `options.atomic: true` for a batch over one object; `POST {basePath}/batch` on the wire",
+ "migrationId": "data-engine-batch-retired",
"toMajor": 17,
- "rationale": "The `cursor` key promised keyset pagination and no driver implemented it: the cursor was accepted and ignored, so every page came back identical — a caller looping \"until hasMore is false\" never terminates. Worse than inert, it had a shipped public producer (`QueryBuilder.cursor()`, removed with the key). The caller-built `Record` shape also leaks sort/storage detail and squats on the reserved REST parameter set; a first-class cursor, if ever designed, will be a response-minted opaque token — a different API, so keeping this one preserved a wrong design rather than a roadmap. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286."
+ "rationale": "`batch?` was declared on `IDataEngine` for as long as that contract existed and was never implemented by any engine: `ObjectQL` has no `batch` method and there is no other engine in the tree. It also had no caller — `DataEngineRequest` was imported by exactly one file, the contract declaring the member. Its entire specification was a three-word doc comment (\"Batch Operations (Transactional)\"), which settles nothing about partial failure, ordering, cross-object references, rollback scope, or what `transaction: false` was supposed to mean — the questions a batch API exists to answer. Contrast its neighbours `getDefaultDriverName?` / `getDriverByName?`, whose optionality is evidenced: each names its implementer and its probing caller. The tell that nobody ever designed against it is in the schema: `DataEngineBatchRequestSchema.requests` nested the request union RECURSIVELY, so a batch could contain batches, with no statement anywhere about what that meant for ordering or rollback. The only test was a type pin — an ad-hoc object literal carrying a `batch` property, asserting the property was defined — which could not fail while the declaration existed and would have passed unchanged for the member's whole life with no engine implementing it. What it claimed is now covered by members that are real, so the removal deletes a false affordance rather than a capability: ADR-0119 D1 made `transaction` reachable through the contract and D4 made `batchData`'s `atomic` honest, while the wire batch has always validated with `CrossObjectBatchRequestSchema` / `BatchUpdateRequestSchema` from `api/batch.zod.ts` — a different schema entirely, untouched here. TS/API surfaces only: an engine is CODE, never stack metadata, so there is no source for the chain to rewrite. Deliberately no schema tombstone either — nothing ever parsed `DataEngineBatchRequestSchema`, so a `retiredKey()` prescription would have no one to reach; its three `authorable-surface.json` baseline lines and its `json-schema.manifest.json` entry are dropped in the same change, deliberately. The enforced channel is tsc. ADR-0049 / ADR-0078, #4618."
},
{
- "surface": "data.query.distinct",
- "replacement": "`groupBy` for unique combinations; the `count_distinct` aggregation for deduplicated counts; the SQL/memory drivers' `distinct(object, field)` door for one column's values",
- "migrationId": "query-distinct-retired",
+ "surface": "api.DataEventType 'data.field.changed'",
+ "replacement": "the `data.record.updated` event, whose payload already carries the per-field detail: `changes` (the changed fields), plus `before` / `after`",
+ "migrationId": "data-field-changed-event-retired",
"toMajor": 17,
- "rationale": "The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND worse pagination metadata, and a side effect that \"confirmed\" the flag was doing something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with the key). The count suppression is deleted in the same change — `total` is truthful for those queries again. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286."
+ "rationale": "`data.field.changed` was declared in `DataEventType` and emitted by nothing — the engine's `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since #4639) `data.records.{updated,deleted}`, and no other producer exists in either repository. A subscriber that switched on it was waiting on an event no producer sends: the branch never ran, and because the surrounding `switch` still compiled, nothing anywhere reported the gap (ADR-0078's silently-inert declaration, on the event vocabulary). `DataEventSchema` could not have carried the semantics even if something had emitted it — the payload is record-shaped (`recordId`, `changes`, `before`, `after`) with no `field` / `oldValue` / `newValue` slot — so the member promised a granularity the contract has no room for. Per-field detail is therefore not lost: it has always ridden on `data.record.updated` as `changes`, which is one event per write rather than N events on a wide table. This is a runtime EVENT surface — no stack, example or template authors an event name (webhooks subscribe through the separate authorable `WebhookTriggerType`, whose vocabulary was already trimmed to producers that exist, #3196) — so there is no source for the chain to rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a retiredKey() fix-it error the way an authorable object key can (the same limit the sharing-rule `full` retirement `owd-full-alias-removed` hit). The enforced channels are tsc, which fails any consumer still naming the value in a `DataEventType` position, and the enum parse, which now rejects the name instead of accepting an event that never arrives. A genuine per-field stream, if one is ever wanted, gets its own honest contract the way #4639 gave bulk writes theirs. ADR-0049 / ADR-0078, #4673."
},
{
- "surface": "data.query.aggregations[].function ('array_agg' / 'string_agg')",
- "replacement": "an ordinary `fields` query, shaped in the caller — or a stored field that materialises the roll-up. For a deduplicated COUNT the live spelling is unchanged: `count_distinct` stays declared",
- "migrationId": "query-array-string-agg-retired",
+ "surface": "stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)",
+ "replacement": "the same declarations, re-read as LIVE HTTP routes: `path` moved under `/api/v1/apps//`, and every entry that declares `authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying `rateLimit: { enabled: true, … }`",
+ "migrationId": "declarative-apis-endpoints-live",
"toMajor": 17,
- "rationale": "The stored half of this retirement is a conversion (`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. `QueryAST` is never stored in stack metadata — it is the client SDK builder's output and the `POST /data/:object/query` body — so there is no source for the chain to rewrite and callers move their own queries. Both values were declared-but-unlowered on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller following the schema against a SQL datasource got a refusal, not an array. They did run on `driver-mongodb` and on the engine's in-memory fallback, which is what makes this the one narrowing in the batch that removes reachable behaviour: an aggregation that worked on one backend and failed on another is exactly the unpredictability the ruling ended, and #5499 has both of those backends frozen. `count_distinct` was deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049's enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049, #6188."
+ "rationale": "This is the one protocol-17 entry that turns metadata ON rather than off, so read it as a SECURITY review item and not as a rename. Before 17 the declarative endpoint surface executed NOTHING: no route was mounted for a declared `path`, no matcher existed, and every key — `authRequired` included — parsed green and gated nothing (#4936, which refused a non-empty `apis:` outright for exactly that reason). Protocol 17 ships the executor (#5040) and narrows that refusal to a per-endpoint publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as soon as the stack is published. So an `apis:` block written against an older major — or one restored from a pre-#4936 source, or authored from a doc that predates the refusal — changes meaning without changing a byte: what used to be inert documentation becomes an execution entry point into the data and automation pipelines. Nothing about that transition can be applied mechanically, because the judgment it needs is \"did the author of this endpoint mean for the internet to reach it?\" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT `authRequired: false` is the only thing that opens anonymous access, and under ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key defaults to `false`, so a budget written without it meters nothing) or the stack refuses to publish. ⚠️ If you author endpoints in TypeScript, annotate them with `ApiEndpoint` — the AUTHOR state — so that omitting `authRequired` compiles: `const e: ApiEndpoint = { name, path, method, type, target }` is legal and is the safe shape this paragraph prescribes. `ApiEndpointParsed` is the POST-parse type (defaults materialized, ADR-0122), where `authRequired` is required — annotating a declaration with it forces you to write the key out, and being made to think about a key whose only unrecoverable value is `false` is the one thing this entry is trying to avoid (#5227). Hold a parse RESULT with `ApiEndpointParsed`; write declarations as `ApiEndpoint`. Grep every `apis:` entry for `authRequired: false` before you upgrade, delete the ones that were never meant to be public, and arm a budget on the ones that were. The path move is the mechanical-looking half and is still yours: ADR-0121 D1/D2 confine a declared path to your own namespace carve-out (`/api/v1/apps//…`), the namespace comes from an explicit `manifest.namespace` with no derivation fallback, and the subpath is the only part you name — rewriting it for you would silently change a URL third parties call."
},
{
- "surface": "data.query.aggregations[].distinct",
- "replacement": "the `count_distinct` aggregation FUNCTION for a deduplicated count — the one deduplicating spelling every face computes, lowered to `COUNT(DISTINCT field)` on both SQL faces since #6409. `SUM(DISTINCT …)` / `AVG(DISTINCT …)` get no replacement: no backend ever computed them here, and a per-row measure that needs deduplicating before summing is a modelling problem to fix in the data",
- "migrationId": "aggregation-node-distinct-retired",
+ "surface": "driver aggregate() call argument — query.aggregate and aggregations[].func",
+ "replacement": "query.aggregations and aggregations[].function — the spellings QueryASTSchema and AggregationNodeSchema have always declared",
+ "migrationId": "driver-aggregate-undeclared-key-aliases-removed",
"toMajor": 17,
- "rationale": "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 frozen under #5499 — 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."
+ "rationale": "`SqlDriver.aggregate` and `RemoteTransport.aggregate` each read two aliases the Query Protocol has never declared: `query.aggregations || query.aggregate` and `agg.function || agg.func`. \"Never declared\" is measured, not assumed — `git log -S` over `data/query.zod.ts` finds no commit that ever introduced either name, there is no `retiredKey()` tombstone and no alias-table entry for them (the file's only alias table is `SortNode`'s `direction` → `order`), and neither appears in any upgrade guide or release note. So this entry does not record a declared surface being withdrawn; it records a LENIENCY being withdrawn, which is why it is here rather than behind a tombstone. The only writers in this repository were the two driver packages' own fixtures — #4984's family, where a fixture spelling the alias keeps the tolerant limb green forever and no test in existence can go red on its deletion — so ADR-0049 enforce-or-remove applies once those are re-spelt. ⚠️ Do NOT read this across to `dashboard`/`page` measures: `aggregate` IS the canonical key there and `func` IS a declared, loudly-suggesting alias (`DatasetMeasureSchema`, ui/dataset.zod.ts). That neighbouring vocabulary is untouched, and it is the most likely reason an off-repo caller ever wrote these keys on a QUERY — one habit, two surfaces, only one of which declared it. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone: nothing ever ran a query through `QueryASTSchema.parse()` on this path. The enforced channel is tsc at the call site, once the parameter is `DriverQuery` — and for an untyped JS caller there is no enforced channel at all, which is exactly why this ledger entry has to exist: the generated upgrade guide is the only way such a reader learns of the rename. Same disposition, and the same reason, as `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011). ADR-0049 / ADR-0087, #6321 (PR #6404)."
},
{
- "surface": "CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / discovery routes.workflow / RestApiRouteCategory workflow",
- "replacement": "the live mechanisms the slot only ever pointed at: `state_machine` validation rules for record state machines, approval flow nodes on the approvals runtime (ADR-0019) for approvals, lifecycle hooks + `record_change` flows (service-automation) for record-triggered automation",
- "migrationId": "workflow-service-slot-retired",
+ "surface": "data.DriverCapabilities.create / data.DriverCapabilities.read / data.DriverCapabilities.update / data.DriverCapabilities.delete / data.DriverCapabilities.bulkCreate / data.DriverCapabilities.bulkUpdate / data.DriverCapabilities.bulkDelete / data.DriverCapabilities.transactions / data.DriverCapabilities.savepoints / data.DriverCapabilities.isolationLevels / data.DriverCapabilities.queryFilters / data.DriverCapabilities.queryAggregations / data.DriverCapabilities.querySorting / data.DriverCapabilities.queryPagination / data.DriverCapabilities.queryWindowFunctions / data.DriverCapabilities.querySubqueries / data.DriverCapabilities.queryCTE / data.DriverCapabilities.joins / data.DriverCapabilities.fullTextSearch / data.DriverCapabilities.jsonQuery / data.DriverCapabilities.geospatialQuery / data.DriverCapabilities.streaming / data.DriverCapabilities.jsonFields / data.DriverCapabilities.arrayFields / data.DriverCapabilities.vectorSearch / data.DriverCapabilities.schemaSync / data.DriverCapabilities.migrations / data.DriverCapabilities.indexes / data.DriverCapabilities.connectionPooling / data.DriverCapabilities.preparedStatements / data.DriverCapabilities.queryCache",
+ "replacement": "(removed — delete the keys. A driver advertises a capability by implementing the corresponding IDataDriver method; the three bits that survive because method presence cannot carry the signal are `queryDateGranularity`, `autonumber` and `batchSchemaSync`)",
+ "migrationId": "driver-capabilities-inert-bits-removed",
"toMajor": 17,
- "rationale": "The workflow slot was declared end to end and implemented nowhere: no code in either repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches were plugin-dev's retired stub probe and the generic discovery walk), no implementation of any WorkflowProtocol method ever existed, and no host ever mounted `/api/v1/workflow` (the pre-#3586 DEFAULT_DISPATCHER_ROUTES listed it among routes that never existed). Every part of it was ADR-0078's silently-inert declaration: a CoreServiceName nothing filled, a contract nothing implemented, a protocol nothing served, a discovery route field no builder could truthfully populate. These are TS/API surfaces and a discovery RESPONSE field — never stored in stack metadata, so there is no source for the chain to rewrite; consumers of the deleted types move their imports themselves. ADR-0049 / ADR-0078, #4451."
+ "rationale": "The #4484 findStream close-out found `DriverCapabilities.streaming` pointing at a capability the contract no longer declares, and the follow-up audit (#4634) checked every bit in the record the same way, across objectstack and cloud (objectui confirmed clean): of 34 declared bits, THREE have a decision-making reader — `queryDateGranularity` (engine aggregate dispatch + checkDateBucketParity), `autonumber` (engine defers generation to the driver), `batchSchemaSync` (engine ANDs it with method presence, because a subclass can inherit `syncSchemasBatch` from a base whose transport batches while its own cannot) — and THIRTY-ONE were written by every driver and read by nothing. Their `.describe()` strings promised engine adaptation (\"if false, ObjectQL will filter/sort/paginate in memory\") that was never built, and zero readers let the values go WRONG unnoticed: SqlDriver declared `streaming: false` while implementing `findStream`; InMemoryDriver declared `streaming: true` over a full-table read (ADR-0078 false affordance, on the capability record itself). The real mechanism everywhere else is METHOD presence: transactions gate on `driver.beginTransaction`, aggregate pushdown on `typeof driver.aggregate`, schema sync on `typeof driver.syncSchema`, and the REQUIRED CRUD/bulk methods are called unconditionally. A driver is CODE, never stack metadata — `supports` literals live in driver classes and `DriverConfig.capabilities` is plugin TS configuration, neither ever a `sys_metadata` shape (the stack-tree neighbour, `datasource.capabilities`, was retired separately in #4583) — so there is no source for the D2 chain to rewrite and this entry is the D3 record. The keys are tombstoned rather than deleted because `DriverCapabilitiesSchema` is not `.strict()` and IS parsed (DriverConfigSchema / SQLDriverConfigSchema / NoSQLDriverConfigSchema embed it): a plain delete would silently strip a vendor's authored bit, replacing one silent no-op with another. `batchSchemaSync` also drops its `.default(false)` for `.optional()` — absence already meant false at both readers, and the default forced every capability object to spell out 30+ bits. ADR-0049 / ADR-0078, #4634."
},
{
- "surface": "contracts.IDataDriver.findStream / data.DriverInterfaceSchema.findStream",
- "replacement": "find() with limit/offset — the paged read whose determinism IS enforced (IDataDriver.find, data/pagination-conformance.ts)",
- "migrationId": "data-driver-find-stream-retired",
+ "surface": "SqlDriver.distinct() third argument — any value",
+ "replacement": "a bare FilterCondition (@objectstack/spec/data) — the same value find() carries under query.where, never a query envelope",
+ "migrationId": "driver-sql-distinct-bare-filter-typed",
"toMajor": 17,
- "rationale": "`findStream` was a REQUIRED contract method documented as \"optimized for large datasets to avoid memory overflow\", and in two of its three implementations it delivered the opposite: `SqlDriver` and `InMemoryDriver` both awaited `find()` for the ENTIRE result set and then yielded it row by row, so the peak memory a caller was promised protection from was already reached before the first yield. The third (`MongoDBDriver._findStream`) did walk a cursor, but it was the one read path in that driver never routed through `buildFindOptions`, so it hardcoded `projection: { _id: 0 }` and silently discarded `query.fields`. None of it was ever observed, because the method had NO caller in either repository: the engine exposes no stream entry, and the REST export, import and bulk-read paths all go through `find()`. The ~20 driver test doubles that existed only to satisfy a required method almost all threw `not implemented`, and nothing ever noticed — which is the proof, not the anecdote. Being REQUIRED, it also taxed every new driver and every test double with an implementation of a capability the platform does not have. Rather than build a caller to justify three implementations, the method is retired; a real cursor-based read should return WITH the caller that needs it (ADR-0049 enforce-or-remove). This is a TS/API contract surface — a driver is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone either: nothing ever ran a driver object through `DriverInterfaceSchema.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it points at callers. ADR-0049 / ADR-0078, #4484."
+ "rationale": "This entry records a TYPE being added, not a surface being withdrawn, and it says so up front because the distinction decides who has to do anything. `distinct` is not declared on `IDataDriver`, so #5181 / #6075 never reached it and it kept `filters?: any` while its body said something far more specific — `applyFilters(builder, filters)` is handed the ARGUMENT ITSELF, never a `.where` off it. ⚠️ RUNTIME BEHAVIOUR IS UNCHANGED by this entry's change: not one statement moved, so no upgrade breaks at run time and nothing that answered correctly stops. What the annotation removes is a compile-time hole, measured rather than assumed: a truthy NON-OBJECT third argument — `distinct('orders', 'product', 'completed')` — used to type-check and resolve the UNFILTERED set, because `applyFilters` emits no predicate at all for a truthy non-object, non-array filter. A call meaning \"which products among completed orders\" answered with EVERY product, silently. That spelling is now TS2345 at the call site. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone, the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540), `actor-user-roles-to-positions` (#6011) and `driver-aggregate-undeclared-key-aliases-removed` (#6321) already carry. ⚠️ It differs from those four in ONE measured way a reader should not have to infer: because nothing changed at run time, an untyped JS caller is not affected BY THE UPGRADE at all. The entry is here for a different reason — such a caller is exactly the one tsc can never reach, and the silent widening above is a defect they may ALREADY be sitting on, before and after this major. The generated upgrade guide is the only channel that reaches them, which is why the fix is written down rather than left to the compiler. ⛔ The reverse mismatch is NOT closed and no type can close it: `FilterCondition` is an open map (`[key: string]: any`) because a filter key IS a field name, so a query envelope `{ object, where }` is structurally a valid filter — one constraining columns named `object` and `where` — and so is a FilterArray. Both reach `distinct` type-checked and are refused at run time, loudly, with INVALID_FILTER / 400. `driver-memory`'s opposite half — where the BARE spelling returns the unfiltered set in silence — stays open under the #5499 freeze (#6320). ADR-0087, #6320."
},
{
- "surface": "api.DataEventType 'data.field.changed'",
- "replacement": "the `data.record.updated` event, whose payload already carries the per-field detail: `changes` (the changed fields), plus `before` / `after`",
- "migrationId": "data-field-changed-event-retired",
+ "surface": "engine.find(object, { orderBy }) and engine.findOne(object, { orderBy }) naming a `formula` field — the direct engine path, not the REST ingress",
+ "replacement": "denormalise the value onto the object (a stored field, written when the source changes) and sort by that — the same remedy the REST ingress has prescribed since #6924 / #6994; a `summary` field is unaffected and still sorts, because it gets a real maintained column",
+ "migrationId": "engine-find-formula-order-by-refused",
"toMajor": 17,
- "rationale": "`data.field.changed` was declared in `DataEventType` and emitted by nothing — the engine's `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since #4639) `data.records.{updated,deleted}`, and no other producer exists in either repository. A subscriber that switched on it was waiting on an event no producer sends: the branch never ran, and because the surrounding `switch` still compiled, nothing anywhere reported the gap (ADR-0078's silently-inert declaration, on the event vocabulary). `DataEventSchema` could not have carried the semantics even if something had emitted it — the payload is record-shaped (`recordId`, `changes`, `before`, `after`) with no `field` / `oldValue` / `newValue` slot — so the member promised a granularity the contract has no room for. Per-field detail is therefore not lost: it has always ridden on `data.record.updated` as `changes`, which is one event per write rather than N events on a wide table. This is a runtime EVENT surface — no stack, example or template authors an event name (webhooks subscribe through the separate authorable `WebhookTriggerType`, whose vocabulary was already trimmed to producers that exist, #3196) — so there is no source for the chain to rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a retiredKey() fix-it error the way an authorable object key can (the same limit the sharing-rule `full` retirement hit above). The enforced channels are tsc, which fails any consumer still naming the value in a `DataEventType` position, and the enum parse, which now rejects the name instead of accepting an event that never arrives. A genuine per-field stream, if one is ever wanted, gets its own honest contract the way #4639 gave bulk writes theirs. ADR-0049 / ADR-0078, #4673."
+ "rationale": "#4226 / #4256 / #6994 closed the SORT axis at the REST ingress (`assertSortFieldsExist`, `400 INVALID_SORT`), which covers everything reaching `findData`: the list route, `POST /data/:object/query`, the export route and the RPC dispatcher. A caller reaching `engine.find()` / `engine.findOne()` DIRECTLY passed through none of it, and a `formula` ORDER BY there was dropped in silence. Measured on a real driver: `asc` and `desc` came back BYTE-IDENTICAL, in insertion order, under a success, with the rows carrying the very values they were asked to be ordered by. No column exists to order by (a formula is computed on read, so no driver materialises one), so the ORDER BY reached the driver, found nothing, and the unknown-column backstop returned the rows unordered.\n\nRuled 2026-08-10 on #7095: an ORDER BY the engine cannot apply is a 4xx with guidance prose at the public boundary, never a silent drop — the same direction as the analytics dataset refusal envelope and the #6924 sort-hint prescription. The engine's documented internal-caller tolerance (`assertProjectionFieldsExist`'s docblock) was to survive only behind a pinned internal path, and only if a MEASURED internal call site relied on it. The #7095 sweep of every in-tree `orderBy` reaching the engine directly — hooks, flows, reports, queue/job adapters, sharing, metadata loaders, expand sub-reads — found NONE: every hardcoded internal sort names a real stored column (`created_at`, `updated_at`, `version`, `priority`, `scheduled_for`, `started_at`, `next_run_at`, `recorded_at`, `id`), and no shipped object in the repo declares a `formula` field at all. So no internal path shipped, and there is no flag to opt back into the drop.\n\nThis is a CODE-path API, not stored metadata, so — like `hook-register-empty-object-target-refused` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and the ledger entry is the notification channel. No mechanical rewrite exists in either direction: the platform cannot invent the stored column the remedy prescribes, and it must not sort post-hoc instead — `driver.find` has already applied `limit` / `offset`, so re-sorting after the formulas are evaluated would reorder an ARBITRARY PAGE, which looks correct on small result sets and is wrong the moment pagination is involved.\n\nONE AUTHOR-REACHABLE SURFACE reaches this indirectly and is why it is not purely a code-side note: a saved report's `query.orderBy` (`sys_saved_report`) is forwarded verbatim into `engine.find` by `plugin-reports`, bypassing the ingress gate. A report authored to sort by a formula field used to run and return rows in an arbitrary order; it now fails loudly, with the remedy in the message. One further path is deliberately NOT a refusal: a nested `expand` sort raises this refusal inside `expandRelatedRecords`, whose pre-existing graceful-degradation `catch` swallows every expand failure and retains the raw foreign keys — so that path moves from silent to OBSERVABLE (a warning naming the field and the fix) rather than refusing. Reversing that backstop is a separate decision on all expand failure modes. #7095, #6994, #6924, #4226, #4256, #3821, ADR-0112."
},
{
- "surface": "contracts.IDataEngine.batch / data.DataEngineBatchRequestSchema",
- "replacement": "`IObjectQLEngine.transaction(cb)` for in-process multi-write atomicity; the metadata protocol's `batchData` with `options.atomic: true` for a batch over one object; `POST {basePath}/batch` on the wire",
- "migrationId": "data-engine-batch-retired",
+ "surface": "api.enhancedApiError.fieldErrors",
+ "replacement": "fields",
+ "migrationId": "enhanced-api-error-field-errors-renamed",
"toMajor": 17,
- "rationale": "`batch?` was declared on `IDataEngine` for as long as that contract existed and was never implemented by any engine: `ObjectQL` has no `batch` method and there is no other engine in the tree. It also had no caller — `DataEngineRequest` was imported by exactly one file, the contract declaring the member. Its entire specification was a three-word doc comment (\"Batch Operations (Transactional)\"), which settles nothing about partial failure, ordering, cross-object references, rollback scope, or what `transaction: false` was supposed to mean — the questions a batch API exists to answer. Contrast its neighbours `getDefaultDriverName?` / `getDriverByName?`, whose optionality is evidenced: each names its implementer and its probing caller. The tell that nobody ever designed against it is in the schema: `DataEngineBatchRequestSchema.requests` nested the request union RECURSIVELY, so a batch could contain batches, with no statement anywhere about what that meant for ordering or rollback. The only test was a type pin — an ad-hoc object literal carrying a `batch` property, asserting the property was defined — which could not fail while the declaration existed and would have passed unchanged for the member's whole life with no engine implementing it. What it claimed is now covered by members that are real, so the removal deletes a false affordance rather than a capability: ADR-0119 D1 made `transaction` reachable through the contract and D4 made `batchData`'s `atomic` honest, while the wire batch has always validated with `CrossObjectBatchRequestSchema` / `BatchUpdateRequestSchema` from `api/batch.zod.ts` — a different schema entirely, untouched here. TS/API surfaces only: an engine is CODE, never stack metadata, so there is no source for the chain to rewrite. Deliberately no schema tombstone either — nothing ever parsed `DataEngineBatchRequestSchema`, so a `retiredKey()` prescription would have no one to reach; its three `authorable-surface.json` baseline lines and its `json-schema.manifest.json` entry are dropped in the same change, deliberately. The enforced channel is tsc. ADR-0049 / ADR-0078, #4618."
+ "rationale": "The wire has always carried `fields` — the validators, import coercion, validation-failure.ts, @objectstack/client and the console's field-error extractor all say `fields`, and nothing ever emitted `fieldErrors`, so a reader keying on it was reading a field no server sent (ADR-0078's silently-inert declaration, on the error envelope). This is a RESPONSE surface: no stack, example or template carries the key, so there is no source for the chain to rewrite — the schema tombstones it via retiredKey() and consumers move their read themselves. ADR-0114 D4, #3977."
},
{
- "surface": "restServer.openApi31",
- "replacement": "(removed — no replacement key exists. Delete the key; for a real outbound webhook use `Webhook` from `@objectstack/spec/automation`. Config-driven OpenAPI 3.1 webhooks/callbacks documentation returns, if ever, via the enforce route of ADR-0049 through a new ADR)",
- "migrationId": "rest-server-openapi31-block-removed",
+ "surface": "automation.etlPipeline / automation.etlPipelineRun / automation.etlSource / automation.etlDestination / automation.etlTransformation (the whole L2 layer of automation/etl.zod.ts, its four enums and the `ETL` factory — 9 defs, 27 exported names)",
+ "replacement": "(removed — no protocol surface replaces it, deliberately. Layer by layer: connector-attached synchronisation is `ConnectorSchema.syncConfig` (`integration/connector.zod.ts`), which IS parsed and executed; per-field value transformation on import is `shared/mapping.zod.ts`, whose `transform` is applied row by row by the REST import path and recorded key by key in `packages/spec/liveness/mapping.json`; scheduling is `system/job.zod.ts`. What has NO replacement is multi-source, multi-stage movement with joins and aggregations — because it never had an implementation either. It returns through the ENFORCE route: the engine first, the vocabulary second)",
+ "migrationId": "etl-pipeline-layer-retired",
"toMajor": 17,
- "rationale": "The `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 reading #4738 used to retire L1 `DataSyncConfig`, re-measured one layer up and identical: narrative-only. No engine ever parsed, scheduled or executed an `ETLPipeline`. Measured on origin/main immediately before the removal: the only non-spec references in this repo are two fumadocs-generated documentation sources (`apps/docs/.source/*.ts`), not executors; objectui has no reference at all; there is no `liveness/etl.json` or `pipeline.json`, so no ADR-0049 gate ever had a reading on it — while the same file family's EXECUTED half does have one (`liveness/mapping.json`), which is the contrast that makes the absence meaningful rather than an oversight. The `etl` string in this registry was the one untested link the finding named, and it is not a loader path: it was the id of the #4962 retry-vocabulary entry, absorbed here. The layer was ADR-0078's asymmetry in its purest form — an author could write a complete ten-stage pipeline, get no error, and get no execution. It was also advertised: `packages/spec/docs/SYNC_ARCHITECTURE.md` named `ETLPipeline` as the recommended destination for authors displaced by the L1 retirement (#4738) and listed ten transformation types with copyable examples down to `script | Custom JavaScript/Python`. That document is rewritten in the same change; a retirement whose own doc still recommends the retired layer is self-contradictory, and forwarding L1's authors to a second layer with no executor was the defect compounding rather than closing. ⚠️ `etl-retry-converged-onto-retry-policy` (#4962) is SUBSUMED here, the #4657/#4834/#5055 way: both land in the unreleased protocol 17, so composed, a rename of `retry.maxAttempts` on a shape that does not survive the major has no observable effect — and keeping both would tell an upgrader to rewrite a key on a schema the same upgrade deletes. The `maxAttempts` `retiredKey()` tombstone goes with the shape that carried it, which is strictly stronger than the tombstone: there is no longer a `retry` block to author the key into. Route 3 — no carrier key, no parse site, so no D2 conversion and no tombstone; RETIRED_DEFS_BY_MAJOR plus this entry are the declaration. ADR-0049, ADR-0078, #6414."
},
{
- "surface": "data.DriverCapabilities.create / data.DriverCapabilities.read / data.DriverCapabilities.update / data.DriverCapabilities.delete / data.DriverCapabilities.bulkCreate / data.DriverCapabilities.bulkUpdate / data.DriverCapabilities.bulkDelete / data.DriverCapabilities.transactions / data.DriverCapabilities.savepoints / data.DriverCapabilities.isolationLevels / data.DriverCapabilities.queryFilters / data.DriverCapabilities.queryAggregations / data.DriverCapabilities.querySorting / data.DriverCapabilities.queryPagination / data.DriverCapabilities.queryWindowFunctions / data.DriverCapabilities.querySubqueries / data.DriverCapabilities.queryCTE / data.DriverCapabilities.joins / data.DriverCapabilities.fullTextSearch / data.DriverCapabilities.jsonQuery / data.DriverCapabilities.geospatialQuery / data.DriverCapabilities.streaming / data.DriverCapabilities.jsonFields / data.DriverCapabilities.arrayFields / data.DriverCapabilities.vectorSearch / data.DriverCapabilities.schemaSync / data.DriverCapabilities.migrations / data.DriverCapabilities.indexes / data.DriverCapabilities.connectionPooling / data.DriverCapabilities.preparedStatements / data.DriverCapabilities.queryCache",
- "replacement": "(removed — delete the keys. A driver advertises a capability by implementing the corresponding IDataDriver method; the three bits that survive because method presence cannot carry the signal are `queryDateGranularity`, `autonumber` and `batchSchemaSync`)",
- "migrationId": "driver-capabilities-inert-bits-removed",
+ "surface": "@objectstack/rest: ExportFieldMeta.required / .system / .readonly / .hasDefault / .min / .max / .minLength / .maxLength (the map built by `buildFieldMetaMap`, reached as `PreparedImport.metaMap` from `prepareImportRequest`)",
+ "replacement": "the object schema you already hold — read `fields[name].required` / `.system` / `.readonly` / `.defaultValue` / `.min` / `.max` / `.minLength` / `.maxLength` off the same `ObjectSchema` you passed to `buildFieldMetaMap`, which is where the ENGINE reads them and therefore the only copy that cannot drift",
+ "migrationId": "export-field-meta-constraints-retired",
"toMajor": 17,
- "rationale": "The #4484 findStream close-out found `DriverCapabilities.streaming` pointing at a capability the contract no longer declares, and the follow-up audit (#4634) checked every bit in the record the same way, across objectstack and cloud (objectui confirmed clean): of 34 declared bits, THREE have a decision-making reader — `queryDateGranularity` (engine aggregate dispatch + checkDateBucketParity), `autonumber` (engine defers generation to the driver), `batchSchemaSync` (engine ANDs it with method presence, because a subclass can inherit `syncSchemasBatch` from a base whose transport batches while its own cannot) — and THIRTY-ONE were written by every driver and read by nothing. Their `.describe()` strings promised engine adaptation (\"if false, ObjectQL will filter/sort/paginate in memory\") that was never built, and zero readers let the values go WRONG unnoticed: SqlDriver declared `streaming: false` while implementing `findStream`; InMemoryDriver declared `streaming: true` over a full-table read (ADR-0078 false affordance, on the capability record itself). The real mechanism everywhere else is METHOD presence: transactions gate on `driver.beginTransaction`, aggregate pushdown on `typeof driver.aggregate`, schema sync on `typeof driver.syncSchema`, and the REQUIRED CRUD/bulk methods are called unconditionally. A driver is CODE, never stack metadata — `supports` literals live in driver classes and `DriverConfig.capabilities` is plugin TS configuration, neither ever a `sys_metadata` shape (the stack-tree neighbour, `datasource.capabilities`, was retired separately in #4583) — so there is no source for the D2 chain to rewrite and this entry is the D3 record. The keys are tombstoned rather than deleted because `DriverCapabilitiesSchema` is not `.strict()` and IS parsed (DriverConfigSchema / SQLDriverConfigSchema / NoSQLDriverConfigSchema embed it): a plain delete would silently strip a vendor's authored bit, replacing one silent no-op with another. `batchSchemaSync` also drops its `.default(false)` for `.optional()` — absence already meant false at both readers, and the default forced every capability object to spell out 30+ bits. ADR-0049 / ADR-0078, #4634."
+ "rationale": "ADR-0049 enforce-or-remove. These eight were never a source of truth: `buildFieldMetaMap(schema)` DERIVED each one from the very `schema` its caller passed in, so the map carried a second copy of facts the caller already held. They existed for exactly one consumer — the import dry run's hand-copied pre-check mirror (`firstMissingRequiredField` / `firstConstraintViolation`, framework#3956) — and #4633 ruling D retired that mirror (PR #6532): the dry run now asks `DataProtocol.validateData` for the engine's verdict, which reads the object's own schema. That left all eight computed on every import and read by NOTHING, which is the declared-and-unread shape ADR-0049 exists for; a constraint vocabulary standing next to the presentation one with no enforcer behind it is precisely the thing an AI-authored consumer mistakes for a contract. Verified zero-reader before removal, per key and by type, across this repo (`packages/rest` itself, and all five in-repo dependents of `@objectstack/rest`: runtime, cli, verify, plugin-auth, plugin-dev) and the `objectui` sibling; plugin-auth's identity import forwards `prepared.metaMap` into `runImport` but reads only the presentation keys through `coerceRow`. Why this needs a ledger entry despite that sweep: it is the `findStream` (#4484) / `IStorageService.list` (#5540) / `actor-user-roles-to-positions` (#6011) disposition — a published TS surface with NO spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry a prescription, and the ledger is the only channel that reaches an upgrader. It is if anything blinder than those three: the keys shipped in a FINAL release (`@objectstack/rest` 14.5.0) and have been published in every release since, and because they were OPTIONAL keys on an interface that itself survives, a JavaScript consumer reading `meta.required` after the upgrade gets `undefined` with no error at all — tsc reports at the read site only for a typed consumer. Why D3 semantic and not a D2 conversion: there is nothing to convert. No authored or stored metadata changes shape — `required` / `min` / `maxLength` and the rest remain fully authorable on a field definition and fully enforced by the engine, which is where they always lived. The only place these eight are ever spelled is inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach them. ADR-0049 / ADR-0087, #6536 (the sweep PR #6532 deliberately deferred)."
},
{
- "surface": "kernel.dynamicLoadRequest.activationEvents / studio.studioPluginManifest.activationEvents",
- "replacement": "(removed — delete the key. Every plugin activates immediately on load/registration, which is the only behaviour that has ever existed; `activate()` still runs at registration time. Lazy activation, if built, returns via the enforce route of ADR-0049 through a new ADR, with a vocabulary its executor actually honours)",
- "migrationId": "plugin-activation-events-retired",
+ "surface": "data.filter $regex / $options — in a STORED filter (dashboard widget filter and globalFilters, report runtimeFilter, page and component filter, solution-blueprint filter), and equally in the where clause of a query request",
+ "replacement": "$icontains for the case-insensitive substring match this was almost always used for, or $contains for a case-sensitive one — a pattern that genuinely needs a regular expression has no filter-level replacement",
+ "migrationId": "filter-regex-options-retired",
"toMajor": 17,
- "rationale": "Both `activationEvents` keys — and the `ActivationEventSchema` trigger vocabulary they embedded (`onCommand` / `onRoute` / … / `onView` after the #4653 convergence) — 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 (#3950: an exported schema with no consumer is read as a capability). #4657. SUPERSEDED ON THE KERNEL SIDE by #4834 (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` below). The studio half of this entry is unaffected and still enforced by the strict manifest parse."
+ "rationale": "Like `driver-aggregate-undeclared-key-aliases-removed` and `driver-sql-distinct-bare-filter-typed`, this entry records a LENIENCY being withdrawn rather than a declared surface: `$regex` was never in `FILTER_OPERATORS` and never a key on `StringOperatorSchema`. That is measured, not assumed — `git log -S'$regex'` over `packages/spec/src` returns only doc comments describing how `$contains` LOWERS to MongoDB (`Contains substring - SQL: LIKE %?% | MongoDB: $regex`), plus #5701 itself, 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 #4706 ruling (B), not just the driver one: the contract half (#5701 — the `$icontains` declaration, the `$contains` family pinned case-sensitive, and the `RETIRED_FILTER_OPERATORS` prescriptions) landed before the ADR-0087 disposition gate (#6148) existed and so was never asked for a ledger entry; the driver half (#5702) is where the refusal became executable. One surface, one entry, registered from the half that made it observable. ADR-0049 / ADR-0087, #4706 / #5701 / #5702."
},
{
- "surface": "kernel.dynamicLoadRequest / kernel.dynamicUnloadRequest / kernel.dynamicPluginResult / kernel.pluginSource / kernel.dynamicPluginOperation",
- "replacement": "(removed — there is no replacement shape, because there is no operation to describe. Plugins are composed at boot: `defineStack` registers them and the kernel runs register → init → start; the set is fixed until the process restarts. Delete the import and the value. Runtime plugin loading, if it is ever built, returns via the enforce route of ADR-0049 through a new ADR — loader first, vocabulary second)",
- "migrationId": "plugin-runtime-family-retired",
+ "surface": "flow.errorHandling.maxRetries (under strategy: 'retry')",
+ "replacement": "an explicit count >= 1 (e.g. maxRetries: 3), or strategy: 'fail'",
+ "migrationId": "flow-retry-max-retries-required",
"toMajor": 17,
- "rationale": "The five schemas declared the \"Dynamic Loading\" capability — runtime load / unload / reload of plugins without a kernel restart, with sandboxing, integrity hashes, drain strategies and dependent-cascade policy — and NOTHING implemented it. A bare-name scan of objectstack, cloud and objectui found zero references outside this package's own declaration, its unit tests and the generated artifacts: no runtime ever received a `DynamicLoadRequest`, performed a load/unload, or produced a `DynamicPluginResult`. That is the ADR-0049 false-compliance shape at its most inviting to an AI author (ADR-0033), who reads `DynamicLoadRequestSchema` in the published IDE bundle as proof the platform hot-loads plugins and constructs a request that parses clean and is received by nobody (#3950: an exported schema with no consumer is read as a capability). The #3896 follow-up removed this module's discovery/sandbox config island and left these five in place explicitly — \"operation contracts, not security promises; the enforce-or-remove call on them is a design decision rather than a correction\" — but that suspension lived only in a changeset paragraph with no issue carrying it. #4834 is that decision, answered REMOVE. `experimental` was considered and rejected: it is only `.describe()` prose and cannot stop an import, the weakest of the three ADR-0049 channels. None of the five is stored metadata — they are root request/result payload shapes embedded in no parent schema and parsed against no metadata document — so no `sys_metadata` row can carry one and there is no source for the D2 chain to rewrite; this entry is the D3 record. The removal also subsumes the kernel half of `plugin-activation-events-retired` (#4657): that tombstone goes with the shape that carried it. ADR-0049, #4834."
+ "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."
},
{
- "surface": "stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)",
- "replacement": "the same declarations, re-read as LIVE HTTP routes: `path` moved under `/api/v1/apps//`, and every entry that declares `authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying `rateLimit: { enabled: true, … }`",
- "migrationId": "declarative-apis-endpoints-live",
+ "surface": "data.hookContext.session.roles",
+ "replacement": "(removed — gate on `session.userId` / `session.isSystem`; for PRIVILEGE ask the security service, which reads `permissions` / `positions` / posture off the execution context, ADR-0095 D3)",
+ "migrationId": "hook-context-session-roles-retired",
"toMajor": 17,
- "rationale": "This is the one protocol-17 entry that turns metadata ON rather than off, so read it as a SECURITY review item and not as a rename. Before 17 the declarative endpoint surface executed NOTHING: no route was mounted for a declared `path`, no matcher existed, and every key — `authRequired` included — parsed green and gated nothing (#4936, which refused a non-empty `apis:` outright for exactly that reason). Protocol 17 ships the executor (#5040) and narrows that refusal to a per-endpoint publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as soon as the stack is published. So an `apis:` block written against an older major — or one restored from a pre-#4936 source, or authored from a doc that predates the refusal — changes meaning without changing a byte: what used to be inert documentation becomes an execution entry point into the data and automation pipelines. Nothing about that transition can be applied mechanically, because the judgment it needs is \"did the author of this endpoint mean for the internet to reach it?\" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT `authRequired: false` is the only thing that opens anonymous access, and under ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key defaults to `false`, so a budget written without it meters nothing) or the stack refuses to publish. ⚠️ If you author endpoints in TypeScript, annotate them with `ApiEndpoint` — the AUTHOR state — so that omitting `authRequired` compiles: `const e: ApiEndpoint = { name, path, method, type, target }` is legal and is the safe shape this paragraph prescribes. `ApiEndpointParsed` is the POST-parse type (defaults materialized, ADR-0122), where `authRequired` is required — annotating a declaration with it forces you to write the key out, and being made to think about a key whose only unrecoverable value is `false` is the one thing this entry is trying to avoid (#5227). Hold a parse RESULT with `ApiEndpointParsed`; write declarations as `ApiEndpoint`. Grep every `apis:` entry for `authRequired: false` before you upgrade, delete the ones that were never meant to be public, and arm a budget on the ones that were. The path move is the mechanical-looking half and is still yours: ADR-0121 D1/D2 confine a declared path to your own namespace carve-out (`/api/v1/apps//…`), the namespace comes from an explicit `manifest.namespace` with no derivation fallback, and the subpath is the only part you name — rewriting it for you would silently change a URL third parties call."
+ "rationale": "Declared on the runtime hook context, read by exactly two consumers, produced by nobody. The two readers were the approvals record lock and the delegation write guard, each opening with `session.roles?.includes('admin')`; ObjectQL's `buildSession()` builds the session field by field and has never written `roles`, and nothing else feeds a HookContext in objectstack, cloud or objectui (cloud's hook consumers read `hookContext?.session?.userId`; objectui's `roles` are the `/auth/me` user payload, a different surface; an ACTION body's `ctx.session` is a different untyped object that does carry `roles`, tracked apart and unaffected). Both branches were therefore dead on every real engine path — an authorization decision in shape only, and a second admin dialect competing with the one ADR-0090 D3 / ADR-0095 D3 sanction. #4839 (PR #5049) removed the readers; 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` (#4579) / `activationEvents` (#4657) 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, #3733 / ADR-0104), so a consumer that parses a context it was handed still meets the prescription. ADR-0049, #5050."
},
{
- "surface": "ui.widgetManifest / ui.widgetLifecycle / ui.widgetEvent / ui.widgetProperty / ui.widgetSource / ui.i18nObject / ui.pluralRule / ui.numberFormat / ui.dateFormat / ui.localeConfig (the widget-registration vocabulary of ui/widget.zod.ts, and the five doorless shapes of ui/i18n.zod.ts — 10 defs, 26 exported names)",
- "replacement": "(removed — there is no replacement key, because there was never a key. A custom field widget is still named the same way it always was: `field.widget` is a plain string naming a component the RENDERER has registered, and objectui's registry has always carried its own runtime manifest for that (`RuntimeWidgetManifest` / `RuntimeWidgetSource` in `@object-ui/types`, objectui#3161 / #4115), which models different keys and never derived from these. For localisation: write the default-language string on `label` / `description` — the framework generates the translation key at registration time from the naming convention — and put translations in translation files, which is the LIVE `system/translation.zod.ts` surface. Widget registration and locale formatting as authorable protocol metadata return via the ENFORCE route of ADR-0049 through a new ADR — the registry / loader / formatter first, the vocabulary second)",
- "migrationId": "ui-widget-i18n-family-retired",
+ "surface": "engine.registerHook(event, handler, { object: '' | [] | [''] }), and a scope whose `excludeObjects` cancels its `object` entirely",
+ "replacement": "name the object(s) — `object: 'account'` / `object: ['account', 'contact']` — or, for a global hook, `object: '*'` or no `object` key at all; for a cancelled scope, widen `object` or drop the overlapping names from `excludeObjects`",
+ "migrationId": "hook-register-empty-object-target-refused",
"toMajor": 17,
- "rationale": "`ui/widget.zod.ts` published a complete widget-registration vocabulary — a manifest with lifecycle hooks, custom events, configurable properties and an npm/remote/inline implementation-source union — and `ui/i18n.zod.ts` published a structured-label, plural-rule and locale-formatting vocabulary. NOTHING in the protocol carried either. Three independent measurements, re-run on `origin/main` immediately before the removal with their controls passing in the SAME run: (1) no module under `packages/spec/src` imported `widget.zod` at all, and the only imports of `i18n.zod` anywhere name `I18nLabelSchema` / `AriaPropsSchema` (both KEPT), so no schema declared a carrier key — `field.widget` is a `z.string()` naming a registered component and has never referenced `WidgetManifest`; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` reached none of them, while `PageSchema` / `ObjectListViewSchema` resolved `direct` in the same run and a synthetic carrier flipped every one of them; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these files' own unit tests. `NumberFormat` / `DateFormat` DID have a carrier key (`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier was itself doorless, so the subtree was `no door` rather than `no gate` and goes whole — leaving the two leaves behind would strand exported schemas with no consumer (#3950). `I18nObjectSchema` was additionally superseded by its own file-neighbour: `I18nLabelSchema`'s documentation already says translation keys are generated at registration time and translations live in translation files, and the live translation surface is `system/translation.zod.ts`, which uses none of these shapes. The 2026-08-06 ruling weighed giving them a carrier (option B) and rejected it: that is a feature with a registry and a renderer behind it, not ledger clean-up. Tightening them to `strictObject` was rejected earlier and explicitly (#4001 批 16) — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave \"a precisely validated dead slot, the more convincing lie\" (#4583). With no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, route 3, the same shape as #4988 (the ui/ interaction config family), #4834 (kernel plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ `WidgetManifest.performance`'s own `retiredKey()` tombstone (#3896 close-out) is SUBSUMED here, the #4657/#4834 way: it goes with the shape that carried it, which is strictly stronger than the tombstone, because there is no longer a manifest to author the key INTO. ⚠️ One of the nine widget sites is deliberately NOT retired. `FieldWidgetPropsSchema` survives: it is a REACT PROPS CONTRACT rather than authorable metadata (it never appeared in `authorable-surface/` or `json-schema.manifest/` — its `onChange` is a `z.function()`), so \"zero parse\" is its design and not its defect, and it acquired a live cross-repo compile-time consumer one day before 批 16 measured: objectui PR #3289 (2026-08-03) renamed `@object-ui/fields`' validation slot onto the spec's `error` with no alias, the form renderer began producing it, and `packages/fields/src/__tests__/spec-symbol-batch7.test.ts` pins the shape against `import type { FieldWidgetProps } from '@objectstack/spec/ui'` as an intentional tripwire. Re-verified on objectui `origin/main` 2026-08-07. ADR-0049, #5055."
+ "rationale": "#4281 ruled that an empty hook target is not \"no target\" and closed the shape at the two METADATA doors — `HookSchema.object`'s refine and `hook-binder.ts`'s `normalizeObjects`. `engine.registerHook`, the CODE door, goes through neither, so all three spellings still registered, each producing a defect the author did not write: `''` is FALSY, so the allow face was skipped entirely and the entry became a GLOBAL hook (#4281's headline failure mode — blank intent taking the broadest possible blast radius); `[]` and `['']` are truthy but admit no object name, so the entry could never fire. #5928 then added the `excludeObjects` face, which brought a fourth shape reached by arithmetic rather than by one bad name: an `object` list every member of which is also excluded admits nothing, so that entry can never fire either. All four are ADR-0078 silently-inert declarations, and all four are now refused at REGISTRATION.\n\nNo mechanical rewrite exists, in either direction. The refused values carry no recoverable intent — `object: ''` could have meant `'*'` (what it actually did) or a specific object name the author forgot to fill in, and those are opposite registrations; choosing between them is a judgment the chain cannot make. Nor could the MATCHING read be changed instead: teaching the matcher that `''` is an unmatchable name would silently convert a hook firing on every object into one firing on none — the same class of defect pointing the other way, which is why #5928 declined to do it in passing.\n\nThis is a RUNTIME registration API, not stored metadata, so — like `hook-context-session-roles-retired` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and the ledger entry is the notification channel. One metadata surface reaches it INDIRECTLY and is the reason this is not purely a code-side note: a `record-change` flow's start node forwards `config.objectName` verbatim into `registerHook` (`RecordChangeTrigger.start`), so a flow authored with a blank `objectName` used to bind a trigger to EVERY object in the tenant. It now fails to bind instead, loudly — the automation engine's per-flow bind guard warns and the `kernel:bootstrapped` binding audit re-reports it — which is the correct end state, but it is an observable change for that flow. #6573, #4281, #4001, #5928, ADR-0078."
},
{
- "surface": "ui.touchInteraction / ui.gestureConfig / ui.dndConfig / ui.keyboardNavigationConfig / ui.componentAnimation / ui.motionConfig / ui.pageTransition / ui.offlineConfig (the whole export surface of ui/touch.zod.ts, ui/dnd.zod.ts, ui/keyboard.zod.ts, ui/animation.zod.ts and ui/offline.zod.ts — 32 defs, 64 exported names)",
- "replacement": "(removed — there is no replacement key, because there was never a key. Touch targets, drag-and-drop, focus management, keyboard shortcuts and motion are RENDERER BUILT-IN behaviour: the component library decides them, not a per-page metadata author. Offline is a platform capability, and its vocabulary belongs on the sync engine that owns the queue, the conflict policy and the cache — none of which exists yet. Delete the import and the value. Whichever of these earns real product pull returns WITH its own vocabulary and its executor, the #4910 way, not by un-retiring a declaration)",
- "migrationId": "ui-interaction-config-family-retired",
+ "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": "Five `@objectstack/spec/ui` modules declared a full interaction-configuration vocabulary — 22 `z.object` sites across touch/gesture, drag-and-drop, focus/keyboard, animation/motion and offline/sync — and NOTHING in the protocol carried them. This is the ADR-0049 false-compliance shape in its most inviting form for an AI author (ADR-0033), and worse than the ordinary declared-but-unread defect: `authorable-surface.json` listed 109 keys under these defs and `content/docs/references/ui/{touch,dnd,keyboard,animation,offline}.mdx` rendered them as authoring tables, so the published documentation advertised a vocabulary with no carrier key anywhere. An author following `dnd.mdx` and writing a `dnd:` block onto a page component was rejected by `PageComponentSchema` for an unrecognized key — the docs and the schema disagreeing about the platform (Prime Directive #10). Three independent measurements, each with its controls passing in the same run: (1) no module under `packages/spec/src` imported any of the five except the `ui/index.ts` barrel, so no schema declared a carrier key; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` (25 roots, 4742 nodes) reached none of the 21 named object shapes, while `PageSchema`, `WebhookSchema` and `StateMachineSchema` all resolved `direct` and a synthetic carrier flipped all 21 — so unreachability was a fact about the graph, not a broken walker; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these modules' own unit tests. objectui holds TYPE re-exports and parity ratchets, never validators, and says so (#2561). The 2026-08-04 ruling weighed wiring a carrier key (option B) and rejected it: that is a feature with a renderer behind it, not ledger clean-up. It also weighed tightening the shapes to `strictObject` and rejected that explicitly — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave \"a precisely validated dead slot, the more convincing lie\" (#4583). Because there was no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, the same route 3 as #4834 (kernel plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ Not to be confused with #5021, which retired the THEME `animation` block — a different file, different defs, and that one did have a carrier key and therefore a tombstone. ADR-0049, #4988."
+ "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."
},
{
- "surface": "ui.notificationAction / ui.embedConfig",
- "replacement": "(removed — there is no replacement shape, because there was never a key to write either into. Delete the import and the value. Notification presentation is still described by the surviving `NotificationType` / `NotificationSeverity` / `NotificationPosition` vocabulary; public access to a form is granted by the LIVE `FormView.sharing` block (`SharingConfig`), which is untouched. Notification action buttons as metadata, and iframe embedding, return via the enforce route of ADR-0049 through a new ADR — carrier key and renderer first, vocabulary second)",
- "migrationId": "ui-notification-action-embed-config-retired",
+ "surface": "api.ImportRequest runAutomations — the declared default of the key on BOTH import bodies, POST /api/v1/data/:object/import (ImportRequest) and its async twin POST /api/v1/data/:object/import/jobs (CreateImportJobRequest, which IS the same schema object). It was declared default(false) and described as \"off by default for bulk\"; it is now default(true), which is what the server has always done",
+ "replacement": "an explicit runAutomations: false on any import request that is meant to load rows without firing triggers/hooks. That spelling is unchanged and has always been the only one the server read — what changes is that omitting the key now DECLARES what it already DID. Callers who want automations on need write nothing",
+ "migrationId": "import-run-automations-declared-default-corrected",
"toMajor": 17,
- "rationale": "Both shapes were published `@objectstack/spec/ui` vocabulary with NO AUTHORING DOOR. #4001 批 14 measured them three ways on 2026-08-03 and this retirement re-ran all three against `origin/main` before removing anything, each with a positive control that passed in the same run: (1) CARRIER — no schema in `packages/spec/src` declared a key of either type (`ui/notification.zod`'s only non-test importer was the barrel; `ui/sharing.zod`'s were the barrel and `ui/view.zod.ts`, which names its SIBLING `SharingConfigSchema`), measured by resolving specifiers rather than substring-matching, because the repo holds two `sharing.zod` modules and a substring test miscredits `stack.zod.ts` to the UI one; (2) REACHABILITY — a BFS from the 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema`, over `build-schemas.ts`'s own walk including its derived-clone bridge, never reached either, while `Page` / `Action` / `DashboardWidget` / `Webhook` and `SharingConfig` itself all resolved `root-graph` in the same run and an injected synthetic carrier flipped both; (3) PARSE — zero `.parse()` in objectstack, cloud or objectui outside their own unit tests. So nobody could author one and nothing ever validated one: the #3950 shape, an exported schema with no consumer read as a capability, and the ADR-0033 trap where an AI author takes `EmbedConfigSchema` in the published bundle as proof the platform serves iframes. Neither is stored metadata and neither has a carrier, so no `sys_metadata` row can hold one and there is no source for the D2 chain to rewrite; this entry is the D3 record. 批 14 deliberately did NOT close them with `.strict()` — strictness is a property of a PARSE, and closing a shape nothing parses buys only \"a precisely-validated dead slot, the more convincing lie\" (#4583) — and filed the disposition as #5015, ruled REMOVE on 2026-08-04. Each was orphaned by an earlier retirement one level up: `NotificationAction` lost its wrappers at #4610 (`NotificationSchema` / `NotificationConfigSchema`, the #4535 C3 dual-source cleanup — that retirement's published \"zero consumers\" evidence was later falsified for objectui and is corrected on `ui/notification.zod`'s tombstone; the removal itself stands, #5781), and `EmbedConfig` lost its key at 17.0.0 when the 2026-06 liveness audit retired `App.embed` (no iframe route ever read it) — that key still stands as a `retiredKey()` tombstone in `app.zod.ts`, so an author who wrote the KEY already meets a prescription; this removes the value shape that outlived it. ⚠️ The retirement is per SCHEMA, not per file: `ui/sharing.zod` KEEPS `SharingConfigSchema`, a live door carried by `FormViewSchema.sharing` and read by `rest-server.ts` to mount the anonymous form routes, and `ui/notification.zod` keeps its three presentation enums. objectui consumed `NotificationActionSchema.shape.variant` as a VOCABULARY (never a parse) to pin its own hand-written `NotificationActionButton` interface — which is exactly why \"has a consumer\" never meant \"has an authoring door\" here; that pin is adapted objectui-side when it refreshes this dependency. ADR-0049, #5015."
+ "rationale": "A DECLARATION corrected to match a runtime that did not move — the inverse of a behaviour flip, and registered here for the reason protocol 12's `rest-requireauth-default-flip` and this major's `action-descriptor-resume-authority-default-flip` are: whether a given import was meant to fire triggers is a judgment no transform can make, so the prescription is a TODO rather than a rewrite. The server decides in import-prepare.ts with `body?.runAutomations !== false`, i.e. an omitted flag runs automations, and has since #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."
},
{
- "surface": "data.hookContext.session.roles",
- "replacement": "(removed — gate on `session.userId` / `session.isSystem`; for PRIVILEGE ask the security service, which reads `permissions` / `positions` / posture off the execution context, ADR-0095 D3)",
- "migrationId": "hook-context-session-roles-retired",
+ "surface": "job.retryPolicy.maxRetries (> 10) / job.retryPolicy.backoffMultiplier (< 1)",
+ "replacement": "maxRetries <= 10, and backoffMultiplier >= 1",
+ "migrationId": "job-retry-policy-constraints-tightened",
"toMajor": 17,
- "rationale": "Declared on the runtime hook context, read by exactly two consumers, produced by nobody. The two readers were the approvals record lock and the delegation write guard, each opening with `session.roles?.includes('admin')`; ObjectQL's `buildSession()` builds the session field by field and has never written `roles`, and nothing else feeds a HookContext in objectstack, cloud or objectui (cloud's hook consumers read `hookContext?.session?.userId`; objectui's `roles` are the `/auth/me` user payload, a different surface; an ACTION body's `ctx.session` is a different untyped object that does carry `roles`, tracked apart and unaffected). Both branches were therefore dead on every real engine path — an authorization decision in shape only, and a second admin dialect competing with the one ADR-0090 D3 / ADR-0095 D3 sanction. #4839 (PR #5049) removed the readers; 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` (#4579) / `activationEvents` (#4657) 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, #3733 / ADR-0104), so a consumer that parses a context it was handed still meets the prescription. ADR-0049, #5050."
+ "rationale": "The converged RetryPolicy (#4661) keeps the automation side's bounds, which the job side never had: `maxRetries` is capped at 10 and `backoffMultiplier` floored at 1. Neither has a lossless rewrite. Clamping `maxRetries: 20` to 10 would halve a retry budget its author chose, and a `backoffMultiplier` below 1 describes a delay that SHRINKS on each attempt — retrying a failing dependency ever faster, which is the opposite of backoff and was never a shape the engine meant to offer. Both now fail at parse time with the bound named, rather than being silently reinterpreted. Choosing the replacement count (or accepting the cap) is the author's call."
},
{
- "surface": "ui.actionSession.roles",
- "replacement": "ui.actionSession.positions (an action body reads `ctx.session.positions`)",
- "migrationId": "action-session-roles-to-positions",
+ "surface": "api.listNotifications cursor — the key on BOTH halves of GET /api/v1/notifications (ListNotificationsRequestSchema and ListNotificationsResponseSchema) and the cursor argument of the client SDK call client.notifications.list(). The same entry covers the limit default: the request schema no longer declares default(20)",
+ "replacement": "a larger `limit` — the route answers the newest N notifications and has no page 2. There is no replacement for `cursor`, deliberately: nothing ever minted one, so no caller holds a value to carry over. Callers that looped on it were re-reading the first window and should read one window sized to what they display (the Console bell polls exactly this way). For the removed `limit` default, send the number you want explicitly if you were relying on 20 — omitting it takes the server window, which is 50 on the platform inbox and clamped into 1..200, and has been since before the declaration existed",
+ "migrationId": "notification-list-cursor-retired",
"toMajor": 17,
- "rationale": "The MIRROR-IMAGE neighbour of the entry above, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright, #5050), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. #5613 ruled contract-first (maintainer, 2026-08-06: \"C skeleton + A semantics\"): phase 1 (#5697) declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it (#5779); the producer emits both for one deprecation window (#5613 runtime half), after which `roles` is removed on the path the v11 session-alias removal already walked (#3280 deprecated → #3290 removed). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087, #5613 / #5779."
+ "rationale": "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."
},
{
- "surface": "action body / AI route: ctx.user.roles (req.user.roles)",
- "replacement": "ctx.user.positions (an AI route handler reads `req.user.positions`) — the same array, under the one spelling ADR-0090 D3 sanctions",
- "migrationId": "actor-user-roles-to-positions",
+ "surface": "kernel.dynamicLoadRequest.activationEvents / studio.studioPluginManifest.activationEvents",
+ "replacement": "(removed — delete the key. Every plugin activates immediately on load/registration, which is the only behaviour that has ever existed; `activate()` still runs at registration time. Lazy activation, if built, returns via the enforce route of ADR-0049 through a new ADR, with a vocabulary its executor actually honours)",
+ "migrationId": "plugin-activation-events-retired",
"toMajor": 17,
- "rationale": "The THIRD face of the ADR-0090 `roles` → `positions` rename, and the only one whose surface the spec never declared. `ActorUser` (`packages/runtime/src/security/actor-user.ts`) is the ONE producer of the `user` envelope handed to an action body as `ctx.user` and to an AI route handler as `req.user`; it declared `positions` and `roles` side by side and filled them from a SINGLE assignment (`roles: core.positions`), so the two keys were verbatim identical on every dispatch — a second spelling of the vocabulary ADR-0090 D3 reserves and bans, published straight into author-written code. The maintainer ruled it closed IMMEDIATELY (2026-08-06 14:49Z, #6011): no deprecation window, no dual-emit, the alias simply gone in 17 (PR #6048). ⚠️ Do not read this entry across to its neighbour above: `action-session-roles-to-positions` governs `ctx.session`, a DIFFERENT object reached through the same `ctx`, and that one KEEPS its one-window dual-emit (#5613). Same word, same dispatch, two faces, two schedules — `ctx.user.roles` is absent in 17 while `ctx.session.roles` still answers for the length of its window. What makes this entry different in KIND from both session-side siblings: `ctx.user` has no spec schema and never had one. It is a runtime TS interface, so unlike `HookContext.session.roles` (tombstoned on a deliberately non-strict `HookContextSchema`, #5050) and unlike `ActionSessionSchema` (declared contract-first at #5697 precisely so its key could be renamed), there is no schema key here to tombstone and no `retiredKey()` prescription that could reach anybody — nothing ever ran an `ActorUser` through a `.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it reports at the READ site inside the author's own body; for an untyped or sandboxed body there is no enforced channel at all, which is exactly why this ledger entry has to exist — `spec-changes.json` and the generated upgrade guide are the ONLY way such a reader learns of the rename. It is the `findStream` (#4484) / `IStorageService.list` (#5540) disposition — a TS/API contract, no stored source, no tombstone, tsc at the call site — applied to a surface that lives one layer further out than either: those two are at least DECLARED in `packages/spec/src/contracts`, this one only in `packages/runtime`. Why it is a D3 semantic TODO and not a D2 conversion, on the same two independent grounds as its session sibling: FIRST, there is no source to convert — an `ActorUser` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key (the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape). SECOND, the only place the key is ever SPELLED is inside an action body or an AI route handler: author-written JS/TS, or a sandboxed script. A declarative transform cannot safely rewrite an identifier inside free-form code — the same reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. The removal's hard precondition was met before it landed, and the result is recorded here because the ledger is where an upgrading consumer meets it: the declaration's own comment claimed the alias was \"kept for the REST/AI shapes\", and that claim was DISPROVEN face by face against `origin/main` — repo-wide `user.roles` was 4 hits, all of them in the pins PR #6048 flipped; the four `ActorUser` construction sites build server-side envelopes that never enter a response body; objectui's `.roles` reads belong to two unrelated producers (the better-auth session, and the `/auth/me/permissions` payload). The `cloud` repo was NOT reachable in that session and is the one consumer face left unverified — this entry, and the changeset's FROM/TO prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087, #6011 (PR #6048)."
+ "rationale": "Both `activationEvents` keys — and the `ActivationEventSchema` trigger vocabulary they embedded (`onCommand` / `onRoute` / … / `onView` after the #4653 convergence) — 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 (#3950: an exported schema with no consumer is read as a capability). #4657. SUPERSEDED ON THE KERNEL SIDE by #4834 (same unreleased major): the whole `DynamicLoadRequest` shape — and the rest of the plugin-runtime family with it — was removed, which took this key's `retiredKey()` tombstone with it. That is strictly stronger than the tombstone, not weaker: there is no longer a `DynamicLoadRequest` to author the key INTO, so the prescription an author needs is no longer \"delete this key\" but \"this request shape does not exist\" (see `plugin-runtime-family-retired`). The studio half of this entry is unaffected and still enforced by the strict manifest parse."
},
{
- "surface": "contracts.IStorageService.list",
- "replacement": "track the keys you wrote (sys_file / file-reference records, queryable through ObjectQL with real pagination) instead of enumerating the bucket — and where no such record exists, the cursor-shaped `list(prefix, { cursor, limit })` this entry reserved, restored in #6781",
- "migrationId": "storage-service-list-retired",
+ "surface": "manifest.loading (the whole block: strategy / preload / codeSplitting / dynamicImport / initialization / dependencyResolution / hotReload / caching / sandboxing / monitoring)",
+ "replacement": "nothing to re-declare — delete the key. Plugins are composed at boot: `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder` in `packages/core/src/plugin-order.ts`). For the isolation `loading.sandboxing` appeared to configure, use the plugin trust tier (`manifest.runtime`, ADR-0025 §3.6) and the manifest permission declarations, which are the surfaces the platform actually enforces",
+ "migrationId": "plugin-manifest-loading-retired",
"toMajor": 17,
- "rationale": "`list(prefix)` was an OPTIONAL contract method documented as \"List files in a directory/prefix\", and the two shipped adapters answered the same call with two different semantics — both of them silently incomplete. `LocalStorageAdapter.list` was a single-level `readdir`, so a nested key `a/b/c` was invisible under `list('a')` (only `a/b` came back), and a subdirectory that `stat` succeeded on was pushed into the result as a file, yielding a `StorageFileInfo` whose `size` is a directory inode and which cannot be downloaded at all. `S3StorageAdapter.list` was RECURSIVE (`ListObjectsV2` matches the whole key) and read neither `IsTruncated` nor `ContinuationToken`, so past 1000 objects the \"all files\" a caller received was the first page, with no signal. One contract method, two dialects, both quietly incomplete — and the first feature that genuinely needed to enumerate a prefix (backup, orphan sweep, migration audit) would have got two different answers on two deployments without an error on either. #5172 was nearly that feature: it planned to drive attachment reclamation off `list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one level down, and switched to queue-driven deferred work instead. Nothing consumed it afterwards: the only in-repo call site was the `SwappableStorageService` pass-through (which itself rejects when the active adapter has no `list`), and REST, CLI and the storage routes never called it. Remove was chosen over align-and-tighten (maintainer ruling, 2026-08-05, #5266): aligning would grow a conformance surface nobody walks, while a prefix listing that cannot paginate is the wrong signature to inherit — when a real caller needs enumeration it returns cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases (nested keys, directory entries, >1000 objects) proving both backends agree. This is a TS/API contract surface — a storage adapter is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription there would reach no one. The enforced channel is tsc, and it reports at the call site. Same disposition, and the same reason, as `data-driver-find-stream-retired` (#4484). ADR-0049 / ADR-0087, #5540 (analysis #5266)."
+ "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-08-04 on #4914. The block declared a complete plugin loading policy and NOTHING read it. A bare-name scan of all three repos — objectstack, cloud (measured 2026-08-09) and objectui (measured at pickup), each with a control probe proving the scan saw the tree — put every hit inside `packages/spec` itself: this module's own declaration, its own unit tests, the `Manifest.loading` embed and the generated artifacts. `manifest.loading.*` had zero readers in `packages/core`, `packages/runtime` and `packages/metadata`. So the key parsed, entered the manifest, and changed nothing — #3950, at the scale of a whole block. What made it outrank ordinary inert-key cleanup is `sandboxing`: it declared process / vm / iframe / web-worker isolation, IPC transports and an `allowedServices` ACL, so an AI author (ADR-0033) reading that vocabulary concluded the platform isolates plugins, wrote the config, and received a clean parse and zero isolation. An inert security control is worse than an absent one because it is believed. Hot reload was additionally a TWO-SOURCE defect: the docs pointed at this dead `PluginHotReloadSchema` while the only implementation body, `HotReloadManager` (`packages/core/src/hot-reload.ts`), reads a different vocabulary — `HotReloadConfigSchema` in `plugin-lifecycle-advanced.zod.ts`. Ruling §2 converges on the surviving side: that schema is KEPT as the starting point for a future enforce decision (it has an implementation body but no runtime composes it yet), and enforcing it is deliberately a separate decision, not this retirement. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections. A package manifest is neither — `PLURAL_TO_SINGULAR` has no `packages` / `plugins` entry, so a manifest is not a stack collection member and a stored manifest row passes that seam through unchanged. A conversion would be a transform with no seam that ever runs."
},
{
- "surface": "driver aggregate() call argument — query.aggregate and aggregations[].func",
- "replacement": "query.aggregations and aggregations[].function — the spellings QueryASTSchema and AggregationNodeSchema have always declared",
- "migrationId": "driver-aggregate-undeclared-key-aliases-removed",
+ "surface": "kernel.dynamicLoadRequest / kernel.dynamicUnloadRequest / kernel.dynamicPluginResult / kernel.pluginSource / kernel.dynamicPluginOperation",
+ "replacement": "(removed — there is no replacement shape, because there is no operation to describe. Plugins are composed at boot: `defineStack` registers them and the kernel runs register → init → start; the set is fixed until the process restarts. Delete the import and the value. Runtime plugin loading, if it is ever built, returns via the enforce route of ADR-0049 through a new ADR — loader first, vocabulary second)",
+ "migrationId": "plugin-runtime-family-retired",
"toMajor": 17,
- "rationale": "`SqlDriver.aggregate` and `RemoteTransport.aggregate` each read two aliases the Query Protocol has never declared: `query.aggregations || query.aggregate` and `agg.function || agg.func`. \"Never declared\" is measured, not assumed — `git log -S` over `data/query.zod.ts` finds no commit that ever introduced either name, there is no `retiredKey()` tombstone and no alias-table entry for them (the file's only alias table is `SortNode`'s `direction` → `order`), and neither appears in any upgrade guide or release note. So this entry does not record a declared surface being withdrawn; it records a LENIENCY being withdrawn, which is why it is here rather than behind a tombstone. The only writers in this repository were the two driver packages' own fixtures — #4984's family, where a fixture spelling the alias keeps the tolerant limb green forever and no test in existence can go red on its deletion — so ADR-0049 enforce-or-remove applies once those are re-spelt. ⚠️ Do NOT read this across to `dashboard`/`page` measures: `aggregate` IS the canonical key there and `func` IS a declared, loudly-suggesting alias (`DatasetMeasureSchema`, ui/dataset.zod.ts). That neighbouring vocabulary is untouched, and it is the most likely reason an off-repo caller ever wrote these keys on a QUERY — one habit, two surfaces, only one of which declared it. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone: nothing ever ran a query through `QueryASTSchema.parse()` on this path. The enforced channel is tsc at the call site, once the parameter is `DriverQuery` — and for an untyped JS caller there is no enforced channel at all, which is exactly why this ledger entry has to exist: the generated upgrade guide is the only way such a reader learns of the rename. Same disposition, and the same reason, as `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011). ADR-0049 / ADR-0087, #6321 (PR #6404)."
+ "rationale": "The five schemas declared the \"Dynamic Loading\" capability — runtime load / unload / reload of plugins without a kernel restart, with sandboxing, integrity hashes, drain strategies and dependent-cascade policy — and NOTHING implemented it. A bare-name scan of objectstack, cloud and objectui found zero references outside this package's own declaration, its unit tests and the generated artifacts: no runtime ever received a `DynamicLoadRequest`, performed a load/unload, or produced a `DynamicPluginResult`. That is the ADR-0049 false-compliance shape at its most inviting to an AI author (ADR-0033), who reads `DynamicLoadRequestSchema` in the published IDE bundle as proof the platform hot-loads plugins and constructs a request that parses clean and is received by nobody (#3950: an exported schema with no consumer is read as a capability). The #3896 follow-up removed this module's discovery/sandbox config island and left these five in place explicitly — \"operation contracts, not security promises; the enforce-or-remove call on them is a design decision rather than a correction\" — but that suspension lived only in a changeset paragraph with no issue carrying it. #4834 is that decision, answered REMOVE. `experimental` was considered and rejected: it is only `.describe()` prose and cannot stop an import, the weakest of the three ADR-0049 channels. None of the five is stored metadata — they are root request/result payload shapes embedded in no parent schema and parsed against no metadata document — so no `sys_metadata` row can carry one and there is no source for the D2 chain to rewrite; this entry is the D3 record. The removal also subsumes the kernel half of `plugin-activation-events-retired` (#4657): that tombstone goes with the shape that carried it. ADR-0049, #4834."
},
{
- "surface": "type alias: the 102 XInput names of @objectstack/spec (ConnectorInput, AppInput, PageInput, ActionInput, ServiceObjectInput, ExecutionContextInput, TaskInput, … — 52 files across api/ automation/ data/ identity/ integration/ kernel/ security/ system/ ui/)",
- "replacement": "the BARE name. ADR-0122 phase 2 moved the author state onto `X`, which makes `XInput` a character-for-character synonym of it — the permanent synonym D3 forbids. Drop the `Input` suffix: `ConnectorInput` -> `Connector`. Symmetrically, a consumer that held a PARSE RESULT under the bare name moves to `XParsed`, which phase 1 (16.x) already declared for every schema whose two shapes differ, so the target name has existed for a release. NINE `*Input` names are NOT retired and need no edit: `ExpressionInput`, `CronExpressionInput`, `TemplateExpressionInput` and `PredicateInput` are the bare aliases of their own `…InputSchema`, and `FormFieldInput`, `QueryInput`, `FieldInput`, `ObjectStackDefinitionInput` and `NavigationItemInput` are composed (recursive or `Partial`-shaped) types no bare alias denotes.",
- "migrationId": "spec-type-alias-input-suffix-retired",
+ "surface": "data.query.aggregations[].function ('array_agg' / 'string_agg')",
+ "replacement": "an ordinary `fields` query, shaped in the caller — or a stored field that materialises the roll-up. For a deduplicated COUNT the live spelling is unchanged: `count_distinct` stays declared",
+ "migrationId": "query-array-string-agg-retired",
"toMajor": 17,
- "rationale": "This entry exists for the reason `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) exist, and it is the same disposition: the surface is a TYPESCRIPT NAME, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — an `XInput` alias never had a carrier key, never emitted a def, and no `.parse()` ever saw it. Measured and verified rather than assumed: `json-schema/`, `json-schema.manifest/` and `authorable-surface/` are BYTE-IDENTICAL across this change, because those generators enumerate runtime `z.ZodType` exports and never read a type alias. So nothing left the published metadata surface and RETIRED_DEFS_BY_MAJOR is deliberately untouched — an entry there would falsely claim the metadata contract shrank. The enforced channel is tsc: the name is gone, so every consumer gets TS2724/TS2305 naming the import. That is loud but MUTE about the replacement — a compile error says `ConnectorInput` does not exist, not that `Connector` now means what it meant. The generated upgrade guide is the only channel that carries the second half, which is precisely the #6048 gap ADR-0087 registration exists to close. ⚠️ Deliberately NOT registered alongside it: the 1384 bare aliases the same change FLIPPED from `z.infer` to `z.input`. Those names all still exist and still resolve; what moved is which of a schema's two shapes they denote, and only where the two differ (663 of 1384 — the rest are isomorphic and the flip is a no-op there, pinned as such). A consumer holding an authored literal is made MORE correct by it, silently; one holding a parse result gets a tsc error at the first defaulted key it reads. Registering that as a rename would misdescribe it — no name was retired — and the changeset carries its own FROM -> TO for it. ADR-0122 D8/D9, #6083 (PR #6279)."
+ "rationale": "The stored half of this retirement is a conversion (`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. `QueryAST` is never stored in stack metadata — it is the client SDK builder's output and the `POST /data/:object/query` body — so there is no source for the chain to rewrite and callers move their own queries. Both values were declared-but-unlowered on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller following the schema against a SQL datasource got a refusal, not an array. They did run on `driver-mongodb` and on the engine's in-memory fallback, which is what makes this the one narrowing in the batch that removes reachable behaviour: an aggregation that worked on one backend and failed on another is exactly the unpredictability the ruling ended, and #5499 has both of those backends frozen. `count_distinct` was deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049's enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049, #6188."
},
{
- "surface": "SqlDriver.distinct() third argument — any value",
- "replacement": "a bare FilterCondition (@objectstack/spec/data) — the same value find() carries under query.where, never a query envelope",
- "migrationId": "driver-sql-distinct-bare-filter-typed",
+ "surface": "data.query.cursor",
+ "replacement": "a `where` predicate on the sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` (the documented manual-keyset pattern)",
+ "migrationId": "query-cursor-retired",
"toMajor": 17,
- "rationale": "This entry records a TYPE being added, not a surface being withdrawn, and it says so up front because the distinction decides who has to do anything. `distinct` is not declared on `IDataDriver`, so #5181 / #6075 never reached it and it kept `filters?: any` while its body said something far more specific — `applyFilters(builder, filters)` is handed the ARGUMENT ITSELF, never a `.where` off it. ⚠️ RUNTIME BEHAVIOUR IS UNCHANGED by this entry's change: not one statement moved, so no upgrade breaks at run time and nothing that answered correctly stops. What the annotation removes is a compile-time hole, measured rather than assumed: a truthy NON-OBJECT third argument — `distinct('orders', 'product', 'completed')` — used to type-check and resolve the UNFILTERED set, because `applyFilters` emits no predicate at all for a truthy non-object, non-array filter. A call meaning \"which products among completed orders\" answered with EVERY product, silently. That spelling is now TS2345 at the call site. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone, the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540), `actor-user-roles-to-positions` (#6011) and `driver-aggregate-undeclared-key-aliases-removed` (#6321) already carry. ⚠️ It differs from those four in ONE measured way a reader should not have to infer: because nothing changed at run time, an untyped JS caller is not affected BY THE UPGRADE at all. The entry is here for a different reason — such a caller is exactly the one tsc can never reach, and the silent widening above is a defect they may ALREADY be sitting on, before and after this major. The generated upgrade guide is the only channel that reaches them, which is why the fix is written down rather than left to the compiler. ⛔ The reverse mismatch is NOT closed and no type can close it: `FilterCondition` is an open map (`[key: string]: any`) because a filter key IS a field name, so a query envelope `{ object, where }` is structurally a valid filter — one constraining columns named `object` and `where` — and so is a FilterArray. Both reach `distinct` type-checked and are refused at run time, loudly, with INVALID_FILTER / 400. `driver-memory`'s opposite half — where the BARE spelling returns the unfiltered set in silence — stays open under the #5499 freeze (#6320). ADR-0087, #6320."
+ "rationale": "The `cursor` key promised keyset pagination and no driver implemented it: the cursor was accepted and ignored, so every page came back identical — a caller looping \"until hasMore is false\" never terminates. Worse than inert, it had a shipped public producer (`QueryBuilder.cursor()`, removed with the key). The caller-built `Record` shape also leaks sort/storage detail and squats on the reserved REST parameter set; a first-class cursor, if ever designed, will be a response-minted opaque token — a different API, so keeping this one preserved a wrong design rather than a roadmap. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286."
},
{
- "surface": "data.filter $regex / $options — in a STORED filter (dashboard widget filter and globalFilters, report runtimeFilter, page and component filter, solution-blueprint filter), and equally in the where clause of a query request",
- "replacement": "$icontains for the case-insensitive substring match this was almost always used for, or $contains for a case-sensitive one — a pattern that genuinely needs a regular expression has no filter-level replacement",
- "migrationId": "filter-regex-options-retired",
+ "surface": "data.query.distinct",
+ "replacement": "`groupBy` for unique combinations; the `count_distinct` aggregation for deduplicated counts; the SQL/memory drivers' `distinct(object, field)` door for one column's values",
+ "migrationId": "query-distinct-retired",
"toMajor": 17,
- "rationale": "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 #5701 itself, 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 #4706 ruling (B), not just the driver one: the contract half (#5701 — the `$icontains` declaration, the `$contains` family pinned case-sensitive, and the `RETIRED_FILTER_OPERATORS` prescriptions) landed before the ADR-0087 disposition gate (#6148) existed and so was never asked for a ledger entry; the driver half (#5702) is where the refusal became executable. One surface, one entry, registered from the half that made it observable. ADR-0049 / ADR-0087, #4706 / #5701 / #5702."
+ "rationale": "The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND worse pagination metadata, and a side effect that \"confirmed\" the flag was doing something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with the key). The count suppression is deleted in the same change — `total` is truthful for those queries again. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286."
},
{
- "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",
+ "surface": "data.query.fields",
+ "replacement": "expand (`expand: { owner: { object: 'user', fields: ['name'] } }`), or a dotted path for a single related column (`fields: ['owner.name']`)",
+ "migrationId": "query-field-node-object-form-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 `FieldNode` union declared a nested-select object form `{ field, fields, alias }` that was inert end to end: no producer emitted it, and no consumer read `.fields` or `.alias` — objectql's formula projection and known-field filters, driver-sql's `select()` and driver-memory's projection all treat the list as `string[]`, driver-mongodb keyed its projection with the entry itself, and the REST ingress stringified it. Nested selection is `expand`, which the engine resolves via batch `$in` queries. This is a REQUEST surface — `QueryAST` is never stored in stack metadata (no view, dataset or report authors one), so there is no source for the chain to rewrite: the schema narrows to `z.string()` and callers move their own select lists. ADR-0049 / ADR-0078, #4196."
},
{
- "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",
+ "surface": "data.query.joins",
+ "replacement": "expand (`expand: { owner: { object: 'user', fields: ['name'] } }`), or a dotted `fields` path for a single related column (`fields: ['owner.name']`)",
+ "migrationId": "query-joins-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": "The `joins` array was declared-but-inert: no engine or driver read `query.joins` anywhere on the query path, so a query carrying it behaved exactly as if the key were absent — while the name squatted on the reserved REST parameter set. Related-record retrieval already has a live spelling (`expand`, resolved by the engine via batch `$in` queries), so the removal deletes the second, broken spelling rather than the capability, and the orphaned `JoinNode`/`JoinType`/`JoinStrategy` cluster goes with the key. A REQUEST surface — `QueryAST` is never stored in stack metadata — so there is no source for the chain to rewrite; callers move their own queries. ADR-0049 / ADR-0078, #4286."
},
{
- "surface": "automation.etlPipeline / automation.etlPipelineRun / automation.etlSource / automation.etlDestination / automation.etlTransformation (the whole L2 layer of automation/etl.zod.ts, its four enums and the `ETL` factory — 9 defs, 27 exported names)",
- "replacement": "(removed — no protocol surface replaces it, deliberately. Layer by layer: connector-attached synchronisation is `ConnectorSchema.syncConfig` (`integration/connector.zod.ts`), which IS parsed and executed; per-field value transformation on import is `shared/mapping.zod.ts`, whose `transform` is applied row by row by the REST import path and recorded key by key in `packages/spec/liveness/mapping.json`; scheduling is `system/job.zod.ts`. What has NO replacement is multi-source, multi-stage movement with joins and aggregations — because it never had an implementation either. It returns through the ENFORCE route: the engine first, the vocabulary second)",
- "migrationId": "etl-pipeline-layer-retired",
+ "surface": "data.query.windowFunctions",
+ "replacement": "`aggregations` + `groupBy` for request-level analytics; `SqlDriver.findWithWindowFunctions(object, query)` for embedders on a SQL datasource",
+ "migrationId": "query-window-functions-retired",
"toMajor": 17,
- "rationale": "The reading #4738 used to retire L1 `DataSyncConfig`, re-measured one layer up and identical: narrative-only. No engine ever parsed, scheduled or executed an `ETLPipeline`. Measured on origin/main immediately before the removal: the only non-spec references in this repo are two fumadocs-generated documentation sources (`apps/docs/.source/*.ts`), not executors; objectui has no reference at all; there is no `liveness/etl.json` or `pipeline.json`, so no ADR-0049 gate ever had a reading on it — while the same file family's EXECUTED half does have one (`liveness/mapping.json`), which is the contrast that makes the absence meaningful rather than an oversight. The `etl` string in this registry was the one untested link the finding named, and it is not a loader path: it was the id of the #4962 retry-vocabulary entry, absorbed here. The layer was ADR-0078's asymmetry in its purest form — an author could write a complete ten-stage pipeline, get no error, and get no execution. It was also advertised: `packages/spec/docs/SYNC_ARCHITECTURE.md` named `ETLPipeline` as the recommended destination for authors displaced by the L1 retirement (#4738) and listed ten transformation types with copyable examples down to `script | Custom JavaScript/Python`. That document is rewritten in the same change; a retirement whose own doc still recommends the retired layer is self-contradictory, and forwarding L1's authors to a second layer with no executor was the defect compounding rather than closing. ⚠️ `etl-retry-converged-onto-retry-policy` (#4962) is SUBSUMED here, the #4657/#4834/#5055 way: both land in the unreleased protocol 17, so composed, a rename of `retry.maxAttempts` on a shape that does not survive the major has no observable effect — and keeping both would tell an upgrader to rewrite a key on a schema the same upgrade deletes. The `maxAttempts` `retiredKey()` tombstone goes with the shape that carried it, which is strictly stronger than the tombstone: there is no longer a `retry` block to author the key into. Route 3 — no carrier key, no parse site, so no D2 conversion and no tombstone; RETIRED_DEFS_BY_MAJOR plus this entry are the declaration. ADR-0049, ADR-0078, #6414."
+ "rationale": "The `windowFunctions` array was declared-but-inert on the query path: `find()` never applied a window function, so every OVER clause a caller declared was silently dropped. The capability only ever ran behind `SqlDriver.findWithWindowFunctions()`, a driver-level door that is not on the `IDataDriver` contract and whose flat input shape (`{ function, alias, partitionBy?, orderBy? }`) the spec vocabulary never matched — `WindowFunctionNodeSchema` declared `field`/`over`/`frame` members the door never read, so that cluster is removed with the key rather than left as a false affordance. A REQUEST surface, never stored; no source to rewrite. ADR-0049 / ADR-0078, #4286."
},
{
- "surface": "automation.ActionDescriptor.resumeAuthority — an OMITTED value on a pausing node descriptor (supportsPause: true, or any executor whose execute() returns suspend: true)",
- "replacement": "an explicit resumeAuthority: 'any' on the descriptor, for a pausing node whose pauses really are meant to be continued through the generic resume route (POST /automation/:name/runs/:runId/resume) — a screen-style collected-input pause, or a signal wait an external producer resumes. Declare 'service' instead if continuing is the tail of a decision your own service must authorize and record first. Either value is a one-line addition; only the silence changed meaning",
- "migrationId": "action-descriptor-resume-authority-default-flip",
+ "surface": "restServer.openApi31",
+ "replacement": "(removed — no replacement key exists. Delete the key; for a real outbound webhook use `Webhook` from `@objectstack/spec/automation`. Config-driven OpenAPI 3.1 webhooks/callbacks documentation returns, if ever, via the enforce route of ADR-0049 through a new ADR)",
+ "migrationId": "rest-server-openapi31-block-removed",
"toMajor": 17,
- "rationale": "A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as protocol 12's `rest-requireauth-default-flip`, and it is registered here for the same reason: whether a given pause is genuinely open to the generic route is a trust judgment no transform can make. The #3801 resume gate keys on the SUSPENDED NODE, and `ActionDescriptor.resumeAuthority` used to default to `'any'`, so a pausing node type shipped raw-resumable unless its author remembered the field. It now resolves to `'service'` when absent: an unclaimed pause is refused on the generic route with `PERMISSION_DENIED` / 403 until its descriptor states who may continue it. #3823 is the incident that decided the direction — ADR-0044 pointed an approval's revise edge at a generic `wait`, `wait` is legitimately `'any'`, and the pause standing in a service-owned position inherited a fail-open value nobody chose; the demonstrated cost was an unaudited resubmit plus a destroyed remote run. The two possible mistakes are asymmetric, which is the whole argument: guessing `'any'` walks past a decision nothing recorded and is silent, while guessing `'service'` returns a refusal naming the missing field. ⚠️ The surface is a DESCRIPTOR FIELD set in plugin CODE, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) already carry. It differs from those in one way a reader should not have to infer: nothing is REMOVED, so tsc reports nothing at all — the field was already optional after step one and an omission still compiles. The enforced channels are all run-time: a registration warning naming the node type (once per type per engine), the refusal message on the resume itself, and `check:resume-authority-declared` for executors living in this repo. For a third-party plugin the generated upgrade guide is the only channel that arrives BEFORE a user hits a run that will not continue. In-tree the flip moves nothing: all six shipped pausing types (screen, wait, subflow, map, approval, approval_revise) declare their authority explicitly. ADR-0044 amendment (2026-07-28) and its 2026-08-08 landing section, ADR-0019 #3801 addendum, #5561."
+ "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."
},
{
- "surface": "@objectstack/rest: ExportFieldMeta.required / .system / .readonly / .hasDefault / .min / .max / .minLength / .maxLength (the map built by `buildFieldMetaMap`, reached as `PreparedImport.metaMap` from `prepareImportRequest`)",
- "replacement": "the object schema you already hold — read `fields[name].required` / `.system` / `.readonly` / `.defaultValue` / `.min` / `.max` / `.minLength` / `.maxLength` off the same `ObjectSchema` you passed to `buildFieldMetaMap`, which is where the ENGINE reads them and therefore the only copy that cannot drift",
- "migrationId": "export-field-meta-constraints-retired",
+ "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",
+ "migrationId": "sharing-execution-context-retired",
"toMajor": 17,
- "rationale": "ADR-0049 enforce-or-remove. These eight were never a source of truth: `buildFieldMetaMap(schema)` DERIVED each one from the very `schema` its caller passed in, so the map carried a second copy of facts the caller already held. They existed for exactly one consumer — the import dry run's hand-copied pre-check mirror (`firstMissingRequiredField` / `firstConstraintViolation`, framework#3956) — and #4633 ruling D retired that mirror (PR #6532): the dry run now asks `DataProtocol.validateData` for the engine's verdict, which reads the object's own schema. That left all eight computed on every import and read by NOTHING, which is the declared-and-unread shape ADR-0049 exists for; a constraint vocabulary standing next to the presentation one with no enforcer behind it is precisely the thing an AI-authored consumer mistakes for a contract. Verified zero-reader before removal, per key and by type, across this repo (`packages/rest` itself, and all five in-repo dependents of `@objectstack/rest`: runtime, cli, verify, plugin-auth, plugin-dev) and the `objectui` sibling; plugin-auth's identity import forwards `prepared.metaMap` into `runImport` but reads only the presentation keys through `coerceRow`. Why this needs a ledger entry despite that sweep: it is the `findStream` (#4484) / `IStorageService.list` (#5540) / `actor-user-roles-to-positions` (#6011) disposition — a published TS surface with NO spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry a prescription, and the ledger is the only channel that reaches an upgrader. It is if anything blinder than those three: the keys shipped in a FINAL release (`@objectstack/rest` 14.5.0) and have been published in every release since, and because they were OPTIONAL keys on an interface that itself survives, a JavaScript consumer reading `meta.required` after the upgrade gets `undefined` with no error at all — tsc reports at the read site only for a typed consumer. Why D3 semantic and not a D2 conversion: there is nothing to convert. No authored or stored metadata changes shape — `required` / `min` / `maxLength` and the rest remain fully authorable on a field definition and fully enforced by the engine, which is where they always lived. The only place these eight are ever spelled is inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach them. ADR-0049 / ADR-0087, #6536 (the sweep PR #6532 deliberately deferred)."
+ "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."
},
{
- "surface": "ActionDescriptor.isAsync (the descriptor an executor publishes via `registerNodeExecutor` / `defineActionDescriptor`)",
- "replacement": "nothing to re-declare — delete the key. Suspension is `execute()` RETURNING `suspend: true`, and permission to suspend is `supportsPause: true` on the same descriptor (with the `resumeAuthority` its pauses need)",
- "migrationId": "action-descriptor-is-async-retired",
+ "surface": "type alias: the 102 XInput names of @objectstack/spec (ConnectorInput, AppInput, PageInput, ActionInput, ServiceObjectInput, ExecutionContextInput, TaskInput, … — 52 files across api/ automation/ data/ identity/ integration/ kernel/ security/ system/ ui/)",
+ "replacement": "the BARE name. ADR-0122 phase 2 moved the author state onto `X`, which makes `XInput` a character-for-character synonym of it — the permanent synonym D3 forbids. Drop the `Input` suffix: `ConnectorInput` -> `Connector`. Symmetrically, a consumer that held a PARSE RESULT under the bare name moves to `XParsed`, which phase 1 (16.x) already declared for every schema whose two shapes differ, so the target name has existed for a release. NINE `*Input` names are NOT retired and need no edit: `ExpressionInput`, `CronExpressionInput`, `TemplateExpressionInput` and `PredicateInput` are the bare aliases of their own `…InputSchema`, and `FormFieldInput`, `QueryInput`, `FieldInput`, `ObjectStackDefinitionInput` and `NavigationItemInput` are composed (recursive or `Partial`-shaped) types no bare alias denotes.",
+ "migrationId": "spec-type-alias-input-suffix-retired",
"toMajor": 17,
- "rationale": "ADR-0049 enforce-or-remove. `isAsync` declared \"this action suspends the flow awaiting an external reply\" and NOTHING read it: a fresh three-repo measurement (#6748, re-run at pickup) found zero property reads across objectstack, objectui and cloud — every hit was the declaration itself, a generated baseline, one of five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. So declaring it never made a node suspend and omitting it never stopped one, which is the silently-inert declaration ADR-0049 exists to end. It was always a second, weaker spelling of the capability `supportsPause` states, and the two diverged in exactly the way a duplicated declaration does: `screen` declared both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling in #6667 — `AutomationEngine` now refuses a suspension whose type does not declare `supportsPause: true` — so the capability this key gestured at is now a real, enforced fact under one name. This one had no consumer to grow into and takes the remove leg. Why D3 semantic and not a D2 conversion: an ActionDescriptor is published from an executor's TypeScript, never stored in stack metadata — no stack, example or template carries the key — so there is no source for the chain to rewrite and `os migrate meta` cannot reach it. The schema tombstones it via `retiredKey()` and descriptor authors delete the key themselves; that rejection (a `tsc` error at the authoring site, and a parse error inside `defineActionDescriptor`) is the channel a third-party plugin author actually meets. The `EnhancedApiError.fieldErrors` disposition, one layer down."
+ "rationale": "This entry exists for the reason `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) exist, and it is the same disposition: the surface is a TYPESCRIPT NAME, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — an `XInput` alias never had a carrier key, never emitted a def, and no `.parse()` ever saw it. Measured and verified rather than assumed: `json-schema/`, `json-schema.manifest/` and `authorable-surface/` are BYTE-IDENTICAL across this change, because those generators enumerate runtime `z.ZodType` exports and never read a type alias. So nothing left the published metadata surface and RETIRED_DEFS_BY_MAJOR is deliberately untouched — an entry there would falsely claim the metadata contract shrank. The enforced channel is tsc: the name is gone, so every consumer gets TS2724/TS2305 naming the import. That is loud but MUTE about the replacement — a compile error says `ConnectorInput` does not exist, not that `Connector` now means what it meant. The generated upgrade guide is the only channel that carries the second half, which is precisely the #6048 gap ADR-0087 registration exists to close. ⚠️ Deliberately NOT registered alongside it: the 1384 bare aliases the same change FLIPPED from `z.infer` to `z.input`. Those names all still exist and still resolve; what moved is which of a schema's two shapes they denote, and only where the two differ (663 of 1384 — the rest are isomorphic and the flip is a no-op there, pinned as such). A consumer holding an authored literal is made MORE correct by it, silently; one holding a parse result gets a tsc error at the first defaulted key it reads. Registering that as a rename would misdescribe it — no name was retired — and the changeset carries its own FROM -> TO for it. ADR-0122 D8/D9, #6083 (PR #6279)."
},
{
- "surface": "api.listNotifications cursor — the key on BOTH halves of GET /api/v1/notifications (ListNotificationsRequestSchema and ListNotificationsResponseSchema) and the cursor argument of the client SDK call client.notifications.list(). The same entry covers the limit default: the request schema no longer declares default(20)",
- "replacement": "a larger `limit` — the route answers the newest N notifications and has no page 2. There is no replacement for `cursor`, deliberately: nothing ever minted one, so no caller holds a value to carry over. Callers that looped on it were re-reading the first window and should read one window sized to what they display (the Console bell polls exactly this way). For the removed `limit` default, send the number you want explicitly if you were relying on 20 — omitting it takes the server window, which is 50 on the platform inbox and clamped into 1..200, and has been since before the declaration existed",
- "migrationId": "notification-list-cursor-retired",
+ "surface": "contracts.IStorageService.list",
+ "replacement": "track the keys you wrote (sys_file / file-reference records, queryable through ObjectQL with real pagination) instead of enumerating the bucket — and where no such record exists, the cursor-shaped `list(prefix, { cursor, limit })` this entry reserved, restored in #6781",
+ "migrationId": "storage-service-list-retired",
"toMajor": 17,
- "rationale": "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."
+ "rationale": "`list(prefix)` was an OPTIONAL contract method documented as \"List files in a directory/prefix\", and the two shipped adapters answered the same call with two different semantics — both of them silently incomplete. `LocalStorageAdapter.list` was a single-level `readdir`, so a nested key `a/b/c` was invisible under `list('a')` (only `a/b` came back), and a subdirectory that `stat` succeeded on was pushed into the result as a file, yielding a `StorageFileInfo` whose `size` is a directory inode and which cannot be downloaded at all. `S3StorageAdapter.list` was RECURSIVE (`ListObjectsV2` matches the whole key) and read neither `IsTruncated` nor `ContinuationToken`, so past 1000 objects the \"all files\" a caller received was the first page, with no signal. One contract method, two dialects, both quietly incomplete — and the first feature that genuinely needed to enumerate a prefix (backup, orphan sweep, migration audit) would have got two different answers on two deployments without an error on either. #5172 was nearly that feature: it planned to drive attachment reclamation off `list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one level down, and switched to queue-driven deferred work instead. Nothing consumed it afterwards: the only in-repo call site was the `SwappableStorageService` pass-through (which itself rejects when the active adapter has no `list`), and REST, CLI and the storage routes never called it. Remove was chosen over align-and-tighten (maintainer ruling, 2026-08-05, #5266): aligning would grow a conformance surface nobody walks, while a prefix listing that cannot paginate is the wrong signature to inherit — when a real caller needs enumeration it returns cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases (nested keys, directory entries, >1000 objects) proving both backends agree. This is a TS/API contract surface — a storage adapter is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription there would reach no one. The enforced channel is tsc, and it reports at the call site. Same disposition, and the same reason, as `data-driver-find-stream-retired` (#4484). ADR-0049 / ADR-0087, #5540 (analysis #5266)."
},
{
- "surface": "manifest.loading (the whole block: strategy / preload / codeSplitting / dynamicImport / initialization / dependencyResolution / hotReload / caching / sandboxing / monitoring)",
- "replacement": "nothing to re-declare — delete the key. Plugins are composed at boot: `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder` in `packages/core/src/plugin-order.ts`). For the isolation `loading.sandboxing` appeared to configure, use the plugin trust tier (`manifest.runtime`, ADR-0025 §3.6) and the manifest permission declarations, which are the surfaces the platform actually enforces",
- "migrationId": "plugin-manifest-loading-retired",
+ "surface": "ui.touchInteraction / ui.gestureConfig / ui.dndConfig / ui.keyboardNavigationConfig / ui.componentAnimation / ui.motionConfig / ui.pageTransition / ui.offlineConfig (the whole export surface of ui/touch.zod.ts, ui/dnd.zod.ts, ui/keyboard.zod.ts, ui/animation.zod.ts and ui/offline.zod.ts — 32 defs, 64 exported names)",
+ "replacement": "(removed — there is no replacement key, because there was never a key. Touch targets, drag-and-drop, focus management, keyboard shortcuts and motion are RENDERER BUILT-IN behaviour: the component library decides them, not a per-page metadata author. Offline is a platform capability, and its vocabulary belongs on the sync engine that owns the queue, the conflict policy and the cache — none of which exists yet. Delete the import and the value. Whichever of these earns real product pull returns WITH its own vocabulary and its executor, the #4910 way, not by un-retiring a declaration)",
+ "migrationId": "ui-interaction-config-family-retired",
"toMajor": 17,
- "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-08-04 on #4914. The block declared a complete plugin loading policy and NOTHING read it. A bare-name scan of all three repos — objectstack, cloud (measured 2026-08-09) and objectui (measured at pickup), each with a control probe proving the scan saw the tree — put every hit inside `packages/spec` itself: this module's own declaration, its own unit tests, the `Manifest.loading` embed and the generated artifacts. `manifest.loading.*` had zero readers in `packages/core`, `packages/runtime` and `packages/metadata`. So the key parsed, entered the manifest, and changed nothing — #3950, at the scale of a whole block. What made it outrank ordinary inert-key cleanup is `sandboxing`: it declared process / vm / iframe / web-worker isolation, IPC transports and an `allowedServices` ACL, so an AI author (ADR-0033) reading that vocabulary concluded the platform isolates plugins, wrote the config, and received a clean parse and zero isolation. An inert security control is worse than an absent one because it is believed. Hot reload was additionally a TWO-SOURCE defect: the docs pointed at this dead `PluginHotReloadSchema` while the only implementation body, `HotReloadManager` (`packages/core/src/hot-reload.ts`), reads a different vocabulary — `HotReloadConfigSchema` in `plugin-lifecycle-advanced.zod.ts`. Ruling §2 converges on the surviving side: that schema is KEPT as the starting point for a future enforce decision (it has an implementation body but no runtime composes it yet), and enforcing it is deliberately a separate decision, not this retirement. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections. A package manifest is neither — `PLURAL_TO_SINGULAR` has no `packages` / `plugins` entry, so a manifest is not a stack collection member and a stored manifest row passes that seam through unchanged. A conversion would be a transform with no seam that ever runs."
+ "rationale": "Five `@objectstack/spec/ui` modules declared a full interaction-configuration vocabulary — 22 `z.object` sites across touch/gesture, drag-and-drop, focus/keyboard, animation/motion and offline/sync — and NOTHING in the protocol carried them. This is the ADR-0049 false-compliance shape in its most inviting form for an AI author (ADR-0033), and worse than the ordinary declared-but-unread defect: `authorable-surface.json` listed 109 keys under these defs and `content/docs/references/ui/{touch,dnd,keyboard,animation,offline}.mdx` rendered them as authoring tables, so the published documentation advertised a vocabulary with no carrier key anywhere. An author following `dnd.mdx` and writing a `dnd:` block onto a page component was rejected by `PageComponentSchema` for an unrecognized key — the docs and the schema disagreeing about the platform (Prime Directive #10). Three independent measurements, each with its controls passing in the same run: (1) no module under `packages/spec/src` imported any of the five except the `ui/index.ts` barrel, so no schema declared a carrier key; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` (25 roots, 4742 nodes) reached none of the 21 named object shapes, while `PageSchema`, `WebhookSchema` and `StateMachineSchema` all resolved `direct` and a synthetic carrier flipped all 21 — so unreachability was a fact about the graph, not a broken walker; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these modules' own unit tests. objectui holds TYPE re-exports and parity ratchets, never validators, and says so (#2561). The 2026-08-04 ruling weighed wiring a carrier key (option B) and rejected it: that is a feature with a renderer behind it, not ledger clean-up. It also weighed tightening the shapes to `strictObject` and rejected that explicitly — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave \"a precisely validated dead slot, the more convincing lie\" (#4583). Because there was no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, the same route 3 as #4834 (kernel plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ Not to be confused with #5021, which retired the THEME `animation` block — a different file, different defs, and that one did have a carrier key and therefore a tombstone. ADR-0049, #4988."
},
{
- "surface": "PUT /api/v1/meta/api/{name} (runtime-authored `api` endpoints, draft and active alike)",
- "replacement": "Declare the endpoint as a stack artifact (`**/*.api.ts`, or `defineStack({ apis })`) and ship it through `publishPackage`",
- "migrationId": "api-runtime-create-withdrawn",
+ "surface": "ui.notificationAction / ui.embedConfig",
+ "replacement": "(removed — there is no replacement shape, because there was never a key to write either into. Delete the import and the value. Notification presentation is still described by the surviving `NotificationType` / `NotificationSeverity` / `NotificationPosition` vocabulary; public access to a form is granted by the LIVE `FormView.sharing` block (`SharingConfig`), which is untouched. Notification action buttons as metadata, and iframe embedding, return via the enforce route of ADR-0049 through a new ADR — carrier key and renderer first, vocabulary second)",
+ "migrationId": "ui-notification-action-embed-config-retired",
"toMajor": 17,
- "rationale": "The `api` registry entry declared `allowRuntimeCreate: true` and the runtime never honoured it. Measured on a real showcase boot (#5488): `PUT /api/v1/meta/api/e8_backdoor` answered 200 with `{\"success\":true,…,\"message\":\"Saved …\"}`, and the declared route then answered 404 forever — with NO `[EndpointMatcher] … EXCLUDED` line, because the endpoint was never in the index to be excluded from. The serving criterion belongs to `IMetadataService.matchEndpoint` -> `EndpointMatcher` -> `MetadataManager.listForIndex('api')`, which reads the manager's registry plus its registered loaders (`[\"filesystem\",\"memory\"]` on dev/serve); a runtime write lands in `sys_metadata`, which is in neither. A declared capability the runtime does not honour is ADR-0049 false compliance, and a write that answers \"Saved\" and then 404s forever is its most dangerous shape for the AI authors ADR-0033 targets. The maintainer ruled REMOVE on 2026-08-07 rather than converge the read path, because making the matcher read `sys_metadata` re-opens cache, invalidation, tenancy and the ADR-0110 D3 miss-vs-outage distinction on a new read path, and there is no business pull for Studio-authored endpoints today (zero `.api.*` artifacts author them at runtime; showcase uses the artifact route, #5040 E8 LIVE). There is NO D2 conversion, for the reason this list exists: nothing in an authored source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an authorable one, and the artifact route it points authors toward is untouched — a `**/*.api.ts` file valid before this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, so it is one semantic TODO for operators and Studio callers rather than a stack conversion — the same disposition `BatchOptions.validateOnly` (#4052) takes. Consequently `gateApiDraftsForPublish` (PR #5279) is retired with it: it gated a promotion into a state the matcher can never read, and with the inlet closed no `api` draft can exist for it to judge. Re-entry is recorded in the ruling: if #2657 Part B promotes `apis` to a registered type WITH A REAL CONSUMPTION PATH, the flag flips back then — implementation first, declaration second. ADR-0049 / ADR-0121, #5488 (subsumes #5311)."
+ "rationale": "Both shapes were published `@objectstack/spec/ui` vocabulary with NO AUTHORING DOOR. #4001 批 14 measured them three ways on 2026-08-03 and this retirement re-ran all three against `origin/main` before removing anything, each with a positive control that passed in the same run: (1) CARRIER — no schema in `packages/spec/src` declared a key of either type (`ui/notification.zod`'s only non-test importer was the barrel; `ui/sharing.zod`'s were the barrel and `ui/view.zod.ts`, which names its SIBLING `SharingConfigSchema`), measured by resolving specifiers rather than substring-matching, because the repo holds two `sharing.zod` modules and a substring test miscredits `stack.zod.ts` to the UI one; (2) REACHABILITY — a BFS from the 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema`, over `build-schemas.ts`'s own walk including its derived-clone bridge, never reached either, while `Page` / `Action` / `DashboardWidget` / `Webhook` and `SharingConfig` itself all resolved `root-graph` in the same run and an injected synthetic carrier flipped both; (3) PARSE — zero `.parse()` in objectstack, cloud or objectui outside their own unit tests. So nobody could author one and nothing ever validated one: the #3950 shape, an exported schema with no consumer read as a capability, and the ADR-0033 trap where an AI author takes `EmbedConfigSchema` in the published bundle as proof the platform serves iframes. Neither is stored metadata and neither has a carrier, so no `sys_metadata` row can hold one and there is no source for the D2 chain to rewrite; this entry is the D3 record. 批 14 deliberately did NOT close them with `.strict()` — strictness is a property of a PARSE, and closing a shape nothing parses buys only \"a precisely-validated dead slot, the more convincing lie\" (#4583) — and filed the disposition as #5015, ruled REMOVE on 2026-08-04. Each was orphaned by an earlier retirement one level up: `NotificationAction` lost its wrappers at #4610 (`NotificationSchema` / `NotificationConfigSchema`, the #4535 C3 dual-source cleanup — that retirement's published \"zero consumers\" evidence was later falsified for objectui and is corrected on `ui/notification.zod`'s tombstone; the removal itself stands, #5781), and `EmbedConfig` lost its key at 17.0.0 when the 2026-06 liveness audit retired `App.embed` (no iframe route ever read it) — that key still stands as a `retiredKey()` tombstone in `app.zod.ts`, so an author who wrote the KEY already meets a prescription; this removes the value shape that outlived it. ⚠️ The retirement is per SCHEMA, not per file: `ui/sharing.zod` KEEPS `SharingConfigSchema`, a live door carried by `FormViewSchema.sharing` and read by `rest-server.ts` to mount the anonymous form routes, and `ui/notification.zod` keeps its three presentation enums. objectui consumed `NotificationActionSchema.shape.variant` as a VOCABULARY (never a parse) to pin its own hand-written `NotificationActionButton` interface — which is exactly why \"has a consumer\" never meant \"has an authoring door\" here; that pin is adapted objectui-side when it refreshes this dependency. ADR-0049, #5015."
},
{
- "surface": "api.ImportRequest runAutomations — the declared default of the key on BOTH import bodies, POST /api/v1/data/:object/import (ImportRequest) and its async twin POST /api/v1/data/:object/import/jobs (CreateImportJobRequest, which IS the same schema object). It was declared default(false) and described as \"off by default for bulk\"; it is now default(true), which is what the server has always done",
- "replacement": "an explicit runAutomations: false on any import request that is meant to load rows without firing triggers/hooks. That spelling is unchanged and has always been the only one the server read — what changes is that omitting the key now DECLARES what it already DID. Callers who want automations on need write nothing",
- "migrationId": "import-run-automations-declared-default-corrected",
+ "surface": "ui.widgetManifest / ui.widgetLifecycle / ui.widgetEvent / ui.widgetProperty / ui.widgetSource / ui.i18nObject / ui.pluralRule / ui.numberFormat / ui.dateFormat / ui.localeConfig (the widget-registration vocabulary of ui/widget.zod.ts, and the five doorless shapes of ui/i18n.zod.ts — 10 defs, 26 exported names)",
+ "replacement": "(removed — there is no replacement key, because there was never a key. A custom field widget is still named the same way it always was: `field.widget` is a plain string naming a component the RENDERER has registered, and objectui's registry has always carried its own runtime manifest for that (`RuntimeWidgetManifest` / `RuntimeWidgetSource` in `@object-ui/types`, objectui#3161 / #4115), which models different keys and never derived from these. For localisation: write the default-language string on `label` / `description` — the framework generates the translation key at registration time from the naming convention — and put translations in translation files, which is the LIVE `system/translation.zod.ts` surface. Widget registration and locale formatting as authorable protocol metadata return via the ENFORCE route of ADR-0049 through a new ADR — the registry / loader / formatter first, the vocabulary second)",
+ "migrationId": "ui-widget-i18n-family-retired",
"toMajor": 17,
- "rationale": "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."
+ "rationale": "`ui/widget.zod.ts` published a complete widget-registration vocabulary — a manifest with lifecycle hooks, custom events, configurable properties and an npm/remote/inline implementation-source union — and `ui/i18n.zod.ts` published a structured-label, plural-rule and locale-formatting vocabulary. NOTHING in the protocol carried either. Three independent measurements, re-run on `origin/main` immediately before the removal with their controls passing in the SAME run: (1) no module under `packages/spec/src` imported `widget.zod` at all, and the only imports of `i18n.zod` anywhere name `I18nLabelSchema` / `AriaPropsSchema` (both KEPT), so no schema declared a carrier key — `field.widget` is a `z.string()` naming a registered component and has never referenced `WidgetManifest`; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` reached none of them, while `PageSchema` / `ObjectListViewSchema` resolved `direct` in the same run and a synthetic carrier flipped every one of them; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these files' own unit tests. `NumberFormat` / `DateFormat` DID have a carrier key (`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier was itself doorless, so the subtree was `no door` rather than `no gate` and goes whole — leaving the two leaves behind would strand exported schemas with no consumer (#3950). `I18nObjectSchema` was additionally superseded by its own file-neighbour: `I18nLabelSchema`'s documentation already says translation keys are generated at registration time and translations live in translation files, and the live translation surface is `system/translation.zod.ts`, which uses none of these shapes. The 2026-08-06 ruling weighed giving them a carrier (option B) and rejected it: that is a feature with a registry and a renderer behind it, not ledger clean-up. Tightening them to `strictObject` was rejected earlier and explicitly (#4001 批 16) — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave \"a precisely validated dead slot, the more convincing lie\" (#4583). With no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, route 3, the same shape as #4988 (the ui/ interaction config family), #4834 (kernel plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ `WidgetManifest.performance`'s own `retiredKey()` tombstone (#3896 close-out) is SUBSUMED here, the #4657/#4834 way: it goes with the shape that carried it, which is strictly stronger than the tombstone, because there is no longer a manifest to author the key INTO. ⚠️ One of the nine widget sites is deliberately NOT retired. `FieldWidgetPropsSchema` survives: it is a REACT PROPS CONTRACT rather than authorable metadata (it never appeared in `authorable-surface/` or `json-schema.manifest/` — its `onChange` is a `z.function()`), so \"zero parse\" is its design and not its defect, and it acquired a live cross-repo compile-time consumer one day before 批 16 measured: objectui PR #3289 (2026-08-03) renamed `@object-ui/fields`' validation slot onto the spec's `error` with no alias, the form renderer began producing it, and `packages/fields/src/__tests__/spec-symbol-batch7.test.ts` pins the shape against `import type { FieldWidgetProps } from '@objectstack/spec/ui'` as an intentional tripwire. Re-verified on objectui `origin/main` 2026-08-07. ADR-0049, #5055."
},
{
"surface": "ui.ViewFilterRule value — the third key of a view filter rule, on every carrier of ViewFilterRuleSchema: ListView.filter, a list view tab filter, Page.filterBy, a related-list component filter and a lookup picker filter. It accepted any declared scalar or array for EVERY operator; the accepted shape is now decided by the rule operator — in / not_in require an array, between requires exactly two bounds, and every other operator is unchanged",
@@ -1676,25 +1683,18 @@
"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."
},
{
- "surface": "engine.registerHook(event, handler, { object: '' | [] | [''] }), and a scope whose `excludeObjects` cancels its `object` entirely",
- "replacement": "name the object(s) — `object: 'account'` / `object: ['account', 'contact']` — or, for a global hook, `object: '*'` or no `object` key at all; for a cancelled scope, widen `object` or drop the overlapping names from `excludeObjects`",
- "migrationId": "hook-register-empty-object-target-refused",
- "toMajor": 17,
- "rationale": "#4281 ruled that an empty hook target is not \"no target\" and closed the shape at the two METADATA doors — `HookSchema.object`'s refine and `hook-binder.ts`'s `normalizeObjects`. `engine.registerHook`, the CODE door, goes through neither, so all three spellings still registered, each producing a defect the author did not write: `''` is FALSY, so the allow face was skipped entirely and the entry became a GLOBAL hook (#4281's headline failure mode — blank intent taking the broadest possible blast radius); `[]` and `['']` are truthy but admit no object name, so the entry could never fire. #5928 then added the `excludeObjects` face, which brought a fourth shape reached by arithmetic rather than by one bad name: an `object` list every member of which is also excluded admits nothing, so that entry can never fire either. All four are ADR-0078 silently-inert declarations, and all four are now refused at REGISTRATION.\n\nNo mechanical rewrite exists, in either direction. The refused values carry no recoverable intent — `object: ''` could have meant `'*'` (what it actually did) or a specific object name the author forgot to fill in, and those are opposite registrations; choosing between them is a judgment the chain cannot make. Nor could the MATCHING read be changed instead: teaching the matcher that `''` is an unmatchable name would silently convert a hook firing on every object into one firing on none — the same class of defect pointing the other way, which is why #5928 declined to do it in passing.\n\nThis is a RUNTIME registration API, not stored metadata, so — like `hook-context-session-roles-retired` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and the ledger entry is the notification channel. One metadata surface reaches it INDIRECTLY and is the reason this is not purely a code-side note: a `record-change` flow's start node forwards `config.objectName` verbatim into `registerHook` (`RecordChangeTrigger.start`), so a flow authored with a blank `objectName` used to bind a trigger to EVERY object in the tenant. It now fails to bind instead, loudly — the automation engine's per-flow bind guard warns and the `kernel:bootstrapped` binding audit re-reports it — which is the correct end state, but it is an observable change for that flow. #6573, #4281, #4001, #5928, ADR-0078."
- },
- {
- "surface": "engine.find(object, { orderBy }) and engine.findOne(object, { orderBy }) naming a `formula` field — the direct engine path, not the REST ingress",
- "replacement": "denormalise the value onto the object (a stored field, written when the source changes) and sort by that — the same remedy the REST ingress has prescribed since #6924 / #6994; a `summary` field is unaffected and still sorts, because it gets a real maintained column",
- "migrationId": "engine-find-formula-order-by-refused",
+ "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": "#4226 / #4256 / #6994 closed the SORT axis at the REST ingress (`assertSortFieldsExist`, `400 INVALID_SORT`), which covers everything reaching `findData`: the list route, `POST /data/:object/query`, the export route and the RPC dispatcher. A caller reaching `engine.find()` / `engine.findOne()` DIRECTLY passed through none of it, and a `formula` ORDER BY there was dropped in silence. Measured on a real driver: `asc` and `desc` came back BYTE-IDENTICAL, in insertion order, under a success, with the rows carrying the very values they were asked to be ordered by. No column exists to order by (a formula is computed on read, so no driver materialises one), so the ORDER BY reached the driver, found nothing, and the unknown-column backstop returned the rows unordered.\n\nRuled 2026-08-10 on #7095: an ORDER BY the engine cannot apply is a 4xx with guidance prose at the public boundary, never a silent drop — the same direction as the analytics dataset refusal envelope and the #6924 sort-hint prescription. The engine's documented internal-caller tolerance (`assertProjectionFieldsExist`'s docblock) was to survive only behind a pinned internal path, and only if a MEASURED internal call site relied on it. The #7095 sweep of every in-tree `orderBy` reaching the engine directly — hooks, flows, reports, queue/job adapters, sharing, metadata loaders, expand sub-reads — found NONE: every hardcoded internal sort names a real stored column (`created_at`, `updated_at`, `version`, `priority`, `scheduled_for`, `started_at`, `next_run_at`, `recorded_at`, `id`), and no shipped object in the repo declares a `formula` field at all. So no internal path shipped, and there is no flag to opt back into the drop.\n\nThis is a CODE-path API, not stored metadata, so — like `hook-register-empty-object-target-refused` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and the ledger entry is the notification channel. No mechanical rewrite exists in either direction: the platform cannot invent the stored column the remedy prescribes, and it must not sort post-hoc instead — `driver.find` has already applied `limit` / `offset`, so re-sorting after the formulas are evaluated would reorder an ARBITRARY PAGE, which looks correct on small result sets and is wrong the moment pagination is involved.\n\nONE AUTHOR-REACHABLE SURFACE reaches this indirectly and is why it is not purely a code-side note: a saved report's `query.orderBy` (`sys_saved_report`) is forwarded verbatim into `engine.find` by `plugin-reports`, bypassing the ingress gate. A report authored to sort by a formula field used to run and return rows in an arbitrary order; it now fails loudly, with the remedy in the message. One further path is deliberately NOT a refusal: a nested `expand` sort raises this refusal inside `expandRelatedRecords`, whose pre-existing graceful-degradation `catch` swallows every expand failure and retains the raw foreign keys — so that path moves from silent to OBSERVABLE (a warning naming the field and the fix) rather than refusing. Reversing that backstop is a separate decision on all expand failure modes. #7095, #6994, #6924, #4226, #4256, #3821, ADR-0112."
+ "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."
},
{
- "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",
- "migrationId": "sharing-execution-context-retired",
+ "surface": "CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / discovery routes.workflow / RestApiRouteCategory workflow",
+ "replacement": "the live mechanisms the slot only ever pointed at: `state_machine` validation rules for record state machines, approval flow nodes on the approvals runtime (ADR-0019) for approvals, lifecycle hooks + `record_change` flows (service-automation) for record-triggered automation",
+ "migrationId": "workflow-service-slot-retired",
"toMajor": 17,
- "rationale": "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": "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."
}
],
"removed": []
diff --git a/packages/spec/src/migrations/entries/README.md b/packages/spec/src/migrations/entries/README.md
new file mode 100644
index 0000000000..2b05c316a8
--- /dev/null
+++ b/packages/spec/src/migrations/entries/README.md
@@ -0,0 +1,85 @@
+# Migration registry entries
+
+One TypeScript file per entry of `../registry.ts`'s three append tables (#7297, the
+registry half of #6957's ruling). `pnpm --filter @objectstack/spec gen:migration-registry`
+concatenates them into that file's marked regions, sorted by entry id;
+`check:migration-registry` proves the regions still match this directory.
+
+| directory | table | id |
+| ---------------- | ------------------------------------ | -------------------------------------- |
+| `semantic/` | `MIGRATIONS_BY_MAJOR[N].semantic` | the `SemanticMigration`'s `id` |
+| `retired-keys/` | `RETIRED_KEYS_BY_MAJOR[N]` | the tombstoned key, `${defKey}:${name}` |
+| `retired-defs/` | `RETIRED_DEFS_BY_MAJOR[N]` | the unpublished def, `${category}/${SchemaName}` |
+
+## Why this is a directory
+
+It used to be three tables in one file, and every retirement card appended to the same
+tail line of the same two of them. Measured on #6957 across 2026-08-06..10: `step17`'s
+semantic list and `RETIRED_KEYS_BY_MAJOR[17]` conflicted in **6 of 11** contended
+re-merge laps — 613 hand-resolved lines of conflict markers in four days.
+
+Wall-clock was never the reason to fix it. **Both tables are consumed as sets**, so a
+conflict resolution that drops a sibling's entry produces **no error anywhere**: the
+tombstone the build gate was waiting for simply never arrives, and the D3 prescription
+leaves the upgrade guide without a trace. Conflict-free by construction beats "resolve
+carefully" precisely when careless is undetectable.
+
+`.changeset/*.md` is the shape this copies, and `scripts/adr-anchors/` (#7301) is the
+pilot that proved it on a smaller file.
+
+## Adding an entry
+
+Write **one new file**, named `..ts` with `/` and `:` replaced by
+`__`, then run `pnpm --filter @objectstack/spec gen:migration-registry`:
+
+```
+17 + data/AggregationNode:distinct → retired-keys/17.data__AggregationNode__distinct.ts
+```
+
+```ts
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+// Why this key was retired — the comment lands directly above the entry in the
+// generated table, so write it for whoever reads that table.
+export const entry = 'data/AggregationNode:distinct';
+```
+
+A `semantic/` entry is the same shape with a typed object literal:
+
+```ts
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'aggregation-node-distinct-retired',
+ surface: 'data.query.aggregations[].distinct',
+ replacement: '…',
+ reason: '…',
+ acceptanceCriteria: '…',
+};
+```
+
+The copyright header and the type import are file scaffolding and are **not** carried
+into the registry; the run of `//` comments immediately above `export const entry` is.
+
+## Three rules that are not style
+
+- **Touch no other file, and never edit inside the markers.** `registry.ts`'s
+ `` regions are output. A hand edit there is reverted by the next
+ `gen:` run and reported by `check:migration-registry` before that.
+- **There is no index, deliberately.** An index is itself a single append-only file
+ every card must edit, which is the exact conflict this directory removes (PM decision
+ on #6957). The directory listing is the index, and order is derived from the id.
+- **The filename is derived from the id, and the generator enforces it.** That is what
+ makes two cards registering *different* entries merge clean while two cards editing
+ the *same* entry collide in git — on a registry where a dropped entry produces no
+ error anywhere, a layout in which the second case merges quietly is a layout that
+ loses one of the two edits.
+
+## What this does not fix
+
+The regeneration lap. `spec-changes.json` and `docs/protocol-upgrade-guide.md` are
+projections of this registry and still have to be regenerated and committed when an
+entry lands. #6957's ruling kept them in version control on purpose — the review diff
+is worth the laps it costs — so a retirement card is not faster, only harder to lose.
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__CreateViewRequest.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__CreateViewRequest.ts
new file mode 100644
index 0000000000..1b0ef5e530
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.api__CreateViewRequest.ts
@@ -0,0 +1,4 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+// #6239 — api/protocol.zod.ts view-management operations
+export const entry = 'api/CreateViewRequest';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__CreateViewResponse.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__CreateViewResponse.ts
new file mode 100644
index 0000000000..b2d39ed8b0
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.api__CreateViewResponse.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'api/CreateViewResponse';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__DeleteViewRequest.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__DeleteViewRequest.ts
new file mode 100644
index 0000000000..160ccd3fed
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.api__DeleteViewRequest.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'api/DeleteViewRequest';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__DeleteViewResponse.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__DeleteViewResponse.ts
new file mode 100644
index 0000000000..056e1e53f1
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.api__DeleteViewResponse.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'api/DeleteViewResponse';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__GetViewRequest.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__GetViewRequest.ts
new file mode 100644
index 0000000000..aadf5c45e5
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.api__GetViewRequest.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'api/GetViewRequest';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__GetViewResponse.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__GetViewResponse.ts
new file mode 100644
index 0000000000..3de2069d8a
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.api__GetViewResponse.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'api/GetViewResponse';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__ListViewsRequest.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__ListViewsRequest.ts
new file mode 100644
index 0000000000..1c7f230fb2
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.api__ListViewsRequest.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'api/ListViewsRequest';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__ListViewsResponse.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__ListViewsResponse.ts
new file mode 100644
index 0000000000..cba1d055c7
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.api__ListViewsResponse.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'api/ListViewsResponse';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__UpdateViewRequest.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__UpdateViewRequest.ts
new file mode 100644
index 0000000000..75662754aa
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.api__UpdateViewRequest.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'api/UpdateViewRequest';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__UpdateViewResponse.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__UpdateViewResponse.ts
new file mode 100644
index 0000000000..84d425c202
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.api__UpdateViewResponse.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'api/UpdateViewResponse';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLDestination.ts b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLDestination.ts
new file mode 100644
index 0000000000..fc694c2787
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLDestination.ts
@@ -0,0 +1,4 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+// #6414 — automation/etl.zod.ts, the whole L2 layer
+export const entry = 'automation/ETLDestination';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLEndpointType.ts b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLEndpointType.ts
new file mode 100644
index 0000000000..248634862c
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLEndpointType.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'automation/ETLEndpointType';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLPipeline.ts b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLPipeline.ts
new file mode 100644
index 0000000000..62ebcdbef6
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLPipeline.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'automation/ETLPipeline';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLPipelineRun.ts b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLPipelineRun.ts
new file mode 100644
index 0000000000..4b95520837
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLPipelineRun.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'automation/ETLPipelineRun';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLRunStatus.ts b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLRunStatus.ts
new file mode 100644
index 0000000000..1c92642825
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLRunStatus.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'automation/ETLRunStatus';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLSource.ts b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLSource.ts
new file mode 100644
index 0000000000..6a96761dea
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLSource.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'automation/ETLSource';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLSyncMode.ts b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLSyncMode.ts
new file mode 100644
index 0000000000..c3155fc570
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLSyncMode.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'automation/ETLSyncMode';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLTransformation.ts b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLTransformation.ts
new file mode 100644
index 0000000000..f4a1ce14fd
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLTransformation.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'automation/ETLTransformation';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLTransformationType.ts b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLTransformationType.ts
new file mode 100644
index 0000000000..eff52c5ef5
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLTransformationType.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'automation/ETLTransformationType';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginCaching.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginCaching.ts
new file mode 100644
index 0000000000..331beecad7
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginCaching.ts
@@ -0,0 +1,16 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+// #4914 — the plugin manifest's `loading` block (ADR-0049 enforce-or-remove,
+// maintainer ruling 2026-08-04). `PluginLoadingConfig` was reachable from
+// authored metadata ONLY through `Manifest.loading`, and the ten members
+// registered with it were embedded only by it, so retiring the carrier key
+// unpublishes the whole closure. The carrier itself is a `retiredKey()` tombstone
+// registered one level up in `RETIRED_KEYS_BY_MAJOR`.
+//
+// ⚠️ `kernel/PluginLoadingEvent` and `kernel/PluginLoadingState` are
+// deliberately NOT here. They live in the same module and share its prefix,
+// but neither was ever embedded in `PluginLoadingConfig` — they are the
+// observational half (a lifecycle event and a per-plugin state), they are
+// not authorable, and they still emit. Module adjacency is not evidence,
+// the `system/ServerRateLimitConfig` note above applies verbatim.
+export const entry = 'kernel/PluginCaching';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginCodeSplitting.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginCodeSplitting.ts
new file mode 100644
index 0000000000..d3e3ee13ba
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginCodeSplitting.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'kernel/PluginCodeSplitting';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginDependencyResolution.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginDependencyResolution.ts
new file mode 100644
index 0000000000..17d9074861
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginDependencyResolution.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'kernel/PluginDependencyResolution';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginDynamicImport.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginDynamicImport.ts
new file mode 100644
index 0000000000..04686103f4
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginDynamicImport.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'kernel/PluginDynamicImport';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginHotReload.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginHotReload.ts
new file mode 100644
index 0000000000..94495ef981
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginHotReload.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'kernel/PluginHotReload';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginInitialization.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginInitialization.ts
new file mode 100644
index 0000000000..b055f785a6
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginInitialization.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'kernel/PluginInitialization';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginLoadingConfig.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginLoadingConfig.ts
new file mode 100644
index 0000000000..b911066395
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginLoadingConfig.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'kernel/PluginLoadingConfig';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginLoadingStrategy.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginLoadingStrategy.ts
new file mode 100644
index 0000000000..3e735c9438
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginLoadingStrategy.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'kernel/PluginLoadingStrategy';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginPerformanceMonitoring.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginPerformanceMonitoring.ts
new file mode 100644
index 0000000000..ab25566970
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginPerformanceMonitoring.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'kernel/PluginPerformanceMonitoring';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginPreloadConfig.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginPreloadConfig.ts
new file mode 100644
index 0000000000..432e74661c
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginPreloadConfig.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'kernel/PluginPreloadConfig';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginSandboxing.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginSandboxing.ts
new file mode 100644
index 0000000000..cd0ac01d6d
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginSandboxing.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'kernel/PluginSandboxing';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.shared__FieldMappingTransform.ts b/packages/spec/src/migrations/entries/retired-defs/17.shared__FieldMappingTransform.ts
new file mode 100644
index 0000000000..9875fcf6fc
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.shared__FieldMappingTransform.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'shared/FieldMappingTransform';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.system__ServerCapabilities.ts b/packages/spec/src/migrations/entries/retired-defs/17.system__ServerCapabilities.ts
new file mode 100644
index 0000000000..bfe2c4af80
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.system__ServerCapabilities.ts
@@ -0,0 +1,4 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+// #5295 — system/http-server.zod.ts runtime vocabulary
+export const entry = 'system/ServerCapabilities';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.system__ServerEvent.ts b/packages/spec/src/migrations/entries/retired-defs/17.system__ServerEvent.ts
new file mode 100644
index 0000000000..560189a7f5
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.system__ServerEvent.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'system/ServerEvent';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.system__ServerEventType.ts b/packages/spec/src/migrations/entries/retired-defs/17.system__ServerEventType.ts
new file mode 100644
index 0000000000..ab99cedf27
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.system__ServerEventType.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'system/ServerEventType';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.system__ServerStatus.ts b/packages/spec/src/migrations/entries/retired-defs/17.system__ServerStatus.ts
new file mode 100644
index 0000000000..ff3b131953
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.system__ServerStatus.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'system/ServerStatus';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__DateFormat.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__DateFormat.ts
new file mode 100644
index 0000000000..62d51f175a
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.ui__DateFormat.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'ui/DateFormat';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__I18nObject.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__I18nObject.ts
new file mode 100644
index 0000000000..fb1d94fd92
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.ui__I18nObject.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'ui/I18nObject';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__LocaleConfig.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__LocaleConfig.ts
new file mode 100644
index 0000000000..65409a4e60
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.ui__LocaleConfig.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'ui/LocaleConfig';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__NumberFormat.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__NumberFormat.ts
new file mode 100644
index 0000000000..e8cc4bbc30
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.ui__NumberFormat.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'ui/NumberFormat';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__PluralRule.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__PluralRule.ts
new file mode 100644
index 0000000000..bf8390d4dc
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.ui__PluralRule.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'ui/PluralRule';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetEvent.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetEvent.ts
new file mode 100644
index 0000000000..cbfd60752b
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetEvent.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'ui/WidgetEvent';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetLifecycle.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetLifecycle.ts
new file mode 100644
index 0000000000..9b9bfc23d2
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetLifecycle.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'ui/WidgetLifecycle';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetManifest.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetManifest.ts
new file mode 100644
index 0000000000..9cd6475bf8
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetManifest.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'ui/WidgetManifest';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetProperty.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetProperty.ts
new file mode 100644
index 0000000000..b6bffe48e0
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetProperty.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'ui/WidgetProperty';
diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetSource.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetSource.ts
new file mode 100644
index 0000000000..2ced2c07e1
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetSource.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'ui/WidgetSource';
diff --git a/packages/spec/src/migrations/entries/retired-keys/17.api__ListNotificationsRequest__cursor.ts b/packages/spec/src/migrations/entries/retired-keys/17.api__ListNotificationsRequest__cursor.ts
new file mode 100644
index 0000000000..05b8f08ce6
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-keys/17.api__ListNotificationsRequest__cursor.ts
@@ -0,0 +1,18 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+// #6361 — the notification-inbox pagination key, tombstoned on BOTH halves
+// of `GET /api/v1/notifications` because one capability is never half-
+// deleted (maintainer ruling 2026-08-07, ruled jointly with #6363). Two
+// keys, one prescription: `NOTIFICATIONS_CURSOR_REMOVED` in
+// `api/protocol.zod.ts` is the single string both rejection sites raise.
+//
+// Registered here but NOT in `src/conversions/registry.ts`, and that
+// asymmetry is the point rather than an omission: a D2 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. The prescription reaches consumers as the D3 semantic entry
+// `notification-list-cursor-retired` plus this tombstone, which is the
+// disposition `BatchOptions.validateOnly` and the `AnalyticsQueryRequest`
+// envelope keys already take in this major ("a semantic TODO for API
+// callers rather than a stack conversion").
+export const entry = 'api/ListNotificationsRequest:cursor';
diff --git a/packages/spec/src/migrations/entries/retired-keys/17.api__ListNotificationsResponse__cursor.ts b/packages/spec/src/migrations/entries/retired-keys/17.api__ListNotificationsResponse__cursor.ts
new file mode 100644
index 0000000000..12ffedb7a2
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-keys/17.api__ListNotificationsResponse__cursor.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'api/ListNotificationsResponse:cursor';
diff --git a/packages/spec/src/migrations/entries/retired-keys/17.automation__ActionDescriptor__isAsync.ts b/packages/spec/src/migrations/entries/retired-keys/17.automation__ActionDescriptor__isAsync.ts
new file mode 100644
index 0000000000..e0afe1c7fb
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-keys/17.automation__ActionDescriptor__isAsync.ts
@@ -0,0 +1,11 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+// #6748 — ADR-0049 enforce-or-remove on the action-descriptor capability
+// block. `isAsync` was a second spelling of `supportsPause` with ZERO
+// readers on a fresh three-repo measurement; its sibling took the enforce
+// leg in #6667 and this one takes the remove leg. Descriptors are published
+// from executor TypeScript, not from stack metadata, so the D2 side is a D3
+// `SemanticMigration` (`action-descriptor-is-async-retired`) rather than a
+// MetadataConversion — there is no stored source for `os migrate meta` to
+// rewrite. The `EnhancedApiError.fieldErrors` precedent.
+export const entry = 'automation/ActionDescriptor:isAsync';
diff --git a/packages/spec/src/migrations/entries/retired-keys/17.data__AggregationNode__distinct.ts b/packages/spec/src/migrations/entries/retired-keys/17.data__AggregationNode__distinct.ts
new file mode 100644
index 0000000000..edec2c08bd
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-keys/17.data__AggregationNode__distinct.ts
@@ -0,0 +1,24 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+// #6815 — the per-aggregation DISTINCT flag, retired under ADR-0049 by
+// maintainer ruling 2026-08-09. ONE key, and one entry, because
+// `AggregationNodeSchema` is reused BY REFERENCE rather than `.extend()`ed:
+// `QuerySchema.aggregations` and `EngineAggregateOptionsSchema.
+// aggregations` are both `z.array(AggregationNodeSchema)`, so the walked
+// shape has a single `data/AggregationNode` def and the baseline marks one
+// line `[RETIRED]`. Contrast the `shared/FieldMapping:transform` trio in
+// this same table, where two `.extend()`s copied the property into three
+// walked shapes and each needed its own registration.
+//
+// Registered here but NOT in `src/conversions/registry.ts`, for the same
+// reason as the `api/ListNotifications{Request,Response}:cursor` pair:
+// `QueryAST` is a REQUEST surface —
+// the client SDK builder's output and the `POST /data/:object/query` body
+// — never stored in stack metadata, so there is no authored source or
+// `sys_metadata` row for a D2 conversion to rewrite. The prescription
+// reaches consumers as the D3 semantic entry
+// `aggregation-node-distinct-retired` plus this tombstone, which is the
+// disposition every other `data.query.*` retirement in this major already
+// takes (`query-joins-retired` / `query-cursor-retired` /
+// `query-distinct-retired` / `query-window-functions-retired`, #4286).
+export const entry = 'data/AggregationNode:distinct';
diff --git a/packages/spec/src/migrations/entries/retired-keys/17.data__ExternalFieldMapping__transform.ts b/packages/spec/src/migrations/entries/retired-keys/17.data__ExternalFieldMapping__transform.ts
new file mode 100644
index 0000000000..0ce8abcc57
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-keys/17.data__ExternalFieldMapping__transform.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'data/ExternalFieldMapping:transform';
diff --git a/packages/spec/src/migrations/entries/retired-keys/17.integration__ConnectorFieldMapping__transform.ts b/packages/spec/src/migrations/entries/retired-keys/17.integration__ConnectorFieldMapping__transform.ts
new file mode 100644
index 0000000000..19556cac0b
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-keys/17.integration__ConnectorFieldMapping__transform.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'integration/ConnectorFieldMapping:transform';
diff --git a/packages/spec/src/migrations/entries/retired-keys/17.kernel__Manifest__loading.ts b/packages/spec/src/migrations/entries/retired-keys/17.kernel__Manifest__loading.ts
new file mode 100644
index 0000000000..193b153a54
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-keys/17.kernel__Manifest__loading.ts
@@ -0,0 +1,22 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+// #4914 — ADR-0049 enforce-or-remove on the plugin manifest's whole
+// `loading` block (maintainer ruling 2026-08-04). ONE tombstoned key here,
+// because `loading` was the single carrier: every schema underneath it
+// (`PluginLoadingConfig` and the ten members it combined) leaves the
+// published set as a whole-def removal and is registered in
+// `RETIRED_DEFS_BY_MAJOR` below, not as ~27 individual key entries.
+//
+// Registered here but NOT in `src/conversions/registry.ts`, for the reason
+// `automation/ActionDescriptor:isAsync` gives: the conversion chain
+// walks a normalized STACK (`mapCollection(stack, 'objects' | 'views' | …)`)
+// and `applyConversionsToStoredItem` maps a metadata type onto one of those
+// collections. A package manifest is neither — there is no `packages` /
+// `plugins` entry in `PLURAL_TO_SINGULAR`, so a manifest is not a stack
+// collection member and a stored manifest row passes that seam through
+// unchanged. A MetadataConversion here would be a transform with no seam
+// that ever runs. The prescription reaches authors instead through the
+// tombstone at the one place a manifest is parsed with an author present
+// (`os plugin build` → `ManifestSchema.safeParse`, which exits non-zero),
+// and through the D3 semantic entry `plugin-manifest-loading-retired`.
+export const entry = 'kernel/Manifest:loading';
diff --git a/packages/spec/src/migrations/entries/retired-keys/17.shared__FieldMapping__transform.ts b/packages/spec/src/migrations/entries/retired-keys/17.shared__FieldMapping__transform.ts
new file mode 100644
index 0000000000..2163775b2b
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-keys/17.shared__FieldMapping__transform.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'shared/FieldMapping:transform';
diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__displayField.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__displayField.ts
new file mode 100644
index 0000000000..4a1a70e60d
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__displayField.ts
@@ -0,0 +1,7 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+// #5775 — the SDUI component-props reconciliation. Three keys on the record
+// picker (`displayField` was the REQUIRED one, and the synonym of the key
+// the renderer actually reads) and the card's second spelling of the
+// composition slot every other container calls `children`.
+export const entry = 'ui/ElementRecordPickerProps:displayField';
diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__multiple.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__multiple.ts
new file mode 100644
index 0000000000..bd1b691ba0
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__multiple.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'ui/ElementRecordPickerProps:multiple';
diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__searchFields.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__searchFields.ts
new file mode 100644
index 0000000000..8a29d1eeff
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__searchFields.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'ui/ElementRecordPickerProps:searchFields';
diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__PageCardProps__actions.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__PageCardProps__actions.ts
new file mode 100644
index 0000000000..7f2e4136b8
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-keys/17.ui__PageCardProps__actions.ts
@@ -0,0 +1,14 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+// #6946 — three SDUI page-component props, retired by maintainer ruling
+// 2026-08-09 (decision-inbox round, 「全部接受」): objectui#3829 route (c)
+// for the first two, objectui#3818 for the third. Registered per key, as
+// gate (b) reads them — nothing radiates from a neighbouring key, and this
+// family needs that literally: `ui/PageHeaderProps:actions` and
+// `ui/RecordHighlightsProps:layout` are LIVE keys sharing these leaf names.
+//
+// The first two are the B class — declared here, read NOWHERE in objectui
+// (the header resolves icons per action; the card renders
+// title/bordered/children/footer and has no actions area), and carried in
+// that repo's own `UNPUBLISHED_EXEMPTIONS` map as exactly that.
+export const entry = 'ui/PageCardProps:actions';
diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__PageCardProps__body.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__PageCardProps__body.ts
new file mode 100644
index 0000000000..05f0bceeaa
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-keys/17.ui__PageCardProps__body.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'ui/PageCardProps:body';
diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__PageHeaderProps__icon.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__PageHeaderProps__icon.ts
new file mode 100644
index 0000000000..d89b888011
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-keys/17.ui__PageHeaderProps__icon.ts
@@ -0,0 +1,3 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+export const entry = 'ui/PageHeaderProps:icon';
diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__PageTabsProps__type.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__PageTabsProps__type.ts
new file mode 100644
index 0000000000..0f68b5c6af
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-keys/17.ui__PageTabsProps__type.ts
@@ -0,0 +1,8 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+// #6776 — #5775's count was incomplete. The tab strip's visual style is the
+// one prop whose declared spelling collides with the page component's own
+// dispatch key, so `type` could never be authored in a flat or JSX carrier
+// and was skipped unvalidated by `sdui-parser`'s `BASE_PROPS`. Renamed to
+// the `tabStyle` every carrier can express and the renderer already reads.
+export const entry = 'ui/PageTabsProps:type';
diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__RecordDetailsProps__layout.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__RecordDetailsProps__layout.ts
new file mode 100644
index 0000000000..aff9ae5215
--- /dev/null
+++ b/packages/spec/src/migrations/entries/retired-keys/17.ui__RecordDetailsProps__layout.ts
@@ -0,0 +1,8 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+// The third is a sharper shape: `layout` IS read, but only against
+// `inline`/`compact` — values its `auto | custom` enum never permitted — so
+// both legal values took the same branch. Declared on BOTH sides with the
+// same enum, which is why the declaration-parity ratchet (two declarations,
+// never a declaration vs an implementation) reported agreement over it.
+export const entry = 'ui/RecordDetailsProps:layout';
diff --git a/packages/spec/src/migrations/entries/semantic/11.object-titleFormat-to-nameField.ts b/packages/spec/src/migrations/entries/semantic/11.object-titleFormat-to-nameField.ts
new file mode 100644
index 0000000000..168a559a5c
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/11.object-titleFormat-to-nameField.ts
@@ -0,0 +1,18 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'object-titleFormat-to-nameField',
+ surface: 'object.titleFormat',
+ replacement: 'object.nameField',
+ reason:
+ 'A single-field `titleFormat` maps 1:1 to `nameField`, but a composite template ' +
+ '(e.g. `{firstName} {lastName}`) has no lossless single-field target — it must ' +
+ 'become a formula field designated as `nameField`. The choice of formula is a ' +
+ 'judgment the transform cannot make.',
+ acceptanceCriteria:
+ 'Each object with a `titleFormat` declares a `nameField`; a composite title is ' +
+ 'backed by a formula field. `objectstack validate` passes and record display ' +
+ 'names render identically to before.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/11.rls-sql-predicate-to-cel.ts b/packages/spec/src/migrations/entries/semantic/11.rls-sql-predicate-to-cel.ts
new file mode 100644
index 0000000000..ef3a37f2ad
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/11.rls-sql-predicate-to-cel.ts
@@ -0,0 +1,16 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'rls-sql-predicate-to-cel',
+ surface: 'security.rls.predicate',
+ replacement: 'CEL predicate',
+ reason:
+ 'SQL-ish RLS predicates were deprecated in favor of canonical CEL. Translation ' +
+ 'is not a pure token rename — operators, functions, and null semantics differ — ' +
+ 'so it cannot be applied losslessly by the chain.',
+ acceptanceCriteria:
+ 'Every RLS predicate parses as CEL and `objectstack validate` reports no ' +
+ 'expression errors; row visibility is unchanged for a representative fixture set.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/12.rest-requireauth-default-flip.ts b/packages/spec/src/migrations/entries/semantic/12.rest-requireauth-default-flip.ts
new file mode 100644
index 0000000000..f7a580af81
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/12.rest-requireauth-default-flip.ts
@@ -0,0 +1,19 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'rest-requireauth-default-flip',
+ surface: 'api.requireAuth',
+ replacement: "explicit `api: { requireAuth: false }` (intentionally-public deployments only)",
+ reason:
+ 'The global default flipped from `false` to `true` in protocol 12: anonymous ' +
+ 'requests to the `/data/*` CRUD and batch endpoints are rejected with 401 ' +
+ 'unless the stack opts out. Whether anonymous access was intentional (demo / ' +
+ 'kiosk) or an accident is a security judgment no transform can make.',
+ acceptanceCriteria:
+ 'A deployment that relies on anonymous data access declares ' +
+ '`api: { requireAuth: false }` on the stack config (and accepts the boot ' +
+ 'warning); every other consumer verifies its clients authenticate. ' +
+ '`objectstack validate` and the consumer test suite pass.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/13.cel-current-user-roles-to-positions.ts b/packages/spec/src/migrations/entries/semantic/13.cel-current-user-roles-to-positions.ts
new file mode 100644
index 0000000000..b4c107150d
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/13.cel-current-user-roles-to-positions.ts
@@ -0,0 +1,18 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'cel-current-user-roles-to-positions',
+ surface: 'CEL/formula: current_user.roles',
+ replacement: 'current_user.positions',
+ reason:
+ 'The EvalUser/CEL contract renamed `current_user.roles` to ' +
+ '`current_user.positions`. The token lives inside free-form expression ' +
+ 'strings, where a blind textual substitution could corrupt string literals ' +
+ 'or comments — so the rewrite is delegated to the author.',
+ acceptanceCriteria:
+ 'No expression references `current_user.roles`; formula validation and ' +
+ '`objectstack validate` report no unknown-identifier errors; predicate ' +
+ 'behavior is unchanged for representative users.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/13.owd-full-alias-removed.ts b/packages/spec/src/migrations/entries/semantic/13.owd-full-alias-removed.ts
new file mode 100644
index 0000000000..adaf9c1588
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/13.owd-full-alias-removed.ts
@@ -0,0 +1,19 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'owd-full-alias-removed',
+ surface: "object.sharingModel: 'full'",
+ replacement: "'public_read_write' or explicit sharing rules",
+ reason:
+ "The legacy `'full'` OWD alias implied full access (including transfer/ " +
+ 'delete) — wider than any canonical OWD value, so it has no lossless ' +
+ "target ('read'/'read_write' converted mechanically; this one did not). " +
+ 'Choosing between `public_read_write` and explicit sharing rules is a ' +
+ 'security-posture decision.',
+ acceptanceCriteria:
+ "No object declares sharingModel 'full'; the chosen replacement posture is " +
+ 'verified against the intended access (who can read/write/delete) for a ' +
+ 'representative fixture set.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/13.permission-set-profile-removed.ts b/packages/spec/src/migrations/entries/semantic/13.permission-set-profile-removed.ts
new file mode 100644
index 0000000000..58154a6d26
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/13.permission-set-profile-removed.ts
@@ -0,0 +1,19 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'permission-set-profile-removed',
+ surface: 'permissionSet.kind / permissionSet.isProfile',
+ replacement: 'position-based assignment + permission-set grants (ADR-0090 D2)',
+ reason:
+ 'The Profile concept was removed: `isProfile` is gone from ' +
+ '`PermissionSetSchema` and the `profile` metadata kind folded into ' +
+ '`position`. Mapping a profile onto positions and permission-set grants is ' +
+ 'an authorization-design decision, not a rename.',
+ acceptanceCriteria:
+ 'No permission set declares `isProfile` or kind `profile`; the intended ' +
+ 'assignees hold equivalent grants via positions/permission sets. The access ' +
+ 'matrix (`os compile` access-matrix gate, where enabled) is reviewed and ' +
+ '`objectstack validate` passes.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/13.position-hierarchy-flattened.ts b/packages/spec/src/migrations/entries/semantic/13.position-hierarchy-flattened.ts
new file mode 100644
index 0000000000..9f723b1dcc
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/13.position-hierarchy-flattened.ts
@@ -0,0 +1,18 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'position-hierarchy-flattened',
+ surface: 'position.parent / sharingRule recipient role_and_subordinates',
+ replacement: 'business-unit tree + `unit_and_subordinates` (ADR-0090 D3)',
+ reason:
+ 'Positions are flat in v2 — `parent` was removed and the ' +
+ '`role_and_subordinates` recipient with it; hierarchy lives on the ' +
+ 'business-unit tree, which expands a DIFFERENT structure than the retired ' +
+ 'role tree. Re-homing an org hierarchy is a judgment call.',
+ acceptanceCriteria:
+ 'No position declares `parent`; former `role_and_subordinates` rules are ' +
+ 're-expressed with `unit_and_subordinates` over an equivalent business-unit ' +
+ 'tree. Row visibility is unchanged for a representative fixture set.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/13.sharing-model-secure-default.ts b/packages/spec/src/migrations/entries/semantic/13.sharing-model-secure-default.ts
new file mode 100644
index 0000000000..a9b566ecb0
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/13.sharing-model-secure-default.ts
@@ -0,0 +1,18 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'sharing-model-secure-default',
+ surface: 'object.sharingModel (absent, custom object with owner field)',
+ replacement: 'an explicit `sharingModel` declaration',
+ reason:
+ 'ADR-0090 D1 secure default: a custom object with an owner field and NO ' +
+ '`sharingModel` now resolves `private` (it used to fall through to fully ' +
+ 'public). Restoring the old exposure must be a deliberate, visible ' +
+ 'declaration — the chain must not silently re-open data.',
+ acceptanceCriteria:
+ 'Every custom object that relied on the implicit public posture declares ' +
+ 'an explicit `sharingModel`; row visibility is verified for a ' +
+ 'representative fixture set (owners, non-owners, admins).',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/15.ui-schemas-strict-unknown-keys.ts b/packages/spec/src/migrations/entries/semantic/15.ui-schemas-strict-unknown-keys.ts
new file mode 100644
index 0000000000..9f0ee54358
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/15.ui-schemas-strict-unknown-keys.ts
@@ -0,0 +1,18 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'ui-schemas-strict-unknown-keys',
+ surface: 'view form fields/sections · page components (undeclared keys)',
+ replacement: 'declared keys only (`visibleWhen` for visibility predicates)',
+ reason:
+ 'The `.strict()` flip (ADR-0089 D3a) turns a previously silently-stripped ' +
+ 'unknown key into a parse error. There is no mapping target for an ' +
+ 'arbitrary unknown key — auto-deleting it would be exactly the silent data ' +
+ 'loss ADR-0078 bans — so each occurrence needs the author to decide: fix ' +
+ 'the typo, move it to the right layer, or delete dead metadata.',
+ acceptanceCriteria:
+ '`objectstack validate` passes with no unknown-key parse errors on form ' +
+ 'fields, form sections, or page components.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/16.dashboard-widget-strict-unknown-keys.ts b/packages/spec/src/migrations/entries/semantic/16.dashboard-widget-strict-unknown-keys.ts
new file mode 100644
index 0000000000..3b45c0b814
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/16.dashboard-widget-strict-unknown-keys.ts
@@ -0,0 +1,21 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'dashboard-widget-strict-unknown-keys',
+ surface: 'dashboard widgets (undeclared top-level keys — legacy inline ' +
+ 'analytics, objectui-internal `component`/`data`, or typos)',
+ replacement: 'declared keys only (`dataset` + `dimensions` + `values` for ' +
+ 'analytics; `options` for renderer-specific extras)',
+ reason:
+ 'The `.strict()` flip turns a previously silently-stripped unknown key into a ' +
+ 'parse error. There is no mapping target for an arbitrary unknown key — ' +
+ 'auto-deleting it would be exactly the silent data loss ADR-0078 bans — so ' +
+ 'each occurrence needs the author to decide: bind a `dataset` and select ' +
+ '`dimensions`/`values`, move a renderer setting under `options`, or delete ' +
+ 'the dead key.',
+ acceptanceCriteria:
+ '`objectstack validate` passes with no unknown-key parse errors on dashboard ' +
+ 'widgets.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.action-descriptor-is-async-retired.ts b/packages/spec/src/migrations/entries/semantic/17.action-descriptor-is-async-retired.ts
new file mode 100644
index 0000000000..b1e4330d33
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.action-descriptor-is-async-retired.ts
@@ -0,0 +1,45 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'action-descriptor-is-async-retired',
+ surface: 'ActionDescriptor.isAsync (the descriptor an executor publishes via `registerNodeExecutor` / `defineActionDescriptor`)',
+ replacement:
+ 'nothing to re-declare — delete the key. Suspension is `execute()` RETURNING '
+ + '`suspend: true`, and permission to suspend is `supportsPause: true` on the same '
+ + 'descriptor (with the `resumeAuthority` its pauses need)',
+ reason:
+ 'ADR-0049 enforce-or-remove. `isAsync` declared "this action suspends the flow '
+ + 'awaiting an external reply" and NOTHING read it: a fresh three-repo measurement '
+ + '(#6748, re-run at pickup) found zero property reads across objectstack, objectui '
+ + 'and cloud — every hit was the declaration itself, a generated baseline, one of '
+ + 'five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. '
+ + 'So declaring it never made a node suspend and omitting it never stopped one, '
+ + 'which is the silently-inert declaration ADR-0049 exists to end. It was always a '
+ + 'second, weaker spelling of the capability `supportsPause` states, and the two '
+ + 'diverged in exactly the way a duplicated declaration does: `screen` declared '
+ + 'both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing '
+ + 'anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling in '
+ + '#6667 — `AutomationEngine` now refuses a suspension whose type does not declare '
+ + '`supportsPause: true` — so the capability this key gestured at is now a real, '
+ + 'enforced fact under one name. This one had no consumer to grow into and takes '
+ + 'the remove leg. '
+ + 'Why D3 semantic and not a D2 conversion: an ActionDescriptor is published from '
+ + "an executor's TypeScript, never stored in stack metadata — no stack, example or "
+ + 'template carries the key — so there is no source for the chain to rewrite and '
+ + '`os migrate meta` cannot reach it. The schema tombstones it via `retiredKey()` '
+ + 'and descriptor authors delete the key themselves; that rejection (a `tsc` error '
+ + 'at the authoring site, and a parse error inside `defineActionDescriptor`) is the '
+ + 'channel a third-party plugin author actually meets. The '
+ + '`EnhancedApiError.fieldErrors` disposition, one layer down.',
+ acceptanceCriteria:
+ 'No descriptor declares `isAsync` — not the five that shipped it (`screen`, `map`, '
+ + '`wait`, `approval`, `approval_revise`), not a plugin\'s. Every node type that '
+ + 'returns `suspend: true` from `execute()` declares `supportsPause: true` on its '
+ + 'descriptor together with a `resumeAuthority`, and its runs still pause and resume '
+ + 'as before: the behaviour never depended on `isAsync`, so deleting the key changes '
+ + 'no run. Authoring `isAsync` fails `tsc` at the descriptor literal and fails '
+ + '`defineActionDescriptor()` at runtime with the prescription, instead of parsing '
+ + 'clean and being stripped.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.action-descriptor-resume-authority-default-flip.ts b/packages/spec/src/migrations/entries/semantic/17.action-descriptor-resume-authority-default-flip.ts
new file mode 100644
index 0000000000..346e0a30f9
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.action-descriptor-resume-authority-default-flip.ts
@@ -0,0 +1,67 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'action-descriptor-resume-authority-default-flip',
+ // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code
+ // span AND a table cell (see the note on `spec-type-alias-input-suffix-retired`).
+ surface:
+ 'automation.ActionDescriptor.resumeAuthority — an OMITTED value on a pausing '
+ + 'node descriptor (supportsPause: true, or any executor whose execute() returns '
+ + 'suspend: true)',
+ replacement:
+ "an explicit resumeAuthority: 'any' on the descriptor, for a pausing node whose "
+ + 'pauses really are meant to be continued through the generic resume route '
+ + '(POST /automation/:name/runs/:runId/resume) — a screen-style collected-input '
+ + "pause, or a signal wait an external producer resumes. Declare 'service' instead "
+ + 'if continuing is the tail of a decision your own service must authorize and '
+ + 'record first. Either value is a one-line addition; only the silence changed '
+ + 'meaning',
+ reason:
+ 'A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as '
+ + "protocol 12's `rest-requireauth-default-flip`, and it is registered here for the "
+ + 'same reason: whether a given pause is genuinely open to the generic route is a '
+ + 'trust judgment no transform can make. The #3801 resume gate keys on the SUSPENDED '
+ + "NODE, and `ActionDescriptor.resumeAuthority` used to default to `'any'`, so a "
+ + 'pausing node type shipped raw-resumable unless its author remembered the field. '
+ + "It now resolves to `'service'` when absent: an unclaimed pause is refused on the "
+ + 'generic route with `PERMISSION_DENIED` / 403 until its descriptor states who may '
+ + 'continue it. #3823 is the incident that decided the direction — ADR-0044 pointed '
+ + "an approval's revise edge at a generic `wait`, `wait` is legitimately `'any'`, and "
+ + 'the pause standing in a service-owned position inherited a fail-open value nobody '
+ + 'chose; the demonstrated cost was an unaudited resubmit plus a destroyed remote '
+ + 'run. The two possible mistakes are asymmetric, which is the whole argument: '
+ + "guessing `'any'` walks past a decision nothing recorded and is silent, while "
+ + "guessing `'service'` returns a refusal naming the missing field. ⚠️ The surface "
+ + 'is a DESCRIPTOR FIELD set in plugin CODE, never stack metadata, so there is no '
+ + 'source for a D2 conversion to rewrite and deliberately no schema tombstone — the '
+ + 'disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` '
+ + '(#5540) and `actor-user-roles-to-positions` (#6011) already carry. It differs from '
+ + 'those in one way a reader should not have to infer: nothing is REMOVED, so tsc '
+ + 'reports nothing at all — the field was already optional after step one and an '
+ + 'omission still compiles. The enforced channels are all run-time: a registration '
+ + 'warning naming the node type (once per type per engine), the refusal message on '
+ + 'the resume itself, and `check:resume-authority-declared` for executors living in '
+ + 'this repo. For a third-party plugin the generated upgrade guide is the only '
+ + 'channel that arrives BEFORE a user hits a run that will not continue. In-tree the '
+ + 'flip moves nothing: all six shipped pausing types (screen, wait, subflow, map, '
+ + 'approval, approval_revise) declare their authority explicitly. ADR-0044 amendment '
+ + '(2026-07-28) and its 2026-08-08 landing section, ADR-0019 #3801 addendum, #5561.',
+ acceptanceCriteria:
+ 'Every action descriptor your plugin registers for a node type that can suspend '
+ + 'declares `resumeAuthority`. Booting the stack logs no `declares supportsPause but '
+ + 'never declares resumeAuthority` warning naming one of your types, and a run parked '
+ + 'on each of your pausing nodes can still be continued the way you intend: a resume '
+ + "through the generic route succeeds for the ones you declared `'any'`, and answers "
+ + "403 (`PERMISSION_DENIED`) for the ones you declared `'service'`, which continue "
+ + 'through your own service API instead. ⚠️ `supportsPause` is no longer the '
+ + 'declaration nothing enforced (#5703, closed by #6667): an executor whose '
+ + '`execute()` returns `suspend: true` while leaving `supportsPause` false is still '
+ + 'warned about by neither warning channel, but '
+ + '`AutomationEngine.refuseUndeclaredSuspension` now refuses that suspension at the '
+ + 'one seam every suspension passes through — a guard-class failure no `fault` edge '
+ + 'routes — so it needs no hand-check. The residue that does: an executor registering '
+ + 'NO descriptor declares nothing for either warning or the refusal to read, so its '
+ + 'pauses are still created and refused only later, on the resume route (#5561).',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.action-session-roles-to-positions.ts b/packages/spec/src/migrations/entries/semantic/17.action-session-roles-to-positions.ts
new file mode 100644
index 0000000000..39e3105b34
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.action-session-roles-to-positions.ts
@@ -0,0 +1,57 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'action-session-roles-to-positions',
+ surface: 'ui.actionSession.roles',
+ replacement: 'ui.actionSession.positions (an action body reads `ctx.session.positions`)',
+ reason:
+ 'The MIRROR-IMAGE sibling of `actor-user-roles-to-positions`, and the reason both are in this '
+ + 'step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed '
+ + 'outright, #5050), while the ACTION body\'s `ctx.session` carries it '
+ + 'produced-and-really-populated. `buildActionSession()` '
+ + '(`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` '
+ + 'into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under '
+ + 'the one spelling that ADR bans — so a body author met two different answers to one '
+ + 'key name on one platform: rejected in a hook, live and full of values in an action. '
+ + '#5613 ruled contract-first (maintainer, 2026-08-06: "C skeleton + A semantics"): '
+ + 'phase 1 (#5697) declared the previously undeclared shape as `ActionSessionSchema`, '
+ + 'and phase 2 renames the key. `positions` is now the canonical key on that schema '
+ + 'and `roles` a deprecated alias of it (#5779); the producer emits both for one '
+ + 'deprecation window (#5613 runtime half), after which `roles` is removed on the path '
+ + 'the v11 session-alias removal already walked (#3280 deprecated → #3290 removed). '
+ + 'Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: '
+ + 'FIRST, there is no source to convert — an action `ctx.session` is constructed per '
+ + 'dispatch and never persisted, so no `sys_metadata` row, example or template can '
+ + 'carry the key — the `openApi31` (#4579) / `activationEvents` (#4657) / '
+ + '`hook-context-session-roles-retired` (#5050) shape. SECOND, the only place the key '
+ + 'is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed '
+ + 'script whose `ScriptContext.session` is still `unknown`. A declarative transform '
+ + 'cannot safely rewrite an identifier inside free-form code — exactly the reason the '
+ + 'ADR-0090 wave delegated `current_user.roles` to the author at step 13 '
+ + '(`cel-current-user-roles-to-positions`) instead of substituting text. '
+ + 'Note what is deliberately NOT done here: the alias is not tombstoned. A '
+ + '`retiredKey()` REJECTS the key, and a deprecation window exists precisely so the '
+ + 'old spelling keeps working while its readers move — tombstoning during the window '
+ + 'would be the removal it is meant to defer. The tombstone (or the plain deletion the '
+ + 'authorable-surface ratchet adjudicates) belongs to the release that closes the '
+ + 'window. Until then this entry IS the channel: `spec-changes.json` and the generated '
+ + 'upgrade guide are how a reader learns the rename before the removal reaches them. '
+ + 'ADR-0090 D3, ADR-0087, #5613 / #5779.',
+ acceptanceCriteria:
+ 'No action body reads `ctx.session.roles`; every such read is `ctx.session.positions` '
+ + 'and observes the same array (the rename is a rename — the VALUE is '
+ + '`ExecutionContext.positions` on both sides, which the runtime pin '
+ + '`action-session-shape-contract.test.ts` asserts independently of the key name). '
+ + 'Privilege is NOT re-derived from either spelling: a read that was '
+ + '`roles.includes(\'admin\')` as an access check is rewritten to ask the security '
+ + 'service (capability grants / placements / derived posture, ADR-0095), never '
+ + 'renamed to `positions.includes(\'admin\')` — renaming that read migrates the defect '
+ + 'rather than the code. Verify against a real dispatch, not a fixture: invoke an '
+ + 'action as a caller holding positions and assert the body observed them under the '
+ + 'canonical key. During the window both keys are present and equal, so a reader can '
+ + 'be migrated and verified before the alias is removed; after it, `roles` is absent '
+ + 'and a body still reading it sees `undefined` — which is why the read must be moved '
+ + 'inside the window rather than at its close.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.actor-user-roles-to-positions.ts b/packages/spec/src/migrations/entries/semantic/17.actor-user-roles-to-positions.ts
new file mode 100644
index 0000000000..8f96e5c67b
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.actor-user-roles-to-positions.ts
@@ -0,0 +1,78 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'actor-user-roles-to-positions',
+ surface: 'action body / AI route: ctx.user.roles (req.user.roles)',
+ replacement:
+ 'ctx.user.positions (an AI route handler reads `req.user.positions`) — the same array, '
+ + 'under the one spelling ADR-0090 D3 sanctions',
+ reason:
+ 'The THIRD face of the ADR-0090 `roles` → `positions` rename, and the only one whose '
+ + 'surface the spec never declared. `ActorUser` '
+ + '(`packages/runtime/src/security/actor-user.ts`) is the ONE producer of the `user` '
+ + 'envelope handed to an action body as `ctx.user` and to an AI route handler as '
+ + '`req.user`; it declared `positions` and `roles` side by side and filled them from a '
+ + 'SINGLE assignment (`roles: core.positions`), so the two keys were verbatim identical '
+ + 'on every dispatch — a second spelling of the vocabulary ADR-0090 D3 reserves and bans, '
+ + 'published straight into author-written code. The maintainer ruled it closed IMMEDIATELY '
+ + '(2026-08-06 14:49Z, #6011): no deprecation window, no dual-emit, the alias simply gone '
+ + 'in 17 (PR #6048). '
+ + '⚠️ Do not read this entry across to its sibling `action-session-roles-to-positions`: '
+ + '`action-session-roles-to-positions` governs `ctx.session`, a DIFFERENT object reached '
+ + 'through the same `ctx`, and that one KEEPS its one-window dual-emit (#5613). Same word, '
+ + 'same dispatch, two faces, two schedules — `ctx.user.roles` is absent in 17 while '
+ + '`ctx.session.roles` still answers for the length of its window. '
+ + 'What makes this entry different in KIND from both session-side siblings: `ctx.user` has '
+ + 'no spec schema and never had one. It is a runtime TS interface, so unlike '
+ + '`HookContext.session.roles` (tombstoned on a deliberately non-strict `HookContextSchema`, '
+ + '#5050) and unlike `ActionSessionSchema` (declared contract-first at #5697 precisely so '
+ + 'its key could be renamed), there is no schema key here to tombstone and no '
+ + '`retiredKey()` prescription that could reach anybody — nothing ever ran an `ActorUser` '
+ + 'through a `.parse()`, so a prescription there would have no one to reach. The enforced '
+ + 'channel is tsc, and it reports at the READ site inside the author\'s own body; for an '
+ + 'untyped or sandboxed body there is no enforced channel at all, which is exactly why '
+ + 'this ledger entry has to exist — `spec-changes.json` and the generated upgrade guide '
+ + 'are the ONLY way such a reader learns of the rename. It is the `findStream` (#4484) / '
+ + '`IStorageService.list` (#5540) disposition — a TS/API contract, no stored source, no '
+ + 'tombstone, tsc at the call site — applied to a surface that lives one layer further '
+ + 'out than either: those two are at least DECLARED in `packages/spec/src/contracts`, '
+ + 'this one only in `packages/runtime`. '
+ + 'Why it is a D3 semantic TODO and not a D2 conversion, on the same two independent '
+ + 'grounds as its session sibling: FIRST, there is no source to convert — an `ActorUser` '
+ + 'is constructed per dispatch and never persisted, so no `sys_metadata` row, example or '
+ + 'template can carry the key (the `openApi31` (#4579) / `activationEvents` (#4657) / '
+ + '`hook-context-session-roles-retired` (#5050) shape). SECOND, the only place the key is '
+ + 'ever SPELLED is inside an action body or an AI route handler: author-written JS/TS, or '
+ + 'a sandboxed script. A declarative transform cannot safely rewrite an identifier inside '
+ + 'free-form code — the same reason the ADR-0090 wave delegated `current_user.roles` to '
+ + 'the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting '
+ + 'text. '
+ + 'The removal\'s hard precondition was met before it landed, and the result is recorded '
+ + 'here because the ledger is where an upgrading consumer meets it: the declaration\'s own '
+ + 'comment claimed the alias was "kept for the REST/AI shapes", and that claim was '
+ + 'DISPROVEN face by face against `origin/main` — repo-wide `user.roles` was 4 hits, all '
+ + 'of them in the pins PR #6048 flipped; the four `ActorUser` construction sites build '
+ + 'server-side envelopes that never enter a response body; objectui\'s `.roles` reads '
+ + 'belong to two unrelated producers (the better-auth session, and the '
+ + '`/auth/me/permissions` payload). The `cloud` repo was NOT reachable in that session and '
+ + 'is the one consumer face left unverified — this entry, and the changeset\'s FROM/TO '
+ + 'prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087, #6011 (PR #6048).',
+ acceptanceCriteria:
+ 'No action body reads `ctx.user.roles` and no AI route handler reads `req.user.roles`; '
+ + 'every such read is `.positions` and observes the SAME array — the value was '
+ + '`ExecutionContext.positions` on both sides, so this is a pure key rename and no value '
+ + 'has to be re-derived. Privilege is NOT re-derived from either spelling: a read that was '
+ + '`roles.includes(\'admin\')` as an access check is rewritten to ask the security service '
+ + '(capability grants / placements / derived posture, ADR-0095), never renamed to '
+ + '`positions.includes(\'admin\')` — renaming that read migrates the defect rather than the '
+ + 'code. Unlike `ctx.session` there is NO window to migrate inside: in 17 the key is '
+ + 'already absent, so a typed body fails `tsc` at the read while an untyped or sandboxed '
+ + 'one silently sees `undefined` — move the read AS you upgrade, not after it. Verify '
+ + 'against a real dispatch rather than a fixture: invoke an action (and an AI route) as a '
+ + 'caller holding positions, assert the body observed them under the canonical key, and '
+ + 'assert the old key is ABSENT by key existence (`\'roles\' in ctx.user === false`) rather '
+ + 'than by `undefined`, which cannot tell a removed key from one left behind holding '
+ + 'nothing — the runtime pin `action-ctx-user-shape.test.ts` asserts both halves that way.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.aggregation-node-distinct-retired.ts b/packages/spec/src/migrations/entries/semantic/17.aggregation-node-distinct-retired.ts
new file mode 100644
index 0000000000..e0fcf64958
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.aggregation-node-distinct-retired.ts
@@ -0,0 +1,52 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'aggregation-node-distinct-retired',
+ surface: 'data.query.aggregations[].distinct',
+ replacement:
+ 'the `count_distinct` aggregation FUNCTION for a deduplicated count — the one '
+ + 'deduplicating spelling every face computes, lowered to `COUNT(DISTINCT field)` on '
+ + 'both SQL faces since #6409. `SUM(DISTINCT …)` / `AVG(DISTINCT …)` get no '
+ + 'replacement: no backend ever computed them here, and a per-row measure that needs '
+ + 'deduplicating before summing is a modelling problem to fix in the data',
+ reason:
+ '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 frozen under #5499 — '
+ + '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.',
+ acceptanceCriteria:
+ '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.',
+};
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
new file mode 100644
index 0000000000..b4eb7c1fe5
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.analytics-query-request-envelope-retired.ts
@@ -0,0 +1,17 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'analytics-query-request-envelope-retired',
+ surface: 'api.analyticsQueryRequest.query',
+ 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 ' +
+ 'chain to rewrite. Callers of POST /analytics/query and /analytics/sql must move the ' +
+ 'query.* fields to the body top level themselves.',
+ acceptanceCriteria:
+ 'Every /analytics/query and /analytics/sql call sends the bare AnalyticsQuery shape and ' +
+ 'succeeds; no request answers 400 VALIDATION_FAILED with the envelope prescription.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.analytics-query-request-format-retired.ts b/packages/spec/src/migrations/entries/semantic/17.analytics-query-request-format-retired.ts
new file mode 100644
index 0000000000..50ac24ea56
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.analytics-query-request-format-retired.ts
@@ -0,0 +1,16 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'analytics-query-request-format-retired',
+ surface: 'api.analyticsQueryRequest.format',
+ replacement: '(removed — responses are always the JSON envelope; use the export surface for CSV/XLSX)',
+ reason:
+ '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.',
+ acceptanceCriteria:
+ 'No /analytics/query or /analytics/sql call sends `format`; exports go through the ' +
+ 'export surface.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.api-runtime-create-withdrawn.ts b/packages/spec/src/migrations/entries/semantic/17.api-runtime-create-withdrawn.ts
new file mode 100644
index 0000000000..cb1611d301
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.api-runtime-create-withdrawn.ts
@@ -0,0 +1,57 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'api-runtime-create-withdrawn',
+ surface: 'PUT /api/v1/meta/api/{name} (runtime-authored `api` endpoints, draft and active alike)',
+ replacement:
+ 'Declare the endpoint as a stack artifact (`**/*.api.ts`, or `defineStack({ apis })`) '
+ + 'and ship it through `publishPackage`',
+ reason:
+ 'The `api` registry entry declared `allowRuntimeCreate: true` and the runtime never '
+ + 'honoured it. Measured on a real showcase boot (#5488): `PUT /api/v1/meta/api/'
+ + 'e8_backdoor` answered 200 with `{"success":true,…,"message":"Saved …"}`, and the '
+ + 'declared route then answered 404 forever — with NO `[EndpointMatcher] … EXCLUDED` '
+ + 'line, because the endpoint was never in the index to be excluded from. The serving '
+ + 'criterion belongs to `IMetadataService.matchEndpoint` -> `EndpointMatcher` -> '
+ + "`MetadataManager.listForIndex('api')`, which reads the manager's registry plus its "
+ + 'registered loaders (`["filesystem","memory"]` on dev/serve); a runtime write lands '
+ + 'in `sys_metadata`, which is in neither. A declared capability the runtime does not '
+ + 'honour is ADR-0049 false compliance, and a write that answers "Saved" and then 404s '
+ + 'forever is its most dangerous shape for the AI authors ADR-0033 targets. The '
+ + 'maintainer ruled REMOVE on 2026-08-07 rather than converge the read path, because '
+ + 'making the matcher read `sys_metadata` re-opens cache, invalidation, tenancy and '
+ + "the ADR-0110 D3 miss-vs-outage distinction on a new read path, and there is no "
+ + 'business pull for Studio-authored endpoints today (zero `.api.*` artifacts author '
+ + 'them at runtime; showcase uses the artifact route, #5040 E8 LIVE). '
+ + 'There is NO D2 conversion, for the reason this list exists: nothing in an authored '
+ + 'source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an '
+ + 'authorable one, and the artifact route it points authors toward is untouched — a '
+ + '`**/*.api.ts` file valid before this change is valid after it, byte for byte. What '
+ + 'changed is a runtime HTTP verdict, so it is one semantic TODO for operators and '
+ + 'Studio callers rather than a stack conversion — the same disposition '
+ + '`BatchOptions.validateOnly` (#4052) takes. Consequently `gateApiDraftsForPublish` '
+ + '(PR #5279) is retired with it: it gated a promotion into a state the matcher can '
+ + 'never read, and with the inlet closed no `api` draft can exist for it to judge. '
+ + 'Re-entry is recorded in the ruling: if #2657 Part B promotes `apis` to a registered '
+ + 'type WITH A REAL CONSUMPTION PATH, the flag flips back then — implementation first, '
+ + 'declaration second. ADR-0049 / ADR-0121, #5488 (subsumes #5311).',
+ acceptanceCriteria:
+ 'No caller creates or updates an `api` item through the runtime metadata API. '
+ + '`PUT /api/v1/meta/api/{name}` answers 403 with `code: "NOT_CREATABLE"` and a body '
+ + 'naming both flags (`allowRuntimeCreate=false, allowOrgOverride=false`) and the '
+ + 'prescription `Declare it in source (**/*.api.ts) and redeploy` — in `?mode=draft` '
+ + 'as well as direct-active, because the gate runs before the draft/publish branch and '
+ + 'does not read `mode`. ⚠️ Verify the artifact route is UNAFFECTED, which is the whole '
+ + 'point of the change: a stack declaring `apis:` still compiles, still passes '
+ + '`validateApiEndpointDeclarations` at publish (`publishPackage`, #5189) and at load '
+ + '(`buildEndpointIndex`, PR #5203), and its endpoints still SERVE — that route was '
+ + 'always the only one that served. An operator who genuinely needs the runtime door '
+ + 'back on one deployment sets `OS_METADATA_WRITABLE=api`, the same single escape '
+ + 'hatch `job` / `agent` / `capability` use; note that this unlocks the WRITE only, and '
+ + 'the endpoint still will not be served, which is why it is a diagnostic and not a '
+ + 'workaround. Any `api` rows already sitting in `sys_metadata` from before this change '
+ + 'were never served either; they can be deleted (`deleteMetaItem` is deliberately not '
+ + 'gated by this refusal, so repair stays possible).',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.batch-options-validate-only-retired.ts b/packages/spec/src/migrations/entries/semantic/17.batch-options-validate-only-retired.ts
new file mode 100644
index 0000000000..134ac8eb03
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.batch-options-validate-only-retired.ts
@@ -0,0 +1,17 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'batch-options-validate-only-retired',
+ surface: 'api.batchOptions.validateOnly',
+ replacement: '(removed — no dry-run today; open an issue to design a no-commit batch preview)',
+ reason:
+ 'The `validateOnly` key promised a dry-run ("validate records without persisting") but no '
+ + 'batch surface ever read it — updateManyData / deleteManyData / batchData persist '
+ + 'regardless. There is no behaviour to preserve and nothing stored to rewrite (it only '
+ + 'ever appeared in an HTTP request body). Callers must stop sending it.',
+ acceptanceCriteria:
+ 'No /batch, /updateMany or /deleteMany call sends `options.validateOnly`; a request that '
+ + 'includes it answers 400 VALIDATION_FAILED with the retirement prescription.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.batch-row-result-schema-shape.ts b/packages/spec/src/migrations/entries/semantic/17.batch-row-result-schema-shape.ts
new file mode 100644
index 0000000000..8435a72bb5
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.batch-row-result-schema-shape.ts
@@ -0,0 +1,37 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'batch-row-result-schema-shape',
+ surface:
+ 'api.batchOperationResult — the per-row `results` entries of BatchUpdateResponse '
+ + '(`POST /data/:object/batch`, `/updateMany`, `/deleteMany`)',
+ replacement:
+ '`errors: ApiError[]` (was `error: string` — read `row.errors?.[0]?.message`, branch on '
+ + '`row.errors?.[0]?.code`), `data` (was `record`), and `index` (new — the row\'s position '
+ + 'in the request array)',
+ reason:
+ 'The rows the three bulk-write endpoints emitted had drifted from the schema that '
+ + 'declared them: `BatchOperationResultSchema`, the client SDK\'s exported '
+ + '`BatchOperationResult` type and the reference docs all said `errors: ApiError[]` / '
+ + '`data` / `index`, while the wire carried `error: string` / `record` and never sent '
+ + '`index` at all. A TypeScript consumer written against the published type compiled, '
+ + 'validated and read `undefined` at runtime — the declared-but-not-delivered shape this '
+ + 'registry exists to close, on the response envelope (ADR-0119 D4 deferred the '
+ + 'reconciliation off a bug fix; this is that tracked change, shipped in the 17 major '
+ + 'window). The ADR-0119/#4620 rollback marking is structured in the same move: the '
+ + '`ROLLED_BACK:` / `NOT_ATTEMPTED:` message-string prefixes become registered '
+ + '`ApiError.code` values (message keeps the human-readable cause and causal row index), '
+ + 'so "attempted and undone" vs "never ran" is machine-readable instead of a regex '
+ + 'convention. A RESPONSE surface — nothing stored in stack metadata carries a batch '
+ + 'row, so there is no source for the chain to rewrite; consumers of the legacy keys '
+ + 'move their reads themselves. Off-contract readers only: the legacy keys were never '
+ + 'in the schema or the SDK types, so a typed consumer needs no change. #4793.',
+ acceptanceCriteria:
+ 'No consumer reads `row.error` or `row.record` on a batch result row; failures are read '
+ + 'from `row.errors` (message via `errors[0].message`, rollback state via '
+ + '`errors[0].code` — ROLLED_BACK / NOT_ATTEMPTED), records from `row.data`, and rows '
+ + 'correlate to the request via `row.index`. Every row the three endpoints emit parses '
+ + 'under `BatchOperationResultSchema` with those keys present.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.dashboard-widget-compareto-offset.ts b/packages/spec/src/migrations/entries/semantic/17.dashboard-widget-compareto-offset.ts
new file mode 100644
index 0000000000..400099784d
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.dashboard-widget-compareto-offset.ts
@@ -0,0 +1,28 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'dashboard-widget-compareto-offset',
+ surface: "dashboard.widgets[].compareTo: { offset: '7d' | '1M' | … } (every duration except '1y')",
+ replacement: "compareTo: { kind: 'previousPeriod' } plus an explicit window on the widget's own `filter`",
+ reason:
+ 'The widget declared three comparison arms; the analytics executor implements one shape, '
+ + '`{ kind, dimension? }`, with no `offset` concept in it at all. On the ADR-0021 dataset '
+ + 'path — the spec\'s single author-facing analytics shape — `{ offset }` was forwarded '
+ + 'verbatim into that contract and threw `compareTo requires a timeDimension "undefined"`, '
+ + 'taking the widget down; the arm ever only ran on the legacy inline chart path (#5011). '
+ + "The conversion rewrites `{ offset: '1y' }`, which IS `previousYear` by definition. Every "
+ + 'other duration has NO faithful target: `previousPeriod` shifts by the length of whatever '
+ + "window the widget's filter resolves to, which equals `7d` only when that window happens "
+ + 'to be seven days long. Rewriting mechanically would silently change which rows the '
+ + 'comparison column counts — a wrong number rather than a missing one, which is strictly '
+ + 'worse and exactly the class this convergence exists to end. Re-stating the intended '
+ + 'window is a judgment about the presentation, not a transform.',
+ acceptanceCriteria:
+ 'No dashboard widget declares `compareTo.offset`. Each former offset comparison states its '
+ + "window on the widget's `filter` and compares with `compareTo: { kind: 'previousPeriod' }` "
+ + "(or `'previousYear'`), and `dimension` is named wherever the selection dates more than one "
+ + 'time dimension. `objectstack validate` passes, and each affected widget renders a '
+ + '`__compare` column over the window its author intended.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.data-driver-find-stream-retired.ts b/packages/spec/src/migrations/entries/semantic/17.data-driver-find-stream-retired.ts
new file mode 100644
index 0000000000..5328804c6d
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.data-driver-find-stream-retired.ts
@@ -0,0 +1,40 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'data-driver-find-stream-retired',
+ surface: 'contracts.IDataDriver.findStream / data.DriverInterfaceSchema.findStream',
+ replacement:
+ 'find() with limit/offset — the paged read whose determinism IS enforced '
+ + '(IDataDriver.find, data/pagination-conformance.ts)',
+ reason:
+ '`findStream` was a REQUIRED contract method documented as "optimized for large '
+ + 'datasets to avoid memory overflow", and in two of its three implementations it '
+ + 'delivered the opposite: `SqlDriver` and `InMemoryDriver` both awaited `find()` for '
+ + 'the ENTIRE result set and then yielded it row by row, so the peak memory a caller '
+ + 'was promised protection from was already reached before the first yield. The third '
+ + '(`MongoDBDriver._findStream`) did walk a cursor, but it was the one read path in '
+ + 'that driver never routed through `buildFindOptions`, so it hardcoded '
+ + '`projection: { _id: 0 }` and silently discarded `query.fields`. None of it was ever '
+ + 'observed, because the method had NO caller in either repository: the engine exposes '
+ + 'no stream entry, and the REST export, import and bulk-read paths all go through '
+ + '`find()`. The ~20 driver test doubles that existed only to satisfy a required '
+ + 'method almost all threw `not implemented`, and nothing ever noticed — which is the '
+ + 'proof, not the anecdote. Being REQUIRED, it also taxed every new driver and every '
+ + 'test double with an implementation of a capability the platform does not have. '
+ + 'Rather than build a caller to justify three implementations, the method is retired; '
+ + 'a real cursor-based read should return WITH the caller that needs it (ADR-0049 '
+ + 'enforce-or-remove). This is a TS/API contract surface — a driver is CODE, never '
+ + 'stack metadata — so there is no source for the chain to rewrite, and deliberately '
+ + 'no schema tombstone either: nothing ever ran a driver object through '
+ + '`DriverInterfaceSchema.parse()`, so a prescription there would have no one to '
+ + 'reach. The enforced channel is tsc, and it points at callers. ADR-0049 / '
+ + 'ADR-0078, #4484.',
+ acceptanceCriteria:
+ 'No code calls `driver.findStream(...)`; large reads page through `find()` with '
+ + '`limit`/`offset` (which guarantees a total order across the whole walk) or go '
+ + 'through the export surface. Drivers and test doubles no longer implement the '
+ + 'method — one left behind still compiles and is simply never reached, so removing '
+ + 'it is cleanup rather than a break, while a CALLER of it no longer type-checks.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.data-engine-batch-retired.ts b/packages/spec/src/migrations/entries/semantic/17.data-engine-batch-retired.ts
new file mode 100644
index 0000000000..c75fc7ba06
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.data-engine-batch-retired.ts
@@ -0,0 +1,47 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'data-engine-batch-retired',
+ surface: 'contracts.IDataEngine.batch / data.DataEngineBatchRequestSchema',
+ replacement:
+ '`IObjectQLEngine.transaction(cb)` for in-process multi-write atomicity; the metadata '
+ + "protocol's `batchData` with `options.atomic: true` for a batch over one object; "
+ + '`POST {basePath}/batch` on the wire',
+ reason:
+ '`batch?` was declared on `IDataEngine` for as long as that contract existed and was '
+ + 'never implemented by any engine: `ObjectQL` has no `batch` method and there is no '
+ + 'other engine in the tree. It also had no caller — `DataEngineRequest` was imported '
+ + 'by exactly one file, the contract declaring the member. Its entire specification '
+ + 'was a three-word doc comment ("Batch Operations (Transactional)"), which settles '
+ + 'nothing about partial failure, ordering, cross-object references, rollback scope, '
+ + 'or what `transaction: false` was supposed to mean — the questions a batch API '
+ + 'exists to answer. Contrast its neighbours `getDefaultDriverName?` / '
+ + '`getDriverByName?`, whose optionality is evidenced: each names its implementer and '
+ + 'its probing caller. The tell that nobody ever designed against it is in the schema: '
+ + '`DataEngineBatchRequestSchema.requests` nested the request union RECURSIVELY, so a '
+ + 'batch could contain batches, with no statement anywhere about what that meant for '
+ + 'ordering or rollback. The only test was a type pin — an ad-hoc object literal '
+ + 'carrying a `batch` property, asserting the property was defined — which could not '
+ + "fail while the declaration existed and would have passed unchanged for the "
+ + "member's whole life with no engine implementing it. What it claimed is now covered "
+ + 'by members that are real, so the removal deletes a false affordance rather than a '
+ + 'capability: ADR-0119 D1 made `transaction` reachable through the contract and D4 '
+ + "made `batchData`'s `atomic` honest, while the wire batch has always validated with "
+ + '`CrossObjectBatchRequestSchema` / `BatchUpdateRequestSchema` from '
+ + '`api/batch.zod.ts` — a different schema entirely, untouched here. TS/API surfaces '
+ + 'only: an engine is CODE, never stack metadata, so there is no source for the chain '
+ + 'to rewrite. Deliberately no schema tombstone either — nothing ever parsed '
+ + '`DataEngineBatchRequestSchema`, so a `retiredKey()` prescription would have no one '
+ + 'to reach; its three `authorable-surface.json` baseline lines and its '
+ + '`json-schema.manifest.json` entry are dropped in the same change, deliberately. '
+ + 'The enforced channel is tsc. ADR-0049 / ADR-0078, #4618.',
+ acceptanceCriteria:
+ 'No code calls `engine.batch(...)` and no type references `DataEngineBatchRequest`; '
+ + 'in-process multi-write atomicity goes through `IObjectQLEngine.transaction(cb)`, a '
+ + 'batch over one object through `batchData` with `options.atomic: true`, and a '
+ + 'cross-object batch over the wire through `POST {basePath}/batch`. Because no engine '
+ + 'implemented the member, an implementation left behind still compiles and is simply '
+ + 'never reached; a CALLER of it no longer type-checks — and there were none.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.data-field-changed-event-retired.ts b/packages/spec/src/migrations/entries/semantic/17.data-field-changed-event-retired.ts
new file mode 100644
index 0000000000..59076427cb
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.data-field-changed-event-retired.ts
@@ -0,0 +1,41 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'data-field-changed-event-retired',
+ surface: "api.DataEventType 'data.field.changed'",
+ replacement:
+ "the `data.record.updated` event, whose payload already carries the per-field "
+ + 'detail: `changes` (the changed fields), plus `before` / `after`',
+ reason:
+ '`data.field.changed` was declared in `DataEventType` and emitted by nothing — the '
+ + 'engine\'s `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since '
+ + '#4639) `data.records.{updated,deleted}`, and no other producer exists in either '
+ + 'repository. A subscriber that switched on it was waiting on an event no producer '
+ + 'sends: the branch never ran, and because the surrounding `switch` still compiled, '
+ + 'nothing anywhere reported the gap (ADR-0078\'s silently-inert declaration, on the '
+ + 'event vocabulary). `DataEventSchema` could not have carried the semantics even if '
+ + 'something had emitted it — the payload is record-shaped (`recordId`, `changes`, '
+ + '`before`, `after`) with no `field` / `oldValue` / `newValue` slot — so the member '
+ + 'promised a granularity the contract has no room for. Per-field detail is therefore '
+ + 'not lost: it has always ridden on `data.record.updated` as `changes`, which is one '
+ + 'event per write rather than N events on a wide table. This is a runtime EVENT '
+ + 'surface — no stack, example or template authors an event name (webhooks subscribe '
+ + 'through the separate authorable `WebhookTriggerType`, whose vocabulary was already '
+ + 'trimmed to producers that exist, #3196) — so there is no source for the chain to '
+ + 'rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a '
+ + 'retiredKey() fix-it error the way an authorable object key can (the same limit the '
+ + 'sharing-rule `full` retirement `owd-full-alias-removed` hit). The enforced channels are tsc, '
+ + 'which '
+ + 'fails any consumer still naming the value in a `DataEventType` position, and the '
+ + 'enum parse, which now rejects the name instead of accepting an event that never '
+ + 'arrives. A genuine per-field stream, if one is ever wanted, gets its own honest '
+ + 'contract the way #4639 gave bulk writes theirs. ADR-0049 / ADR-0078, #4673.',
+ acceptanceCriteria:
+ 'No consumer subscribes to or switches on `data.field.changed`; per-field change '
+ + 'detail is read from a `data.record.updated` event\'s `changes` map (with `before` / '
+ + '`after` for the surrounding state). Deleting the dead branch changes no observable '
+ + 'behaviour — it never executed — so the migration is removing code that could not '
+ + 'run, not rebuilding a capability.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.declarative-apis-endpoints-live.ts b/packages/spec/src/migrations/entries/semantic/17.declarative-apis-endpoints-live.ts
new file mode 100644
index 0000000000..9d1da3c90d
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.declarative-apis-endpoints-live.ts
@@ -0,0 +1,61 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'declarative-apis-endpoints-live',
+ surface: 'stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)',
+ replacement:
+ 'the same declarations, re-read as LIVE HTTP routes: `path` moved under '
+ + '`/api/v1/apps//`, and every entry that declares '
+ + '`authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying '
+ + '`rateLimit: { enabled: true, … }`',
+ reason:
+ 'This is the one protocol-17 entry that turns metadata ON rather than off, so read it '
+ + 'as a SECURITY review item and not as a rename. Before 17 the declarative endpoint '
+ + 'surface executed NOTHING: no route was mounted for a declared `path`, no matcher '
+ + 'existed, and every key — `authRequired` included — parsed green and gated nothing '
+ + '(#4936, which refused a non-empty `apis:` outright for exactly that reason). '
+ + 'Protocol 17 ships the executor (#5040) and narrows that refusal to a per-endpoint '
+ + 'publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as '
+ + 'soon as the stack is published. So an `apis:` block written against an older major — '
+ + 'or one restored from a pre-#4936 source, or authored from a doc that predates the '
+ + 'refusal — changes meaning without changing a byte: what used to be inert '
+ + 'documentation becomes an execution entry point into the data and automation '
+ + 'pipelines. Nothing about that transition can be applied mechanically, because the '
+ + 'judgment it needs is "did the author of this endpoint mean for the internet to reach '
+ + 'it?" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its '
+ + 'schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT '
+ + '`authRequired: false` is the only thing that opens anonymous access, and under '
+ + 'ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key '
+ + 'defaults to `false`, so a budget written without it meters nothing) or the stack '
+ + 'refuses to publish. ⚠️ If you author endpoints in TypeScript, annotate them with '
+ + '`ApiEndpoint` — the AUTHOR state — so that omitting `authRequired` compiles: '
+ + '`const e: ApiEndpoint = { name, path, method, type, target }` is legal and is the '
+ + 'safe shape this paragraph prescribes. `ApiEndpointParsed` is the POST-parse type '
+ + '(defaults materialized, ADR-0122), where `authRequired` is required — annotating a '
+ + 'declaration with it forces you to write the key out, and being made to think about a '
+ + 'key whose only unrecoverable value is `false` is the one thing this entry is trying '
+ + 'to avoid (#5227). Hold a parse RESULT with `ApiEndpointParsed`; write declarations '
+ + 'as `ApiEndpoint`. Grep every `apis:` entry for `authRequired: false` before you '
+ + 'upgrade, delete the ones that were never meant to be public, and arm a budget on the '
+ + 'ones that were. The path move is the mechanical-looking half and is still yours: '
+ + 'ADR-0121 D1/D2 confine a declared path to your own namespace carve-out '
+ + '(`/api/v1/apps//…`), the namespace comes from an explicit '
+ + '`manifest.namespace` with no derivation fallback, and the subpath is the only part '
+ + 'you name — rewriting it for you would silently change a URL third parties call.',
+ acceptanceCriteria:
+ 'You have READ every entry of every `apis:` block, not just the ones that fail to '
+ + 'publish. Concretely: (1) each declared `path` is '
+ + '`/api/v1/apps//` and the stack declares that '
+ + '`manifest.namespace` explicitly; (2) every entry declaring `authRequired: false` is '
+ + 'one you INTEND to be reachable without a session, and each carries '
+ + '`rateLimit: { enabled: true, windowMs, maxRequests }` — entries that were not '
+ + 'intended to be anonymous have the key removed so the safe default (`true`) applies; '
+ + '(3) `objectstack validate` passes, which also proves no endpoint declares a shape '
+ + '17.x cannot execute (`type: script` / `proxy`, mapping `transform`, an '
+ + '`object_operation` missing `objectParams`, `cacheTtl` on a non-GET method, '
+ + '`inputMapping` on find/get/delete, or two endpoints claiming one METHOD + path); and '
+ + '(4) after publishing, each endpoint answers as you expect — an anonymous request to '
+ + 'a session-only endpoint returns 401 rather than data.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.driver-aggregate-undeclared-key-aliases-removed.ts b/packages/spec/src/migrations/entries/semantic/17.driver-aggregate-undeclared-key-aliases-removed.ts
new file mode 100644
index 0000000000..e2d679f130
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.driver-aggregate-undeclared-key-aliases-removed.ts
@@ -0,0 +1,49 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'driver-aggregate-undeclared-key-aliases-removed',
+ // No backticks in `surface`: the upgrade-guide renderer wraps this string
+ // in a code span of its own, and a nested pair renders as literal ticks.
+ surface: "driver aggregate() call argument — query.aggregate and aggregations[].func",
+ replacement:
+ 'query.aggregations and aggregations[].function — the spellings QueryASTSchema and '
+ + 'AggregationNodeSchema have always declared',
+ reason:
+ '`SqlDriver.aggregate` and `RemoteTransport.aggregate` each read two aliases the '
+ + 'Query Protocol has never declared: `query.aggregations || query.aggregate` and '
+ + '`agg.function || agg.func`. "Never declared" is measured, not assumed — `git log '
+ + '-S` over `data/query.zod.ts` finds no commit that ever introduced either name, '
+ + 'there is no `retiredKey()` tombstone and no alias-table entry for them (the file\'s '
+ + 'only alias table is `SortNode`\'s `direction` → `order`), and neither appears in any '
+ + 'upgrade guide or release note. So this entry does not record a declared surface '
+ + 'being withdrawn; it records a LENIENCY being withdrawn, which is why it is here '
+ + 'rather than behind a tombstone. The only writers in this repository were the two '
+ + 'driver packages\' own fixtures — #4984\'s family, where a fixture spelling the alias '
+ + 'keeps the tolerant limb green forever and no test in existence can go red on its '
+ + 'deletion — so ADR-0049 enforce-or-remove applies once those are re-spelt. ⚠️ Do '
+ + 'NOT read this across to `dashboard`/`page` measures: `aggregate` IS the canonical '
+ + 'key there and `func` IS a declared, loudly-suggesting alias (`DatasetMeasureSchema`, '
+ + 'ui/dataset.zod.ts). That neighbouring vocabulary is untouched, and it is the most '
+ + 'likely reason an off-repo caller ever wrote these keys on a QUERY — one habit, two '
+ + 'surfaces, only one of which declared it. This is a driver CALL ARGUMENT — code, '
+ + 'never stack metadata — so there is no source for the D2 chain to rewrite and '
+ + 'deliberately no schema tombstone: nothing ever ran a query through '
+ + '`QueryASTSchema.parse()` on this path. The enforced channel is tsc at the call '
+ + 'site, once the parameter is `DriverQuery` — and for an untyped JS caller there is '
+ + 'no enforced channel at all, which is exactly why this ledger entry has to exist: '
+ + 'the generated upgrade guide is the only way such a reader learns of the rename. '
+ + 'Same disposition, and the same reason, as `data-driver-find-stream-retired` '
+ + '(#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` '
+ + '(#6011). ADR-0049 / ADR-0087, #6321 (PR #6404).',
+ acceptanceCriteria:
+ 'No caller passes `aggregate:` to a driver\'s `aggregate()`, and no aggregation entry '
+ + 'spells its function `func:`; both are written `aggregations:` / `function:`. An '
+ + 'inline literal still using either old spelling no longer type-checks (TS2353 at the '
+ + 'call site). An untyped JS caller that keeps writing `aggregate:` silently receives '
+ + 'no aggregate column — the grouping still happens, the measure is simply absent — '
+ + 'and one that keeps writing `func:` receives INVALID_QUERY / 400 naming the '
+ + 'undeclared function, identically on the local driver and the Turso remote '
+ + 'transport.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.driver-capabilities-inert-bits-removed.ts b/packages/spec/src/migrations/entries/semantic/17.driver-capabilities-inert-bits-removed.ts
new file mode 100644
index 0000000000..fa86b2a95b
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.driver-capabilities-inert-bits-removed.ts
@@ -0,0 +1,68 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'driver-capabilities-inert-bits-removed',
+ surface:
+ 'data.DriverCapabilities.create / data.DriverCapabilities.read / '
+ + 'data.DriverCapabilities.update / data.DriverCapabilities.delete / '
+ + 'data.DriverCapabilities.bulkCreate / data.DriverCapabilities.bulkUpdate / '
+ + 'data.DriverCapabilities.bulkDelete / data.DriverCapabilities.transactions / '
+ + 'data.DriverCapabilities.savepoints / data.DriverCapabilities.isolationLevels / '
+ + 'data.DriverCapabilities.queryFilters / data.DriverCapabilities.queryAggregations / '
+ + 'data.DriverCapabilities.querySorting / data.DriverCapabilities.queryPagination / '
+ + 'data.DriverCapabilities.queryWindowFunctions / data.DriverCapabilities.querySubqueries / '
+ + 'data.DriverCapabilities.queryCTE / data.DriverCapabilities.joins / '
+ + 'data.DriverCapabilities.fullTextSearch / data.DriverCapabilities.jsonQuery / '
+ + 'data.DriverCapabilities.geospatialQuery / data.DriverCapabilities.streaming / '
+ + 'data.DriverCapabilities.jsonFields / data.DriverCapabilities.arrayFields / '
+ + 'data.DriverCapabilities.vectorSearch / data.DriverCapabilities.schemaSync / '
+ + 'data.DriverCapabilities.migrations / data.DriverCapabilities.indexes / '
+ + 'data.DriverCapabilities.connectionPooling / data.DriverCapabilities.preparedStatements / '
+ + 'data.DriverCapabilities.queryCache',
+ replacement:
+ '(removed — delete the keys. A driver advertises a capability by implementing the '
+ + 'corresponding IDataDriver method; the three bits that survive because method '
+ + 'presence cannot carry the signal are `queryDateGranularity`, `autonumber` and '
+ + '`batchSchemaSync`)',
+ reason:
+ 'The #4484 findStream close-out found `DriverCapabilities.streaming` pointing at a '
+ + 'capability the contract no longer declares, and the follow-up audit (#4634) checked '
+ + 'every bit in the record the same way, across objectstack and cloud (objectui '
+ + 'confirmed clean): of 34 declared bits, THREE have a decision-making reader — '
+ + '`queryDateGranularity` (engine aggregate dispatch + checkDateBucketParity), '
+ + '`autonumber` (engine defers generation to the driver), `batchSchemaSync` (engine '
+ + 'ANDs it with method presence, because a subclass can inherit `syncSchemasBatch` '
+ + 'from a base whose transport batches while its own cannot) — and THIRTY-ONE were '
+ + 'written by every driver and read by nothing. Their `.describe()` strings promised '
+ + 'engine adaptation ("if false, ObjectQL will filter/sort/paginate in memory") that '
+ + 'was never built, and zero readers let the values go WRONG unnoticed: SqlDriver '
+ + 'declared `streaming: false` while implementing `findStream`; InMemoryDriver '
+ + 'declared `streaming: true` over a full-table read (ADR-0078 false affordance, on '
+ + 'the capability record itself). The real mechanism everywhere else is METHOD '
+ + 'presence: transactions gate on `driver.beginTransaction`, aggregate pushdown on '
+ + '`typeof driver.aggregate`, schema sync on `typeof driver.syncSchema`, and the '
+ + 'REQUIRED CRUD/bulk methods are called unconditionally. A driver is CODE, never '
+ + 'stack metadata — `supports` literals live in driver classes and '
+ + '`DriverConfig.capabilities` is plugin TS configuration, neither ever a '
+ + '`sys_metadata` shape (the stack-tree neighbour, `datasource.capabilities`, was '
+ + 'retired separately in #4583) — so there is no source for the D2 chain to rewrite '
+ + 'and this entry is the D3 record. The keys are tombstoned rather than deleted '
+ + 'because `DriverCapabilitiesSchema` is not `.strict()` and IS parsed '
+ + '(DriverConfigSchema / SQLDriverConfigSchema / NoSQLDriverConfigSchema embed it): '
+ + 'a plain delete would silently strip a vendor\'s authored bit, replacing one '
+ + 'silent no-op with another. `batchSchemaSync` also drops its `.default(false)` '
+ + 'for `.optional()` — absence already meant false at both readers, and the default '
+ + 'forced every capability object to spell out 30+ bits. ADR-0049 / ADR-0078, #4634.',
+ acceptanceCriteria:
+ 'No `supports` literal or `DriverConfig.capabilities` object authors any of the 31 '
+ + 'retired bits — a driver class that still writes one fails tsc against '
+ + '`IDataDriver.supports` (the bit is `never`), and a parsed config fails with the '
+ + 'per-key prescription. The three in-repo drivers (memory / mongodb / sql) declare '
+ + 'only live bits; cloud\'s TursoDriver keeps compiling via its `...super.supports` '
+ + 'spread (its stale explicit overrides are cleanup, tracked cloud-side). Engine '
+ + 'behaviour is byte-identical: every removed bit had zero readers, and the three '
+ + 'live bits keep their readers (engine.ts autonumber defer / aggregate dispatch, '
+ + 'plugin.ts + engine.ts batched schema sync, verify date-bucket parity).',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.driver-sql-distinct-bare-filter-typed.ts b/packages/spec/src/migrations/entries/semantic/17.driver-sql-distinct-bare-filter-typed.ts
new file mode 100644
index 0000000000..950faa19de
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.driver-sql-distinct-bare-filter-typed.ts
@@ -0,0 +1,57 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'driver-sql-distinct-bare-filter-typed',
+ // No backticks in `surface` — build-upgrade-guide.ts renders it inside a
+ // code span already, and a nested backtick would close it.
+ surface: 'SqlDriver.distinct() third argument — any value',
+ replacement:
+ 'a bare FilterCondition (@objectstack/spec/data) — the same value find() carries '
+ + 'under query.where, never a query envelope',
+ reason:
+ 'This entry records a TYPE being added, not a surface being withdrawn, and it says '
+ + 'so up front because the distinction decides who has to do anything. `distinct` is '
+ + 'not declared on `IDataDriver`, so #5181 / #6075 never reached it and it kept '
+ + '`filters?: any` while its body said something far more specific — '
+ + '`applyFilters(builder, filters)` is handed the ARGUMENT ITSELF, never a `.where` '
+ + 'off it. ⚠️ RUNTIME BEHAVIOUR IS UNCHANGED by this entry\'s change: not one '
+ + 'statement moved, so no upgrade breaks at run time and nothing that answered '
+ + 'correctly stops. What the annotation removes is a compile-time hole, measured '
+ + 'rather than assumed: a truthy NON-OBJECT third argument — '
+ + '`distinct(\'orders\', \'product\', \'completed\')` — used to type-check and resolve '
+ + 'the UNFILTERED set, because `applyFilters` emits no predicate at all for a truthy '
+ + 'non-object, non-array filter. A call meaning "which products among completed '
+ + 'orders" answered with EVERY product, silently. That spelling is now TS2345 at the '
+ + 'call site. This is a driver CALL ARGUMENT — code, never stack metadata — so there '
+ + 'is no source for the D2 chain to rewrite and deliberately no schema tombstone, the '
+ + 'disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` '
+ + '(#5540), `actor-user-roles-to-positions` (#6011) and '
+ + '`driver-aggregate-undeclared-key-aliases-removed` (#6321) already carry. ⚠️ It '
+ + 'differs from those four in ONE measured way a reader should not have to infer: '
+ + 'because nothing changed at run time, an untyped JS caller is not affected BY THE '
+ + 'UPGRADE at all. The entry is here for a different reason — such a caller is exactly '
+ + 'the one tsc can never reach, and the silent widening above is a defect they may '
+ + 'ALREADY be sitting on, before and after this major. The generated upgrade guide is '
+ + 'the only channel that reaches them, which is why the fix is written down rather '
+ + 'than left to the compiler. ⛔ The reverse mismatch is NOT closed and no type can '
+ + 'close it: `FilterCondition` is an open map (`[key: string]: any`) because a filter '
+ + 'key IS a field name, so a query envelope `{ object, where }` is structurally a '
+ + 'valid filter — one constraining columns named `object` and `where` — and so is a '
+ + 'FilterArray. Both reach `distinct` type-checked and are refused at run time, '
+ + 'loudly, with INVALID_FILTER / 400. `driver-memory`\'s opposite half — where the '
+ + 'BARE spelling returns the unfiltered set in silence — stays open under the #5499 '
+ + 'freeze (#6320). ADR-0087, #6320.',
+ acceptanceCriteria:
+ 'No caller passes a non-object to `distinct()`\'s third argument. A scalar there is '
+ + 'now a compile error (`TS2345: Argument of type \'string\' is not assignable to '
+ + 'parameter of type \'FilterCondition\'`); rewrite it as the bare filter it was '
+ + 'always meant to be — `\'completed\'` becomes `{ status: \'completed\' }`. ⚠️ That '
+ + 'is NOT an equivalent rewrite: the old spelling returned the UNFILTERED set, so the '
+ + 'answer changes once fixed, and the changed answer is the one the call always meant. '
+ + 'An untyped JS caller gets no compile error and no behaviour change — for them this '
+ + 'entry is the only notice that the spelling never filtered anything. A query '
+ + 'envelope or a FilterArray in that slot still compiles and is rejected at run time '
+ + 'with INVALID_FILTER / 400.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.engine-find-formula-order-by-refused.ts b/packages/spec/src/migrations/entries/semantic/17.engine-find-formula-order-by-refused.ts
new file mode 100644
index 0000000000..0f3df8122c
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.engine-find-formula-order-by-refused.ts
@@ -0,0 +1,65 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'engine-find-formula-order-by-refused',
+ surface:
+ 'engine.find(object, { orderBy }) and engine.findOne(object, { orderBy }) naming a '
+ + '`formula` field — the direct engine path, not the REST ingress',
+ replacement:
+ 'denormalise the value onto the object (a stored field, written when the source '
+ + 'changes) and sort by that — the same remedy the REST ingress has prescribed since '
+ + '#6924 / #6994; a `summary` field is unaffected and still sorts, because it gets a '
+ + 'real maintained column',
+ reason:
+ '#4226 / #4256 / #6994 closed the SORT axis at the REST ingress '
+ + '(`assertSortFieldsExist`, `400 INVALID_SORT`), which covers everything reaching '
+ + '`findData`: the list route, `POST /data/:object/query`, the export route and the '
+ + 'RPC dispatcher. A caller reaching `engine.find()` / `engine.findOne()` DIRECTLY '
+ + 'passed through none of it, and a `formula` ORDER BY there was dropped in silence. '
+ + 'Measured on a real driver: `asc` and `desc` came back BYTE-IDENTICAL, in insertion '
+ + 'order, under a success, with the rows carrying the very values they were asked to '
+ + 'be ordered by. No column exists to order by (a formula is computed on read, so no '
+ + 'driver materialises one), so the ORDER BY reached the driver, found nothing, and '
+ + 'the unknown-column backstop returned the rows unordered.\n\n'
+ + 'Ruled 2026-08-10 on #7095: an ORDER BY the engine cannot apply is a 4xx with '
+ + 'guidance prose at the public boundary, never a silent drop — the same direction as '
+ + 'the analytics dataset refusal envelope and the #6924 sort-hint prescription. The '
+ + "engine's documented internal-caller tolerance (`assertProjectionFieldsExist`'s "
+ + 'docblock) was to survive only behind a pinned internal path, and only if a MEASURED '
+ + 'internal call site relied on it. The #7095 sweep of every in-tree `orderBy` reaching '
+ + 'the engine directly — hooks, flows, reports, queue/job adapters, sharing, metadata '
+ + 'loaders, expand sub-reads — found NONE: every hardcoded internal sort names a real '
+ + 'stored column (`created_at`, `updated_at`, `version`, `priority`, `scheduled_for`, '
+ + '`started_at`, `next_run_at`, `recorded_at`, `id`), and no shipped object in the repo '
+ + 'declares a `formula` field at all. So no internal path shipped, and there is no flag '
+ + 'to opt back into the drop.\n\n'
+ + 'This is a CODE-path API, not stored metadata, so — like '
+ + '`hook-register-empty-object-target-refused` at this step — there is no `sys_metadata` '
+ + 'row for the D2 chain to rewrite and the ledger entry is the notification channel. '
+ + 'No mechanical rewrite exists in either direction: the platform cannot invent the '
+ + 'stored column the remedy prescribes, and it must not sort post-hoc instead — '
+ + '`driver.find` has already applied `limit` / `offset`, so re-sorting after the '
+ + 'formulas are evaluated would reorder an ARBITRARY PAGE, which looks correct on small '
+ + 'result sets and is wrong the moment pagination is involved.\n\n'
+ + 'ONE AUTHOR-REACHABLE SURFACE reaches this indirectly and is why it is not purely a '
+ + "code-side note: a saved report's `query.orderBy` (`sys_saved_report`) is forwarded "
+ + 'verbatim into `engine.find` by `plugin-reports`, bypassing the ingress gate. A '
+ + 'report authored to sort by a formula field used to run and return rows in an '
+ + 'arbitrary order; it now fails loudly, with the remedy in the message. One further '
+ + 'path is deliberately NOT a refusal: a nested `expand` sort raises this refusal '
+ + 'inside `expandRelatedRecords`, whose pre-existing graceful-degradation `catch` '
+ + 'swallows every expand failure and retains the raw foreign keys — so that path moves '
+ + 'from silent to OBSERVABLE (a warning naming the field and the fix) rather than '
+ + 'refusing. Reversing that backstop is a separate decision on all expand failure '
+ + 'modes. #7095, #6994, #6924, #4226, #4256, #3821, ADR-0112.',
+ acceptanceCriteria:
+ 'No `engine.find` / `engine.findOne` call site sorts by a `formula` field, and no saved '
+ + "report's `query.orderBy` names one — grep your report definitions for an `orderBy` "
+ + 'field whose object declares it as a `formula`, and denormalise it onto a stored '
+ + 'column written when the source changes. A `summary` / rollup field needs no action: '
+ + 'it has a real maintained column and sorts correctly. Reads complete with no '
+ + '`INVALID_SORT` naming a formula field, and no "Failed to expand relationship field" '
+ + 'warning whose error text names one.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.enhanced-api-error-field-errors-renamed.ts b/packages/spec/src/migrations/entries/semantic/17.enhanced-api-error-field-errors-renamed.ts
new file mode 100644
index 0000000000..f9f6539153
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.enhanced-api-error-field-errors-renamed.ts
@@ -0,0 +1,21 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'enhanced-api-error-field-errors-renamed',
+ surface: 'api.enhancedApiError.fieldErrors',
+ replacement: 'fields',
+ reason:
+ 'The wire has always carried `fields` — the validators, import coercion, ' +
+ 'validation-failure.ts, @objectstack/client and the console\'s field-error extractor ' +
+ 'all say `fields`, and nothing ever emitted `fieldErrors`, so a reader keying on it ' +
+ 'was reading a field no server sent (ADR-0078\'s silently-inert declaration, on the ' +
+ 'error envelope). This is a RESPONSE surface: no stack, example or template carries ' +
+ 'the key, so there is no source for the chain to rewrite — the schema tombstones it ' +
+ 'via retiredKey() and consumers move their read themselves. ADR-0114 D4, #3977.',
+ acceptanceCriteria:
+ 'No consumer reads `error.fieldErrors`; per-field validation detail is read from ' +
+ '`error.fields`, and constructing an EnhancedApiError with `fieldErrors` fails to parse ' +
+ 'with the rename prescription instead of silently losing the array.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.etl-pipeline-layer-retired.ts b/packages/spec/src/migrations/entries/semantic/17.etl-pipeline-layer-retired.ts
new file mode 100644
index 0000000000..3072806d9d
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.etl-pipeline-layer-retired.ts
@@ -0,0 +1,64 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'etl-pipeline-layer-retired',
+ surface:
+ 'automation.etlPipeline / automation.etlPipelineRun / automation.etlSource / '
+ + 'automation.etlDestination / automation.etlTransformation (the whole L2 layer of '
+ + 'automation/etl.zod.ts, its four enums and the `ETL` factory — 9 defs, 27 exported '
+ + 'names)',
+ replacement:
+ '(removed — no protocol surface replaces it, deliberately. Layer by layer: '
+ + 'connector-attached synchronisation is `ConnectorSchema.syncConfig` '
+ + '(`integration/connector.zod.ts`), which IS parsed and executed; per-field value '
+ + 'transformation on import is `shared/mapping.zod.ts`, whose `transform` is applied '
+ + 'row by row by the REST import path and recorded key by key in '
+ + '`packages/spec/liveness/mapping.json`; scheduling is `system/job.zod.ts`. What has '
+ + 'NO replacement is multi-source, multi-stage movement with joins and aggregations — '
+ + 'because it never had an implementation either. It returns through the ENFORCE route: '
+ + 'the engine first, the vocabulary second)',
+ reason:
+ 'The reading #4738 used to retire L1 `DataSyncConfig`, re-measured one layer up and '
+ + 'identical: narrative-only. No engine ever parsed, scheduled or executed an '
+ + '`ETLPipeline`. Measured on origin/main immediately before the removal: the only '
+ + 'non-spec references in this repo are two fumadocs-generated documentation sources '
+ + '(`apps/docs/.source/*.ts`), not executors; objectui has no reference at all; there '
+ + 'is no `liveness/etl.json` or `pipeline.json`, so no ADR-0049 gate ever had a reading '
+ + 'on it — while the same file family\'s EXECUTED half does have one '
+ + '(`liveness/mapping.json`), which is the contrast that makes the absence meaningful '
+ + 'rather than an oversight. The `etl` string in this registry was the one untested '
+ + 'link the finding named, and it is not a loader path: it was the id of the #4962 '
+ + 'retry-vocabulary entry, absorbed here. '
+ + 'The layer was ADR-0078\'s asymmetry in its purest form — an author could write a '
+ + 'complete ten-stage pipeline, get no error, and get no execution. It was also '
+ + 'advertised: `packages/spec/docs/SYNC_ARCHITECTURE.md` named `ETLPipeline` as the '
+ + 'recommended destination for authors displaced by the L1 retirement (#4738) and '
+ + 'listed ten transformation types with copyable examples down to '
+ + '`script | Custom JavaScript/Python`. That document is rewritten in the same change; '
+ + 'a retirement whose own doc still recommends the retired layer is self-contradictory, '
+ + 'and forwarding L1\'s authors to a second layer with no executor was the defect '
+ + 'compounding rather than closing. '
+ + '⚠️ `etl-retry-converged-onto-retry-policy` (#4962) is SUBSUMED here, the '
+ + '#4657/#4834/#5055 way: both land in the unreleased protocol 17, so composed, a '
+ + 'rename of `retry.maxAttempts` on a shape that does not survive the major has no '
+ + 'observable effect — and keeping both would tell an upgrader to rewrite a key on a '
+ + 'schema the same upgrade deletes. The `maxAttempts` `retiredKey()` tombstone goes '
+ + 'with the shape that carried it, which is strictly stronger than the tombstone: there '
+ + 'is no longer a `retry` block to author the key into. Route 3 — no carrier key, no '
+ + 'parse site, so no D2 conversion and no tombstone; RETIRED_DEFS_BY_MAJOR plus this '
+ + 'entry are the declaration. ADR-0049, ADR-0078, #6414.',
+ acceptanceCriteria:
+ 'No source imports `ETLPipeline`, `ETLPipelineParsed`, `ETLPipelineSchema`, '
+ + '`ETLPipelineRun(Schema)`, `ETLSource(Schema)`, `ETLDestination(Schema)`, '
+ + '`ETLTransformation(Schema)`, `ETLEndpointType(Schema)`, '
+ + '`ETLTransformationType(Schema)`, `ETLSyncMode(Schema)`, `ETLRunStatus(Schema)` or '
+ + 'the `ETL` factory from `@objectstack/spec/automation`; `tsc` reports TS2724/TS2305 '
+ + 'on any that survives. Every author who was pointed at L2 has been re-pointed by '
+ + 'name: SYNC_ARCHITECTURE.md no longer lists an L2 row, no longer recommends '
+ + '`ETLPipeline` as L1\'s destination and no longer advertises a transformation-type '
+ + 'table. The surviving layers still parse unchanged — a connector declaring '
+ + '`syncConfig` and an import declaring `mapping.transform` both behave exactly as they '
+ + 'did in 16.x.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.export-field-meta-constraints-retired.ts b/packages/spec/src/migrations/entries/semantic/17.export-field-meta-constraints-retired.ts
new file mode 100644
index 0000000000..d1d2797cd0
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.export-field-meta-constraints-retired.ts
@@ -0,0 +1,61 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'export-field-meta-constraints-retired',
+ surface:
+ '@objectstack/rest: ExportFieldMeta.required / .system / .readonly / .hasDefault / '
+ + '.min / .max / .minLength / .maxLength (the map built by `buildFieldMetaMap`, '
+ + 'reached as `PreparedImport.metaMap` from `prepareImportRequest`)',
+ replacement:
+ 'the object schema you already hold — read `fields[name].required` / `.system` / '
+ + '`.readonly` / `.defaultValue` / `.min` / `.max` / `.minLength` / `.maxLength` off '
+ + 'the same `ObjectSchema` you passed to `buildFieldMetaMap`, which is where the '
+ + 'ENGINE reads them and therefore the only copy that cannot drift',
+ reason:
+ 'ADR-0049 enforce-or-remove. These eight were never a source of truth: '
+ + '`buildFieldMetaMap(schema)` DERIVED each one from the very `schema` its caller '
+ + 'passed in, so the map carried a second copy of facts the caller already held. '
+ + "They existed for exactly one consumer — the import dry run's hand-copied "
+ + 'pre-check mirror (`firstMissingRequiredField` / `firstConstraintViolation`, '
+ + 'framework#3956) — and #4633 ruling D retired that mirror (PR #6532): the dry run '
+ + "now asks `DataProtocol.validateData` for the engine's verdict, which reads the "
+ + "object's own schema. That left all eight computed on every import and read by "
+ + 'NOTHING, which is the declared-and-unread shape ADR-0049 exists for; a constraint '
+ + 'vocabulary standing next to the presentation one with no enforcer behind it is '
+ + 'precisely the thing an AI-authored consumer mistakes for a contract. Verified '
+ + 'zero-reader before removal, per key and by type, across this repo (`packages/rest` '
+ + 'itself, and all five in-repo dependents of `@objectstack/rest`: runtime, cli, '
+ + "verify, plugin-auth, plugin-dev) and the `objectui` sibling; plugin-auth's "
+ + 'identity import forwards `prepared.metaMap` into `runImport` but reads only the '
+ + 'presentation keys through `coerceRow`. '
+ + 'Why this needs a ledger entry despite that sweep: it is the `findStream` (#4484) / '
+ + '`IStorageService.list` (#5540) / `actor-user-roles-to-positions` (#6011) '
+ + 'disposition — a published TS surface with NO spec schema, so there is no '
+ + '`retiredKey()` tombstone and no parse rejection that could carry a prescription, '
+ + 'and the ledger is the only channel that reaches an upgrader. It is if anything '
+ + 'blinder than those three: the keys shipped in a FINAL release (`@objectstack/rest` '
+ + '14.5.0) and have been published in every release since, and because they were '
+ + 'OPTIONAL keys on an interface that itself survives, a JavaScript consumer reading '
+ + '`meta.required` after the upgrade gets `undefined` with no error at all — tsc '
+ + 'reports at the read site only for a typed consumer. '
+ + 'Why D3 semantic and not a D2 conversion: there is nothing to convert. No authored '
+ + 'or stored metadata changes shape — `required` / `min` / `maxLength` and the rest '
+ + 'remain fully authorable on a field definition and fully enforced by the engine, '
+ + 'which is where they always lived. The only place these eight are ever spelled is '
+ + "inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can "
+ + 'reach them. ADR-0049 / ADR-0087, #6536 (the sweep PR #6532 deliberately deferred).',
+ acceptanceCriteria:
+ 'No code of yours reads any of the eight off a `buildFieldMetaMap` / '
+ + '`prepareImportRequest` result. Grep your sources for `.required` / `.hasDefault` / '
+ + '`.minLength` / `.maxLength` / `.min` / `.max` / `.system` / `.readonly` on an '
+ + '`ExportFieldMeta`-typed value; each hit moves to the object schema you already '
+ + 'passed in. ⚠️ Prove it against a RUN, not against tsc: these were optional keys, '
+ + 'so an untyped or `any`-typed read compiles clean and silently becomes `undefined` '
+ + '— assert that the constraint your code acts on is still observed on a real import, '
+ + 'not merely that the build is green. Note `hasDefault` has no one-to-one '
+ + "replacement key: it was the derived predicate `defaultValue != null`, mirroring the "
+ + "engine's `applyFieldDefaults` gate, so read `fields[name].defaultValue` and apply "
+ + 'that same `!= null` test yourself.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.filter-regex-options-retired.ts b/packages/spec/src/migrations/entries/semantic/17.filter-regex-options-retired.ts
new file mode 100644
index 0000000000..dce0841d3c
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.filter-regex-options-retired.ts
@@ -0,0 +1,66 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'filter-regex-options-retired',
+ // No backticks in `surface` — build-upgrade-guide.ts renders it inside a
+ // code span already, and a nested backtick would close it.
+ surface:
+ 'data.filter $regex / $options — in a STORED filter (dashboard widget filter and '
+ + 'globalFilters, report runtimeFilter, page and component filter, solution-blueprint '
+ + 'filter), and equally in the where clause of a query request',
+ replacement:
+ '$icontains for the case-insensitive substring match this was almost always used '
+ + 'for, or $contains for a case-sensitive one — a pattern that genuinely needs a '
+ + 'regular expression has no filter-level replacement',
+ reason:
+ '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 #5701 itself, 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 #4706 ruling (B), not just the driver one: the contract half '
+ + '(#5701 — the `$icontains` declaration, the `$contains` family pinned '
+ + 'case-sensitive, and the `RETIRED_FILTER_OPERATORS` prescriptions) landed before the '
+ + 'ADR-0087 disposition gate (#6148) existed and so was never asked for a ledger entry; '
+ + 'the driver half (#5702) is where the refusal became executable. One surface, one '
+ + 'entry, registered from the half that made it observable. ADR-0049 / ADR-0087, '
+ + '#4706 / #5701 / #5702.',
+ acceptanceCriteria:
+ '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.',
+};
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
new file mode 100644
index 0000000000..37228b4a2c
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.flow-retry-max-retries-required.ts
@@ -0,0 +1,34 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+// `etl-retry-converged-onto-retry-policy` (#4962) was registered in this step and
+// ABSORBED by `etl-pipeline-layer-retired` (#6414), the §0 same-major
+// rule: both land in the unreleased protocol 17, and composed, the rename
+// `ETLPipeline.retry.maxAttempts` -> `maxRetries` has no observable effect
+// because the shape carrying it does not survive the major. Leaving both
+// would tell an upgrader to rewrite a key on a schema this same upgrade
+// deletes, and would break the fixture-disjointness the replay contract
+// asserts. The `agent.knowledge` / `WidgetManifest.performance` precedent:
+// a tombstone goes with the shape that carried it, which is strictly
+// stronger than the tombstone.
+export const entry: SemanticMigration = {
+ id: 'flow-retry-max-retries-required',
+ surface: "flow.errorHandling.maxRetries (under strategy: 'retry')",
+ replacement: 'an explicit count >= 1 (e.g. maxRetries: 3), or strategy: \'fail\'',
+ 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 ' +
+ '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.',
+ acceptanceCriteria:
+ "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.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.hook-context-session-roles-retired.ts b/packages/spec/src/migrations/entries/semantic/17.hook-context-session-roles-retired.ts
new file mode 100644
index 0000000000..d8b9d16588
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.hook-context-session-roles-retired.ts
@@ -0,0 +1,41 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'hook-context-session-roles-retired',
+ surface: 'data.hookContext.session.roles',
+ replacement:
+ '(removed — gate on `session.userId` / `session.isSystem`; for PRIVILEGE ask the '
+ + 'security service, which reads `permissions` / `positions` / posture off the '
+ + 'execution context, ADR-0095 D3)',
+ reason:
+ '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. #4839 (PR #5049) '
+ + 'removed the readers; 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` (#4579) / `activationEvents` (#4657) 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, #3733 / ADR-0104), so a '
+ + 'consumer that parses a context it was handed still meets the prescription. '
+ + 'ADR-0049, #5050.',
+ acceptanceCriteria:
+ 'No hook reads `ctx.session.roles`; caller gating uses `ctx.session.userId` / '
+ + '`ctx.session.isSystem`, and privilege comes from the security service '
+ + '(`permissions` / `positions` / posture). Constructing a HookContext session with '
+ + '`roles` fails `tsc` (the input type is `never`) and fails `HookContextSchema.parse` '
+ + 'with the retirement prescription instead of being silently stripped. Nothing '
+ + 'regresses at runtime: the key had no producer, so no decision anywhere ever saw a '
+ + 'value in it.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.hook-register-empty-object-target-refused.ts b/packages/spec/src/migrations/entries/semantic/17.hook-register-empty-object-target-refused.ts
new file mode 100644
index 0000000000..2a2230986f
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.hook-register-empty-object-target-refused.ts
@@ -0,0 +1,53 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'hook-register-empty-object-target-refused',
+ surface:
+ "engine.registerHook(event, handler, { object: '' | [] | [''] }), and a scope whose "
+ + '`excludeObjects` cancels its `object` entirely',
+ replacement:
+ "name the object(s) — `object: 'account'` / `object: ['account', 'contact']` — or, for "
+ + "a global hook, `object: '*'` or no `object` key at all; for a cancelled scope, widen "
+ + '`object` or drop the overlapping names from `excludeObjects`',
+ reason:
+ '#4281 ruled that an empty hook target is not "no target" and closed the shape at the '
+ + "two METADATA doors — `HookSchema.object`'s refine and `hook-binder.ts`'s "
+ + '`normalizeObjects`. `engine.registerHook`, the CODE door, goes through neither, so '
+ + 'all three spellings still registered, each producing a defect the author did not '
+ + "write: `''` is FALSY, so the allow face was skipped entirely and the entry became a "
+ + "GLOBAL hook (#4281's headline failure mode — blank intent taking the broadest "
+ + "possible blast radius); `[]` and `['']` are truthy but admit no object name, so the "
+ + 'entry could never fire. #5928 then added the `excludeObjects` face, which brought a '
+ + 'fourth shape reached by arithmetic rather than by one bad name: an `object` list '
+ + 'every member of which is also excluded admits nothing, so that entry can never fire '
+ + 'either. All four are ADR-0078 silently-inert declarations, and all four are now '
+ + 'refused at REGISTRATION.\n\n'
+ + 'No mechanical rewrite exists, in either direction. The refused values carry no '
+ + "recoverable intent — `object: ''` could have meant `'*'` (what it actually did) or a "
+ + 'specific object name the author forgot to fill in, and those are opposite '
+ + 'registrations; choosing between them is a judgment the chain cannot make. Nor could '
+ + "the MATCHING read be changed instead: teaching the matcher that `''` is an "
+ + 'unmatchable name would silently convert a hook firing on every object into one '
+ + 'firing on none — the same class of defect pointing the other way, which is why '
+ + '#5928 declined to do it in passing.\n\n'
+ + '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. #6573, #4281, #4001, '
+ + '#5928, ADR-0078.',
+ acceptanceCriteria:
+ '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.',
+};
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
new file mode 100644
index 0000000000..b195cc7cdf
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.http-server-runtime-vocabulary-retired.ts
@@ -0,0 +1,56 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'http-server-runtime-vocabulary-retired',
+ 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)',
+ 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 '
+ + '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.',
+ acceptanceCriteria:
+ '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.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.import-run-automations-declared-default-corrected.ts b/packages/spec/src/migrations/entries/semantic/17.import-run-automations-declared-default-corrected.ts
new file mode 100644
index 0000000000..101551552a
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.import-run-automations-declared-default-corrected.ts
@@ -0,0 +1,68 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'import-run-automations-declared-default-corrected',
+ // No backticks in `surface` — build-upgrade-guide.ts renders it inside a
+ // code span AND a table cell (see the note on `spec-type-alias-input-suffix-retired`).
+ surface:
+ 'api.ImportRequest runAutomations — the declared default of the key on BOTH import '
+ + 'bodies, POST /api/v1/data/:object/import (ImportRequest) and its async twin POST '
+ + '/api/v1/data/:object/import/jobs (CreateImportJobRequest, which IS the same schema '
+ + 'object). It was declared default(false) and described as "off by default for '
+ + 'bulk"; it is now default(true), which is what the server has always done',
+ replacement:
+ 'an explicit runAutomations: false on any import request that is meant to load rows '
+ + 'without firing triggers/hooks. That spelling is unchanged and has always been the '
+ + 'only one the server read — what changes is that omitting the key now DECLARES what '
+ + 'it already DID. Callers who want automations on need write nothing',
+ reason:
+ '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.',
+ acceptanceCriteria:
+ 'Every import request of yours that must NOT fire triggers sends `runAutomations: '
+ + 'false` explicitly, rather than omitting the key and trusting the old declared '
+ + 'default. The check is worth doing precisely where it looks unnecessary: if you '
+ + 'build the body by parsing it through `ImportRequestSchema` (or the published JSON '
+ + 'Schema) and then send the PARSED object, your bulk loads were running with '
+ + 'automations OFF and will now run with them ON — that is the only class whose '
+ + 'behaviour changes, and it changes toward what an unvalidated caller always got. '
+ + '⚠️ Behaviour on the wire is deliberately UNCHANGED and should be verified as '
+ + 'such: a body that omits `runAutomations` fired triggers before this change and '
+ + 'fires them after, and `runAutomations: false` turns them off before and after. '
+ + 'Nothing starts being refused — the route never validated this body against the '
+ + 'schema and does not begin to. `dryRun` is unaffected and still runs NO automations '
+ + 'whatever the flag says (#6037).',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.job-retry-policy-constraints-tightened.ts b/packages/spec/src/migrations/entries/semantic/17.job-retry-policy-constraints-tightened.ts
new file mode 100644
index 0000000000..e3f1714d01
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.job-retry-policy-constraints-tightened.ts
@@ -0,0 +1,23 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'job-retry-policy-constraints-tightened',
+ surface: 'job.retryPolicy.maxRetries (> 10) / job.retryPolicy.backoffMultiplier (< 1)',
+ replacement: 'maxRetries <= 10, and backoffMultiplier >= 1',
+ reason:
+ 'The converged RetryPolicy (#4661) keeps the automation side\'s bounds, which the job '
+ + 'side never had: `maxRetries` is capped at 10 and `backoffMultiplier` floored at 1. '
+ + 'Neither has a lossless rewrite. Clamping `maxRetries: 20` to 10 would halve a '
+ + 'retry budget its author chose, and a `backoffMultiplier` below 1 describes a delay '
+ + 'that SHRINKS on each attempt — retrying a failing dependency ever faster, which is '
+ + 'the opposite of backoff and was never a shape the engine meant to offer. Both now '
+ + 'fail at parse time with the bound named, rather than being silently reinterpreted. '
+ + 'Choosing the replacement count (or accepting the cap) is the author\'s call.',
+ acceptanceCriteria:
+ 'Every job declaring `retryPolicy` parses: no `maxRetries` above 10 and no '
+ + '`backoffMultiplier` below 1 remain, and each adjusted value was re-chosen knowing a '
+ + 'retry re-runs the handler with its writes and callouts. No job fails to register '
+ + 'with the retry-policy bound prescription.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.notification-list-cursor-retired.ts b/packages/spec/src/migrations/entries/semantic/17.notification-list-cursor-retired.ts
new file mode 100644
index 0000000000..440936d265
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.notification-list-cursor-retired.ts
@@ -0,0 +1,77 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'notification-list-cursor-retired',
+ // No backticks in `surface` — build-upgrade-guide.ts renders it inside a
+ // code span AND a table cell (see the note on `spec-type-alias-input-suffix-retired`).
+ surface:
+ 'api.listNotifications cursor — the key on BOTH halves of GET /api/v1/notifications '
+ + '(ListNotificationsRequestSchema and ListNotificationsResponseSchema) and the cursor '
+ + 'argument of the client SDK call client.notifications.list(). The same entry covers '
+ + 'the limit default: the request schema no longer declares default(20)',
+ replacement:
+ 'a larger `limit` — the route answers the newest N notifications and has no page 2. '
+ + 'There is no replacement for `cursor`, deliberately: nothing ever minted one, so no '
+ + 'caller holds a value to carry over. Callers that looped on it were re-reading the '
+ + 'first window and should read one window sized to what they display (the Console '
+ + 'bell polls exactly this way). For the removed `limit` default, send the number you '
+ + 'want explicitly if you were relying on 20 — omitting it takes the server window, '
+ + 'which is 50 on the platform inbox and clamped into 1..200, and has been since '
+ + 'before the declaration existed',
+ reason:
+ '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.',
+ acceptanceCriteria:
+ '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.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.plugin-activation-events-retired.ts b/packages/spec/src/migrations/entries/semantic/17.plugin-activation-events-retired.ts
new file mode 100644
index 0000000000..f9984761fc
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.plugin-activation-events-retired.ts
@@ -0,0 +1,51 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'plugin-activation-events-retired',
+ surface:
+ 'kernel.dynamicLoadRequest.activationEvents / studio.studioPluginManifest.activationEvents',
+ replacement:
+ '(removed — delete the key. Every plugin activates immediately on load/registration, '
+ + 'which is the only behaviour that has ever existed; `activate()` still runs at '
+ + 'registration time. Lazy activation, if built, returns via the enforce route of '
+ + 'ADR-0049 through a new ADR, with a vocabulary its executor actually honours)',
+ reason:
+ 'Both `activationEvents` keys — and the `ActivationEventSchema` trigger vocabulary '
+ + 'they embedded (`onCommand` / `onRoute` / … / `onView` after the #4653 convergence) — '
+ + '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 '
+ + '(#3950: an exported schema with no consumer is read as a capability). #4657. '
+ + 'SUPERSEDED ON THE KERNEL SIDE by #4834 (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.',
+ acceptanceCriteria:
+ '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 #4834 criterion 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.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.plugin-manifest-loading-retired.ts b/packages/spec/src/migrations/entries/semantic/17.plugin-manifest-loading-retired.ts
new file mode 100644
index 0000000000..c0368f9d66
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.plugin-manifest-loading-retired.ts
@@ -0,0 +1,59 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'plugin-manifest-loading-retired',
+ surface:
+ 'manifest.loading (the whole block: strategy / preload / codeSplitting / dynamicImport / '
+ + 'initialization / dependencyResolution / hotReload / caching / sandboxing / monitoring)',
+ replacement:
+ 'nothing to re-declare — delete the key. Plugins are composed at boot: `defineStack` '
+ + 'registers them and the kernel runs `init` then `start` in an order topologically '
+ + "resolved from each composed plugin's own `dependencies` / `optionalDependencies` "
+ + '(`resolvePluginOrder` in `packages/core/src/plugin-order.ts`). For the isolation '
+ + '`loading.sandboxing` appeared to configure, use the plugin trust tier '
+ + '(`manifest.runtime`, ADR-0025 §3.6) and the manifest permission declarations, which '
+ + 'are the surfaces the platform actually enforces',
+ reason:
+ 'ADR-0049 enforce-or-remove; maintainer ruling 2026-08-04 on #4914. The block declared a '
+ + 'complete plugin loading policy and NOTHING read it. A bare-name scan of all three '
+ + 'repos — objectstack, cloud (measured 2026-08-09) and objectui (measured at pickup), '
+ + 'each with a control probe proving the scan saw the tree — put every hit inside '
+ + '`packages/spec` itself: this module\'s own declaration, its own unit tests, the '
+ + '`Manifest.loading` embed and the generated artifacts. `manifest.loading.*` had zero '
+ + 'readers in `packages/core`, `packages/runtime` and `packages/metadata`. So the key '
+ + 'parsed, entered the manifest, and changed nothing — #3950, at the scale of a whole '
+ + 'block. What made it outrank ordinary inert-key cleanup is `sandboxing`: it declared '
+ + 'process / vm / iframe / web-worker isolation, IPC transports and an `allowedServices` '
+ + 'ACL, so an AI author (ADR-0033) reading that vocabulary concluded the platform '
+ + 'isolates plugins, wrote the config, and received a clean parse and zero isolation. An '
+ + 'inert security control is worse than an absent one because it is believed. Hot reload '
+ + 'was additionally a TWO-SOURCE defect: the docs pointed at this dead '
+ + '`PluginHotReloadSchema` while the only implementation body, `HotReloadManager` '
+ + '(`packages/core/src/hot-reload.ts`), reads a different vocabulary — '
+ + '`HotReloadConfigSchema` in `plugin-lifecycle-advanced.zod.ts`. Ruling §2 converges on '
+ + 'the surviving side: that schema is KEPT as the starting point for a future enforce '
+ + 'decision (it has an implementation body but no runtime composes it yet), and '
+ + 'enforcing it is deliberately a separate decision, not this retirement. '
+ + 'Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and '
+ + '`applyConversionsToStoredItem` maps a metadata type onto one of its collections. A '
+ + 'package manifest is neither — `PLURAL_TO_SINGULAR` has no `packages` / `plugins` '
+ + 'entry, so a manifest is not a stack collection member and a stored manifest row '
+ + 'passes that seam through unchanged. A conversion would be a transform with no seam '
+ + 'that ever runs.',
+ acceptanceCriteria:
+ 'No `objectstack.plugin.json` and no stored package manifest carries a `loading` key. '
+ + 'The enforced channel is the one place a manifest is parsed with an author present: '
+ + '`os plugin build` runs `ManifestSchema.safeParse` and exits non-zero, printing the '
+ + 'tombstone prescription, so a manifest still declaring `loading` fails its build '
+ + 'rather than shipping. TypeScript authors get it earlier still — `loading` is typed '
+ + '`never`, so assigning it is a `tsc` error. ⚠️ Runtime behaviour is deliberately '
+ + 'UNCHANGED and must be verified as such: nothing ever read the block, so removing it '
+ + 'removes no behaviour. A package ALREADY INSTALLED whose stored manifest carries '
+ + '`loading` keeps working — the registry\'s `validate()` is an explicit diagnostic and '
+ + 'not a gate (it catches, logs `[metadata_spec_invalid]`, and registers the item '
+ + 'anyway, deliberately, so bad metadata is never a data outage), so such a row '
+ + 'degrades to one log line at registration rather than a boot failure. Clear it by '
+ + 'deleting the key from the source manifest and reinstalling.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.plugin-runtime-family-retired.ts b/packages/spec/src/migrations/entries/semantic/17.plugin-runtime-family-retired.ts
new file mode 100644
index 0000000000..659cdc88cf
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.plugin-runtime-family-retired.ts
@@ -0,0 +1,52 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'plugin-runtime-family-retired',
+ surface:
+ 'kernel.dynamicLoadRequest / kernel.dynamicUnloadRequest / kernel.dynamicPluginResult '
+ + '/ kernel.pluginSource / kernel.dynamicPluginOperation',
+ replacement:
+ '(removed — there is no replacement shape, because there is no operation to describe. '
+ + 'Plugins are composed at boot: `defineStack` registers them and the kernel runs '
+ + 'register → init → start; the set is fixed until the process restarts. Delete the '
+ + 'import and the value. Runtime plugin loading, if it is ever built, returns via the '
+ + 'enforce route of ADR-0049 through a new ADR — loader first, vocabulary second)',
+ reason:
+ 'The five schemas declared the "Dynamic Loading" capability — runtime load / unload / '
+ + 'reload of plugins without a kernel restart, with sandboxing, integrity hashes, '
+ + 'drain strategies and dependent-cascade policy — and NOTHING implemented it. A '
+ + 'bare-name scan of objectstack, cloud and objectui found zero references outside '
+ + "this package's own declaration, its unit tests and the generated artifacts: no "
+ + 'runtime ever received a `DynamicLoadRequest`, performed a load/unload, or produced '
+ + 'a `DynamicPluginResult`. That is the ADR-0049 false-compliance shape at its most '
+ + 'inviting to an AI author (ADR-0033), who reads `DynamicLoadRequestSchema` in the '
+ + 'published IDE bundle as proof the platform hot-loads plugins and constructs a '
+ + 'request that parses clean and is received by nobody (#3950: an exported schema '
+ + 'with no consumer is read as a capability). The #3896 follow-up removed this '
+ + "module's discovery/sandbox config island and left these five in place explicitly — "
+ + '"operation contracts, not security promises; the enforce-or-remove call on them is '
+ + 'a design decision rather than a correction" — but that suspension lived only in a '
+ + 'changeset paragraph with no issue carrying it. #4834 is that decision, answered '
+ + 'REMOVE. `experimental` was considered and rejected: it is only `.describe()` prose '
+ + 'and cannot stop an import, the weakest of the three ADR-0049 channels. None of the '
+ + 'five is stored metadata — they are root request/result payload shapes embedded in '
+ + 'no parent schema and parsed against no metadata document — so no `sys_metadata` '
+ + 'row can carry one and there is no source for the D2 chain to rewrite; this entry '
+ + 'is the D3 record. The removal also subsumes the kernel half of '
+ + '`plugin-activation-events-retired` (#4657): that tombstone goes with the shape '
+ + 'that carried it. ADR-0049, #4834.',
+ acceptanceCriteria:
+ 'No code imports `DynamicLoadRequestSchema`, `DynamicUnloadRequestSchema`, '
+ + '`DynamicPluginResultSchema`, `PluginSourceSchema`, `DynamicPluginOperationSchema` '
+ + 'or any of their type aliases (`DynamicLoadRequest`, `DynamicUnloadRequest`, '
+ + '`DynamicPluginResult`, `PluginSource`, `DynamicPluginOperation`, '
+ + '`DynamicLoadRequestInput`, `DynamicUnloadRequestInput`) from '
+ + '`@objectstack/spec` or `@objectstack/spec/kernel` — every one is TS2305 after '
+ + 'upgrade, on every public entry (pinned by symbol identity in '
+ + '`plugin-runtime-retirement.test.ts`). Nothing regresses at runtime, because '
+ + 'nothing called anything: a caller that believed it was hot-loading a plugin was '
+ + 'already only building an object. Boot-time composition through `defineStack` is '
+ + 'unchanged.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.query-array-string-agg-retired.ts b/packages/spec/src/migrations/entries/semantic/17.query-array-string-agg-retired.ts
new file mode 100644
index 0000000000..b447c5275a
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.query-array-string-agg-retired.ts
@@ -0,0 +1,33 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'query-array-string-agg-retired',
+ surface: "data.query.aggregations[].function ('array_agg' / 'string_agg')",
+ replacement:
+ 'an ordinary `fields` query, shaped in the caller — or a stored field that materialises '
+ + 'the roll-up. For a deduplicated COUNT the live spelling is unchanged: '
+ + '`count_distinct` stays declared',
+ reason:
+ 'The stored half of this retirement is a conversion '
+ + '(`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. '
+ + '`QueryAST` is never stored in stack metadata — it is the client SDK builder\'s output '
+ + 'and the `POST /data/:object/query` body — so there is no source for the chain to '
+ + 'rewrite and callers move their own queries. Both values were declared-but-unlowered '
+ + 'on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso '
+ + '`RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller '
+ + 'following the schema against a SQL datasource got a refusal, not an array. They did '
+ + 'run on `driver-mongodb` and on the engine\'s in-memory fallback, which is what makes '
+ + 'this the one narrowing in the batch that removes reachable behaviour: an aggregation '
+ + 'that worked on one backend and failed on another is exactly the unpredictability the '
+ + 'ruling ended, and #5499 has both of those backends frozen. `count_distinct` was '
+ + 'deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049\'s '
+ + 'enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049, #6188.',
+ acceptanceCriteria:
+ 'No caller sends `array_agg` or `string_agg` in `aggregations[].function`; list-style '
+ + 'roll-ups are assembled by the caller from an ordinary `fields` query, or materialised '
+ + 'as a stored field. A query still carrying either value fails to parse with the '
+ + 'removal prescription naming it, and authoring it is a `tsc` error at the call site; '
+ + '`count_distinct` continues to parse and is unaffected.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.query-cursor-retired.ts b/packages/spec/src/migrations/entries/semantic/17.query-cursor-retired.ts
new file mode 100644
index 0000000000..c65b58b664
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.query-cursor-retired.ts
@@ -0,0 +1,26 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'query-cursor-retired',
+ surface: 'data.query.cursor',
+ replacement:
+ 'a `where` predicate on the sort key — `where: { created_at: { $gt: last.created_at } }` '
+ + 'with the matching `orderBy` (the documented manual-keyset pattern)',
+ reason:
+ 'The `cursor` key promised keyset pagination and no driver implemented it: the cursor '
+ + 'was accepted and ignored, so every page came back identical — a caller looping '
+ + '"until hasMore is false" never terminates. Worse than inert, it had a shipped public '
+ + 'producer (`QueryBuilder.cursor()`, removed with the key). The caller-built '
+ + '`Record` shape also leaks sort/storage detail and squats on the '
+ + 'reserved REST parameter set; a first-class cursor, if ever designed, will be a '
+ + 'response-minted opaque token — a different API, so keeping this one preserved a '
+ + 'wrong design rather than a roadmap. A REQUEST surface, never stored; nothing to '
+ + 'rewrite. ADR-0049 / ADR-0078, #4286.',
+ acceptanceCriteria:
+ 'No caller sends `cursor` and no SDK call site uses `QueryBuilder.cursor()`; deep '
+ + 'pagination expresses the keyset as a `where` predicate on the sort key. A query '
+ + 'still carrying `cursor` fails to parse with the removal prescription, and authoring '
+ + 'it is a `tsc` error.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.query-distinct-retired.ts b/packages/spec/src/migrations/entries/semantic/17.query-distinct-retired.ts
new file mode 100644
index 0000000000..3e12ccdcca
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.query-distinct-retired.ts
@@ -0,0 +1,26 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'query-distinct-retired',
+ surface: 'data.query.distinct',
+ replacement:
+ '`groupBy` for unique combinations; the `count_distinct` aggregation for deduplicated '
+ + "counts; the SQL/memory drivers' `distinct(object, field)` door for one column's values",
+ reason:
+ 'The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it '
+ + 'was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list '
+ + 'path treated a distinct query as not countable and silently degraded '
+ + '`total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND '
+ + 'worse pagination metadata, and a side effect that "confirmed" the flag was doing '
+ + 'something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with '
+ + 'the key). The count suppression is deleted in the same change — `total` is truthful '
+ + 'for those queries again. A REQUEST surface, never stored; nothing to rewrite. '
+ + 'ADR-0049 / ADR-0078, #4286.',
+ acceptanceCriteria:
+ 'No caller sends `distinct` and no SDK call site uses `QueryBuilder.distinct()`; '
+ + 'deduplication goes through `groupBy` / `count_distinct` / the drivers\' `distinct()` '
+ + 'door. A query still carrying the key fails to parse with the removal prescription, '
+ + 'and the REST list response reports a real `total` for queries that used to send it.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.query-field-node-object-form-retired.ts b/packages/spec/src/migrations/entries/semantic/17.query-field-node-object-form-retired.ts
new file mode 100644
index 0000000000..c548e6dc78
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.query-field-node-object-form-retired.ts
@@ -0,0 +1,24 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'query-field-node-object-form-retired',
+ surface: 'data.query.fields',
+ replacement: "expand (`expand: { owner: { object: 'user', fields: ['name'] } }`), or a dotted path for a single related column (`fields: ['owner.name']`)",
+ reason:
+ 'The `FieldNode` union declared a nested-select object form `{ field, fields, alias }` that '
+ + 'was inert end to end: no producer emitted it, and no consumer read `.fields` or `.alias` '
+ + '— objectql\'s formula projection and known-field filters, driver-sql\'s `select()` and '
+ + 'driver-memory\'s projection all treat the list as `string[]`, driver-mongodb keyed its '
+ + 'projection with the entry itself, and the REST ingress stringified it. Nested selection '
+ + 'is `expand`, which the engine resolves via batch `$in` queries. This is a REQUEST '
+ + 'surface — `QueryAST` is never stored in stack metadata (no view, dataset or report '
+ + 'authors one), so there is no source for the chain to rewrite: the schema narrows to '
+ + '`z.string()` and callers move their own select lists. ADR-0049 / ADR-0078, #4196.',
+ acceptanceCriteria:
+ 'No caller puts an object in `fields[]`; related records are read through `expand` and '
+ + 'single related columns through dotted paths. A `fields` entry that is not a string '
+ + 'fails to parse with the removal prescription, and the list/query/export routes answer '
+ + '400 INVALID_FIELD naming the retired form instead of the field `"[object Object]"`.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.query-joins-retired.ts b/packages/spec/src/migrations/entries/semantic/17.query-joins-retired.ts
new file mode 100644
index 0000000000..ca248d3ec7
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.query-joins-retired.ts
@@ -0,0 +1,26 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'query-joins-retired',
+ surface: 'data.query.joins',
+ replacement:
+ "expand (`expand: { owner: { object: 'user', fields: ['name'] } }`), or a dotted "
+ + "`fields` path for a single related column (`fields: ['owner.name']`)",
+ reason:
+ 'The `joins` array was declared-but-inert: no engine or driver read `query.joins` '
+ + 'anywhere on the query path, so a query carrying it behaved exactly as if the key were '
+ + 'absent — while the name squatted on the reserved REST parameter set. Related-record '
+ + 'retrieval already has a live spelling (`expand`, resolved by the engine via batch '
+ + '`$in` queries), so the removal deletes the second, broken spelling rather than the '
+ + 'capability, and the orphaned `JoinNode`/`JoinType`/`JoinStrategy` cluster goes with '
+ + 'the key. A REQUEST surface — `QueryAST` is never stored in stack metadata — so there '
+ + 'is no source for the chain to rewrite; callers move their own queries. '
+ + 'ADR-0049 / ADR-0078, #4286.',
+ acceptanceCriteria:
+ 'No caller sends `joins`; related records are read through `expand` and single related '
+ + 'columns through dotted `fields` paths. A query that still carries `joins` fails to '
+ + 'parse with the removal prescription (even as an empty array), and authoring it is a '
+ + '`tsc` error at the call site.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.query-window-functions-retired.ts b/packages/spec/src/migrations/entries/semantic/17.query-window-functions-retired.ts
new file mode 100644
index 0000000000..02de089e65
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.query-window-functions-retired.ts
@@ -0,0 +1,26 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'query-window-functions-retired',
+ surface: 'data.query.windowFunctions',
+ replacement:
+ '`aggregations` + `groupBy` for request-level analytics; '
+ + '`SqlDriver.findWithWindowFunctions(object, query)` for embedders on a SQL datasource',
+ reason:
+ 'The `windowFunctions` array was declared-but-inert on the query path: `find()` never '
+ + 'applied a window function, so every OVER clause a caller declared was silently '
+ + 'dropped. The capability only ever ran behind `SqlDriver.findWithWindowFunctions()`, '
+ + 'a driver-level door that is not on the `IDataDriver` contract and whose flat input '
+ + 'shape (`{ function, alias, partitionBy?, orderBy? }`) the spec vocabulary never '
+ + 'matched — `WindowFunctionNodeSchema` declared `field`/`over`/`frame` members the door '
+ + 'never read, so that cluster is removed with the key rather than left as a false '
+ + 'affordance. A REQUEST surface, never stored; no source to rewrite. '
+ + 'ADR-0049 / ADR-0078, #4286.',
+ acceptanceCriteria:
+ 'No caller sends `windowFunctions` in a query; request-level analytics use '
+ + '`aggregations` + `groupBy`, and embedders needing OVER-clause SQL call the SQL '
+ + "driver's `findWithWindowFunctions` door directly. A query that still carries the key "
+ + 'fails to parse with the removal prescription naming that door.',
+};
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
new file mode 100644
index 0000000000..ba72a8c65d
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.rest-server-openapi31-block-removed.ts
@@ -0,0 +1,36 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'rest-server-openapi31-block-removed',
+ surface: 'restServer.openApi31',
+ replacement:
+ '(removed — no replacement key exists. Delete the key; for a real outbound webhook use '
+ + '`Webhook` from `@objectstack/spec/automation`. Config-driven OpenAPI 3.1 '
+ + 'webhooks/callbacks documentation returns, if ever, via the enforce route of ADR-0049 '
+ + 'through a new ADR)',
+ reason:
+ '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.',
+ 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 '
+ + '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.',
+};
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
new file mode 100644
index 0000000000..4807dd330a
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.sharing-execution-context-retired.ts
@@ -0,0 +1,55 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'sharing-execution-context-retired',
+ 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',
+ 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 '
+ + '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.',
+ acceptanceCriteria:
+ '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.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.spec-type-alias-input-suffix-retired.ts b/packages/spec/src/migrations/entries/semantic/17.spec-type-alias-input-suffix-retired.ts
new file mode 100644
index 0000000000..e9f098cc6f
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.spec-type-alias-input-suffix-retired.ts
@@ -0,0 +1,60 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'spec-type-alias-input-suffix-retired',
+ // Plain text, no markdown: build-upgrade-guide.ts renders this field inside a
+ // code span AND inside a table cell, so backticks here break both.
+ surface:
+ 'type alias: the 102 XInput names of @objectstack/spec '
+ + '(ConnectorInput, AppInput, PageInput, ActionInput, ServiceObjectInput, '
+ + 'ExecutionContextInput, TaskInput, … — 52 files across api/ automation/ data/ '
+ + 'identity/ integration/ kernel/ security/ system/ ui/)',
+ replacement:
+ 'the BARE name. ADR-0122 phase 2 moved the author state onto `X`, which makes `XInput` '
+ + 'a character-for-character synonym of it — the permanent synonym D3 forbids. Drop the '
+ + '`Input` suffix: `ConnectorInput` -> `Connector`. Symmetrically, a consumer that held '
+ + 'a PARSE RESULT under the bare name moves to `XParsed`, which phase 1 (16.x) already '
+ + 'declared for every schema whose two shapes differ, so the target name has existed for '
+ + 'a release. NINE `*Input` names are NOT retired and need no edit: `ExpressionInput`, '
+ + '`CronExpressionInput`, `TemplateExpressionInput` and `PredicateInput` are the bare '
+ + 'aliases of their own `…InputSchema`, and `FormFieldInput`, `QueryInput`, `FieldInput`, '
+ + '`ObjectStackDefinitionInput` and `NavigationItemInput` are composed (recursive or '
+ + '`Partial`-shaped) types no bare alias denotes.',
+ reason:
+ 'This entry exists for the reason `data-driver-find-stream-retired` (#4484), '
+ + '`storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) '
+ + 'exist, and it is the same disposition: the surface is a TYPESCRIPT NAME, never stack '
+ + 'metadata, so there is no source for a D2 conversion to rewrite and deliberately no '
+ + 'schema tombstone — an `XInput` alias never had a carrier key, never emitted a def, '
+ + 'and no `.parse()` ever saw it. Measured and verified rather than assumed: '
+ + '`json-schema/`, `json-schema.manifest/` and `authorable-surface/` are BYTE-IDENTICAL '
+ + 'across this change, because those generators enumerate runtime `z.ZodType` exports '
+ + 'and never read a type alias. So nothing left the published metadata surface and '
+ + 'RETIRED_DEFS_BY_MAJOR is deliberately untouched — an entry there would falsely claim '
+ + 'the metadata contract shrank. The enforced channel is tsc: the name is gone, so every '
+ + 'consumer gets TS2724/TS2305 naming the import. That is loud but MUTE about the '
+ + 'replacement — a compile error says `ConnectorInput` does not exist, not that '
+ + '`Connector` now means what it meant. The generated upgrade guide is the only channel '
+ + 'that carries the second half, which is precisely the #6048 gap ADR-0087 registration '
+ + 'exists to close. ⚠️ Deliberately NOT registered alongside it: the 1384 bare aliases '
+ + 'the same change FLIPPED from `z.infer` to `z.input`. Those names all still exist and '
+ + 'still resolve; what moved is which of a schema\'s two shapes they denote, and only '
+ + 'where the two differ (663 of 1384 — the rest are isomorphic and the flip is a no-op '
+ + 'there, pinned as such). A consumer holding an authored literal is made MORE correct '
+ + 'by it, silently; one holding a parse result gets a tsc error at the first defaulted '
+ + 'key it reads. Registering that as a rename would misdescribe it — no name was '
+ + 'retired — and the changeset carries its own FROM -> TO for it. ADR-0122 D8/D9, '
+ + '#6083 (PR #6279).',
+ acceptanceCriteria:
+ 'No source imports a name ending `Input` from `@objectstack/spec` except the nine listed '
+ + 'above: `rg "\\b\\w+Input\\b" --type ts` over consumer code resolves only to those. A '
+ + 'literal annotated with a bare spec type compiles while listing ONLY the keys the '
+ + 'author means — `const c: Connector = { name, label, type }` type-checks, which it did '
+ + 'not in 16.x — and a value read out of `XSchema.parse()` annotated with the bare name '
+ + 'no longer compiles at the first defaulted key it reads (TS18048/TS2532), the signal '
+ + 'that the annotation should be `XParsed`. `pnpm check:spec-parsed-alias` reports every '
+ + 'bare alias as `z.input` and refuses both a bare `z.infer` alias and a reintroduced '
+ + '`XInput` synonym.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.storage-service-list-retired.ts b/packages/spec/src/migrations/entries/semantic/17.storage-service-list-retired.ts
new file mode 100644
index 0000000000..30d37d2cd8
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.storage-service-list-retired.ts
@@ -0,0 +1,69 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'storage-service-list-retired',
+ surface: 'contracts.IStorageService.list',
+ replacement:
+ 'track the keys you wrote (sys_file / file-reference records, queryable through '
+ + 'ObjectQL with real pagination) instead of enumerating the bucket — and where no '
+ + 'such record exists, the cursor-shaped `list(prefix, { cursor, limit })` this '
+ + 'entry reserved, restored in #6781',
+ reason:
+ '`list(prefix)` was an OPTIONAL contract method documented as "List files in a '
+ + 'directory/prefix", and the two shipped adapters answered the same call with two '
+ + 'different semantics — both of them silently incomplete. `LocalStorageAdapter.list` '
+ + 'was a single-level `readdir`, so a nested key `a/b/c` was invisible under '
+ + '`list(\'a\')` (only `a/b` came back), and a subdirectory that `stat` succeeded on '
+ + 'was pushed into the result as a file, yielding a `StorageFileInfo` whose `size` is '
+ + 'a directory inode and which cannot be downloaded at all. `S3StorageAdapter.list` '
+ + 'was RECURSIVE (`ListObjectsV2` matches the whole key) and read neither '
+ + '`IsTruncated` nor `ContinuationToken`, so past 1000 objects the "all files" a '
+ + 'caller received was the first page, with no signal. One contract method, two '
+ + 'dialects, both quietly incomplete — and the first feature that genuinely needed to '
+ + 'enumerate a prefix (backup, orphan sweep, migration audit) would have got two '
+ + 'different answers on two deployments without an error on either. #5172 was nearly '
+ + 'that feature: it planned to drive attachment reclamation off '
+ + '`list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one '
+ + 'level down, and switched to queue-driven deferred work instead. Nothing consumed '
+ + 'it afterwards: the only in-repo call site was the `SwappableStorageService` '
+ + 'pass-through (which itself rejects when the active adapter has no `list`), and '
+ + 'REST, CLI and the storage routes never called it. Remove was chosen over '
+ + 'align-and-tighten (maintainer ruling, 2026-08-05, #5266): aligning would grow a '
+ + 'conformance surface nobody walks, while a prefix listing that cannot paginate is '
+ + 'the wrong signature to inherit — when a real caller needs enumeration it returns '
+ + 'cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases '
+ + '(nested keys, directory entries, >1000 objects) proving both backends agree. This '
+ + 'is a TS/API contract surface — a storage adapter is CODE, never stack metadata — '
+ + 'so there is no source for the chain to rewrite, and deliberately no schema '
+ + 'tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription '
+ + 'there would reach no one. The enforced channel is tsc, and it reports at the call '
+ + 'site. Same disposition, and the same reason, as '
+ + '`data-driver-find-stream-retired` (#4484). ADR-0049 / ADR-0087, #5540 '
+ + '(analysis #5266).',
+ acceptanceCriteria:
+ 'No code calls `storage.list(...)` on the `file-storage` service or on any '
+ + '`IStorageService` value. Code that needed "which files are under this prefix" '
+ + 'reads the records it wrote — `sys_file` / file-reference rows carry the storage '
+ + 'key and page deterministically through ObjectQL — rather than asking the bucket, '
+ + 'which is also the only form that stays correct past 1000 objects and across both '
+ + 'adapters. An adapter that still IMPLEMENTS `list` keeps compiling (an extra '
+ + 'method is not an error on a class) and is simply unreachable through the '
+ + 'contract, so deleting it is cleanup that can follow. The break is on the CALLER '
+ + 'side: `storage.list(...)` no longer type-checks, and a PROXY typed against '
+ + '`IStorageService` that forwards to `inner.list` is exactly such a caller — the '
+ + 'one in `@objectstack/service-storage` goes with the adapters (#5541). '
+ + '⚠️ AMENDED 2026-08-09 (#6781, maintainer ruling on cloud#1203, option B): the '
+ + 'RESERVED route in the paragraph above was taken. `list` exists again on the '
+ + 'contract, cursor-shaped — `list(prefix, { cursor, limit })` returning '
+ + '`{ items, nextCursor }` — because cloud had two first-party callers this repo '
+ + 'could not see when the measurement said "nothing calls it" (tenant attachment '
+ + 'reclamation, marketplace snapshot GC). This does NOT un-retire anything and the '
+ + 'acceptance criterion above is unchanged for what it actually governs: the '
+ + 'single-argument `list(prefix): StorageFileInfo[]` is gone for good, a call written '
+ + 'against it still fails to compile, and the two dialects it had are now pinned '
+ + 'against each other in `storage-adapter-list.conformance.test.ts` rather than left '
+ + 'to diverge. What changed for an upgrader is only the destination: prefer the '
+ + 'records you wrote, and reach for the restored member when there are none.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.ui-interaction-config-family-retired.ts b/packages/spec/src/migrations/entries/semantic/17.ui-interaction-config-family-retired.ts
new file mode 100644
index 0000000000..5613fe209d
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.ui-interaction-config-family-retired.ts
@@ -0,0 +1,74 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'ui-interaction-config-family-retired',
+ surface:
+ 'ui.touchInteraction / ui.gestureConfig / ui.dndConfig / ui.keyboardNavigationConfig '
+ + '/ ui.componentAnimation / ui.motionConfig / ui.pageTransition / ui.offlineConfig '
+ + '(the whole export surface of ui/touch.zod.ts, ui/dnd.zod.ts, ui/keyboard.zod.ts, '
+ + 'ui/animation.zod.ts and ui/offline.zod.ts — 32 defs, 64 exported names)',
+ replacement:
+ '(removed — there is no replacement key, because there was never a key. Touch targets, '
+ + 'drag-and-drop, focus management, keyboard shortcuts and motion are RENDERER BUILT-IN '
+ + 'behaviour: the component library decides them, not a per-page metadata author. '
+ + 'Offline is a platform capability, and its vocabulary belongs on the sync engine that '
+ + 'owns the queue, the conflict policy and the cache — none of which exists yet. Delete '
+ + 'the import and the value. Whichever of these earns real product pull returns WITH its '
+ + 'own vocabulary and its executor, the #4910 way, not by un-retiring a declaration)',
+ reason:
+ 'Five `@objectstack/spec/ui` modules declared a full interaction-configuration '
+ + 'vocabulary — 22 `z.object` sites across touch/gesture, drag-and-drop, '
+ + 'focus/keyboard, animation/motion and offline/sync — and NOTHING in the protocol '
+ + 'carried them. This is the ADR-0049 false-compliance shape in its most inviting form '
+ + 'for an AI author (ADR-0033), and worse than the ordinary declared-but-unread defect: '
+ + '`authorable-surface.json` listed 109 keys under these defs and '
+ + '`content/docs/references/ui/{touch,dnd,keyboard,animation,offline}.mdx` rendered them '
+ + 'as authoring tables, so the published documentation advertised a vocabulary with no '
+ + 'carrier key anywhere. An author following `dnd.mdx` and writing a `dnd:` block onto a '
+ + 'page component was rejected by `PageComponentSchema` for an unrecognized key — the '
+ + 'docs and the schema disagreeing about the platform (Prime Directive #10). Three '
+ + 'independent measurements, each with its controls passing in the same run: (1) no '
+ + 'module under `packages/spec/src` imported any of the five except the `ui/index.ts` '
+ + 'barrel, so no schema declared a carrier key; (2) a BFS over the in-memory Zod graph '
+ + 'from all 24 metadata-type roots plus `defineStack`\'s `ObjectStackSchema` (25 roots, '
+ + '4742 nodes) reached none of the 21 named object shapes, while `PageSchema`, '
+ + '`WebhookSchema` and `StateMachineSchema` all resolved `direct` and a synthetic '
+ + 'carrier flipped all 21 — so unreachability was a fact about the graph, not a broken '
+ + 'walker; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud '
+ + 'outside these modules\' own unit tests. objectui holds TYPE re-exports and parity '
+ + 'ratchets, never validators, and says so (#2561). The 2026-08-04 ruling weighed '
+ + 'wiring a carrier key (option B) and rejected it: that is a feature with a renderer '
+ + 'behind it, not ledger clean-up. It also weighed tightening the shapes to '
+ + '`strictObject` and rejected that explicitly — strictness is a property of a PARSE and '
+ + 'there is no parse, so it would spend a breaking change to leave "a precisely '
+ + 'validated dead slot, the more convincing lie" (#4583). Because there was no carrier '
+ + 'key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 '
+ + 'conversion to rewrite: this entry is the D3 record, the same route 3 as #4834 (kernel '
+ + 'plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ Not to be confused with '
+ + '#5021, which retired the THEME `animation` block — a different file, different defs, '
+ + 'and that one did have a carrier key and therefore a tombstone. ADR-0049, #4988.',
+ acceptanceCriteria:
+ 'No code imports any of the 64 retired names from `@objectstack/spec` or '
+ + '`@objectstack/spec/ui` — `TouchTargetConfig(Schema)`, `GestureType(Schema)`, '
+ + '`SwipeDirection(Schema)`, `SwipeGestureConfig(Schema)`, `PinchGestureConfig(Schema)`, '
+ + '`LongPressGestureConfig(Schema)`, `GestureConfig(Schema)`, `TouchInteraction(Schema)`, '
+ + '`TransitionPreset(Schema)`, `EasingFunction(Schema)`, `TransitionConfig(Schema)`, '
+ + '`AnimationTrigger(Schema)`, `ComponentAnimation(Schema)`, `PageTransition(Schema)`, '
+ + '`MotionConfig(Schema)`, `DragHandle(Schema)`, `DropEffect(Schema)`, '
+ + '`DragConstraint(Schema)`, `DropZone(Schema)`, `DragItem(Schema)`, `DndConfig(Schema)`, '
+ + '`FocusTrapConfig(Schema)`, `KeyboardShortcut(Schema)`, `FocusManagement(Schema)`, '
+ + '`KeyboardNavigationConfig(Schema)`, `OfflineStrategy(Schema)`, '
+ + '`ConflictResolution(Schema)`, `SyncConfig(Schema)`, `PersistStorage(Schema)`, '
+ + '`EvictionPolicy(Schema)`, `OfflineCacheConfig(Schema)`, `OfflineConfig(Schema)` — '
+ + 'every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol '
+ + 'identity in `ui/interaction-config-retirement.test.ts`). No metadata document needs '
+ + 'editing, because none could ever carry one of these blocks: a stack that parsed '
+ + 'before parses byte-for-byte the same after. If you consumed the bare '
+ + '`ConflictResolution` from `@objectstack/spec/ui` as a TYPE for your own offline code, '
+ + 'declare that union locally — it is your client\'s policy, not the platform\'s. '
+ + '`@objectstack/spec/integration`\'s `ConnectorConflictResolution` (connector sync) and '
+ + '`@objectstack/spec/api`\'s `ConflictResolutionStrategy` (route merge policy) are '
+ + 'different concepts and are untouched.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.ui-notification-action-embed-config-retired.ts b/packages/spec/src/migrations/entries/semantic/17.ui-notification-action-embed-config-retired.ts
new file mode 100644
index 0000000000..c88c9e71eb
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.ui-notification-action-embed-config-retired.ts
@@ -0,0 +1,68 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'ui-notification-action-embed-config-retired',
+ surface: 'ui.notificationAction / ui.embedConfig',
+ replacement:
+ '(removed — there is no replacement shape, because there was never a key to write '
+ + 'either into. Delete the import and the value. Notification presentation is still '
+ + 'described by the surviving `NotificationType` / `NotificationSeverity` / '
+ + '`NotificationPosition` vocabulary; public access to a form is granted by the LIVE '
+ + '`FormView.sharing` block (`SharingConfig`), which is untouched. Notification action '
+ + 'buttons as metadata, and iframe embedding, return via the enforce route of ADR-0049 '
+ + 'through a new ADR — carrier key and renderer first, vocabulary second)',
+ reason:
+ 'Both shapes were published `@objectstack/spec/ui` vocabulary with NO AUTHORING DOOR. '
+ + '#4001 批 14 measured them three ways on 2026-08-03 and this retirement re-ran all '
+ + 'three against `origin/main` before removing anything, each with a positive control '
+ + 'that passed in the same run: (1) CARRIER — no schema in `packages/spec/src` declared '
+ + 'a key of either type (`ui/notification.zod`\'s only non-test importer was the '
+ + 'barrel; `ui/sharing.zod`\'s were the barrel and `ui/view.zod.ts`, which names its '
+ + 'SIBLING `SharingConfigSchema`), measured by resolving specifiers rather than '
+ + 'substring-matching, because the repo holds two `sharing.zod` modules and a substring '
+ + 'test miscredits `stack.zod.ts` to the UI one; (2) REACHABILITY — a BFS from the 24 '
+ + 'metadata-type roots plus `defineStack`\'s `ObjectStackSchema`, over '
+ + '`build-schemas.ts`\'s own walk including its derived-clone bridge, never reached '
+ + 'either, while `Page` / `Action` / `DashboardWidget` / `Webhook` and `SharingConfig` '
+ + 'itself all resolved `root-graph` in the same run and an injected synthetic carrier '
+ + 'flipped both; (3) PARSE — zero `.parse()` in objectstack, cloud or objectui outside '
+ + 'their own unit tests. So nobody could author one and nothing ever validated one: '
+ + 'the #3950 shape, an exported schema with no consumer read as a capability, and the '
+ + 'ADR-0033 trap where an AI author takes `EmbedConfigSchema` in the published bundle '
+ + 'as proof the platform serves iframes. Neither is stored metadata and neither has a '
+ + 'carrier, so no `sys_metadata` row can hold one and there is no source for the D2 '
+ + 'chain to rewrite; this entry is the D3 record. 批 14 deliberately did NOT close them '
+ + 'with `.strict()` — strictness is a property of a PARSE, and closing a shape nothing '
+ + 'parses buys only "a precisely-validated dead slot, the more convincing lie" (#4583) '
+ + '— and filed the disposition as #5015, ruled REMOVE on 2026-08-04. Each was orphaned '
+ + 'by an earlier retirement one level up: `NotificationAction` lost its wrappers at '
+ + '#4610 (`NotificationSchema` / `NotificationConfigSchema`, the #4535 C3 dual-source '
+ + 'cleanup — that retirement\'s published "zero consumers" evidence was later falsified '
+ + 'for objectui and is corrected on `ui/notification.zod`\'s tombstone; the removal '
+ + 'itself stands, #5781), and `EmbedConfig` lost its key at 17.0.0 when the 2026-06 '
+ + 'liveness audit retired `App.embed` (no iframe route ever read it) — that key still '
+ + 'stands as a `retiredKey()` tombstone in `app.zod.ts`, so an author who wrote the KEY already '
+ + 'meets a prescription; this removes the value shape that outlived it. ⚠️ The '
+ + 'retirement is per SCHEMA, not per file: `ui/sharing.zod` KEEPS `SharingConfigSchema`, '
+ + 'a live door carried by `FormViewSchema.sharing` and read by `rest-server.ts` to mount '
+ + 'the anonymous form routes, and `ui/notification.zod` keeps its three presentation '
+ + 'enums. objectui consumed `NotificationActionSchema.shape.variant` as a VOCABULARY '
+ + '(never a parse) to pin its own hand-written `NotificationActionButton` interface — '
+ + 'which is exactly why "has a consumer" never meant "has an authoring door" here; that '
+ + 'pin is adapted objectui-side when it refreshes this dependency. ADR-0049, #5015.',
+ acceptanceCriteria:
+ 'No code imports `NotificationActionSchema`, `NotificationAction`, `EmbedConfigSchema` '
+ + 'or `EmbedConfig` from `@objectstack/spec` or `@objectstack/spec/ui` — both are '
+ + 'TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in '
+ + '`notification-embed-retirement.test.ts`). The same pin asserts the SURVIVORS in the '
+ + 'same run, and that half is equally load-bearing: `NotificationTypeSchema` / '
+ + '`NotificationSeveritySchema` / `NotificationPositionSchema` and `SharingConfigSchema` '
+ + 'must still be exported from `./ui`, and both modules must still load — a retirement '
+ + 'that deleted either file would satisfy the absence half while destroying working '
+ + 'surface. Nothing regresses at runtime, because nothing ever ran: no notification '
+ + 'action was ever parsed from metadata and no iframe route ever read an embed config. '
+ + 'Public form sharing is unaffected — `FormView.sharing` still gates the anonymous '
+ + 'endpoints on `allowAnonymous` + `publicLink`.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.ui-widget-i18n-family-retired.ts b/packages/spec/src/migrations/entries/semantic/17.ui-widget-i18n-family-retired.ts
new file mode 100644
index 0000000000..e690cd8ac7
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.ui-widget-i18n-family-retired.ts
@@ -0,0 +1,91 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'ui-widget-i18n-family-retired',
+ surface:
+ 'ui.widgetManifest / ui.widgetLifecycle / ui.widgetEvent / ui.widgetProperty '
+ + '/ ui.widgetSource / ui.i18nObject / ui.pluralRule / ui.numberFormat / ui.dateFormat '
+ + '/ ui.localeConfig (the widget-registration vocabulary of ui/widget.zod.ts, and the '
+ + 'five doorless shapes of ui/i18n.zod.ts — 10 defs, 26 exported names)',
+ replacement:
+ '(removed — there is no replacement key, because there was never a key. A custom field '
+ + 'widget is still named the same way it always was: `field.widget` is a plain string '
+ + 'naming a component the RENDERER has registered, and objectui\'s registry has always '
+ + 'carried its own runtime manifest for that (`RuntimeWidgetManifest` / '
+ + '`RuntimeWidgetSource` in `@object-ui/types`, objectui#3161 / #4115), which models '
+ + 'different keys and never derived from these. For localisation: write the '
+ + 'default-language string on `label` / `description` — the framework generates the '
+ + 'translation key at registration time from the naming convention — and put '
+ + 'translations in translation files, which is the LIVE `system/translation.zod.ts` '
+ + 'surface. Widget registration and locale formatting as authorable protocol metadata '
+ + 'return via the ENFORCE route of ADR-0049 through a new ADR — the registry / loader / '
+ + 'formatter first, the vocabulary second)',
+ reason:
+ '`ui/widget.zod.ts` published a complete widget-registration vocabulary — a manifest '
+ + 'with lifecycle hooks, custom events, configurable properties and an '
+ + 'npm/remote/inline implementation-source union — and `ui/i18n.zod.ts` published a '
+ + 'structured-label, plural-rule and locale-formatting vocabulary. NOTHING in the '
+ + 'protocol carried either. Three independent measurements, re-run on `origin/main` '
+ + 'immediately before the removal with their controls passing in the SAME run: (1) no '
+ + 'module under `packages/spec/src` imported `widget.zod` at all, and the only imports '
+ + 'of `i18n.zod` anywhere name `I18nLabelSchema` / `AriaPropsSchema` (both KEPT), so no '
+ + 'schema declared a carrier key — `field.widget` is a `z.string()` naming a registered '
+ + 'component and has never referenced `WidgetManifest`; (2) a BFS over the in-memory '
+ + 'Zod graph from all 24 metadata-type roots plus `defineStack`\'s `ObjectStackSchema` '
+ + 'reached none of them, while `PageSchema` / `ObjectListViewSchema` resolved `direct` '
+ + 'in the same run and a synthetic carrier flipped every one of them; (3) zero '
+ + '`.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these files\' '
+ + 'own unit tests. `NumberFormat` / `DateFormat` DID have a carrier key '
+ + '(`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier was itself doorless, '
+ + 'so the subtree was `no door` rather than `no gate` and goes whole — leaving the two '
+ + 'leaves behind would strand exported schemas with no consumer (#3950). '
+ + '`I18nObjectSchema` was additionally superseded by its own file-neighbour: '
+ + '`I18nLabelSchema`\'s documentation already says translation keys are generated at '
+ + 'registration time and translations live in translation files, and the live '
+ + 'translation surface is `system/translation.zod.ts`, which uses none of these shapes. '
+ + 'The 2026-08-06 ruling weighed giving them a carrier (option B) and rejected it: that '
+ + 'is a feature with a registry and a renderer behind it, not ledger clean-up. '
+ + 'Tightening them to `strictObject` was rejected earlier and explicitly (#4001 批 16) '
+ + '— strictness is a property of a PARSE and there is no parse, so it would spend a '
+ + 'breaking change to leave "a precisely validated dead slot, the more convincing lie" '
+ + '(#4583). With no carrier key there is nothing to tombstone and no `sys_metadata` row '
+ + 'or source file for a D2 conversion to rewrite: this entry is the D3 record, route 3, '
+ + 'the same shape as #4988 (the ui/ interaction config family), #4834 (kernel '
+ + 'plugin-runtime family) and #4938 (`HttpServerConfig`). '
+ + '⚠️ `WidgetManifest.performance`\'s own `retiredKey()` tombstone (#3896 close-out) is '
+ + 'SUBSUMED here, the #4657/#4834 way: it goes with the shape that carried it, which is '
+ + 'strictly stronger than the tombstone, because there is no longer a manifest to '
+ + 'author the key INTO. '
+ + '⚠️ One of the nine widget sites is deliberately NOT retired. '
+ + '`FieldWidgetPropsSchema` survives: it is a REACT PROPS CONTRACT rather than '
+ + 'authorable metadata (it never appeared in `authorable-surface/` or '
+ + '`json-schema.manifest/` — its `onChange` is a `z.function()`), so "zero parse" is its '
+ + 'design and not its defect, and it acquired a live cross-repo compile-time consumer '
+ + 'one day before 批 16 measured: objectui PR #3289 (2026-08-03) renamed '
+ + '`@object-ui/fields`\' validation slot onto the spec\'s `error` with no alias, the '
+ + 'form renderer began producing it, and '
+ + '`packages/fields/src/__tests__/spec-symbol-batch7.test.ts` pins the shape against '
+ + '`import type { FieldWidgetProps } from \'@objectstack/spec/ui\'` as an intentional '
+ + 'tripwire. Re-verified on objectui `origin/main` 2026-08-07. ADR-0049, #5055.',
+ acceptanceCriteria:
+ 'No code imports `WidgetManifest(Schema|Parsed)`, `WidgetLifecycle(Schema)`, '
+ + '`WidgetEvent(Schema|Parsed)`, `WidgetProperty(Schema|Parsed)`, '
+ + '`WidgetSource(Schema|Parsed)`, `I18nObject(Schema)`, `PluralRule(Schema)`, '
+ + '`NumberFormat(Schema|Parsed)`, `DateFormat(Schema)` or '
+ + '`LocaleConfig(Schema|Parsed)` from `@objectstack/spec` or `@objectstack/spec/ui` — '
+ + 'every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol '
+ + 'identity in `ui/widget-i18n-retirement.test.ts`). No metadata document needs '
+ + 'editing, because none could ever carry one of these shapes: a stack that parsed '
+ + 'before parses byte-for-byte the same after, and a `field.widget: "my_picker"` string '
+ + 'is untouched. `FieldWidgetProps` / `FieldWidgetPropsSchema` / '
+ + '`FieldWidgetPropsParsed`, `I18nLabel(Schema)` and `AriaProps(Schema)` all still '
+ + 'resolve on `@objectstack/spec/ui` and are asserted to. ⚠️ objectui needs a companion '
+ + 'PR in the same window: `packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts` '
+ + 'asserts the spec STILL owns `WidgetManifest` / `WidgetSource` (it is the '
+ + '"a workaround should not outlive its reason" half of the objectui#3169 tripwire, '
+ + 'designed to go red exactly here), and `packages/types/src/widget.ts`\'s '
+ + '"Renamed off the spec\'s `WidgetManifest` name" comments now point at names that no '
+ + 'longer exist. Both are prescribed responses to this removal, not collateral damage.',
+};
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
new file mode 100644
index 0000000000..f6a06f69cb
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.view-filter-rule-value-shaped-by-operator.ts
@@ -0,0 +1,64 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'view-filter-rule-value-shaped-by-operator',
+ // No backticks in `surface` — build-upgrade-guide.ts renders it inside a
+ // code span already, and a nested backtick would close it.
+ surface:
+ 'ui.ViewFilterRule value — the third key of a view filter rule, on every carrier of '
+ + 'ViewFilterRuleSchema: ListView.filter, a list view tab filter, Page.filterBy, a '
+ + 'related-list component filter and a lookup picker filter. It accepted any declared '
+ + 'scalar or array for EVERY operator; the accepted shape is now decided by the rule '
+ + 'operator — in / not_in require an array, between requires exactly two bounds, and '
+ + 'every other operator is unchanged',
+ replacement:
+ 'an ARRAY for in / not_in (a single value becomes a one-element list: value: "won" '
+ + 'becomes value: ["won"]), and a two-element [min, max] array for between. The empty '
+ + 'list [] stays legal for in / not_in and keeps its meaning. Nothing else moves: a '
+ + 'scalar operator carrying an array, a string operator carrying a number, and a unary '
+ + 'operator carrying an ignored value all still parse',
+ reason:
+ '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.',
+ acceptanceCriteria:
+ '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.',
+};
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
new file mode 100644
index 0000000000..df3d4c5f95
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.view-management-protocol-retired.ts
@@ -0,0 +1,57 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'view-management-protocol-retired',
+ 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',
+ reason:
+ '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.',
+ acceptanceCriteria:
+ '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.',
+};
diff --git a/packages/spec/src/migrations/entries/semantic/17.workflow-service-slot-retired.ts b/packages/spec/src/migrations/entries/semantic/17.workflow-service-slot-retired.ts
new file mode 100644
index 0000000000..00d2f9f327
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/17.workflow-service-slot-retired.ts
@@ -0,0 +1,34 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'workflow-service-slot-retired',
+ surface:
+ "CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / "
+ + 'discovery routes.workflow / RestApiRouteCategory workflow',
+ replacement:
+ 'the live mechanisms the slot only ever pointed at: `state_machine` validation rules '
+ + 'for record state machines, approval flow nodes on the approvals runtime (ADR-0019) '
+ + 'for approvals, lifecycle hooks + `record_change` flows (service-automation) for '
+ + 'record-triggered automation',
+ reason:
+ '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.',
+ acceptanceCriteria:
+ 'No import of IWorkflowService, WorkflowProtocol or the Get/WorkflowState/Config/'
+ + 'Transition types resolves; no code calls getService(\'workflow\') or reads '
+ + 'discovery `routes.workflow` / `services.workflow`; record state machines, '
+ + 'approvals and record-triggered automation go through the replacement mechanisms. '
+ + 'Discovery output on a default boot is unchanged (the slot was always reported '
+ + 'unavailable; now it is simply absent).',
+};
diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts
index bcade358b2..7133a29139 100644
--- a/packages/spec/src/migrations/registry.ts
+++ b/packages/spec/src/migrations/registry.ts
@@ -14,6 +14,28 @@
* (`migrations.test.ts`). The support floor is an explicit release-policy knob —
* how far back `migrate meta --from N` reaches — revisitable per major, never an
* accident of deletion.
+ *
+ * ## ⚠️ The three tables below are GENERATED (#7297)
+ *
+ * Each step's `semantic` list, {@link RETIRED_KEYS_BY_MAJOR} and
+ * {@link RETIRED_DEFS_BY_MAJOR} are concatenated into their ``
+ * regions from `./entries/`, **one file per entry**, sorted by entry id. Add an
+ * entry by adding a FILE and running
+ * `pnpm --filter @objectstack/spec gen:migration-registry` — never by typing
+ * between the markers. See `./entries/README.md`.
+ *
+ * They were three hand-authored APPEND tables, and every retirement card
+ * appended to the same tail line of the same two of them: `step17`'s semantic
+ * list and `RETIRED_KEYS_BY_MAJOR[17]` conflicted in **6 of 11** contended
+ * re-merge laps over 2026-08-06..10, for 613 hand-resolved lines of conflict
+ * markers in four days (#6957's measurement, ruling adopted 2026-08-10). The
+ * danger was never the wall-clock: **both tables are consumed as sets**, so a
+ * resolution that drops a sibling's entry produces no error anywhere and the
+ * retirement it declared silently stops being declared.
+ *
+ * Everything OUTSIDE the markers — this header, each step's `rationale` and
+ * `conversionIds`, and the two tables' load-bearing doc comments — is still
+ * hand-written and still merges as text.
*/
import type { MigrationStep } from './types.js';
@@ -55,6 +77,10 @@ const step11: MigrationStep = {
'object-compactLayout-to-highlightFields',
],
semantic: [
+ // One file per entry under `entries/semantic/`, concatenated here sorted by
+ // entry id by `gen:migration-registry` (#7297). Add an entry by adding a
+ // FILE — never by editing between the markers, which is generated.
+ //
{
id: 'object-titleFormat-to-nameField',
surface: 'object.titleFormat',
@@ -81,6 +107,7 @@ const step11: MigrationStep = {
'Every RLS predicate parses as CEL and `objectstack validate` reports no ' +
'expression errors; row visibility is unchanged for a representative fixture set.',
},
+ //
],
};
@@ -102,6 +129,10 @@ const step12: MigrationStep = {
'data anonymously must now declare that posture explicitly.',
conversionIds: [],
semantic: [
+ // One file per entry under `entries/semantic/`, concatenated here sorted by
+ // entry id by `gen:migration-registry` (#7297). Add an entry by adding a
+ // FILE — never by editing between the markers, which is generated.
+ //
{
id: 'rest-requireauth-default-flip',
surface: 'api.requireAuth',
@@ -117,6 +148,7 @@ const step12: MigrationStep = {
'warning); every other consumer verifies its clients authenticate. ' +
'`objectstack validate` and the consumer test suite pass.',
},
+ //
],
};
@@ -146,35 +178,10 @@ const step13: MigrationStep = {
'sharing-recipient-role-to-position',
],
semantic: [
- {
- id: 'permission-set-profile-removed',
- surface: 'permissionSet.kind / permissionSet.isProfile',
- replacement: 'position-based assignment + permission-set grants (ADR-0090 D2)',
- reason:
- 'The Profile concept was removed: `isProfile` is gone from ' +
- '`PermissionSetSchema` and the `profile` metadata kind folded into ' +
- '`position`. Mapping a profile onto positions and permission-set grants is ' +
- 'an authorization-design decision, not a rename.',
- acceptanceCriteria:
- 'No permission set declares `isProfile` or kind `profile`; the intended ' +
- 'assignees hold equivalent grants via positions/permission sets. The access ' +
- 'matrix (`os compile` access-matrix gate, where enabled) is reviewed and ' +
- '`objectstack validate` passes.',
- },
- {
- id: 'position-hierarchy-flattened',
- surface: 'position.parent / sharingRule recipient role_and_subordinates',
- replacement: 'business-unit tree + `unit_and_subordinates` (ADR-0090 D3)',
- reason:
- 'Positions are flat in v2 — `parent` was removed and the ' +
- '`role_and_subordinates` recipient with it; hierarchy lives on the ' +
- 'business-unit tree, which expands a DIFFERENT structure than the retired ' +
- 'role tree. Re-homing an org hierarchy is a judgment call.',
- acceptanceCriteria:
- 'No position declares `parent`; former `role_and_subordinates` rules are ' +
- 're-expressed with `unit_and_subordinates` over an equivalent business-unit ' +
- 'tree. Row visibility is unchanged for a representative fixture set.',
- },
+ // One file per entry under `entries/semantic/`, concatenated here sorted by
+ // entry id by `gen:migration-registry` (#7297). Add an entry by adding a
+ // FILE — never by editing between the markers, which is generated.
+ //
{
id: 'cel-current-user-roles-to-positions',
surface: 'CEL/formula: current_user.roles',
@@ -204,6 +211,35 @@ const step13: MigrationStep = {
'verified against the intended access (who can read/write/delete) for a ' +
'representative fixture set.',
},
+ {
+ id: 'permission-set-profile-removed',
+ surface: 'permissionSet.kind / permissionSet.isProfile',
+ replacement: 'position-based assignment + permission-set grants (ADR-0090 D2)',
+ reason:
+ 'The Profile concept was removed: `isProfile` is gone from ' +
+ '`PermissionSetSchema` and the `profile` metadata kind folded into ' +
+ '`position`. Mapping a profile onto positions and permission-set grants is ' +
+ 'an authorization-design decision, not a rename.',
+ acceptanceCriteria:
+ 'No permission set declares `isProfile` or kind `profile`; the intended ' +
+ 'assignees hold equivalent grants via positions/permission sets. The access ' +
+ 'matrix (`os compile` access-matrix gate, where enabled) is reviewed and ' +
+ '`objectstack validate` passes.',
+ },
+ {
+ id: 'position-hierarchy-flattened',
+ surface: 'position.parent / sharingRule recipient role_and_subordinates',
+ replacement: 'business-unit tree + `unit_and_subordinates` (ADR-0090 D3)',
+ reason:
+ 'Positions are flat in v2 — `parent` was removed and the ' +
+ '`role_and_subordinates` recipient with it; hierarchy lives on the ' +
+ 'business-unit tree, which expands a DIFFERENT structure than the retired ' +
+ 'role tree. Re-homing an org hierarchy is a judgment call.',
+ acceptanceCriteria:
+ 'No position declares `parent`; former `role_and_subordinates` rules are ' +
+ 're-expressed with `unit_and_subordinates` over an equivalent business-unit ' +
+ 'tree. Row visibility is unchanged for a representative fixture set.',
+ },
{
id: 'sharing-model-secure-default',
surface: 'object.sharingModel (absent, custom object with owner field)',
@@ -218,6 +254,7 @@ const step13: MigrationStep = {
'an explicit `sharingModel`; row visibility is verified for a ' +
'representative fixture set (owners, non-owners, admins).',
},
+ //
],
};
@@ -236,7 +273,13 @@ const step14: MigrationStep = {
'ADR-0090 D9). A pure key rename, preserved as a retired conversion; there ' +
'is no semantic residue.',
conversionIds: ['book-audience-profile-to-permission-set'],
- semantic: [],
+ semantic: [
+ // One file per entry under `entries/semantic/`, concatenated here sorted by
+ // entry id by `gen:migration-registry` (#7297). Add an entry by adding a
+ // FILE — never by editing between the markers, which is generated.
+ //
+ //
+ ],
};
/**
@@ -260,6 +303,10 @@ const step15: MigrationStep = {
'is now a loud parse error instead of a silent strip (ADR-0049/0078).',
conversionIds: ['view-visibleOn-to-visibleWhen', 'page-component-visibility-to-visibleWhen'],
semantic: [
+ // One file per entry under `entries/semantic/`, concatenated here sorted by
+ // entry id by `gen:migration-registry` (#7297). Add an entry by adding a
+ // FILE — never by editing between the markers, which is generated.
+ //
{
id: 'ui-schemas-strict-unknown-keys',
surface: 'view form fields/sections · page components (undeclared keys)',
@@ -274,6 +321,7 @@ const step15: MigrationStep = {
'`objectstack validate` passes with no unknown-key parse errors on form ' +
'fields, form sections, or page components.',
},
+ //
],
};
@@ -302,6 +350,10 @@ const step16: MigrationStep = {
'because an arbitrary unknown key has no lossless canonical target.',
conversionIds: [],
semantic: [
+ // One file per entry under `entries/semantic/`, concatenated here sorted by
+ // entry id by `gen:migration-registry` (#7297). Add an entry by adding a
+ // FILE — never by editing between the markers, which is generated.
+ //
{
id: 'dashboard-widget-strict-unknown-keys',
surface: 'dashboard widgets (undeclared top-level keys — legacy inline ' +
@@ -319,6 +371,7 @@ const step16: MigrationStep = {
'`objectstack validate` passes with no unknown-key parse errors on dashboard ' +
'widgets.',
},
+ //
],
};
@@ -1347,81 +1400,291 @@ const step17: MigrationStep = {
'action-global-nav-location-removed',
],
semantic: [
+ // One file per entry under `entries/semantic/`, concatenated here sorted by
+ // entry id by `gen:migration-registry` (#7297). Add an entry by adding a
+ // FILE — never by editing between the markers, which is generated.
+ //
{
- id: 'dashboard-widget-compareto-offset',
- surface: "dashboard.widgets[].compareTo: { offset: '7d' | '1M' | … } (every duration except '1y')",
- replacement: "compareTo: { kind: 'previousPeriod' } plus an explicit window on the widget's own `filter`",
+ id: 'action-descriptor-is-async-retired',
+ surface: 'ActionDescriptor.isAsync (the descriptor an executor publishes via `registerNodeExecutor` / `defineActionDescriptor`)',
+ replacement:
+ 'nothing to re-declare — delete the key. Suspension is `execute()` RETURNING '
+ + '`suspend: true`, and permission to suspend is `supportsPause: true` on the same '
+ + 'descriptor (with the `resumeAuthority` its pauses need)',
reason:
- 'The widget declared three comparison arms; the analytics executor implements one shape, '
- + '`{ kind, dimension? }`, with no `offset` concept in it at all. On the ADR-0021 dataset '
- + 'path — the spec\'s single author-facing analytics shape — `{ offset }` was forwarded '
- + 'verbatim into that contract and threw `compareTo requires a timeDimension "undefined"`, '
- + 'taking the widget down; the arm ever only ran on the legacy inline chart path (#5011). '
- + "The conversion rewrites `{ offset: '1y' }`, which IS `previousYear` by definition. Every "
- + 'other duration has NO faithful target: `previousPeriod` shifts by the length of whatever '
- + "window the widget's filter resolves to, which equals `7d` only when that window happens "
- + 'to be seven days long. Rewriting mechanically would silently change which rows the '
- + 'comparison column counts — a wrong number rather than a missing one, which is strictly '
- + 'worse and exactly the class this convergence exists to end. Re-stating the intended '
- + 'window is a judgment about the presentation, not a transform.',
+ 'ADR-0049 enforce-or-remove. `isAsync` declared "this action suspends the flow '
+ + 'awaiting an external reply" and NOTHING read it: a fresh three-repo measurement '
+ + '(#6748, re-run at pickup) found zero property reads across objectstack, objectui '
+ + 'and cloud — every hit was the declaration itself, a generated baseline, one of '
+ + 'five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. '
+ + 'So declaring it never made a node suspend and omitting it never stopped one, '
+ + 'which is the silently-inert declaration ADR-0049 exists to end. It was always a '
+ + 'second, weaker spelling of the capability `supportsPause` states, and the two '
+ + 'diverged in exactly the way a duplicated declaration does: `screen` declared '
+ + 'both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing '
+ + 'anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling in '
+ + '#6667 — `AutomationEngine` now refuses a suspension whose type does not declare '
+ + '`supportsPause: true` — so the capability this key gestured at is now a real, '
+ + 'enforced fact under one name. This one had no consumer to grow into and takes '
+ + 'the remove leg. '
+ + 'Why D3 semantic and not a D2 conversion: an ActionDescriptor is published from '
+ + "an executor's TypeScript, never stored in stack metadata — no stack, example or "
+ + 'template carries the key — so there is no source for the chain to rewrite and '
+ + '`os migrate meta` cannot reach it. The schema tombstones it via `retiredKey()` '
+ + 'and descriptor authors delete the key themselves; that rejection (a `tsc` error '
+ + 'at the authoring site, and a parse error inside `defineActionDescriptor`) is the '
+ + 'channel a third-party plugin author actually meets. The '
+ + '`EnhancedApiError.fieldErrors` disposition, one layer down.',
acceptanceCriteria:
- 'No dashboard widget declares `compareTo.offset`. Each former offset comparison states its '
- + "window on the widget's `filter` and compares with `compareTo: { kind: 'previousPeriod' }` "
- + "(or `'previousYear'`), and `dimension` is named wherever the selection dates more than one "
- + 'time dimension. `objectstack validate` passes, and each affected widget renders a '
- + '`__compare` column over the window its author intended.',
+ 'No descriptor declares `isAsync` — not the five that shipped it (`screen`, `map`, '
+ + '`wait`, `approval`, `approval_revise`), not a plugin\'s. Every node type that '
+ + 'returns `suspend: true` from `execute()` declares `supportsPause: true` on its '
+ + 'descriptor together with a `resumeAuthority`, and its runs still pause and resume '
+ + 'as before: the behaviour never depended on `isAsync`, so deleting the key changes '
+ + 'no run. Authoring `isAsync` fails `tsc` at the descriptor literal and fails '
+ + '`defineActionDescriptor()` at runtime with the prescription, instead of parsing '
+ + 'clean and being stripped.',
},
{
- id: 'job-retry-policy-constraints-tightened',
- surface: 'job.retryPolicy.maxRetries (> 10) / job.retryPolicy.backoffMultiplier (< 1)',
- replacement: 'maxRetries <= 10, and backoffMultiplier >= 1',
+ id: 'action-descriptor-resume-authority-default-flip',
+ // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code
+ // span AND a table cell (see the note on `spec-type-alias-input-suffix-retired`).
+ surface:
+ 'automation.ActionDescriptor.resumeAuthority — an OMITTED value on a pausing '
+ + 'node descriptor (supportsPause: true, or any executor whose execute() returns '
+ + 'suspend: true)',
+ replacement:
+ "an explicit resumeAuthority: 'any' on the descriptor, for a pausing node whose "
+ + 'pauses really are meant to be continued through the generic resume route '
+ + '(POST /automation/:name/runs/:runId/resume) — a screen-style collected-input '
+ + "pause, or a signal wait an external producer resumes. Declare 'service' instead "
+ + 'if continuing is the tail of a decision your own service must authorize and '
+ + 'record first. Either value is a one-line addition; only the silence changed '
+ + 'meaning',
reason:
- 'The converged RetryPolicy (#4661) keeps the automation side\'s bounds, which the job '
- + 'side never had: `maxRetries` is capped at 10 and `backoffMultiplier` floored at 1. '
- + 'Neither has a lossless rewrite. Clamping `maxRetries: 20` to 10 would halve a '
- + 'retry budget its author chose, and a `backoffMultiplier` below 1 describes a delay '
- + 'that SHRINKS on each attempt — retrying a failing dependency ever faster, which is '
- + 'the opposite of backoff and was never a shape the engine meant to offer. Both now '
- + 'fail at parse time with the bound named, rather than being silently reinterpreted. '
- + 'Choosing the replacement count (or accepting the cap) is the author\'s call.',
+ 'A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as '
+ + "protocol 12's `rest-requireauth-default-flip`, and it is registered here for the "
+ + 'same reason: whether a given pause is genuinely open to the generic route is a '
+ + 'trust judgment no transform can make. The #3801 resume gate keys on the SUSPENDED '
+ + "NODE, and `ActionDescriptor.resumeAuthority` used to default to `'any'`, so a "
+ + 'pausing node type shipped raw-resumable unless its author remembered the field. '
+ + "It now resolves to `'service'` when absent: an unclaimed pause is refused on the "
+ + 'generic route with `PERMISSION_DENIED` / 403 until its descriptor states who may '
+ + 'continue it. #3823 is the incident that decided the direction — ADR-0044 pointed '
+ + "an approval's revise edge at a generic `wait`, `wait` is legitimately `'any'`, and "
+ + 'the pause standing in a service-owned position inherited a fail-open value nobody '
+ + 'chose; the demonstrated cost was an unaudited resubmit plus a destroyed remote '
+ + 'run. The two possible mistakes are asymmetric, which is the whole argument: '
+ + "guessing `'any'` walks past a decision nothing recorded and is silent, while "
+ + "guessing `'service'` returns a refusal naming the missing field. ⚠️ The surface "
+ + 'is a DESCRIPTOR FIELD set in plugin CODE, never stack metadata, so there is no '
+ + 'source for a D2 conversion to rewrite and deliberately no schema tombstone — the '
+ + 'disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` '
+ + '(#5540) and `actor-user-roles-to-positions` (#6011) already carry. It differs from '
+ + 'those in one way a reader should not have to infer: nothing is REMOVED, so tsc '
+ + 'reports nothing at all — the field was already optional after step one and an '
+ + 'omission still compiles. The enforced channels are all run-time: a registration '
+ + 'warning naming the node type (once per type per engine), the refusal message on '
+ + 'the resume itself, and `check:resume-authority-declared` for executors living in '
+ + 'this repo. For a third-party plugin the generated upgrade guide is the only '
+ + 'channel that arrives BEFORE a user hits a run that will not continue. In-tree the '
+ + 'flip moves nothing: all six shipped pausing types (screen, wait, subflow, map, '
+ + 'approval, approval_revise) declare their authority explicitly. ADR-0044 amendment '
+ + '(2026-07-28) and its 2026-08-08 landing section, ADR-0019 #3801 addendum, #5561.',
acceptanceCriteria:
- 'Every job declaring `retryPolicy` parses: no `maxRetries` above 10 and no '
- + '`backoffMultiplier` below 1 remain, and each adjusted value was re-chosen knowing a '
- + 'retry re-runs the handler with its writes and callouts. No job fails to register '
- + 'with the retry-policy bound prescription.',
+ 'Every action descriptor your plugin registers for a node type that can suspend '
+ + 'declares `resumeAuthority`. Booting the stack logs no `declares supportsPause but '
+ + 'never declares resumeAuthority` warning naming one of your types, and a run parked '
+ + 'on each of your pausing nodes can still be continued the way you intend: a resume '
+ + "through the generic route succeeds for the ones you declared `'any'`, and answers "
+ + "403 (`PERMISSION_DENIED`) for the ones you declared `'service'`, which continue "
+ + 'through your own service API instead. ⚠️ `supportsPause` is no longer the '
+ + 'declaration nothing enforced (#5703, closed by #6667): an executor whose '
+ + '`execute()` returns `suspend: true` while leaving `supportsPause` false is still '
+ + 'warned about by neither warning channel, but '
+ + '`AutomationEngine.refuseUndeclaredSuspension` now refuses that suspension at the '
+ + 'one seam every suspension passes through — a guard-class failure no `fault` edge '
+ + 'routes — so it needs no hand-check. The residue that does: an executor registering '
+ + 'NO descriptor declares nothing for either warning or the refusal to read, so its '
+ + 'pauses are still created and refused only later, on the resume route (#5561).',
},
- // `etl-retry-converged-onto-retry-policy` (#4962) stood here and was
- // ABSORBED by `etl-pipeline-layer-retired` below (#6414), the §0 same-major
- // rule: both land in the unreleased protocol 17, and composed, the rename
- // `ETLPipeline.retry.maxAttempts` -> `maxRetries` has no observable effect
- // because the shape carrying it does not survive the major. Leaving both
- // would tell an upgrader to rewrite a key on a schema this same upgrade
- // deletes, and would break the fixture-disjointness the replay contract
- // asserts. The `agent.knowledge` / `WidgetManifest.performance` precedent:
- // a tombstone goes with the shape that carried it, which is strictly
- // stronger than the tombstone.
{
- id: 'flow-retry-max-retries-required',
- surface: "flow.errorHandling.maxRetries (under strategy: 'retry')",
- replacement: 'an explicit count >= 1 (e.g. maxRetries: 3), or strategy: \'fail\'',
+ id: 'action-session-roles-to-positions',
+ surface: 'ui.actionSession.roles',
+ replacement: 'ui.actionSession.positions (an action body reads `ctx.session.positions`)',
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 ' +
- '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.',
+ 'The MIRROR-IMAGE sibling of `actor-user-roles-to-positions`, and the reason both are in this '
+ + 'step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed '
+ + 'outright, #5050), while the ACTION body\'s `ctx.session` carries it '
+ + 'produced-and-really-populated. `buildActionSession()` '
+ + '(`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` '
+ + 'into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under '
+ + 'the one spelling that ADR bans — so a body author met two different answers to one '
+ + 'key name on one platform: rejected in a hook, live and full of values in an action. '
+ + '#5613 ruled contract-first (maintainer, 2026-08-06: "C skeleton + A semantics"): '
+ + 'phase 1 (#5697) declared the previously undeclared shape as `ActionSessionSchema`, '
+ + 'and phase 2 renames the key. `positions` is now the canonical key on that schema '
+ + 'and `roles` a deprecated alias of it (#5779); the producer emits both for one '
+ + 'deprecation window (#5613 runtime half), after which `roles` is removed on the path '
+ + 'the v11 session-alias removal already walked (#3280 deprecated → #3290 removed). '
+ + 'Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: '
+ + 'FIRST, there is no source to convert — an action `ctx.session` is constructed per '
+ + 'dispatch and never persisted, so no `sys_metadata` row, example or template can '
+ + 'carry the key — the `openApi31` (#4579) / `activationEvents` (#4657) / '
+ + '`hook-context-session-roles-retired` (#5050) shape. SECOND, the only place the key '
+ + 'is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed '
+ + 'script whose `ScriptContext.session` is still `unknown`. A declarative transform '
+ + 'cannot safely rewrite an identifier inside free-form code — exactly the reason the '
+ + 'ADR-0090 wave delegated `current_user.roles` to the author at step 13 '
+ + '(`cel-current-user-roles-to-positions`) instead of substituting text. '
+ + 'Note what is deliberately NOT done here: the alias is not tombstoned. A '
+ + '`retiredKey()` REJECTS the key, and a deprecation window exists precisely so the '
+ + 'old spelling keeps working while its readers move — tombstoning during the window '
+ + 'would be the removal it is meant to defer. The tombstone (or the plain deletion the '
+ + 'authorable-surface ratchet adjudicates) belongs to the release that closes the '
+ + 'window. Until then this entry IS the channel: `spec-changes.json` and the generated '
+ + 'upgrade guide are how a reader learns the rename before the removal reaches them. '
+ + 'ADR-0090 D3, ADR-0087, #5613 / #5779.',
acceptanceCriteria:
- "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.',
- },
- {
- id: 'analytics-query-request-envelope-retired',
+ 'No action body reads `ctx.session.roles`; every such read is `ctx.session.positions` '
+ + 'and observes the same array (the rename is a rename — the VALUE is '
+ + '`ExecutionContext.positions` on both sides, which the runtime pin '
+ + '`action-session-shape-contract.test.ts` asserts independently of the key name). '
+ + 'Privilege is NOT re-derived from either spelling: a read that was '
+ + '`roles.includes(\'admin\')` as an access check is rewritten to ask the security '
+ + 'service (capability grants / placements / derived posture, ADR-0095), never '
+ + 'renamed to `positions.includes(\'admin\')` — renaming that read migrates the defect '
+ + 'rather than the code. Verify against a real dispatch, not a fixture: invoke an '
+ + 'action as a caller holding positions and assert the body observed them under the '
+ + 'canonical key. During the window both keys are present and equal, so a reader can '
+ + 'be migrated and verified before the alias is removed; after it, `roles` is absent '
+ + 'and a body still reading it sees `undefined` — which is why the read must be moved '
+ + 'inside the window rather than at its close.',
+ },
+ {
+ id: 'actor-user-roles-to-positions',
+ surface: 'action body / AI route: ctx.user.roles (req.user.roles)',
+ replacement:
+ 'ctx.user.positions (an AI route handler reads `req.user.positions`) — the same array, '
+ + 'under the one spelling ADR-0090 D3 sanctions',
+ reason:
+ 'The THIRD face of the ADR-0090 `roles` → `positions` rename, and the only one whose '
+ + 'surface the spec never declared. `ActorUser` '
+ + '(`packages/runtime/src/security/actor-user.ts`) is the ONE producer of the `user` '
+ + 'envelope handed to an action body as `ctx.user` and to an AI route handler as '
+ + '`req.user`; it declared `positions` and `roles` side by side and filled them from a '
+ + 'SINGLE assignment (`roles: core.positions`), so the two keys were verbatim identical '
+ + 'on every dispatch — a second spelling of the vocabulary ADR-0090 D3 reserves and bans, '
+ + 'published straight into author-written code. The maintainer ruled it closed IMMEDIATELY '
+ + '(2026-08-06 14:49Z, #6011): no deprecation window, no dual-emit, the alias simply gone '
+ + 'in 17 (PR #6048). '
+ + '⚠️ Do not read this entry across to its sibling `action-session-roles-to-positions`: '
+ + '`action-session-roles-to-positions` governs `ctx.session`, a DIFFERENT object reached '
+ + 'through the same `ctx`, and that one KEEPS its one-window dual-emit (#5613). Same word, '
+ + 'same dispatch, two faces, two schedules — `ctx.user.roles` is absent in 17 while '
+ + '`ctx.session.roles` still answers for the length of its window. '
+ + 'What makes this entry different in KIND from both session-side siblings: `ctx.user` has '
+ + 'no spec schema and never had one. It is a runtime TS interface, so unlike '
+ + '`HookContext.session.roles` (tombstoned on a deliberately non-strict `HookContextSchema`, '
+ + '#5050) and unlike `ActionSessionSchema` (declared contract-first at #5697 precisely so '
+ + 'its key could be renamed), there is no schema key here to tombstone and no '
+ + '`retiredKey()` prescription that could reach anybody — nothing ever ran an `ActorUser` '
+ + 'through a `.parse()`, so a prescription there would have no one to reach. The enforced '
+ + 'channel is tsc, and it reports at the READ site inside the author\'s own body; for an '
+ + 'untyped or sandboxed body there is no enforced channel at all, which is exactly why '
+ + 'this ledger entry has to exist — `spec-changes.json` and the generated upgrade guide '
+ + 'are the ONLY way such a reader learns of the rename. It is the `findStream` (#4484) / '
+ + '`IStorageService.list` (#5540) disposition — a TS/API contract, no stored source, no '
+ + 'tombstone, tsc at the call site — applied to a surface that lives one layer further '
+ + 'out than either: those two are at least DECLARED in `packages/spec/src/contracts`, '
+ + 'this one only in `packages/runtime`. '
+ + 'Why it is a D3 semantic TODO and not a D2 conversion, on the same two independent '
+ + 'grounds as its session sibling: FIRST, there is no source to convert — an `ActorUser` '
+ + 'is constructed per dispatch and never persisted, so no `sys_metadata` row, example or '
+ + 'template can carry the key (the `openApi31` (#4579) / `activationEvents` (#4657) / '
+ + '`hook-context-session-roles-retired` (#5050) shape). SECOND, the only place the key is '
+ + 'ever SPELLED is inside an action body or an AI route handler: author-written JS/TS, or '
+ + 'a sandboxed script. A declarative transform cannot safely rewrite an identifier inside '
+ + 'free-form code — the same reason the ADR-0090 wave delegated `current_user.roles` to '
+ + 'the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting '
+ + 'text. '
+ + 'The removal\'s hard precondition was met before it landed, and the result is recorded '
+ + 'here because the ledger is where an upgrading consumer meets it: the declaration\'s own '
+ + 'comment claimed the alias was "kept for the REST/AI shapes", and that claim was '
+ + 'DISPROVEN face by face against `origin/main` — repo-wide `user.roles` was 4 hits, all '
+ + 'of them in the pins PR #6048 flipped; the four `ActorUser` construction sites build '
+ + 'server-side envelopes that never enter a response body; objectui\'s `.roles` reads '
+ + 'belong to two unrelated producers (the better-auth session, and the '
+ + '`/auth/me/permissions` payload). The `cloud` repo was NOT reachable in that session and '
+ + 'is the one consumer face left unverified — this entry, and the changeset\'s FROM/TO '
+ + 'prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087, #6011 (PR #6048).',
+ acceptanceCriteria:
+ 'No action body reads `ctx.user.roles` and no AI route handler reads `req.user.roles`; '
+ + 'every such read is `.positions` and observes the SAME array — the value was '
+ + '`ExecutionContext.positions` on both sides, so this is a pure key rename and no value '
+ + 'has to be re-derived. Privilege is NOT re-derived from either spelling: a read that was '
+ + '`roles.includes(\'admin\')` as an access check is rewritten to ask the security service '
+ + '(capability grants / placements / derived posture, ADR-0095), never renamed to '
+ + '`positions.includes(\'admin\')` — renaming that read migrates the defect rather than the '
+ + 'code. Unlike `ctx.session` there is NO window to migrate inside: in 17 the key is '
+ + 'already absent, so a typed body fails `tsc` at the read while an untyped or sandboxed '
+ + 'one silently sees `undefined` — move the read AS you upgrade, not after it. Verify '
+ + 'against a real dispatch rather than a fixture: invoke an action (and an AI route) as a '
+ + 'caller holding positions, assert the body observed them under the canonical key, and '
+ + 'assert the old key is ABSENT by key existence (`\'roles\' in ctx.user === false`) rather '
+ + 'than by `undefined`, which cannot tell a removed key from one left behind holding '
+ + 'nothing — the runtime pin `action-ctx-user-shape.test.ts` asserts both halves that way.',
+ },
+ {
+ id: 'aggregation-node-distinct-retired',
+ surface: 'data.query.aggregations[].distinct',
+ replacement:
+ 'the `count_distinct` aggregation FUNCTION for a deduplicated count — the one '
+ + 'deduplicating spelling every face computes, lowered to `COUNT(DISTINCT field)` on '
+ + 'both SQL faces since #6409. `SUM(DISTINCT …)` / `AVG(DISTINCT …)` get no '
+ + 'replacement: no backend ever computed them here, and a per-row measure that needs '
+ + 'deduplicating before summing is a modelling problem to fix in the data',
+ reason:
+ '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 frozen under #5499 — '
+ + '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.',
+ acceptanceCriteria:
+ '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.',
+ },
+ {
+ id: 'analytics-query-request-envelope-retired',
surface: 'api.analyticsQueryRequest.query',
replacement: 'bare AnalyticsQuery body (top-level cube/measures/dimensions/where/...)',
reason:
@@ -1433,23 +1696,6 @@ const step17: MigrationStep = {
'Every /analytics/query and /analytics/sql call sends the bare AnalyticsQuery shape and ' +
'succeeds; no request answers 400 VALIDATION_FAILED with the envelope prescription.',
},
- {
- id: 'enhanced-api-error-field-errors-renamed',
- surface: 'api.enhancedApiError.fieldErrors',
- replacement: 'fields',
- reason:
- 'The wire has always carried `fields` — the validators, import coercion, ' +
- 'validation-failure.ts, @objectstack/client and the console\'s field-error extractor ' +
- 'all say `fields`, and nothing ever emitted `fieldErrors`, so a reader keying on it ' +
- 'was reading a field no server sent (ADR-0078\'s silently-inert declaration, on the ' +
- 'error envelope). This is a RESPONSE surface: no stack, example or template carries ' +
- 'the key, so there is no source for the chain to rewrite — the schema tombstones it ' +
- 'via retiredKey() and consumers move their read themselves. ADR-0114 D4, #3977.',
- acceptanceCriteria:
- 'No consumer reads `error.fieldErrors`; per-field validation detail is read from ' +
- '`error.fields`, and constructing an EnhancedApiError with `fieldErrors` fails to parse ' +
- 'with the rename prescription instead of silently losing the array.',
- },
{
id: 'analytics-query-request-format-retired',
surface: 'api.analyticsQueryRequest.format',
@@ -1463,24 +1709,57 @@ const step17: MigrationStep = {
'export surface.',
},
{
- id: 'query-field-node-object-form-retired',
- surface: 'data.query.fields',
- replacement: "expand (`expand: { owner: { object: 'user', fields: ['name'] } }`), or a dotted path for a single related column (`fields: ['owner.name']`)",
+ id: 'api-runtime-create-withdrawn',
+ surface: 'PUT /api/v1/meta/api/{name} (runtime-authored `api` endpoints, draft and active alike)',
+ replacement:
+ 'Declare the endpoint as a stack artifact (`**/*.api.ts`, or `defineStack({ apis })`) '
+ + 'and ship it through `publishPackage`',
reason:
- 'The `FieldNode` union declared a nested-select object form `{ field, fields, alias }` that '
- + 'was inert end to end: no producer emitted it, and no consumer read `.fields` or `.alias` '
- + '— objectql\'s formula projection and known-field filters, driver-sql\'s `select()` and '
- + 'driver-memory\'s projection all treat the list as `string[]`, driver-mongodb keyed its '
- + 'projection with the entry itself, and the REST ingress stringified it. Nested selection '
- + 'is `expand`, which the engine resolves via batch `$in` queries. This is a REQUEST '
- + 'surface — `QueryAST` is never stored in stack metadata (no view, dataset or report '
- + 'authors one), so there is no source for the chain to rewrite: the schema narrows to '
- + '`z.string()` and callers move their own select lists. ADR-0049 / ADR-0078, #4196.',
+ 'The `api` registry entry declared `allowRuntimeCreate: true` and the runtime never '
+ + 'honoured it. Measured on a real showcase boot (#5488): `PUT /api/v1/meta/api/'
+ + 'e8_backdoor` answered 200 with `{"success":true,…,"message":"Saved …"}`, and the '
+ + 'declared route then answered 404 forever — with NO `[EndpointMatcher] … EXCLUDED` '
+ + 'line, because the endpoint was never in the index to be excluded from. The serving '
+ + 'criterion belongs to `IMetadataService.matchEndpoint` -> `EndpointMatcher` -> '
+ + "`MetadataManager.listForIndex('api')`, which reads the manager's registry plus its "
+ + 'registered loaders (`["filesystem","memory"]` on dev/serve); a runtime write lands '
+ + 'in `sys_metadata`, which is in neither. A declared capability the runtime does not '
+ + 'honour is ADR-0049 false compliance, and a write that answers "Saved" and then 404s '
+ + 'forever is its most dangerous shape for the AI authors ADR-0033 targets. The '
+ + 'maintainer ruled REMOVE on 2026-08-07 rather than converge the read path, because '
+ + 'making the matcher read `sys_metadata` re-opens cache, invalidation, tenancy and '
+ + "the ADR-0110 D3 miss-vs-outage distinction on a new read path, and there is no "
+ + 'business pull for Studio-authored endpoints today (zero `.api.*` artifacts author '
+ + 'them at runtime; showcase uses the artifact route, #5040 E8 LIVE). '
+ + 'There is NO D2 conversion, for the reason this list exists: nothing in an authored '
+ + 'source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an '
+ + 'authorable one, and the artifact route it points authors toward is untouched — a '
+ + '`**/*.api.ts` file valid before this change is valid after it, byte for byte. What '
+ + 'changed is a runtime HTTP verdict, so it is one semantic TODO for operators and '
+ + 'Studio callers rather than a stack conversion — the same disposition '
+ + '`BatchOptions.validateOnly` (#4052) takes. Consequently `gateApiDraftsForPublish` '
+ + '(PR #5279) is retired with it: it gated a promotion into a state the matcher can '
+ + 'never read, and with the inlet closed no `api` draft can exist for it to judge. '
+ + 'Re-entry is recorded in the ruling: if #2657 Part B promotes `apis` to a registered '
+ + 'type WITH A REAL CONSUMPTION PATH, the flag flips back then — implementation first, '
+ + 'declaration second. ADR-0049 / ADR-0121, #5488 (subsumes #5311).',
acceptanceCriteria:
- 'No caller puts an object in `fields[]`; related records are read through `expand` and '
- + 'single related columns through dotted paths. A `fields` entry that is not a string '
- + 'fails to parse with the removal prescription, and the list/query/export routes answer '
- + '400 INVALID_FIELD naming the retired form instead of the field `"[object Object]"`.',
+ 'No caller creates or updates an `api` item through the runtime metadata API. '
+ + '`PUT /api/v1/meta/api/{name}` answers 403 with `code: "NOT_CREATABLE"` and a body '
+ + 'naming both flags (`allowRuntimeCreate=false, allowOrgOverride=false`) and the '
+ + 'prescription `Declare it in source (**/*.api.ts) and redeploy` — in `?mode=draft` '
+ + 'as well as direct-active, because the gate runs before the draft/publish branch and '
+ + 'does not read `mode`. ⚠️ Verify the artifact route is UNAFFECTED, which is the whole '
+ + 'point of the change: a stack declaring `apis:` still compiles, still passes '
+ + '`validateApiEndpointDeclarations` at publish (`publishPackage`, #5189) and at load '
+ + '(`buildEndpointIndex`, PR #5203), and its endpoints still SERVE — that route was '
+ + 'always the only one that served. An operator who genuinely needs the runtime door '
+ + 'back on one deployment sets `OS_METADATA_WRITABLE=api`, the same single escape '
+ + 'hatch `job` / `agent` / `capability` use; note that this unlocks the WRITE only, and '
+ + 'the endpoint still will not be served, which is why it is a diagnostic and not a '
+ + 'workaround. Any `api` rows already sitting in `sys_metadata` from before this change '
+ + 'were never served either; they can be deleted (`deleteMetaItem` is deliberately not '
+ + 'gated by this refusal, so repair stays possible).',
},
{
id: 'batch-options-validate-only-retired',
@@ -1529,199 +1808,28 @@ const step17: MigrationStep = {
+ 'under `BatchOperationResultSchema` with those keys present.',
},
{
- id: 'query-joins-retired',
- surface: 'data.query.joins',
- replacement:
- "expand (`expand: { owner: { object: 'user', fields: ['name'] } }`), or a dotted "
- + "`fields` path for a single related column (`fields: ['owner.name']`)",
+ id: 'dashboard-widget-compareto-offset',
+ surface: "dashboard.widgets[].compareTo: { offset: '7d' | '1M' | … } (every duration except '1y')",
+ replacement: "compareTo: { kind: 'previousPeriod' } plus an explicit window on the widget's own `filter`",
reason:
- 'The `joins` array was declared-but-inert: no engine or driver read `query.joins` '
- + 'anywhere on the query path, so a query carrying it behaved exactly as if the key were '
- + 'absent — while the name squatted on the reserved REST parameter set. Related-record '
- + 'retrieval already has a live spelling (`expand`, resolved by the engine via batch '
- + '`$in` queries), so the removal deletes the second, broken spelling rather than the '
- + 'capability, and the orphaned `JoinNode`/`JoinType`/`JoinStrategy` cluster goes with '
- + 'the key. A REQUEST surface — `QueryAST` is never stored in stack metadata — so there '
- + 'is no source for the chain to rewrite; callers move their own queries. '
- + 'ADR-0049 / ADR-0078, #4286.',
+ 'The widget declared three comparison arms; the analytics executor implements one shape, '
+ + '`{ kind, dimension? }`, with no `offset` concept in it at all. On the ADR-0021 dataset '
+ + 'path — the spec\'s single author-facing analytics shape — `{ offset }` was forwarded '
+ + 'verbatim into that contract and threw `compareTo requires a timeDimension "undefined"`, '
+ + 'taking the widget down; the arm ever only ran on the legacy inline chart path (#5011). '
+ + "The conversion rewrites `{ offset: '1y' }`, which IS `previousYear` by definition. Every "
+ + 'other duration has NO faithful target: `previousPeriod` shifts by the length of whatever '
+ + "window the widget's filter resolves to, which equals `7d` only when that window happens "
+ + 'to be seven days long. Rewriting mechanically would silently change which rows the '
+ + 'comparison column counts — a wrong number rather than a missing one, which is strictly '
+ + 'worse and exactly the class this convergence exists to end. Re-stating the intended '
+ + 'window is a judgment about the presentation, not a transform.',
acceptanceCriteria:
- 'No caller sends `joins`; related records are read through `expand` and single related '
- + 'columns through dotted `fields` paths. A query that still carries `joins` fails to '
- + 'parse with the removal prescription (even as an empty array), and authoring it is a '
- + '`tsc` error at the call site.',
- },
- {
- id: 'query-window-functions-retired',
- surface: 'data.query.windowFunctions',
- replacement:
- '`aggregations` + `groupBy` for request-level analytics; '
- + '`SqlDriver.findWithWindowFunctions(object, query)` for embedders on a SQL datasource',
- reason:
- 'The `windowFunctions` array was declared-but-inert on the query path: `find()` never '
- + 'applied a window function, so every OVER clause a caller declared was silently '
- + 'dropped. The capability only ever ran behind `SqlDriver.findWithWindowFunctions()`, '
- + 'a driver-level door that is not on the `IDataDriver` contract and whose flat input '
- + 'shape (`{ function, alias, partitionBy?, orderBy? }`) the spec vocabulary never '
- + 'matched — `WindowFunctionNodeSchema` declared `field`/`over`/`frame` members the door '
- + 'never read, so that cluster is removed with the key rather than left as a false '
- + 'affordance. A REQUEST surface, never stored; no source to rewrite. '
- + 'ADR-0049 / ADR-0078, #4286.',
- acceptanceCriteria:
- 'No caller sends `windowFunctions` in a query; request-level analytics use '
- + '`aggregations` + `groupBy`, and embedders needing OVER-clause SQL call the SQL '
- + "driver's `findWithWindowFunctions` door directly. A query that still carries the key "
- + 'fails to parse with the removal prescription naming that door.',
- },
- {
- id: 'query-cursor-retired',
- surface: 'data.query.cursor',
- replacement:
- 'a `where` predicate on the sort key — `where: { created_at: { $gt: last.created_at } }` '
- + 'with the matching `orderBy` (the documented manual-keyset pattern)',
- reason:
- 'The `cursor` key promised keyset pagination and no driver implemented it: the cursor '
- + 'was accepted and ignored, so every page came back identical — a caller looping '
- + '"until hasMore is false" never terminates. Worse than inert, it had a shipped public '
- + 'producer (`QueryBuilder.cursor()`, removed with the key). The caller-built '
- + '`Record` shape also leaks sort/storage detail and squats on the '
- + 'reserved REST parameter set; a first-class cursor, if ever designed, will be a '
- + 'response-minted opaque token — a different API, so keeping this one preserved a '
- + 'wrong design rather than a roadmap. A REQUEST surface, never stored; nothing to '
- + 'rewrite. ADR-0049 / ADR-0078, #4286.',
- acceptanceCriteria:
- 'No caller sends `cursor` and no SDK call site uses `QueryBuilder.cursor()`; deep '
- + 'pagination expresses the keyset as a `where` predicate on the sort key. A query '
- + 'still carrying `cursor` fails to parse with the removal prescription, and authoring '
- + 'it is a `tsc` error.',
- },
- {
- id: 'query-distinct-retired',
- surface: 'data.query.distinct',
- replacement:
- '`groupBy` for unique combinations; the `count_distinct` aggregation for deduplicated '
- + "counts; the SQL/memory drivers' `distinct(object, field)` door for one column's values",
- reason:
- 'The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it '
- + 'was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list '
- + 'path treated a distinct query as not countable and silently degraded '
- + '`total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND '
- + 'worse pagination metadata, and a side effect that "confirmed" the flag was doing '
- + 'something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with '
- + 'the key). The count suppression is deleted in the same change — `total` is truthful '
- + 'for those queries again. A REQUEST surface, never stored; nothing to rewrite. '
- + 'ADR-0049 / ADR-0078, #4286.',
- acceptanceCriteria:
- 'No caller sends `distinct` and no SDK call site uses `QueryBuilder.distinct()`; '
- + 'deduplication goes through `groupBy` / `count_distinct` / the drivers\' `distinct()` '
- + 'door. A query still carrying the key fails to parse with the removal prescription, '
- + 'and the REST list response reports a real `total` for queries that used to send it.',
- },
- {
- id: 'query-array-string-agg-retired',
- surface: "data.query.aggregations[].function ('array_agg' / 'string_agg')",
- replacement:
- 'an ordinary `fields` query, shaped in the caller — or a stored field that materialises '
- + 'the roll-up. For a deduplicated COUNT the live spelling is unchanged: '
- + '`count_distinct` stays declared',
- reason:
- 'The stored half of this retirement is a conversion '
- + '(`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. '
- + '`QueryAST` is never stored in stack metadata — it is the client SDK builder\'s output '
- + 'and the `POST /data/:object/query` body — so there is no source for the chain to '
- + 'rewrite and callers move their own queries. Both values were declared-but-unlowered '
- + 'on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso '
- + '`RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller '
- + 'following the schema against a SQL datasource got a refusal, not an array. They did '
- + 'run on `driver-mongodb` and on the engine\'s in-memory fallback, which is what makes '
- + 'this the one narrowing in the batch that removes reachable behaviour: an aggregation '
- + 'that worked on one backend and failed on another is exactly the unpredictability the '
- + 'ruling ended, and #5499 has both of those backends frozen. `count_distinct` was '
- + 'deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049\'s '
- + 'enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049, #6188.',
- acceptanceCriteria:
- 'No caller sends `array_agg` or `string_agg` in `aggregations[].function`; list-style '
- + 'roll-ups are assembled by the caller from an ordinary `fields` query, or materialised '
- + 'as a stored field. A query still carrying either value fails to parse with the '
- + 'removal prescription naming it, and authoring it is a `tsc` error at the call site; '
- + '`count_distinct` continues to parse and is unaffected.',
- },
- {
- id: 'aggregation-node-distinct-retired',
- surface: 'data.query.aggregations[].distinct',
- replacement:
- 'the `count_distinct` aggregation FUNCTION for a deduplicated count — the one '
- + 'deduplicating spelling every face computes, lowered to `COUNT(DISTINCT field)` on '
- + 'both SQL faces since #6409. `SUM(DISTINCT …)` / `AVG(DISTINCT …)` get no '
- + 'replacement: no backend ever computed them here, and a per-row measure that needs '
- + 'deduplicating before summing is a modelling problem to fix in the data',
- reason:
- '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 frozen under #5499 — '
- + '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.',
- acceptanceCriteria:
- '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.',
- },
- {
- id: 'workflow-service-slot-retired',
- surface:
- "CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / "
- + 'discovery routes.workflow / RestApiRouteCategory workflow',
- replacement:
- 'the live mechanisms the slot only ever pointed at: `state_machine` validation rules '
- + 'for record state machines, approval flow nodes on the approvals runtime (ADR-0019) '
- + 'for approvals, lifecycle hooks + `record_change` flows (service-automation) for '
- + 'record-triggered automation',
- reason:
- '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.',
- acceptanceCriteria:
- '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).',
+ 'No dashboard widget declares `compareTo.offset`. Each former offset comparison states its '
+ + "window on the widget's `filter` and compares with `compareTo: { kind: 'previousPeriod' }` "
+ + "(or `'previousYear'`), and `dimension` is named wherever the selection dates more than one "
+ + 'time dimension. `objectstack validate` passes, and each affected widget renders a '
+ + '`__compare` column over the window its author intended.',
},
{
id: 'data-driver-find-stream-retired',
@@ -1759,42 +1867,6 @@ const step17: MigrationStep = {
+ 'method — one left behind still compiles and is simply never reached, so removing '
+ 'it is cleanup rather than a break, while a CALLER of it no longer type-checks.',
},
- {
- id: 'data-field-changed-event-retired',
- surface: "api.DataEventType 'data.field.changed'",
- replacement:
- "the `data.record.updated` event, whose payload already carries the per-field "
- + 'detail: `changes` (the changed fields), plus `before` / `after`',
- reason:
- '`data.field.changed` was declared in `DataEventType` and emitted by nothing — the '
- + 'engine\'s `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since '
- + '#4639) `data.records.{updated,deleted}`, and no other producer exists in either '
- + 'repository. A subscriber that switched on it was waiting on an event no producer '
- + 'sends: the branch never ran, and because the surrounding `switch` still compiled, '
- + 'nothing anywhere reported the gap (ADR-0078\'s silently-inert declaration, on the '
- + 'event vocabulary). `DataEventSchema` could not have carried the semantics even if '
- + 'something had emitted it — the payload is record-shaped (`recordId`, `changes`, '
- + '`before`, `after`) with no `field` / `oldValue` / `newValue` slot — so the member '
- + 'promised a granularity the contract has no room for. Per-field detail is therefore '
- + 'not lost: it has always ridden on `data.record.updated` as `changes`, which is one '
- + 'event per write rather than N events on a wide table. This is a runtime EVENT '
- + 'surface — no stack, example or template authors an event name (webhooks subscribe '
- + 'through the separate authorable `WebhookTriggerType`, whose vocabulary was already '
- + 'trimmed to producers that exist, #3196) — so there is no source for the chain to '
- + 'rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a '
- + 'retiredKey() fix-it error the way an authorable object key can (the same limit the '
- + 'sharing-rule `full` retirement hit above). The enforced channels are tsc, which '
- + 'fails any consumer still naming the value in a `DataEventType` position, and the '
- + 'enum parse, which now rejects the name instead of accepting an event that never '
- + 'arrives. A genuine per-field stream, if one is ever wanted, gets its own honest '
- + 'contract the way #4639 gave bulk writes theirs. ADR-0049 / ADR-0078, #4673.',
- acceptanceCriteria:
- 'No consumer subscribes to or switches on `data.field.changed`; per-field change '
- + 'detail is read from a `data.record.updated` event\'s `changes` map (with `before` / '
- + '`after` for the surrounding state). Deleting the dead branch changes no observable '
- + 'behaviour — it never executed — so the migration is removing code that could not '
- + 'run, not rebuilding a capability.',
- },
{
id: 'data-engine-batch-retired',
surface: 'contracts.IDataEngine.batch / data.DataEngineBatchRequestSchema',
@@ -1839,36 +1911,143 @@ const step17: MigrationStep = {
+ 'never reached; a CALLER of it no longer type-checks — and there were none.',
},
{
- id: 'rest-server-openapi31-block-removed',
- surface: 'restServer.openApi31',
+ id: 'data-field-changed-event-retired',
+ surface: "api.DataEventType 'data.field.changed'",
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)',
+ "the `data.record.updated` event, whose payload already carries the per-field "
+ + 'detail: `changes` (the changed fields), plus `before` / `after`',
reason:
- '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.',
+ '`data.field.changed` was declared in `DataEventType` and emitted by nothing — the '
+ + 'engine\'s `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since '
+ + '#4639) `data.records.{updated,deleted}`, and no other producer exists in either '
+ + 'repository. A subscriber that switched on it was waiting on an event no producer '
+ + 'sends: the branch never ran, and because the surrounding `switch` still compiled, '
+ + 'nothing anywhere reported the gap (ADR-0078\'s silently-inert declaration, on the '
+ + 'event vocabulary). `DataEventSchema` could not have carried the semantics even if '
+ + 'something had emitted it — the payload is record-shaped (`recordId`, `changes`, '
+ + '`before`, `after`) with no `field` / `oldValue` / `newValue` slot — so the member '
+ + 'promised a granularity the contract has no room for. Per-field detail is therefore '
+ + 'not lost: it has always ridden on `data.record.updated` as `changes`, which is one '
+ + 'event per write rather than N events on a wide table. This is a runtime EVENT '
+ + 'surface — no stack, example or template authors an event name (webhooks subscribe '
+ + 'through the separate authorable `WebhookTriggerType`, whose vocabulary was already '
+ + 'trimmed to producers that exist, #3196) — so there is no source for the chain to '
+ + 'rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a '
+ + 'retiredKey() fix-it error the way an authorable object key can (the same limit the '
+ + 'sharing-rule `full` retirement `owd-full-alias-removed` hit). The enforced channels are tsc, '
+ + 'which '
+ + 'fails any consumer still naming the value in a `DataEventType` position, and the '
+ + 'enum parse, which now rejects the name instead of accepting an event that never '
+ + 'arrives. A genuine per-field stream, if one is ever wanted, gets its own honest '
+ + 'contract the way #4639 gave bulk writes theirs. ADR-0049 / ADR-0078, #4673.',
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 '
- + '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.',
+ 'No consumer subscribes to or switches on `data.field.changed`; per-field change '
+ + 'detail is read from a `data.record.updated` event\'s `changes` map (with `before` / '
+ + '`after` for the surrounding state). Deleting the dead branch changes no observable '
+ + 'behaviour — it never executed — so the migration is removing code that could not '
+ + 'run, not rebuilding a capability.',
+ },
+ {
+ id: 'declarative-apis-endpoints-live',
+ surface: 'stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)',
+ replacement:
+ 'the same declarations, re-read as LIVE HTTP routes: `path` moved under '
+ + '`/api/v1/apps//`, and every entry that declares '
+ + '`authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying '
+ + '`rateLimit: { enabled: true, … }`',
+ reason:
+ 'This is the one protocol-17 entry that turns metadata ON rather than off, so read it '
+ + 'as a SECURITY review item and not as a rename. Before 17 the declarative endpoint '
+ + 'surface executed NOTHING: no route was mounted for a declared `path`, no matcher '
+ + 'existed, and every key — `authRequired` included — parsed green and gated nothing '
+ + '(#4936, which refused a non-empty `apis:` outright for exactly that reason). '
+ + 'Protocol 17 ships the executor (#5040) and narrows that refusal to a per-endpoint '
+ + 'publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as '
+ + 'soon as the stack is published. So an `apis:` block written against an older major — '
+ + 'or one restored from a pre-#4936 source, or authored from a doc that predates the '
+ + 'refusal — changes meaning without changing a byte: what used to be inert '
+ + 'documentation becomes an execution entry point into the data and automation '
+ + 'pipelines. Nothing about that transition can be applied mechanically, because the '
+ + 'judgment it needs is "did the author of this endpoint mean for the internet to reach '
+ + 'it?" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its '
+ + 'schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT '
+ + '`authRequired: false` is the only thing that opens anonymous access, and under '
+ + 'ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key '
+ + 'defaults to `false`, so a budget written without it meters nothing) or the stack '
+ + 'refuses to publish. ⚠️ If you author endpoints in TypeScript, annotate them with '
+ + '`ApiEndpoint` — the AUTHOR state — so that omitting `authRequired` compiles: '
+ + '`const e: ApiEndpoint = { name, path, method, type, target }` is legal and is the '
+ + 'safe shape this paragraph prescribes. `ApiEndpointParsed` is the POST-parse type '
+ + '(defaults materialized, ADR-0122), where `authRequired` is required — annotating a '
+ + 'declaration with it forces you to write the key out, and being made to think about a '
+ + 'key whose only unrecoverable value is `false` is the one thing this entry is trying '
+ + 'to avoid (#5227). Hold a parse RESULT with `ApiEndpointParsed`; write declarations '
+ + 'as `ApiEndpoint`. Grep every `apis:` entry for `authRequired: false` before you '
+ + 'upgrade, delete the ones that were never meant to be public, and arm a budget on the '
+ + 'ones that were. The path move is the mechanical-looking half and is still yours: '
+ + 'ADR-0121 D1/D2 confine a declared path to your own namespace carve-out '
+ + '(`/api/v1/apps//…`), the namespace comes from an explicit '
+ + '`manifest.namespace` with no derivation fallback, and the subpath is the only part '
+ + 'you name — rewriting it for you would silently change a URL third parties call.',
+ acceptanceCriteria:
+ 'You have READ every entry of every `apis:` block, not just the ones that fail to '
+ + 'publish. Concretely: (1) each declared `path` is '
+ + '`/api/v1/apps//` and the stack declares that '
+ + '`manifest.namespace` explicitly; (2) every entry declaring `authRequired: false` is '
+ + 'one you INTEND to be reachable without a session, and each carries '
+ + '`rateLimit: { enabled: true, windowMs, maxRequests }` — entries that were not '
+ + 'intended to be anonymous have the key removed so the safe default (`true`) applies; '
+ + '(3) `objectstack validate` passes, which also proves no endpoint declares a shape '
+ + '17.x cannot execute (`type: script` / `proxy`, mapping `transform`, an '
+ + '`object_operation` missing `objectParams`, `cacheTtl` on a non-GET method, '
+ + '`inputMapping` on find/get/delete, or two endpoints claiming one METHOD + path); and '
+ + '(4) after publishing, each endpoint answers as you expect — an anonymous request to '
+ + 'a session-only endpoint returns 401 rather than data.',
+ },
+ {
+ id: 'driver-aggregate-undeclared-key-aliases-removed',
+ // No backticks in `surface`: the upgrade-guide renderer wraps this string
+ // in a code span of its own, and a nested pair renders as literal ticks.
+ surface: "driver aggregate() call argument — query.aggregate and aggregations[].func",
+ replacement:
+ 'query.aggregations and aggregations[].function — the spellings QueryASTSchema and '
+ + 'AggregationNodeSchema have always declared',
+ reason:
+ '`SqlDriver.aggregate` and `RemoteTransport.aggregate` each read two aliases the '
+ + 'Query Protocol has never declared: `query.aggregations || query.aggregate` and '
+ + '`agg.function || agg.func`. "Never declared" is measured, not assumed — `git log '
+ + '-S` over `data/query.zod.ts` finds no commit that ever introduced either name, '
+ + 'there is no `retiredKey()` tombstone and no alias-table entry for them (the file\'s '
+ + 'only alias table is `SortNode`\'s `direction` → `order`), and neither appears in any '
+ + 'upgrade guide or release note. So this entry does not record a declared surface '
+ + 'being withdrawn; it records a LENIENCY being withdrawn, which is why it is here '
+ + 'rather than behind a tombstone. The only writers in this repository were the two '
+ + 'driver packages\' own fixtures — #4984\'s family, where a fixture spelling the alias '
+ + 'keeps the tolerant limb green forever and no test in existence can go red on its '
+ + 'deletion — so ADR-0049 enforce-or-remove applies once those are re-spelt. ⚠️ Do '
+ + 'NOT read this across to `dashboard`/`page` measures: `aggregate` IS the canonical '
+ + 'key there and `func` IS a declared, loudly-suggesting alias (`DatasetMeasureSchema`, '
+ + 'ui/dataset.zod.ts). That neighbouring vocabulary is untouched, and it is the most '
+ + 'likely reason an off-repo caller ever wrote these keys on a QUERY — one habit, two '
+ + 'surfaces, only one of which declared it. This is a driver CALL ARGUMENT — code, '
+ + 'never stack metadata — so there is no source for the D2 chain to rewrite and '
+ + 'deliberately no schema tombstone: nothing ever ran a query through '
+ + '`QueryASTSchema.parse()` on this path. The enforced channel is tsc at the call '
+ + 'site, once the parameter is `DriverQuery` — and for an untyped JS caller there is '
+ + 'no enforced channel at all, which is exactly why this ledger entry has to exist: '
+ + 'the generated upgrade guide is the only way such a reader learns of the rename. '
+ + 'Same disposition, and the same reason, as `data-driver-find-stream-retired` '
+ + '(#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` '
+ + '(#6011). ADR-0049 / ADR-0087, #6321 (PR #6404).',
+ acceptanceCriteria:
+ 'No caller passes `aggregate:` to a driver\'s `aggregate()`, and no aggregation entry '
+ + 'spells its function `func:`; both are written `aggregations:` / `function:`. An '
+ + 'inline literal still using either old spelling no longer type-checks (TS2353 at the '
+ + 'call site). An untyped JS caller that keeps writing `aggregate:` silently receives '
+ + 'no aggregate column — the grouping still happens, the measure is simply absent — '
+ + 'and one that keeps writing `func:` receives INVALID_QUERY / 400 naming the '
+ + 'undeclared function, identically on the local driver and the Turso remote '
+ + 'transport.',
},
{
id: 'driver-capabilities-inert-bits-removed',
@@ -1935,377 +2114,344 @@ const step17: MigrationStep = {
+ 'plugin.ts + engine.ts batched schema sync, verify date-bucket parity).',
},
{
- id: 'plugin-activation-events-retired',
- surface:
- 'kernel.dynamicLoadRequest.activationEvents / studio.studioPluginManifest.activationEvents',
+ id: 'driver-sql-distinct-bare-filter-typed',
+ // No backticks in `surface` — build-upgrade-guide.ts renders it inside a
+ // code span already, and a nested backtick would close it.
+ surface: 'SqlDriver.distinct() third argument — any value',
replacement:
- '(removed — delete the key. Every plugin activates immediately on load/registration, '
- + 'which is the only behaviour that has ever existed; `activate()` still runs at '
- + 'registration time. Lazy activation, if built, returns via the enforce route of '
- + 'ADR-0049 through a new ADR, with a vocabulary its executor actually honours)',
+ 'a bare FilterCondition (@objectstack/spec/data) — the same value find() carries '
+ + 'under query.where, never a query envelope',
reason:
- 'Both `activationEvents` keys — and the `ActivationEventSchema` trigger vocabulary '
- + 'they embedded (`onCommand` / `onRoute` / … / `onView` after the #4653 convergence) — '
- + '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 '
- + '(#3950: an exported schema with no consumer is read as a capability). #4657. '
- + 'SUPERSEDED ON THE KERNEL SIDE by #4834 (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` below). The studio half of this entry is '
- + 'unaffected and still enforced by the strict manifest parse.',
+ 'This entry records a TYPE being added, not a surface being withdrawn, and it says '
+ + 'so up front because the distinction decides who has to do anything. `distinct` is '
+ + 'not declared on `IDataDriver`, so #5181 / #6075 never reached it and it kept '
+ + '`filters?: any` while its body said something far more specific — '
+ + '`applyFilters(builder, filters)` is handed the ARGUMENT ITSELF, never a `.where` '
+ + 'off it. ⚠️ RUNTIME BEHAVIOUR IS UNCHANGED by this entry\'s change: not one '
+ + 'statement moved, so no upgrade breaks at run time and nothing that answered '
+ + 'correctly stops. What the annotation removes is a compile-time hole, measured '
+ + 'rather than assumed: a truthy NON-OBJECT third argument — '
+ + '`distinct(\'orders\', \'product\', \'completed\')` — used to type-check and resolve '
+ + 'the UNFILTERED set, because `applyFilters` emits no predicate at all for a truthy '
+ + 'non-object, non-array filter. A call meaning "which products among completed '
+ + 'orders" answered with EVERY product, silently. That spelling is now TS2345 at the '
+ + 'call site. This is a driver CALL ARGUMENT — code, never stack metadata — so there '
+ + 'is no source for the D2 chain to rewrite and deliberately no schema tombstone, the '
+ + 'disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` '
+ + '(#5540), `actor-user-roles-to-positions` (#6011) and '
+ + '`driver-aggregate-undeclared-key-aliases-removed` (#6321) already carry. ⚠️ It '
+ + 'differs from those four in ONE measured way a reader should not have to infer: '
+ + 'because nothing changed at run time, an untyped JS caller is not affected BY THE '
+ + 'UPGRADE at all. The entry is here for a different reason — such a caller is exactly '
+ + 'the one tsc can never reach, and the silent widening above is a defect they may '
+ + 'ALREADY be sitting on, before and after this major. The generated upgrade guide is '
+ + 'the only channel that reaches them, which is why the fix is written down rather '
+ + 'than left to the compiler. ⛔ The reverse mismatch is NOT closed and no type can '
+ + 'close it: `FilterCondition` is an open map (`[key: string]: any`) because a filter '
+ + 'key IS a field name, so a query envelope `{ object, where }` is structurally a '
+ + 'valid filter — one constraining columns named `object` and `where` — and so is a '
+ + 'FilterArray. Both reach `distinct` type-checked and are refused at run time, '
+ + 'loudly, with INVALID_FILTER / 400. `driver-memory`\'s opposite half — where the '
+ + 'BARE spelling returns the unfiltered set in silence — stays open under the #5499 '
+ + 'freeze (#6320). ADR-0087, #6320.',
acceptanceCriteria:
- '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 #4834 criterion 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.',
+ 'No caller passes a non-object to `distinct()`\'s third argument. A scalar there is '
+ + 'now a compile error (`TS2345: Argument of type \'string\' is not assignable to '
+ + 'parameter of type \'FilterCondition\'`); rewrite it as the bare filter it was '
+ + 'always meant to be — `\'completed\'` becomes `{ status: \'completed\' }`. ⚠️ That '
+ + 'is NOT an equivalent rewrite: the old spelling returned the UNFILTERED set, so the '
+ + 'answer changes once fixed, and the changed answer is the one the call always meant. '
+ + 'An untyped JS caller gets no compile error and no behaviour change — for them this '
+ + 'entry is the only notice that the spelling never filtered anything. A query '
+ + 'envelope or a FilterArray in that slot still compiles and is rejected at run time '
+ + 'with INVALID_FILTER / 400.',
},
{
- id: 'plugin-runtime-family-retired',
+ id: 'engine-find-formula-order-by-refused',
surface:
- 'kernel.dynamicLoadRequest / kernel.dynamicUnloadRequest / kernel.dynamicPluginResult '
- + '/ kernel.pluginSource / kernel.dynamicPluginOperation',
+ 'engine.find(object, { orderBy }) and engine.findOne(object, { orderBy }) naming a '
+ + '`formula` field — the direct engine path, not the REST ingress',
replacement:
- '(removed — there is no replacement shape, because there is no operation to describe. '
- + 'Plugins are composed at boot: `defineStack` registers them and the kernel runs '
- + 'register → init → start; the set is fixed until the process restarts. Delete the '
- + 'import and the value. Runtime plugin loading, if it is ever built, returns via the '
- + 'enforce route of ADR-0049 through a new ADR — loader first, vocabulary second)',
+ 'denormalise the value onto the object (a stored field, written when the source '
+ + 'changes) and sort by that — the same remedy the REST ingress has prescribed since '
+ + '#6924 / #6994; a `summary` field is unaffected and still sorts, because it gets a '
+ + 'real maintained column',
reason:
- 'The five schemas declared the "Dynamic Loading" capability — runtime load / unload / '
- + 'reload of plugins without a kernel restart, with sandboxing, integrity hashes, '
- + 'drain strategies and dependent-cascade policy — and NOTHING implemented it. A '
- + 'bare-name scan of objectstack, cloud and objectui found zero references outside '
- + "this package's own declaration, its unit tests and the generated artifacts: no "
- + 'runtime ever received a `DynamicLoadRequest`, performed a load/unload, or produced '
- + 'a `DynamicPluginResult`. That is the ADR-0049 false-compliance shape at its most '
- + 'inviting to an AI author (ADR-0033), who reads `DynamicLoadRequestSchema` in the '
- + 'published IDE bundle as proof the platform hot-loads plugins and constructs a '
- + 'request that parses clean and is received by nobody (#3950: an exported schema '
- + 'with no consumer is read as a capability). The #3896 follow-up removed this '
- + "module's discovery/sandbox config island and left these five in place explicitly — "
- + '"operation contracts, not security promises; the enforce-or-remove call on them is '
- + 'a design decision rather than a correction" — but that suspension lived only in a '
- + 'changeset paragraph with no issue carrying it. #4834 is that decision, answered '
- + 'REMOVE. `experimental` was considered and rejected: it is only `.describe()` prose '
- + 'and cannot stop an import, the weakest of the three ADR-0049 channels. None of the '
- + 'five is stored metadata — they are root request/result payload shapes embedded in '
- + 'no parent schema and parsed against no metadata document — so no `sys_metadata` '
- + 'row can carry one and there is no source for the D2 chain to rewrite; this entry '
- + 'is the D3 record. The removal also subsumes the kernel half of '
- + '`plugin-activation-events-retired` (#4657): that tombstone goes with the shape '
- + 'that carried it. ADR-0049, #4834.',
- acceptanceCriteria:
- 'No code imports `DynamicLoadRequestSchema`, `DynamicUnloadRequestSchema`, '
- + '`DynamicPluginResultSchema`, `PluginSourceSchema`, `DynamicPluginOperationSchema` '
- + 'or any of their type aliases (`DynamicLoadRequest`, `DynamicUnloadRequest`, '
- + '`DynamicPluginResult`, `PluginSource`, `DynamicPluginOperation`, '
- + '`DynamicLoadRequestInput`, `DynamicUnloadRequestInput`) from '
- + '`@objectstack/spec` or `@objectstack/spec/kernel` — every one is TS2305 after '
- + 'upgrade, on every public entry (pinned by symbol identity in '
- + '`plugin-runtime-retirement.test.ts`). Nothing regresses at runtime, because '
- + 'nothing called anything: a caller that believed it was hot-loading a plugin was '
- + 'already only building an object. Boot-time composition through `defineStack` is '
- + 'unchanged.',
- },
- {
- id: 'declarative-apis-endpoints-live',
- surface: 'stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)',
- replacement:
- 'the same declarations, re-read as LIVE HTTP routes: `path` moved under '
- + '`/api/v1/apps//`, and every entry that declares '
- + '`authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying '
- + '`rateLimit: { enabled: true, … }`',
+ '#4226 / #4256 / #6994 closed the SORT axis at the REST ingress '
+ + '(`assertSortFieldsExist`, `400 INVALID_SORT`), which covers everything reaching '
+ + '`findData`: the list route, `POST /data/:object/query`, the export route and the '
+ + 'RPC dispatcher. A caller reaching `engine.find()` / `engine.findOne()` DIRECTLY '
+ + 'passed through none of it, and a `formula` ORDER BY there was dropped in silence. '
+ + 'Measured on a real driver: `asc` and `desc` came back BYTE-IDENTICAL, in insertion '
+ + 'order, under a success, with the rows carrying the very values they were asked to '
+ + 'be ordered by. No column exists to order by (a formula is computed on read, so no '
+ + 'driver materialises one), so the ORDER BY reached the driver, found nothing, and '
+ + 'the unknown-column backstop returned the rows unordered.\n\n'
+ + 'Ruled 2026-08-10 on #7095: an ORDER BY the engine cannot apply is a 4xx with '
+ + 'guidance prose at the public boundary, never a silent drop — the same direction as '
+ + 'the analytics dataset refusal envelope and the #6924 sort-hint prescription. The '
+ + "engine's documented internal-caller tolerance (`assertProjectionFieldsExist`'s "
+ + 'docblock) was to survive only behind a pinned internal path, and only if a MEASURED '
+ + 'internal call site relied on it. The #7095 sweep of every in-tree `orderBy` reaching '
+ + 'the engine directly — hooks, flows, reports, queue/job adapters, sharing, metadata '
+ + 'loaders, expand sub-reads — found NONE: every hardcoded internal sort names a real '
+ + 'stored column (`created_at`, `updated_at`, `version`, `priority`, `scheduled_for`, '
+ + '`started_at`, `next_run_at`, `recorded_at`, `id`), and no shipped object in the repo '
+ + 'declares a `formula` field at all. So no internal path shipped, and there is no flag '
+ + 'to opt back into the drop.\n\n'
+ + 'This is a CODE-path API, not stored metadata, so — like '
+ + '`hook-register-empty-object-target-refused` at this step — there is no `sys_metadata` '
+ + 'row for the D2 chain to rewrite and the ledger entry is the notification channel. '
+ + 'No mechanical rewrite exists in either direction: the platform cannot invent the '
+ + 'stored column the remedy prescribes, and it must not sort post-hoc instead — '
+ + '`driver.find` has already applied `limit` / `offset`, so re-sorting after the '
+ + 'formulas are evaluated would reorder an ARBITRARY PAGE, which looks correct on small '
+ + 'result sets and is wrong the moment pagination is involved.\n\n'
+ + 'ONE AUTHOR-REACHABLE SURFACE reaches this indirectly and is why it is not purely a '
+ + "code-side note: a saved report's `query.orderBy` (`sys_saved_report`) is forwarded "
+ + 'verbatim into `engine.find` by `plugin-reports`, bypassing the ingress gate. A '
+ + 'report authored to sort by a formula field used to run and return rows in an '
+ + 'arbitrary order; it now fails loudly, with the remedy in the message. One further '
+ + 'path is deliberately NOT a refusal: a nested `expand` sort raises this refusal '
+ + 'inside `expandRelatedRecords`, whose pre-existing graceful-degradation `catch` '
+ + 'swallows every expand failure and retains the raw foreign keys — so that path moves '
+ + 'from silent to OBSERVABLE (a warning naming the field and the fix) rather than '
+ + 'refusing. Reversing that backstop is a separate decision on all expand failure '
+ + 'modes. #7095, #6994, #6924, #4226, #4256, #3821, ADR-0112.',
+ acceptanceCriteria:
+ 'No `engine.find` / `engine.findOne` call site sorts by a `formula` field, and no saved '
+ + "report's `query.orderBy` names one — grep your report definitions for an `orderBy` "
+ + 'field whose object declares it as a `formula`, and denormalise it onto a stored '
+ + 'column written when the source changes. A `summary` / rollup field needs no action: '
+ + 'it has a real maintained column and sorts correctly. Reads complete with no '
+ + '`INVALID_SORT` naming a formula field, and no "Failed to expand relationship field" '
+ + 'warning whose error text names one.',
+ },
+ {
+ id: 'enhanced-api-error-field-errors-renamed',
+ surface: 'api.enhancedApiError.fieldErrors',
+ replacement: 'fields',
reason:
- 'This is the one protocol-17 entry that turns metadata ON rather than off, so read it '
- + 'as a SECURITY review item and not as a rename. Before 17 the declarative endpoint '
- + 'surface executed NOTHING: no route was mounted for a declared `path`, no matcher '
- + 'existed, and every key — `authRequired` included — parsed green and gated nothing '
- + '(#4936, which refused a non-empty `apis:` outright for exactly that reason). '
- + 'Protocol 17 ships the executor (#5040) and narrows that refusal to a per-endpoint '
- + 'publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as '
- + 'soon as the stack is published. So an `apis:` block written against an older major — '
- + 'or one restored from a pre-#4936 source, or authored from a doc that predates the '
- + 'refusal — changes meaning without changing a byte: what used to be inert '
- + 'documentation becomes an execution entry point into the data and automation '
- + 'pipelines. Nothing about that transition can be applied mechanically, because the '
- + 'judgment it needs is "did the author of this endpoint mean for the internet to reach '
- + 'it?" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its '
- + 'schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT '
- + '`authRequired: false` is the only thing that opens anonymous access, and under '
- + 'ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key '
- + 'defaults to `false`, so a budget written without it meters nothing) or the stack '
- + 'refuses to publish. ⚠️ If you author endpoints in TypeScript, annotate them with '
- + '`ApiEndpoint` — the AUTHOR state — so that omitting `authRequired` compiles: '
- + '`const e: ApiEndpoint = { name, path, method, type, target }` is legal and is the '
- + 'safe shape this paragraph prescribes. `ApiEndpointParsed` is the POST-parse type '
- + '(defaults materialized, ADR-0122), where `authRequired` is required — annotating a '
- + 'declaration with it forces you to write the key out, and being made to think about a '
- + 'key whose only unrecoverable value is `false` is the one thing this entry is trying '
- + 'to avoid (#5227). Hold a parse RESULT with `ApiEndpointParsed`; write declarations '
- + 'as `ApiEndpoint`. Grep every `apis:` entry for `authRequired: false` before you '
- + 'upgrade, delete the ones that were never meant to be public, and arm a budget on the '
- + 'ones that were. The path move is the mechanical-looking half and is still yours: '
- + 'ADR-0121 D1/D2 confine a declared path to your own namespace carve-out '
- + '(`/api/v1/apps//…`), the namespace comes from an explicit '
- + '`manifest.namespace` with no derivation fallback, and the subpath is the only part '
- + 'you name — rewriting it for you would silently change a URL third parties call.',
+ 'The wire has always carried `fields` — the validators, import coercion, ' +
+ 'validation-failure.ts, @objectstack/client and the console\'s field-error extractor ' +
+ 'all say `fields`, and nothing ever emitted `fieldErrors`, so a reader keying on it ' +
+ 'was reading a field no server sent (ADR-0078\'s silently-inert declaration, on the ' +
+ 'error envelope). This is a RESPONSE surface: no stack, example or template carries ' +
+ 'the key, so there is no source for the chain to rewrite — the schema tombstones it ' +
+ 'via retiredKey() and consumers move their read themselves. ADR-0114 D4, #3977.',
acceptanceCriteria:
- 'You have READ every entry of every `apis:` block, not just the ones that fail to '
- + 'publish. Concretely: (1) each declared `path` is '
- + '`/api/v1/apps//` and the stack declares that '
- + '`manifest.namespace` explicitly; (2) every entry declaring `authRequired: false` is '
- + 'one you INTEND to be reachable without a session, and each carries '
- + '`rateLimit: { enabled: true, windowMs, maxRequests }` — entries that were not '
- + 'intended to be anonymous have the key removed so the safe default (`true`) applies; '
- + '(3) `objectstack validate` passes, which also proves no endpoint declares a shape '
- + '17.x cannot execute (`type: script` / `proxy`, mapping `transform`, an '
- + '`object_operation` missing `objectParams`, `cacheTtl` on a non-GET method, '
- + '`inputMapping` on find/get/delete, or two endpoints claiming one METHOD + path); and '
- + '(4) after publishing, each endpoint answers as you expect — an anonymous request to '
- + 'a session-only endpoint returns 401 rather than data.',
+ 'No consumer reads `error.fieldErrors`; per-field validation detail is read from ' +
+ '`error.fields`, and constructing an EnhancedApiError with `fieldErrors` fails to parse ' +
+ 'with the rename prescription instead of silently losing the array.',
},
{
- id: 'ui-widget-i18n-family-retired',
+ id: 'etl-pipeline-layer-retired',
surface:
- 'ui.widgetManifest / ui.widgetLifecycle / ui.widgetEvent / ui.widgetProperty '
- + '/ ui.widgetSource / ui.i18nObject / ui.pluralRule / ui.numberFormat / ui.dateFormat '
- + '/ ui.localeConfig (the widget-registration vocabulary of ui/widget.zod.ts, and the '
- + 'five doorless shapes of ui/i18n.zod.ts — 10 defs, 26 exported names)',
+ 'automation.etlPipeline / automation.etlPipelineRun / automation.etlSource / '
+ + 'automation.etlDestination / automation.etlTransformation (the whole L2 layer of '
+ + 'automation/etl.zod.ts, its four enums and the `ETL` factory — 9 defs, 27 exported '
+ + 'names)',
replacement:
- '(removed — there is no replacement key, because there was never a key. A custom field '
- + 'widget is still named the same way it always was: `field.widget` is a plain string '
- + 'naming a component the RENDERER has registered, and objectui\'s registry has always '
- + 'carried its own runtime manifest for that (`RuntimeWidgetManifest` / '
- + '`RuntimeWidgetSource` in `@object-ui/types`, objectui#3161 / #4115), which models '
- + 'different keys and never derived from these. For localisation: write the '
- + 'default-language string on `label` / `description` — the framework generates the '
- + 'translation key at registration time from the naming convention — and put '
- + 'translations in translation files, which is the LIVE `system/translation.zod.ts` '
- + 'surface. Widget registration and locale formatting as authorable protocol metadata '
- + 'return via the ENFORCE route of ADR-0049 through a new ADR — the registry / loader / '
- + 'formatter first, the vocabulary second)',
+ '(removed — no protocol surface replaces it, deliberately. Layer by layer: '
+ + 'connector-attached synchronisation is `ConnectorSchema.syncConfig` '
+ + '(`integration/connector.zod.ts`), which IS parsed and executed; per-field value '
+ + 'transformation on import is `shared/mapping.zod.ts`, whose `transform` is applied '
+ + 'row by row by the REST import path and recorded key by key in '
+ + '`packages/spec/liveness/mapping.json`; scheduling is `system/job.zod.ts`. What has '
+ + 'NO replacement is multi-source, multi-stage movement with joins and aggregations — '
+ + 'because it never had an implementation either. It returns through the ENFORCE route: '
+ + 'the engine first, the vocabulary second)',
reason:
- '`ui/widget.zod.ts` published a complete widget-registration vocabulary — a manifest '
- + 'with lifecycle hooks, custom events, configurable properties and an '
- + 'npm/remote/inline implementation-source union — and `ui/i18n.zod.ts` published a '
- + 'structured-label, plural-rule and locale-formatting vocabulary. NOTHING in the '
- + 'protocol carried either. Three independent measurements, re-run on `origin/main` '
- + 'immediately before the removal with their controls passing in the SAME run: (1) no '
- + 'module under `packages/spec/src` imported `widget.zod` at all, and the only imports '
- + 'of `i18n.zod` anywhere name `I18nLabelSchema` / `AriaPropsSchema` (both KEPT), so no '
- + 'schema declared a carrier key — `field.widget` is a `z.string()` naming a registered '
- + 'component and has never referenced `WidgetManifest`; (2) a BFS over the in-memory '
- + 'Zod graph from all 24 metadata-type roots plus `defineStack`\'s `ObjectStackSchema` '
- + 'reached none of them, while `PageSchema` / `ObjectListViewSchema` resolved `direct` '
- + 'in the same run and a synthetic carrier flipped every one of them; (3) zero '
- + '`.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these files\' '
- + 'own unit tests. `NumberFormat` / `DateFormat` DID have a carrier key '
- + '(`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier was itself doorless, '
- + 'so the subtree was `no door` rather than `no gate` and goes whole — leaving the two '
- + 'leaves behind would strand exported schemas with no consumer (#3950). '
- + '`I18nObjectSchema` was additionally superseded by its own file-neighbour: '
- + '`I18nLabelSchema`\'s documentation already says translation keys are generated at '
- + 'registration time and translations live in translation files, and the live '
- + 'translation surface is `system/translation.zod.ts`, which uses none of these shapes. '
- + 'The 2026-08-06 ruling weighed giving them a carrier (option B) and rejected it: that '
- + 'is a feature with a registry and a renderer behind it, not ledger clean-up. '
- + 'Tightening them to `strictObject` was rejected earlier and explicitly (#4001 批 16) '
- + '— strictness is a property of a PARSE and there is no parse, so it would spend a '
- + 'breaking change to leave "a precisely validated dead slot, the more convincing lie" '
- + '(#4583). With no carrier key there is nothing to tombstone and no `sys_metadata` row '
- + 'or source file for a D2 conversion to rewrite: this entry is the D3 record, route 3, '
- + 'the same shape as #4988 (the ui/ interaction config family), #4834 (kernel '
- + 'plugin-runtime family) and #4938 (`HttpServerConfig`). '
- + '⚠️ `WidgetManifest.performance`\'s own `retiredKey()` tombstone (#3896 close-out) is '
- + 'SUBSUMED here, the #4657/#4834 way: it goes with the shape that carried it, which is '
- + 'strictly stronger than the tombstone, because there is no longer a manifest to '
- + 'author the key INTO. '
- + '⚠️ One of the nine widget sites is deliberately NOT retired. '
- + '`FieldWidgetPropsSchema` survives: it is a REACT PROPS CONTRACT rather than '
- + 'authorable metadata (it never appeared in `authorable-surface/` or '
- + '`json-schema.manifest/` — its `onChange` is a `z.function()`), so "zero parse" is its '
- + 'design and not its defect, and it acquired a live cross-repo compile-time consumer '
- + 'one day before 批 16 measured: objectui PR #3289 (2026-08-03) renamed '
- + '`@object-ui/fields`\' validation slot onto the spec\'s `error` with no alias, the '
- + 'form renderer began producing it, and '
- + '`packages/fields/src/__tests__/spec-symbol-batch7.test.ts` pins the shape against '
- + '`import type { FieldWidgetProps } from \'@objectstack/spec/ui\'` as an intentional '
- + 'tripwire. Re-verified on objectui `origin/main` 2026-08-07. ADR-0049, #5055.',
+ 'The reading #4738 used to retire L1 `DataSyncConfig`, re-measured one layer up and '
+ + 'identical: narrative-only. No engine ever parsed, scheduled or executed an '
+ + '`ETLPipeline`. Measured on origin/main immediately before the removal: the only '
+ + 'non-spec references in this repo are two fumadocs-generated documentation sources '
+ + '(`apps/docs/.source/*.ts`), not executors; objectui has no reference at all; there '
+ + 'is no `liveness/etl.json` or `pipeline.json`, so no ADR-0049 gate ever had a reading '
+ + 'on it — while the same file family\'s EXECUTED half does have one '
+ + '(`liveness/mapping.json`), which is the contrast that makes the absence meaningful '
+ + 'rather than an oversight. The `etl` string in this registry was the one untested '
+ + 'link the finding named, and it is not a loader path: it was the id of the #4962 '
+ + 'retry-vocabulary entry, absorbed here. '
+ + 'The layer was ADR-0078\'s asymmetry in its purest form — an author could write a '
+ + 'complete ten-stage pipeline, get no error, and get no execution. It was also '
+ + 'advertised: `packages/spec/docs/SYNC_ARCHITECTURE.md` named `ETLPipeline` as the '
+ + 'recommended destination for authors displaced by the L1 retirement (#4738) and '
+ + 'listed ten transformation types with copyable examples down to '
+ + '`script | Custom JavaScript/Python`. That document is rewritten in the same change; '
+ + 'a retirement whose own doc still recommends the retired layer is self-contradictory, '
+ + 'and forwarding L1\'s authors to a second layer with no executor was the defect '
+ + 'compounding rather than closing. '
+ + '⚠️ `etl-retry-converged-onto-retry-policy` (#4962) is SUBSUMED here, the '
+ + '#4657/#4834/#5055 way: both land in the unreleased protocol 17, so composed, a '
+ + 'rename of `retry.maxAttempts` on a shape that does not survive the major has no '
+ + 'observable effect — and keeping both would tell an upgrader to rewrite a key on a '
+ + 'schema the same upgrade deletes. The `maxAttempts` `retiredKey()` tombstone goes '
+ + 'with the shape that carried it, which is strictly stronger than the tombstone: there '
+ + 'is no longer a `retry` block to author the key into. Route 3 — no carrier key, no '
+ + 'parse site, so no D2 conversion and no tombstone; RETIRED_DEFS_BY_MAJOR plus this '
+ + 'entry are the declaration. ADR-0049, ADR-0078, #6414.',
acceptanceCriteria:
- 'No code imports `WidgetManifest(Schema|Parsed)`, `WidgetLifecycle(Schema)`, '
- + '`WidgetEvent(Schema|Parsed)`, `WidgetProperty(Schema|Parsed)`, '
- + '`WidgetSource(Schema|Parsed)`, `I18nObject(Schema)`, `PluralRule(Schema)`, '
- + '`NumberFormat(Schema|Parsed)`, `DateFormat(Schema)` or '
- + '`LocaleConfig(Schema|Parsed)` from `@objectstack/spec` or `@objectstack/spec/ui` — '
- + 'every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol '
- + 'identity in `ui/widget-i18n-retirement.test.ts`). No metadata document needs '
- + 'editing, because none could ever carry one of these shapes: a stack that parsed '
- + 'before parses byte-for-byte the same after, and a `field.widget: "my_picker"` string '
- + 'is untouched. `FieldWidgetProps` / `FieldWidgetPropsSchema` / '
- + '`FieldWidgetPropsParsed`, `I18nLabel(Schema)` and `AriaProps(Schema)` all still '
- + 'resolve on `@objectstack/spec/ui` and are asserted to. ⚠️ objectui needs a companion '
- + 'PR in the same window: `packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts` '
- + 'asserts the spec STILL owns `WidgetManifest` / `WidgetSource` (it is the '
- + '"a workaround should not outlive its reason" half of the objectui#3169 tripwire, '
- + 'designed to go red exactly here), and `packages/types/src/widget.ts`\'s '
- + '"Renamed off the spec\'s `WidgetManifest` name" comments now point at names that no '
- + 'longer exist. Both are prescribed responses to this removal, not collateral damage.',
+ 'No source imports `ETLPipeline`, `ETLPipelineParsed`, `ETLPipelineSchema`, '
+ + '`ETLPipelineRun(Schema)`, `ETLSource(Schema)`, `ETLDestination(Schema)`, '
+ + '`ETLTransformation(Schema)`, `ETLEndpointType(Schema)`, '
+ + '`ETLTransformationType(Schema)`, `ETLSyncMode(Schema)`, `ETLRunStatus(Schema)` or '
+ + 'the `ETL` factory from `@objectstack/spec/automation`; `tsc` reports TS2724/TS2305 '
+ + 'on any that survives. Every author who was pointed at L2 has been re-pointed by '
+ + 'name: SYNC_ARCHITECTURE.md no longer lists an L2 row, no longer recommends '
+ + '`ETLPipeline` as L1\'s destination and no longer advertises a transformation-type '
+ + 'table. The surviving layers still parse unchanged — a connector declaring '
+ + '`syncConfig` and an import declaring `mapping.transform` both behave exactly as they '
+ + 'did in 16.x.',
},
{
- id: 'ui-interaction-config-family-retired',
+ id: 'export-field-meta-constraints-retired',
surface:
- 'ui.touchInteraction / ui.gestureConfig / ui.dndConfig / ui.keyboardNavigationConfig '
- + '/ ui.componentAnimation / ui.motionConfig / ui.pageTransition / ui.offlineConfig '
- + '(the whole export surface of ui/touch.zod.ts, ui/dnd.zod.ts, ui/keyboard.zod.ts, '
- + 'ui/animation.zod.ts and ui/offline.zod.ts — 32 defs, 64 exported names)',
+ '@objectstack/rest: ExportFieldMeta.required / .system / .readonly / .hasDefault / '
+ + '.min / .max / .minLength / .maxLength (the map built by `buildFieldMetaMap`, '
+ + 'reached as `PreparedImport.metaMap` from `prepareImportRequest`)',
replacement:
- '(removed — there is no replacement key, because there was never a key. Touch targets, '
- + 'drag-and-drop, focus management, keyboard shortcuts and motion are RENDERER BUILT-IN '
- + 'behaviour: the component library decides them, not a per-page metadata author. '
- + 'Offline is a platform capability, and its vocabulary belongs on the sync engine that '
- + 'owns the queue, the conflict policy and the cache — none of which exists yet. Delete '
- + 'the import and the value. Whichever of these earns real product pull returns WITH its '
- + 'own vocabulary and its executor, the #4910 way, not by un-retiring a declaration)',
+ 'the object schema you already hold — read `fields[name].required` / `.system` / '
+ + '`.readonly` / `.defaultValue` / `.min` / `.max` / `.minLength` / `.maxLength` off '
+ + 'the same `ObjectSchema` you passed to `buildFieldMetaMap`, which is where the '
+ + 'ENGINE reads them and therefore the only copy that cannot drift',
reason:
- 'Five `@objectstack/spec/ui` modules declared a full interaction-configuration '
- + 'vocabulary — 22 `z.object` sites across touch/gesture, drag-and-drop, '
- + 'focus/keyboard, animation/motion and offline/sync — and NOTHING in the protocol '
- + 'carried them. This is the ADR-0049 false-compliance shape in its most inviting form '
- + 'for an AI author (ADR-0033), and worse than the ordinary declared-but-unread defect: '
- + '`authorable-surface.json` listed 109 keys under these defs and '
- + '`content/docs/references/ui/{touch,dnd,keyboard,animation,offline}.mdx` rendered them '
- + 'as authoring tables, so the published documentation advertised a vocabulary with no '
- + 'carrier key anywhere. An author following `dnd.mdx` and writing a `dnd:` block onto a '
- + 'page component was rejected by `PageComponentSchema` for an unrecognized key — the '
- + 'docs and the schema disagreeing about the platform (Prime Directive #10). Three '
- + 'independent measurements, each with its controls passing in the same run: (1) no '
- + 'module under `packages/spec/src` imported any of the five except the `ui/index.ts` '
- + 'barrel, so no schema declared a carrier key; (2) a BFS over the in-memory Zod graph '
- + 'from all 24 metadata-type roots plus `defineStack`\'s `ObjectStackSchema` (25 roots, '
- + '4742 nodes) reached none of the 21 named object shapes, while `PageSchema`, '
- + '`WebhookSchema` and `StateMachineSchema` all resolved `direct` and a synthetic '
- + 'carrier flipped all 21 — so unreachability was a fact about the graph, not a broken '
- + 'walker; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud '
- + 'outside these modules\' own unit tests. objectui holds TYPE re-exports and parity '
- + 'ratchets, never validators, and says so (#2561). The 2026-08-04 ruling weighed '
- + 'wiring a carrier key (option B) and rejected it: that is a feature with a renderer '
- + 'behind it, not ledger clean-up. It also weighed tightening the shapes to '
- + '`strictObject` and rejected that explicitly — strictness is a property of a PARSE and '
- + 'there is no parse, so it would spend a breaking change to leave "a precisely '
- + 'validated dead slot, the more convincing lie" (#4583). Because there was no carrier '
- + 'key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 '
- + 'conversion to rewrite: this entry is the D3 record, the same route 3 as #4834 (kernel '
- + 'plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ Not to be confused with '
- + '#5021, which retired the THEME `animation` block — a different file, different defs, '
- + 'and that one did have a carrier key and therefore a tombstone. ADR-0049, #4988.',
- acceptanceCriteria:
- 'No code imports any of the 64 retired names from `@objectstack/spec` or '
- + '`@objectstack/spec/ui` — `TouchTargetConfig(Schema)`, `GestureType(Schema)`, '
- + '`SwipeDirection(Schema)`, `SwipeGestureConfig(Schema)`, `PinchGestureConfig(Schema)`, '
- + '`LongPressGestureConfig(Schema)`, `GestureConfig(Schema)`, `TouchInteraction(Schema)`, '
- + '`TransitionPreset(Schema)`, `EasingFunction(Schema)`, `TransitionConfig(Schema)`, '
- + '`AnimationTrigger(Schema)`, `ComponentAnimation(Schema)`, `PageTransition(Schema)`, '
- + '`MotionConfig(Schema)`, `DragHandle(Schema)`, `DropEffect(Schema)`, '
- + '`DragConstraint(Schema)`, `DropZone(Schema)`, `DragItem(Schema)`, `DndConfig(Schema)`, '
- + '`FocusTrapConfig(Schema)`, `KeyboardShortcut(Schema)`, `FocusManagement(Schema)`, '
- + '`KeyboardNavigationConfig(Schema)`, `OfflineStrategy(Schema)`, '
- + '`ConflictResolution(Schema)`, `SyncConfig(Schema)`, `PersistStorage(Schema)`, '
- + '`EvictionPolicy(Schema)`, `OfflineCacheConfig(Schema)`, `OfflineConfig(Schema)` — '
- + 'every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol '
- + 'identity in `ui/interaction-config-retirement.test.ts`). No metadata document needs '
- + 'editing, because none could ever carry one of these blocks: a stack that parsed '
- + 'before parses byte-for-byte the same after. If you consumed the bare '
- + '`ConflictResolution` from `@objectstack/spec/ui` as a TYPE for your own offline code, '
- + 'declare that union locally — it is your client\'s policy, not the platform\'s. '
- + '`@objectstack/spec/integration`\'s `ConnectorConflictResolution` (connector sync) and '
- + '`@objectstack/spec/api`\'s `ConflictResolutionStrategy` (route merge policy) are '
- + 'different concepts and are untouched.',
+ 'ADR-0049 enforce-or-remove. These eight were never a source of truth: '
+ + '`buildFieldMetaMap(schema)` DERIVED each one from the very `schema` its caller '
+ + 'passed in, so the map carried a second copy of facts the caller already held. '
+ + "They existed for exactly one consumer — the import dry run's hand-copied "
+ + 'pre-check mirror (`firstMissingRequiredField` / `firstConstraintViolation`, '
+ + 'framework#3956) — and #4633 ruling D retired that mirror (PR #6532): the dry run '
+ + "now asks `DataProtocol.validateData` for the engine's verdict, which reads the "
+ + "object's own schema. That left all eight computed on every import and read by "
+ + 'NOTHING, which is the declared-and-unread shape ADR-0049 exists for; a constraint '
+ + 'vocabulary standing next to the presentation one with no enforcer behind it is '
+ + 'precisely the thing an AI-authored consumer mistakes for a contract. Verified '
+ + 'zero-reader before removal, per key and by type, across this repo (`packages/rest` '
+ + 'itself, and all five in-repo dependents of `@objectstack/rest`: runtime, cli, '
+ + "verify, plugin-auth, plugin-dev) and the `objectui` sibling; plugin-auth's "
+ + 'identity import forwards `prepared.metaMap` into `runImport` but reads only the '
+ + 'presentation keys through `coerceRow`. '
+ + 'Why this needs a ledger entry despite that sweep: it is the `findStream` (#4484) / '
+ + '`IStorageService.list` (#5540) / `actor-user-roles-to-positions` (#6011) '
+ + 'disposition — a published TS surface with NO spec schema, so there is no '
+ + '`retiredKey()` tombstone and no parse rejection that could carry a prescription, '
+ + 'and the ledger is the only channel that reaches an upgrader. It is if anything '
+ + 'blinder than those three: the keys shipped in a FINAL release (`@objectstack/rest` '
+ + '14.5.0) and have been published in every release since, and because they were '
+ + 'OPTIONAL keys on an interface that itself survives, a JavaScript consumer reading '
+ + '`meta.required` after the upgrade gets `undefined` with no error at all — tsc '
+ + 'reports at the read site only for a typed consumer. '
+ + 'Why D3 semantic and not a D2 conversion: there is nothing to convert. No authored '
+ + 'or stored metadata changes shape — `required` / `min` / `maxLength` and the rest '
+ + 'remain fully authorable on a field definition and fully enforced by the engine, '
+ + 'which is where they always lived. The only place these eight are ever spelled is '
+ + "inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can "
+ + 'reach them. ADR-0049 / ADR-0087, #6536 (the sweep PR #6532 deliberately deferred).',
+ acceptanceCriteria:
+ 'No code of yours reads any of the eight off a `buildFieldMetaMap` / '
+ + '`prepareImportRequest` result. Grep your sources for `.required` / `.hasDefault` / '
+ + '`.minLength` / `.maxLength` / `.min` / `.max` / `.system` / `.readonly` on an '
+ + '`ExportFieldMeta`-typed value; each hit moves to the object schema you already '
+ + 'passed in. ⚠️ Prove it against a RUN, not against tsc: these were optional keys, '
+ + 'so an untyped or `any`-typed read compiles clean and silently becomes `undefined` '
+ + '— assert that the constraint your code acts on is still observed on a real import, '
+ + 'not merely that the build is green. Note `hasDefault` has no one-to-one '
+ + "replacement key: it was the derived predicate `defaultValue != null`, mirroring the "
+ + "engine's `applyFieldDefaults` gate, so read `fields[name].defaultValue` and apply "
+ + 'that same `!= null` test yourself.',
},
{
- id: 'ui-notification-action-embed-config-retired',
- surface: 'ui.notificationAction / ui.embedConfig',
+ id: 'filter-regex-options-retired',
+ // No backticks in `surface` — build-upgrade-guide.ts renders it inside a
+ // code span already, and a nested backtick would close it.
+ surface:
+ 'data.filter $regex / $options — in a STORED filter (dashboard widget filter and '
+ + 'globalFilters, report runtimeFilter, page and component filter, solution-blueprint '
+ + 'filter), and equally in the where clause of a query request',
replacement:
- '(removed — there is no replacement shape, because there was never a key to write '
- + 'either into. Delete the import and the value. Notification presentation is still '
- + 'described by the surviving `NotificationType` / `NotificationSeverity` / '
- + '`NotificationPosition` vocabulary; public access to a form is granted by the LIVE '
- + '`FormView.sharing` block (`SharingConfig`), which is untouched. Notification action '
- + 'buttons as metadata, and iframe embedding, return via the enforce route of ADR-0049 '
- + 'through a new ADR — carrier key and renderer first, vocabulary second)',
+ '$icontains for the case-insensitive substring match this was almost always used '
+ + 'for, or $contains for a case-sensitive one — a pattern that genuinely needs a '
+ + 'regular expression has no filter-level replacement',
reason:
- 'Both shapes were published `@objectstack/spec/ui` vocabulary with NO AUTHORING DOOR. '
- + '#4001 批 14 measured them three ways on 2026-08-03 and this retirement re-ran all '
- + 'three against `origin/main` before removing anything, each with a positive control '
- + 'that passed in the same run: (1) CARRIER — no schema in `packages/spec/src` declared '
- + 'a key of either type (`ui/notification.zod`\'s only non-test importer was the '
- + 'barrel; `ui/sharing.zod`\'s were the barrel and `ui/view.zod.ts`, which names its '
- + 'SIBLING `SharingConfigSchema`), measured by resolving specifiers rather than '
- + 'substring-matching, because the repo holds two `sharing.zod` modules and a substring '
- + 'test miscredits `stack.zod.ts` to the UI one; (2) REACHABILITY — a BFS from the 24 '
- + 'metadata-type roots plus `defineStack`\'s `ObjectStackSchema`, over '
- + '`build-schemas.ts`\'s own walk including its derived-clone bridge, never reached '
- + 'either, while `Page` / `Action` / `DashboardWidget` / `Webhook` and `SharingConfig` '
- + 'itself all resolved `root-graph` in the same run and an injected synthetic carrier '
- + 'flipped both; (3) PARSE — zero `.parse()` in objectstack, cloud or objectui outside '
- + 'their own unit tests. So nobody could author one and nothing ever validated one: '
- + 'the #3950 shape, an exported schema with no consumer read as a capability, and the '
- + 'ADR-0033 trap where an AI author takes `EmbedConfigSchema` in the published bundle '
- + 'as proof the platform serves iframes. Neither is stored metadata and neither has a '
- + 'carrier, so no `sys_metadata` row can hold one and there is no source for the D2 '
- + 'chain to rewrite; this entry is the D3 record. 批 14 deliberately did NOT close them '
- + 'with `.strict()` — strictness is a property of a PARSE, and closing a shape nothing '
- + 'parses buys only "a precisely-validated dead slot, the more convincing lie" (#4583) '
- + '— and filed the disposition as #5015, ruled REMOVE on 2026-08-04. Each was orphaned '
- + 'by an earlier retirement one level up: `NotificationAction` lost its wrappers at '
- + '#4610 (`NotificationSchema` / `NotificationConfigSchema`, the #4535 C3 dual-source '
- + 'cleanup — that retirement\'s published "zero consumers" evidence was later falsified '
- + 'for objectui and is corrected on `ui/notification.zod`\'s tombstone; the removal '
- + 'itself stands, #5781), and `EmbedConfig` lost its key at 17.0.0 when the 2026-06 '
- + 'liveness audit retired `App.embed` (no iframe route ever read it) — that key still '
- + 'stands as a `retiredKey()` tombstone in `app.zod.ts`, so an author who wrote the KEY already '
- + 'meets a prescription; this removes the value shape that outlived it. ⚠️ The '
- + 'retirement is per SCHEMA, not per file: `ui/sharing.zod` KEEPS `SharingConfigSchema`, '
- + 'a live door carried by `FormViewSchema.sharing` and read by `rest-server.ts` to mount '
- + 'the anonymous form routes, and `ui/notification.zod` keeps its three presentation '
- + 'enums. objectui consumed `NotificationActionSchema.shape.variant` as a VOCABULARY '
- + '(never a parse) to pin its own hand-written `NotificationActionButton` interface — '
- + 'which is exactly why "has a consumer" never meant "has an authoring door" here; that '
- + 'pin is adapted objectui-side when it refreshes this dependency. ADR-0049, #5015.',
+ '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 #5701 itself, 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 #4706 ruling (B), not just the driver one: the contract half '
+ + '(#5701 — the `$icontains` declaration, the `$contains` family pinned '
+ + 'case-sensitive, and the `RETIRED_FILTER_OPERATORS` prescriptions) landed before the '
+ + 'ADR-0087 disposition gate (#6148) existed and so was never asked for a ledger entry; '
+ + 'the driver half (#5702) is where the refusal became executable. One surface, one '
+ + 'entry, registered from the half that made it observable. ADR-0049 / ADR-0087, '
+ + '#4706 / #5701 / #5702.',
acceptanceCriteria:
- 'No code imports `NotificationActionSchema`, `NotificationAction`, `EmbedConfigSchema` '
- + 'or `EmbedConfig` from `@objectstack/spec` or `@objectstack/spec/ui` — both are '
- + 'TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in '
- + '`notification-embed-retirement.test.ts`). The same pin asserts the SURVIVORS in the '
- + 'same run, and that half is equally load-bearing: `NotificationTypeSchema` / '
- + '`NotificationSeveritySchema` / `NotificationPositionSchema` and `SharingConfigSchema` '
- + 'must still be exported from `./ui`, and both modules must still load — a retirement '
- + 'that deleted either file would satisfy the absence half while destroying working '
- + 'surface. Nothing regresses at runtime, because nothing ever ran: no notification '
- + 'action was ever parsed from metadata and no iframe route ever read an embed config. '
- + 'Public form sharing is unaffected — `FormView.sharing` still gates the anonymous '
- + 'endpoints on `allowAnonymous` + `publicLink`.',
+ '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.',
+ },
+ // `etl-retry-converged-onto-retry-policy` (#4962) was registered in this step and
+ // ABSORBED by `etl-pipeline-layer-retired` (#6414), the §0 same-major
+ // rule: both land in the unreleased protocol 17, and composed, the rename
+ // `ETLPipeline.retry.maxAttempts` -> `maxRetries` has no observable effect
+ // because the shape carrying it does not survive the major. Leaving both
+ // would tell an upgrader to rewrite a key on a schema this same upgrade
+ // deletes, and would break the fixture-disjointness the replay contract
+ // asserts. The `agent.knowledge` / `WidgetManifest.performance` precedent:
+ // a tombstone goes with the shape that carried it, which is strictly
+ // stronger than the tombstone.
+ {
+ id: 'flow-retry-max-retries-required',
+ surface: "flow.errorHandling.maxRetries (under strategy: 'retry')",
+ replacement: 'an explicit count >= 1 (e.g. maxRetries: 3), or strategy: \'fail\'',
+ 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 ' +
+ '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.',
+ acceptanceCriteria:
+ "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.',
},
{
id: 'hook-context-session-roles-retired',
@@ -2345,986 +2491,978 @@ const step17: MigrationStep = {
+ 'value in it.',
},
{
- id: 'action-session-roles-to-positions',
- surface: 'ui.actionSession.roles',
- replacement: 'ui.actionSession.positions (an action body reads `ctx.session.positions`)',
+ id: 'hook-register-empty-object-target-refused',
+ surface:
+ "engine.registerHook(event, handler, { object: '' | [] | [''] }), and a scope whose "
+ + '`excludeObjects` cancels its `object` entirely',
+ replacement:
+ "name the object(s) — `object: 'account'` / `object: ['account', 'contact']` — or, for "
+ + "a global hook, `object: '*'` or no `object` key at all; for a cancelled scope, widen "
+ + '`object` or drop the overlapping names from `excludeObjects`',
+ reason:
+ '#4281 ruled that an empty hook target is not "no target" and closed the shape at the '
+ + "two METADATA doors — `HookSchema.object`'s refine and `hook-binder.ts`'s "
+ + '`normalizeObjects`. `engine.registerHook`, the CODE door, goes through neither, so '
+ + 'all three spellings still registered, each producing a defect the author did not '
+ + "write: `''` is FALSY, so the allow face was skipped entirely and the entry became a "
+ + "GLOBAL hook (#4281's headline failure mode — blank intent taking the broadest "
+ + "possible blast radius); `[]` and `['']` are truthy but admit no object name, so the "
+ + 'entry could never fire. #5928 then added the `excludeObjects` face, which brought a '
+ + 'fourth shape reached by arithmetic rather than by one bad name: an `object` list '
+ + 'every member of which is also excluded admits nothing, so that entry can never fire '
+ + 'either. All four are ADR-0078 silently-inert declarations, and all four are now '
+ + 'refused at REGISTRATION.\n\n'
+ + 'No mechanical rewrite exists, in either direction. The refused values carry no '
+ + "recoverable intent — `object: ''` could have meant `'*'` (what it actually did) or a "
+ + 'specific object name the author forgot to fill in, and those are opposite '
+ + 'registrations; choosing between them is a judgment the chain cannot make. Nor could '
+ + "the MATCHING read be changed instead: teaching the matcher that `''` is an "
+ + 'unmatchable name would silently convert a hook firing on every object into one '
+ + 'firing on none — the same class of defect pointing the other way, which is why '
+ + '#5928 declined to do it in passing.\n\n'
+ + '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. #6573, #4281, #4001, '
+ + '#5928, ADR-0078.',
+ acceptanceCriteria:
+ '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.',
+ },
+ {
+ id: 'http-server-runtime-vocabulary-retired',
+ 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)',
+ 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 '
+ + '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.',
+ acceptanceCriteria:
+ '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.',
+ },
+ {
+ id: 'import-run-automations-declared-default-corrected',
+ // No backticks in `surface` — build-upgrade-guide.ts renders it inside a
+ // code span AND a table cell (see the note on `spec-type-alias-input-suffix-retired`).
+ surface:
+ 'api.ImportRequest runAutomations — the declared default of the key on BOTH import '
+ + 'bodies, POST /api/v1/data/:object/import (ImportRequest) and its async twin POST '
+ + '/api/v1/data/:object/import/jobs (CreateImportJobRequest, which IS the same schema '
+ + 'object). It was declared default(false) and described as "off by default for '
+ + 'bulk"; it is now default(true), which is what the server has always done',
+ replacement:
+ 'an explicit runAutomations: false on any import request that is meant to load rows '
+ + 'without firing triggers/hooks. That spelling is unchanged and has always been the '
+ + 'only one the server read — what changes is that omitting the key now DECLARES what '
+ + 'it already DID. Callers who want automations on need write nothing',
reason:
- 'The MIRROR-IMAGE neighbour of the entry above, and the reason both are in this '
- + 'step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed '
- + 'outright, #5050), while the ACTION body\'s `ctx.session` carries it '
- + 'produced-and-really-populated. `buildActionSession()` '
- + '(`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` '
- + 'into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under '
- + 'the one spelling that ADR bans — so a body author met two different answers to one '
- + 'key name on one platform: rejected in a hook, live and full of values in an action. '
- + '#5613 ruled contract-first (maintainer, 2026-08-06: "C skeleton + A semantics"): '
- + 'phase 1 (#5697) declared the previously undeclared shape as `ActionSessionSchema`, '
- + 'and phase 2 renames the key. `positions` is now the canonical key on that schema '
- + 'and `roles` a deprecated alias of it (#5779); the producer emits both for one '
- + 'deprecation window (#5613 runtime half), after which `roles` is removed on the path '
- + 'the v11 session-alias removal already walked (#3280 deprecated → #3290 removed). '
- + 'Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: '
- + 'FIRST, there is no source to convert — an action `ctx.session` is constructed per '
- + 'dispatch and never persisted, so no `sys_metadata` row, example or template can '
- + 'carry the key — the `openApi31` (#4579) / `activationEvents` (#4657) / '
- + '`hook-context-session-roles-retired` (#5050) shape. SECOND, the only place the key '
- + 'is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed '
- + 'script whose `ScriptContext.session` is still `unknown`. A declarative transform '
- + 'cannot safely rewrite an identifier inside free-form code — exactly the reason the '
- + 'ADR-0090 wave delegated `current_user.roles` to the author at step 13 '
- + '(`cel-current-user-roles-to-positions`) instead of substituting text. '
- + 'Note what is deliberately NOT done here: the alias is not tombstoned. A '
- + '`retiredKey()` REJECTS the key, and a deprecation window exists precisely so the '
- + 'old spelling keeps working while its readers move — tombstoning during the window '
- + 'would be the removal it is meant to defer. The tombstone (or the plain deletion the '
- + 'authorable-surface ratchet adjudicates) belongs to the release that closes the '
- + 'window. Until then this entry IS the channel: `spec-changes.json` and the generated '
- + 'upgrade guide are how a reader learns the rename before the removal reaches them. '
- + 'ADR-0090 D3, ADR-0087, #5613 / #5779.',
+ '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.',
acceptanceCriteria:
- 'No action body reads `ctx.session.roles`; every such read is `ctx.session.positions` '
- + 'and observes the same array (the rename is a rename — the VALUE is '
- + '`ExecutionContext.positions` on both sides, which the runtime pin '
- + '`action-session-shape-contract.test.ts` asserts independently of the key name). '
- + 'Privilege is NOT re-derived from either spelling: a read that was '
- + '`roles.includes(\'admin\')` as an access check is rewritten to ask the security '
- + 'service (capability grants / placements / derived posture, ADR-0095), never '
- + 'renamed to `positions.includes(\'admin\')` — renaming that read migrates the defect '
- + 'rather than the code. Verify against a real dispatch, not a fixture: invoke an '
- + 'action as a caller holding positions and assert the body observed them under the '
- + 'canonical key. During the window both keys are present and equal, so a reader can '
- + 'be migrated and verified before the alias is removed; after it, `roles` is absent '
- + 'and a body still reading it sees `undefined` — which is why the read must be moved '
- + 'inside the window rather than at its close.',
+ 'Every import request of yours that must NOT fire triggers sends `runAutomations: '
+ + 'false` explicitly, rather than omitting the key and trusting the old declared '
+ + 'default. The check is worth doing precisely where it looks unnecessary: if you '
+ + 'build the body by parsing it through `ImportRequestSchema` (or the published JSON '
+ + 'Schema) and then send the PARSED object, your bulk loads were running with '
+ + 'automations OFF and will now run with them ON — that is the only class whose '
+ + 'behaviour changes, and it changes toward what an unvalidated caller always got. '
+ + '⚠️ Behaviour on the wire is deliberately UNCHANGED and should be verified as '
+ + 'such: a body that omits `runAutomations` fired triggers before this change and '
+ + 'fires them after, and `runAutomations: false` turns them off before and after. '
+ + 'Nothing starts being refused — the route never validated this body against the '
+ + 'schema and does not begin to. `dryRun` is unaffected and still runs NO automations '
+ + 'whatever the flag says (#6037).',
},
{
- id: 'actor-user-roles-to-positions',
- surface: 'action body / AI route: ctx.user.roles (req.user.roles)',
- replacement:
- 'ctx.user.positions (an AI route handler reads `req.user.positions`) — the same array, '
- + 'under the one spelling ADR-0090 D3 sanctions',
+ id: 'job-retry-policy-constraints-tightened',
+ surface: 'job.retryPolicy.maxRetries (> 10) / job.retryPolicy.backoffMultiplier (< 1)',
+ replacement: 'maxRetries <= 10, and backoffMultiplier >= 1',
reason:
- 'The THIRD face of the ADR-0090 `roles` → `positions` rename, and the only one whose '
- + 'surface the spec never declared. `ActorUser` '
- + '(`packages/runtime/src/security/actor-user.ts`) is the ONE producer of the `user` '
- + 'envelope handed to an action body as `ctx.user` and to an AI route handler as '
- + '`req.user`; it declared `positions` and `roles` side by side and filled them from a '
- + 'SINGLE assignment (`roles: core.positions`), so the two keys were verbatim identical '
- + 'on every dispatch — a second spelling of the vocabulary ADR-0090 D3 reserves and bans, '
- + 'published straight into author-written code. The maintainer ruled it closed IMMEDIATELY '
- + '(2026-08-06 14:49Z, #6011): no deprecation window, no dual-emit, the alias simply gone '
- + 'in 17 (PR #6048). '
- + '⚠️ Do not read this entry across to its neighbour above: '
- + '`action-session-roles-to-positions` governs `ctx.session`, a DIFFERENT object reached '
- + 'through the same `ctx`, and that one KEEPS its one-window dual-emit (#5613). Same word, '
- + 'same dispatch, two faces, two schedules — `ctx.user.roles` is absent in 17 while '
- + '`ctx.session.roles` still answers for the length of its window. '
- + 'What makes this entry different in KIND from both session-side siblings: `ctx.user` has '
- + 'no spec schema and never had one. It is a runtime TS interface, so unlike '
- + '`HookContext.session.roles` (tombstoned on a deliberately non-strict `HookContextSchema`, '
- + '#5050) and unlike `ActionSessionSchema` (declared contract-first at #5697 precisely so '
- + 'its key could be renamed), there is no schema key here to tombstone and no '
- + '`retiredKey()` prescription that could reach anybody — nothing ever ran an `ActorUser` '
- + 'through a `.parse()`, so a prescription there would have no one to reach. The enforced '
- + 'channel is tsc, and it reports at the READ site inside the author\'s own body; for an '
- + 'untyped or sandboxed body there is no enforced channel at all, which is exactly why '
- + 'this ledger entry has to exist — `spec-changes.json` and the generated upgrade guide '
- + 'are the ONLY way such a reader learns of the rename. It is the `findStream` (#4484) / '
- + '`IStorageService.list` (#5540) disposition — a TS/API contract, no stored source, no '
- + 'tombstone, tsc at the call site — applied to a surface that lives one layer further '
- + 'out than either: those two are at least DECLARED in `packages/spec/src/contracts`, '
- + 'this one only in `packages/runtime`. '
- + 'Why it is a D3 semantic TODO and not a D2 conversion, on the same two independent '
- + 'grounds as its session sibling: FIRST, there is no source to convert — an `ActorUser` '
- + 'is constructed per dispatch and never persisted, so no `sys_metadata` row, example or '
- + 'template can carry the key (the `openApi31` (#4579) / `activationEvents` (#4657) / '
- + '`hook-context-session-roles-retired` (#5050) shape). SECOND, the only place the key is '
- + 'ever SPELLED is inside an action body or an AI route handler: author-written JS/TS, or '
- + 'a sandboxed script. A declarative transform cannot safely rewrite an identifier inside '
- + 'free-form code — the same reason the ADR-0090 wave delegated `current_user.roles` to '
- + 'the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting '
- + 'text. '
- + 'The removal\'s hard precondition was met before it landed, and the result is recorded '
- + 'here because the ledger is where an upgrading consumer meets it: the declaration\'s own '
- + 'comment claimed the alias was "kept for the REST/AI shapes", and that claim was '
- + 'DISPROVEN face by face against `origin/main` — repo-wide `user.roles` was 4 hits, all '
- + 'of them in the pins PR #6048 flipped; the four `ActorUser` construction sites build '
- + 'server-side envelopes that never enter a response body; objectui\'s `.roles` reads '
- + 'belong to two unrelated producers (the better-auth session, and the '
- + '`/auth/me/permissions` payload). The `cloud` repo was NOT reachable in that session and '
- + 'is the one consumer face left unverified — this entry, and the changeset\'s FROM/TO '
- + 'prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087, #6011 (PR #6048).',
+ 'The converged RetryPolicy (#4661) keeps the automation side\'s bounds, which the job '
+ + 'side never had: `maxRetries` is capped at 10 and `backoffMultiplier` floored at 1. '
+ + 'Neither has a lossless rewrite. Clamping `maxRetries: 20` to 10 would halve a '
+ + 'retry budget its author chose, and a `backoffMultiplier` below 1 describes a delay '
+ + 'that SHRINKS on each attempt — retrying a failing dependency ever faster, which is '
+ + 'the opposite of backoff and was never a shape the engine meant to offer. Both now '
+ + 'fail at parse time with the bound named, rather than being silently reinterpreted. '
+ + 'Choosing the replacement count (or accepting the cap) is the author\'s call.',
acceptanceCriteria:
- 'No action body reads `ctx.user.roles` and no AI route handler reads `req.user.roles`; '
- + 'every such read is `.positions` and observes the SAME array — the value was '
- + '`ExecutionContext.positions` on both sides, so this is a pure key rename and no value '
- + 'has to be re-derived. Privilege is NOT re-derived from either spelling: a read that was '
- + '`roles.includes(\'admin\')` as an access check is rewritten to ask the security service '
- + '(capability grants / placements / derived posture, ADR-0095), never renamed to '
- + '`positions.includes(\'admin\')` — renaming that read migrates the defect rather than the '
- + 'code. Unlike `ctx.session` there is NO window to migrate inside: in 17 the key is '
- + 'already absent, so a typed body fails `tsc` at the read while an untyped or sandboxed '
- + 'one silently sees `undefined` — move the read AS you upgrade, not after it. Verify '
- + 'against a real dispatch rather than a fixture: invoke an action (and an AI route) as a '
- + 'caller holding positions, assert the body observed them under the canonical key, and '
- + 'assert the old key is ABSENT by key existence (`\'roles\' in ctx.user === false`) rather '
- + 'than by `undefined`, which cannot tell a removed key from one left behind holding '
- + 'nothing — the runtime pin `action-ctx-user-shape.test.ts` asserts both halves that way.',
+ 'Every job declaring `retryPolicy` parses: no `maxRetries` above 10 and no '
+ + '`backoffMultiplier` below 1 remain, and each adjusted value was re-chosen knowing a '
+ + 'retry re-runs the handler with its writes and callouts. No job fails to register '
+ + 'with the retry-policy bound prescription.',
},
{
- id: 'storage-service-list-retired',
- surface: 'contracts.IStorageService.list',
+ id: 'notification-list-cursor-retired',
+ // No backticks in `surface` — build-upgrade-guide.ts renders it inside a
+ // code span AND a table cell (see the note on `spec-type-alias-input-suffix-retired`).
+ surface:
+ 'api.listNotifications cursor — the key on BOTH halves of GET /api/v1/notifications '
+ + '(ListNotificationsRequestSchema and ListNotificationsResponseSchema) and the cursor '
+ + 'argument of the client SDK call client.notifications.list(). The same entry covers '
+ + 'the limit default: the request schema no longer declares default(20)',
replacement:
- 'track the keys you wrote (sys_file / file-reference records, queryable through '
- + 'ObjectQL with real pagination) instead of enumerating the bucket — and where no '
- + 'such record exists, the cursor-shaped `list(prefix, { cursor, limit })` this '
- + 'entry reserved, restored in #6781',
+ 'a larger `limit` — the route answers the newest N notifications and has no page 2. '
+ + 'There is no replacement for `cursor`, deliberately: nothing ever minted one, so no '
+ + 'caller holds a value to carry over. Callers that looped on it were re-reading the '
+ + 'first window and should read one window sized to what they display (the Console '
+ + 'bell polls exactly this way). For the removed `limit` default, send the number you '
+ + 'want explicitly if you were relying on 20 — omitting it takes the server window, '
+ + 'which is 50 on the platform inbox and clamped into 1..200, and has been since '
+ + 'before the declaration existed',
reason:
- '`list(prefix)` was an OPTIONAL contract method documented as "List files in a '
- + 'directory/prefix", and the two shipped adapters answered the same call with two '
- + 'different semantics — both of them silently incomplete. `LocalStorageAdapter.list` '
- + 'was a single-level `readdir`, so a nested key `a/b/c` was invisible under '
- + '`list(\'a\')` (only `a/b` came back), and a subdirectory that `stat` succeeded on '
- + 'was pushed into the result as a file, yielding a `StorageFileInfo` whose `size` is '
- + 'a directory inode and which cannot be downloaded at all. `S3StorageAdapter.list` '
- + 'was RECURSIVE (`ListObjectsV2` matches the whole key) and read neither '
- + '`IsTruncated` nor `ContinuationToken`, so past 1000 objects the "all files" a '
- + 'caller received was the first page, with no signal. One contract method, two '
- + 'dialects, both quietly incomplete — and the first feature that genuinely needed to '
- + 'enumerate a prefix (backup, orphan sweep, migration audit) would have got two '
- + 'different answers on two deployments without an error on either. #5172 was nearly '
- + 'that feature: it planned to drive attachment reclamation off '
- + '`list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one '
- + 'level down, and switched to queue-driven deferred work instead. Nothing consumed '
- + 'it afterwards: the only in-repo call site was the `SwappableStorageService` '
- + 'pass-through (which itself rejects when the active adapter has no `list`), and '
- + 'REST, CLI and the storage routes never called it. Remove was chosen over '
- + 'align-and-tighten (maintainer ruling, 2026-08-05, #5266): aligning would grow a '
- + 'conformance surface nobody walks, while a prefix listing that cannot paginate is '
- + 'the wrong signature to inherit — when a real caller needs enumeration it returns '
- + 'cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases '
- + '(nested keys, directory entries, >1000 objects) proving both backends agree. This '
- + 'is a TS/API contract surface — a storage adapter is CODE, never stack metadata — '
- + 'so there is no source for the chain to rewrite, and deliberately no schema '
- + 'tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription '
- + 'there would reach no one. The enforced channel is tsc, and it reports at the call '
- + 'site. Same disposition, and the same reason, as '
- + '`data-driver-find-stream-retired` (#4484). ADR-0049 / ADR-0087, #5540 '
- + '(analysis #5266).',
+ '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.',
acceptanceCriteria:
- 'No code calls `storage.list(...)` on the `file-storage` service or on any '
- + '`IStorageService` value. Code that needed "which files are under this prefix" '
- + 'reads the records it wrote — `sys_file` / file-reference rows carry the storage '
- + 'key and page deterministically through ObjectQL — rather than asking the bucket, '
- + 'which is also the only form that stays correct past 1000 objects and across both '
- + 'adapters. An adapter that still IMPLEMENTS `list` keeps compiling (an extra '
- + 'method is not an error on a class) and is simply unreachable through the '
- + 'contract, so deleting it is cleanup that can follow. The break is on the CALLER '
- + 'side: `storage.list(...)` no longer type-checks, and a PROXY typed against '
- + '`IStorageService` that forwards to `inner.list` is exactly such a caller — the '
- + 'one in `@objectstack/service-storage` goes with the adapters (#5541). '
- + '⚠️ AMENDED 2026-08-09 (#6781, maintainer ruling on cloud#1203, option B): the '
- + 'RESERVED route in the paragraph above was taken. `list` exists again on the '
- + 'contract, cursor-shaped — `list(prefix, { cursor, limit })` returning '
- + '`{ items, nextCursor }` — because cloud had two first-party callers this repo '
- + 'could not see when the measurement said "nothing calls it" (tenant attachment '
- + 'reclamation, marketplace snapshot GC). This does NOT un-retire anything and the '
- + 'acceptance criterion above is unchanged for what it actually governs: the '
- + 'single-argument `list(prefix): StorageFileInfo[]` is gone for good, a call written '
- + 'against it still fails to compile, and the two dialects it had are now pinned '
- + 'against each other in `storage-adapter-list.conformance.test.ts` rather than left '
- + 'to diverge. What changed for an upgrader is only the destination: prefer the '
- + 'records you wrote, and reach for the restored member when there are none.',
+ '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.',
},
{
- id: 'driver-aggregate-undeclared-key-aliases-removed',
- // No backticks in `surface`: the upgrade-guide renderer wraps this string
- // in a code span of its own, and a nested pair renders as literal ticks.
- surface: "driver aggregate() call argument — query.aggregate and aggregations[].func",
+ id: 'plugin-activation-events-retired',
+ surface:
+ 'kernel.dynamicLoadRequest.activationEvents / studio.studioPluginManifest.activationEvents',
replacement:
- 'query.aggregations and aggregations[].function — the spellings QueryASTSchema and '
- + 'AggregationNodeSchema have always declared',
+ '(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)',
reason:
- '`SqlDriver.aggregate` and `RemoteTransport.aggregate` each read two aliases the '
- + 'Query Protocol has never declared: `query.aggregations || query.aggregate` and '
- + '`agg.function || agg.func`. "Never declared" is measured, not assumed — `git log '
- + '-S` over `data/query.zod.ts` finds no commit that ever introduced either name, '
- + 'there is no `retiredKey()` tombstone and no alias-table entry for them (the file\'s '
- + 'only alias table is `SortNode`\'s `direction` → `order`), and neither appears in any '
- + 'upgrade guide or release note. So this entry does not record a declared surface '
- + 'being withdrawn; it records a LENIENCY being withdrawn, which is why it is here '
- + 'rather than behind a tombstone. The only writers in this repository were the two '
- + 'driver packages\' own fixtures — #4984\'s family, where a fixture spelling the alias '
- + 'keeps the tolerant limb green forever and no test in existence can go red on its '
- + 'deletion — so ADR-0049 enforce-or-remove applies once those are re-spelt. ⚠️ Do '
- + 'NOT read this across to `dashboard`/`page` measures: `aggregate` IS the canonical '
- + 'key there and `func` IS a declared, loudly-suggesting alias (`DatasetMeasureSchema`, '
- + 'ui/dataset.zod.ts). That neighbouring vocabulary is untouched, and it is the most '
- + 'likely reason an off-repo caller ever wrote these keys on a QUERY — one habit, two '
- + 'surfaces, only one of which declared it. This is a driver CALL ARGUMENT — code, '
- + 'never stack metadata — so there is no source for the D2 chain to rewrite and '
- + 'deliberately no schema tombstone: nothing ever ran a query through '
- + '`QueryASTSchema.parse()` on this path. The enforced channel is tsc at the call '
- + 'site, once the parameter is `DriverQuery` — and for an untyped JS caller there is '
- + 'no enforced channel at all, which is exactly why this ledger entry has to exist: '
- + 'the generated upgrade guide is the only way such a reader learns of the rename. '
- + 'Same disposition, and the same reason, as `data-driver-find-stream-retired` '
- + '(#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` '
- + '(#6011). ADR-0049 / ADR-0087, #6321 (PR #6404).',
+ 'Both `activationEvents` keys — and the `ActivationEventSchema` trigger vocabulary '
+ + 'they embedded (`onCommand` / `onRoute` / … / `onView` after the #4653 convergence) — '
+ + '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 '
+ + '(#3950: an exported schema with no consumer is read as a capability). #4657. '
+ + 'SUPERSEDED ON THE KERNEL SIDE by #4834 (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.',
acceptanceCriteria:
- 'No caller passes `aggregate:` to a driver\'s `aggregate()`, and no aggregation entry '
- + 'spells its function `func:`; both are written `aggregations:` / `function:`. An '
- + 'inline literal still using either old spelling no longer type-checks (TS2353 at the '
- + 'call site). An untyped JS caller that keeps writing `aggregate:` silently receives '
- + 'no aggregate column — the grouping still happens, the measure is simply absent — '
- + 'and one that keeps writing `func:` receives INVALID_QUERY / 400 naming the '
- + 'undeclared function, identically on the local driver and the Turso remote '
- + 'transport.',
+ '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 #4834 criterion 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.',
},
{
- id: 'spec-type-alias-input-suffix-retired',
- // Plain text, no markdown: build-upgrade-guide.ts renders this field inside a
- // code span AND inside a table cell, so backticks here break both.
+ id: 'plugin-manifest-loading-retired',
surface:
- 'type alias: the 102 XInput names of @objectstack/spec '
- + '(ConnectorInput, AppInput, PageInput, ActionInput, ServiceObjectInput, '
- + 'ExecutionContextInput, TaskInput, … — 52 files across api/ automation/ data/ '
- + 'identity/ integration/ kernel/ security/ system/ ui/)',
+ 'manifest.loading (the whole block: strategy / preload / codeSplitting / dynamicImport / '
+ + 'initialization / dependencyResolution / hotReload / caching / sandboxing / monitoring)',
replacement:
- 'the BARE name. ADR-0122 phase 2 moved the author state onto `X`, which makes `XInput` '
- + 'a character-for-character synonym of it — the permanent synonym D3 forbids. Drop the '
- + '`Input` suffix: `ConnectorInput` -> `Connector`. Symmetrically, a consumer that held '
- + 'a PARSE RESULT under the bare name moves to `XParsed`, which phase 1 (16.x) already '
- + 'declared for every schema whose two shapes differ, so the target name has existed for '
- + 'a release. NINE `*Input` names are NOT retired and need no edit: `ExpressionInput`, '
- + '`CronExpressionInput`, `TemplateExpressionInput` and `PredicateInput` are the bare '
- + 'aliases of their own `…InputSchema`, and `FormFieldInput`, `QueryInput`, `FieldInput`, '
- + '`ObjectStackDefinitionInput` and `NavigationItemInput` are composed (recursive or '
- + '`Partial`-shaped) types no bare alias denotes.',
+ 'nothing to re-declare — delete the key. Plugins are composed at boot: `defineStack` '
+ + 'registers them and the kernel runs `init` then `start` in an order topologically '
+ + "resolved from each composed plugin's own `dependencies` / `optionalDependencies` "
+ + '(`resolvePluginOrder` in `packages/core/src/plugin-order.ts`). For the isolation '
+ + '`loading.sandboxing` appeared to configure, use the plugin trust tier '
+ + '(`manifest.runtime`, ADR-0025 §3.6) and the manifest permission declarations, which '
+ + 'are the surfaces the platform actually enforces',
reason:
- 'This entry exists for the reason `data-driver-find-stream-retired` (#4484), '
- + '`storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) '
- + 'exist, and it is the same disposition: the surface is a TYPESCRIPT NAME, never stack '
- + 'metadata, so there is no source for a D2 conversion to rewrite and deliberately no '
- + 'schema tombstone — an `XInput` alias never had a carrier key, never emitted a def, '
- + 'and no `.parse()` ever saw it. Measured and verified rather than assumed: '
- + '`json-schema/`, `json-schema.manifest/` and `authorable-surface/` are BYTE-IDENTICAL '
- + 'across this change, because those generators enumerate runtime `z.ZodType` exports '
- + 'and never read a type alias. So nothing left the published metadata surface and '
- + 'RETIRED_DEFS_BY_MAJOR is deliberately untouched — an entry there would falsely claim '
- + 'the metadata contract shrank. The enforced channel is tsc: the name is gone, so every '
- + 'consumer gets TS2724/TS2305 naming the import. That is loud but MUTE about the '
- + 'replacement — a compile error says `ConnectorInput` does not exist, not that '
- + '`Connector` now means what it meant. The generated upgrade guide is the only channel '
- + 'that carries the second half, which is precisely the #6048 gap ADR-0087 registration '
- + 'exists to close. ⚠️ Deliberately NOT registered alongside it: the 1384 bare aliases '
- + 'the same change FLIPPED from `z.infer` to `z.input`. Those names all still exist and '
- + 'still resolve; what moved is which of a schema\'s two shapes they denote, and only '
- + 'where the two differ (663 of 1384 — the rest are isomorphic and the flip is a no-op '
- + 'there, pinned as such). A consumer holding an authored literal is made MORE correct '
- + 'by it, silently; one holding a parse result gets a tsc error at the first defaulted '
- + 'key it reads. Registering that as a rename would misdescribe it — no name was '
- + 'retired — and the changeset carries its own FROM -> TO for it. ADR-0122 D8/D9, '
- + '#6083 (PR #6279).',
+ 'ADR-0049 enforce-or-remove; maintainer ruling 2026-08-04 on #4914. The block declared a '
+ + 'complete plugin loading policy and NOTHING read it. A bare-name scan of all three '
+ + 'repos — objectstack, cloud (measured 2026-08-09) and objectui (measured at pickup), '
+ + 'each with a control probe proving the scan saw the tree — put every hit inside '
+ + '`packages/spec` itself: this module\'s own declaration, its own unit tests, the '
+ + '`Manifest.loading` embed and the generated artifacts. `manifest.loading.*` had zero '
+ + 'readers in `packages/core`, `packages/runtime` and `packages/metadata`. So the key '
+ + 'parsed, entered the manifest, and changed nothing — #3950, at the scale of a whole '
+ + 'block. What made it outrank ordinary inert-key cleanup is `sandboxing`: it declared '
+ + 'process / vm / iframe / web-worker isolation, IPC transports and an `allowedServices` '
+ + 'ACL, so an AI author (ADR-0033) reading that vocabulary concluded the platform '
+ + 'isolates plugins, wrote the config, and received a clean parse and zero isolation. An '
+ + 'inert security control is worse than an absent one because it is believed. Hot reload '
+ + 'was additionally a TWO-SOURCE defect: the docs pointed at this dead '
+ + '`PluginHotReloadSchema` while the only implementation body, `HotReloadManager` '
+ + '(`packages/core/src/hot-reload.ts`), reads a different vocabulary — '
+ + '`HotReloadConfigSchema` in `plugin-lifecycle-advanced.zod.ts`. Ruling §2 converges on '
+ + 'the surviving side: that schema is KEPT as the starting point for a future enforce '
+ + 'decision (it has an implementation body but no runtime composes it yet), and '
+ + 'enforcing it is deliberately a separate decision, not this retirement. '
+ + 'Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and '
+ + '`applyConversionsToStoredItem` maps a metadata type onto one of its collections. A '
+ + 'package manifest is neither — `PLURAL_TO_SINGULAR` has no `packages` / `plugins` '
+ + 'entry, so a manifest is not a stack collection member and a stored manifest row '
+ + 'passes that seam through unchanged. A conversion would be a transform with no seam '
+ + 'that ever runs.',
acceptanceCriteria:
- 'No source imports a name ending `Input` from `@objectstack/spec` except the nine listed '
- + 'above: `rg "\\b\\w+Input\\b" --type ts` over consumer code resolves only to those. A '
- + 'literal annotated with a bare spec type compiles while listing ONLY the keys the '
- + 'author means — `const c: Connector = { name, label, type }` type-checks, which it did '
- + 'not in 16.x — and a value read out of `XSchema.parse()` annotated with the bare name '
- + 'no longer compiles at the first defaulted key it reads (TS18048/TS2532), the signal '
- + 'that the annotation should be `XParsed`. `pnpm check:spec-parsed-alias` reports every '
- + 'bare alias as `z.input` and refuses both a bare `z.infer` alias and a reintroduced '
- + '`XInput` synonym.',
+ 'No `objectstack.plugin.json` and no stored package manifest carries a `loading` key. '
+ + 'The enforced channel is the one place a manifest is parsed with an author present: '
+ + '`os plugin build` runs `ManifestSchema.safeParse` and exits non-zero, printing the '
+ + 'tombstone prescription, so a manifest still declaring `loading` fails its build '
+ + 'rather than shipping. TypeScript authors get it earlier still — `loading` is typed '
+ + '`never`, so assigning it is a `tsc` error. ⚠️ Runtime behaviour is deliberately '
+ + 'UNCHANGED and must be verified as such: nothing ever read the block, so removing it '
+ + 'removes no behaviour. A package ALREADY INSTALLED whose stored manifest carries '
+ + '`loading` keeps working — the registry\'s `validate()` is an explicit diagnostic and '
+ + 'not a gate (it catches, logs `[metadata_spec_invalid]`, and registers the item '
+ + 'anyway, deliberately, so bad metadata is never a data outage), so such a row '
+ + 'degrades to one log line at registration rather than a boot failure. Clear it by '
+ + 'deleting the key from the source manifest and reinstalling.',
},
{
- id: 'driver-sql-distinct-bare-filter-typed',
- // No backticks in `surface` — see the note on the entry above.
- surface: 'SqlDriver.distinct() third argument — any value',
+ id: 'plugin-runtime-family-retired',
+ surface:
+ 'kernel.dynamicLoadRequest / kernel.dynamicUnloadRequest / kernel.dynamicPluginResult '
+ + '/ kernel.pluginSource / kernel.dynamicPluginOperation',
replacement:
- 'a bare FilterCondition (@objectstack/spec/data) — the same value find() carries '
- + 'under query.where, never a query envelope',
+ '(removed — there is no replacement shape, because there is no operation to describe. '
+ + 'Plugins are composed at boot: `defineStack` registers them and the kernel runs '
+ + 'register → init → start; the set is fixed until the process restarts. Delete the '
+ + 'import and the value. Runtime plugin loading, if it is ever built, returns via the '
+ + 'enforce route of ADR-0049 through a new ADR — loader first, vocabulary second)',
reason:
- 'This entry records a TYPE being added, not a surface being withdrawn, and it says '
- + 'so up front because the distinction decides who has to do anything. `distinct` is '
- + 'not declared on `IDataDriver`, so #5181 / #6075 never reached it and it kept '
- + '`filters?: any` while its body said something far more specific — '
- + '`applyFilters(builder, filters)` is handed the ARGUMENT ITSELF, never a `.where` '
- + 'off it. ⚠️ RUNTIME BEHAVIOUR IS UNCHANGED by this entry\'s change: not one '
- + 'statement moved, so no upgrade breaks at run time and nothing that answered '
- + 'correctly stops. What the annotation removes is a compile-time hole, measured '
- + 'rather than assumed: a truthy NON-OBJECT third argument — '
- + '`distinct(\'orders\', \'product\', \'completed\')` — used to type-check and resolve '
- + 'the UNFILTERED set, because `applyFilters` emits no predicate at all for a truthy '
- + 'non-object, non-array filter. A call meaning "which products among completed '
- + 'orders" answered with EVERY product, silently. That spelling is now TS2345 at the '
- + 'call site. This is a driver CALL ARGUMENT — code, never stack metadata — so there '
- + 'is no source for the D2 chain to rewrite and deliberately no schema tombstone, the '
- + 'disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` '
- + '(#5540), `actor-user-roles-to-positions` (#6011) and '
- + '`driver-aggregate-undeclared-key-aliases-removed` (#6321) already carry. ⚠️ It '
- + 'differs from those four in ONE measured way a reader should not have to infer: '
- + 'because nothing changed at run time, an untyped JS caller is not affected BY THE '
- + 'UPGRADE at all. The entry is here for a different reason — such a caller is exactly '
- + 'the one tsc can never reach, and the silent widening above is a defect they may '
- + 'ALREADY be sitting on, before and after this major. The generated upgrade guide is '
- + 'the only channel that reaches them, which is why the fix is written down rather '
- + 'than left to the compiler. ⛔ The reverse mismatch is NOT closed and no type can '
- + 'close it: `FilterCondition` is an open map (`[key: string]: any`) because a filter '
- + 'key IS a field name, so a query envelope `{ object, where }` is structurally a '
- + 'valid filter — one constraining columns named `object` and `where` — and so is a '
- + 'FilterArray. Both reach `distinct` type-checked and are refused at run time, '
- + 'loudly, with INVALID_FILTER / 400. `driver-memory`\'s opposite half — where the '
- + 'BARE spelling returns the unfiltered set in silence — stays open under the #5499 '
- + 'freeze (#6320). ADR-0087, #6320.',
+ 'The five schemas declared the "Dynamic Loading" capability — runtime load / unload / '
+ + 'reload of plugins without a kernel restart, with sandboxing, integrity hashes, '
+ + 'drain strategies and dependent-cascade policy — and NOTHING implemented it. A '
+ + 'bare-name scan of objectstack, cloud and objectui found zero references outside '
+ + "this package's own declaration, its unit tests and the generated artifacts: no "
+ + 'runtime ever received a `DynamicLoadRequest`, performed a load/unload, or produced '
+ + 'a `DynamicPluginResult`. That is the ADR-0049 false-compliance shape at its most '
+ + 'inviting to an AI author (ADR-0033), who reads `DynamicLoadRequestSchema` in the '
+ + 'published IDE bundle as proof the platform hot-loads plugins and constructs a '
+ + 'request that parses clean and is received by nobody (#3950: an exported schema '
+ + 'with no consumer is read as a capability). The #3896 follow-up removed this '
+ + "module's discovery/sandbox config island and left these five in place explicitly — "
+ + '"operation contracts, not security promises; the enforce-or-remove call on them is '
+ + 'a design decision rather than a correction" — but that suspension lived only in a '
+ + 'changeset paragraph with no issue carrying it. #4834 is that decision, answered '
+ + 'REMOVE. `experimental` was considered and rejected: it is only `.describe()` prose '
+ + 'and cannot stop an import, the weakest of the three ADR-0049 channels. None of the '
+ + 'five is stored metadata — they are root request/result payload shapes embedded in '
+ + 'no parent schema and parsed against no metadata document — so no `sys_metadata` '
+ + 'row can carry one and there is no source for the D2 chain to rewrite; this entry '
+ + 'is the D3 record. The removal also subsumes the kernel half of '
+ + '`plugin-activation-events-retired` (#4657): that tombstone goes with the shape '
+ + 'that carried it. ADR-0049, #4834.',
acceptanceCriteria:
- 'No caller passes a non-object to `distinct()`\'s third argument. A scalar there is '
- + 'now a compile error (`TS2345: Argument of type \'string\' is not assignable to '
- + 'parameter of type \'FilterCondition\'`); rewrite it as the bare filter it was '
- + 'always meant to be — `\'completed\'` becomes `{ status: \'completed\' }`. ⚠️ That '
- + 'is NOT an equivalent rewrite: the old spelling returned the UNFILTERED set, so the '
- + 'answer changes once fixed, and the changed answer is the one the call always meant. '
- + 'An untyped JS caller gets no compile error and no behaviour change — for them this '
- + 'entry is the only notice that the spelling never filtered anything. A query '
- + 'envelope or a FilterArray in that slot still compiles and is rejected at run time '
- + 'with INVALID_FILTER / 400.',
+ 'No code imports `DynamicLoadRequestSchema`, `DynamicUnloadRequestSchema`, '
+ + '`DynamicPluginResultSchema`, `PluginSourceSchema`, `DynamicPluginOperationSchema` '
+ + 'or any of their type aliases (`DynamicLoadRequest`, `DynamicUnloadRequest`, '
+ + '`DynamicPluginResult`, `PluginSource`, `DynamicPluginOperation`, '
+ + '`DynamicLoadRequestInput`, `DynamicUnloadRequestInput`) from '
+ + '`@objectstack/spec` or `@objectstack/spec/kernel` — every one is TS2305 after '
+ + 'upgrade, on every public entry (pinned by symbol identity in '
+ + '`plugin-runtime-retirement.test.ts`). Nothing regresses at runtime, because '
+ + 'nothing called anything: a caller that believed it was hot-loading a plugin was '
+ + 'already only building an object. Boot-time composition through `defineStack` is '
+ + 'unchanged.',
},
{
- id: 'filter-regex-options-retired',
- // No backticks in `surface` — see the note two entries above.
- surface:
- 'data.filter $regex / $options — in a STORED filter (dashboard widget filter and '
- + 'globalFilters, report runtimeFilter, page and component filter, solution-blueprint '
- + 'filter), and equally in the where clause of a query request',
+ id: 'query-array-string-agg-retired',
+ surface: "data.query.aggregations[].function ('array_agg' / 'string_agg')",
replacement:
- '$icontains for the case-insensitive substring match this was almost always used '
- + 'for, or $contains for a case-sensitive one — a pattern that genuinely needs a '
- + 'regular expression has no filter-level replacement',
+ 'an ordinary `fields` query, shaped in the caller — or a stored field that materialises '
+ + 'the roll-up. For a deduplicated COUNT the live spelling is unchanged: '
+ + '`count_distinct` stays declared',
reason:
- '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 #5701 itself, 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 #4706 ruling (B), not just the driver one: the contract half '
- + '(#5701 — the `$icontains` declaration, the `$contains` family pinned '
- + 'case-sensitive, and the `RETIRED_FILTER_OPERATORS` prescriptions) landed before the '
- + 'ADR-0087 disposition gate (#6148) existed and so was never asked for a ledger entry; '
- + 'the driver half (#5702) is where the refusal became executable. One surface, one '
- + 'entry, registered from the half that made it observable. ADR-0049 / ADR-0087, '
- + '#4706 / #5701 / #5702.',
+ 'The stored half of this retirement is a conversion '
+ + '(`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. '
+ + '`QueryAST` is never stored in stack metadata — it is the client SDK builder\'s output '
+ + 'and the `POST /data/:object/query` body — so there is no source for the chain to '
+ + 'rewrite and callers move their own queries. Both values were declared-but-unlowered '
+ + 'on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso '
+ + '`RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller '
+ + 'following the schema against a SQL datasource got a refusal, not an array. They did '
+ + 'run on `driver-mongodb` and on the engine\'s in-memory fallback, which is what makes '
+ + 'this the one narrowing in the batch that removes reachable behaviour: an aggregation '
+ + 'that worked on one backend and failed on another is exactly the unpredictability the '
+ + 'ruling ended, and #5499 has both of those backends frozen. `count_distinct` was '
+ + 'deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049\'s '
+ + 'enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049, #6188.',
acceptanceCriteria:
- '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.',
+ 'No caller sends `array_agg` or `string_agg` in `aggregations[].function`; list-style '
+ + 'roll-ups are assembled by the caller from an ordinary `fields` query, or materialised '
+ + 'as a stored field. A query still carrying either value fails to parse with the '
+ + 'removal prescription naming it, and authoring it is a `tsc` error at the call site; '
+ + '`count_distinct` continues to parse and is unaffected.',
},
{
- id: 'http-server-runtime-vocabulary-retired',
- 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)',
+ id: 'query-cursor-retired',
+ surface: 'data.query.cursor',
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)',
+ 'a `where` predicate on the sort key — `where: { created_at: { $gt: last.created_at } }` '
+ + 'with the matching `orderBy` (the documented manual-keyset pattern)',
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 '
- + '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.',
+ 'The `cursor` key promised keyset pagination and no driver implemented it: the cursor '
+ + 'was accepted and ignored, so every page came back identical — a caller looping '
+ + '"until hasMore is false" never terminates. Worse than inert, it had a shipped public '
+ + 'producer (`QueryBuilder.cursor()`, removed with the key). The caller-built '
+ + '`Record` shape also leaks sort/storage detail and squats on the '
+ + 'reserved REST parameter set; a first-class cursor, if ever designed, will be a '
+ + 'response-minted opaque token — a different API, so keeping this one preserved a '
+ + 'wrong design rather than a roadmap. A REQUEST surface, never stored; nothing to '
+ + 'rewrite. ADR-0049 / ADR-0078, #4286.',
acceptanceCriteria:
- '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.',
+ 'No caller sends `cursor` and no SDK call site uses `QueryBuilder.cursor()`; deep '
+ + 'pagination expresses the keyset as a `where` predicate on the sort key. A query '
+ + 'still carrying `cursor` fails to parse with the removal prescription, and authoring '
+ + 'it is a `tsc` error.',
},
{
- id: 'view-management-protocol-retired',
- 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)',
+ id: 'query-distinct-retired',
+ surface: 'data.query.distinct',
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',
+ '`groupBy` for unique combinations; the `count_distinct` aggregation for deduplicated '
+ + "counts; the SQL/memory drivers' `distinct(object, field)` door for one column's values",
+ reason:
+ 'The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it '
+ + 'was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list '
+ + 'path treated a distinct query as not countable and silently degraded '
+ + '`total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND '
+ + 'worse pagination metadata, and a side effect that "confirmed" the flag was doing '
+ + 'something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with '
+ + 'the key). The count suppression is deleted in the same change — `total` is truthful '
+ + 'for those queries again. A REQUEST surface, never stored; nothing to rewrite. '
+ + 'ADR-0049 / ADR-0078, #4286.',
+ acceptanceCriteria:
+ 'No caller sends `distinct` and no SDK call site uses `QueryBuilder.distinct()`; '
+ + 'deduplication goes through `groupBy` / `count_distinct` / the drivers\' `distinct()` '
+ + 'door. A query still carrying the key fails to parse with the removal prescription, '
+ + 'and the REST list response reports a real `total` for queries that used to send it.',
+ },
+ {
+ id: 'query-field-node-object-form-retired',
+ surface: 'data.query.fields',
+ replacement: "expand (`expand: { owner: { object: 'user', fields: ['name'] } }`), or a dotted path for a single related column (`fields: ['owner.name']`)",
reason:
- '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.',
+ 'The `FieldNode` union declared a nested-select object form `{ field, fields, alias }` that '
+ + 'was inert end to end: no producer emitted it, and no consumer read `.fields` or `.alias` '
+ + '— objectql\'s formula projection and known-field filters, driver-sql\'s `select()` and '
+ + 'driver-memory\'s projection all treat the list as `string[]`, driver-mongodb keyed its '
+ + 'projection with the entry itself, and the REST ingress stringified it. Nested selection '
+ + 'is `expand`, which the engine resolves via batch `$in` queries. This is a REQUEST '
+ + 'surface — `QueryAST` is never stored in stack metadata (no view, dataset or report '
+ + 'authors one), so there is no source for the chain to rewrite: the schema narrows to '
+ + '`z.string()` and callers move their own select lists. ADR-0049 / ADR-0078, #4196.',
acceptanceCriteria:
- '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.',
+ 'No caller puts an object in `fields[]`; related records are read through `expand` and '
+ + 'single related columns through dotted paths. A `fields` entry that is not a string '
+ + 'fails to parse with the removal prescription, and the list/query/export routes answer '
+ + '400 INVALID_FIELD naming the retired form instead of the field `"[object Object]"`.',
},
{
- id: 'etl-pipeline-layer-retired',
- surface:
- 'automation.etlPipeline / automation.etlPipelineRun / automation.etlSource / '
- + 'automation.etlDestination / automation.etlTransformation (the whole L2 layer of '
- + 'automation/etl.zod.ts, its four enums and the `ETL` factory — 9 defs, 27 exported '
- + 'names)',
+ id: 'query-joins-retired',
+ surface: 'data.query.joins',
replacement:
- '(removed — no protocol surface replaces it, deliberately. Layer by layer: '
- + 'connector-attached synchronisation is `ConnectorSchema.syncConfig` '
- + '(`integration/connector.zod.ts`), which IS parsed and executed; per-field value '
- + 'transformation on import is `shared/mapping.zod.ts`, whose `transform` is applied '
- + 'row by row by the REST import path and recorded key by key in '
- + '`packages/spec/liveness/mapping.json`; scheduling is `system/job.zod.ts`. What has '
- + 'NO replacement is multi-source, multi-stage movement with joins and aggregations — '
- + 'because it never had an implementation either. It returns through the ENFORCE route: '
- + 'the engine first, the vocabulary second)',
+ "expand (`expand: { owner: { object: 'user', fields: ['name'] } }`), or a dotted "
+ + "`fields` path for a single related column (`fields: ['owner.name']`)",
reason:
- 'The reading #4738 used to retire L1 `DataSyncConfig`, re-measured one layer up and '
- + 'identical: narrative-only. No engine ever parsed, scheduled or executed an '
- + '`ETLPipeline`. Measured on origin/main immediately before the removal: the only '
- + 'non-spec references in this repo are two fumadocs-generated documentation sources '
- + '(`apps/docs/.source/*.ts`), not executors; objectui has no reference at all; there '
- + 'is no `liveness/etl.json` or `pipeline.json`, so no ADR-0049 gate ever had a reading '
- + 'on it — while the same file family\'s EXECUTED half does have one '
- + '(`liveness/mapping.json`), which is the contrast that makes the absence meaningful '
- + 'rather than an oversight. The `etl` string in this registry was the one untested '
- + 'link the finding named, and it is not a loader path: it was the id of the #4962 '
- + 'retry-vocabulary entry, absorbed here. '
- + 'The layer was ADR-0078\'s asymmetry in its purest form — an author could write a '
- + 'complete ten-stage pipeline, get no error, and get no execution. It was also '
- + 'advertised: `packages/spec/docs/SYNC_ARCHITECTURE.md` named `ETLPipeline` as the '
- + 'recommended destination for authors displaced by the L1 retirement (#4738) and '
- + 'listed ten transformation types with copyable examples down to '
- + '`script | Custom JavaScript/Python`. That document is rewritten in the same change; '
- + 'a retirement whose own doc still recommends the retired layer is self-contradictory, '
- + 'and forwarding L1\'s authors to a second layer with no executor was the defect '
- + 'compounding rather than closing. '
- + '⚠️ `etl-retry-converged-onto-retry-policy` (#4962) is SUBSUMED here, the '
- + '#4657/#4834/#5055 way: both land in the unreleased protocol 17, so composed, a '
- + 'rename of `retry.maxAttempts` on a shape that does not survive the major has no '
- + 'observable effect — and keeping both would tell an upgrader to rewrite a key on a '
- + 'schema the same upgrade deletes. The `maxAttempts` `retiredKey()` tombstone goes '
- + 'with the shape that carried it, which is strictly stronger than the tombstone: there '
- + 'is no longer a `retry` block to author the key into. Route 3 — no carrier key, no '
- + 'parse site, so no D2 conversion and no tombstone; RETIRED_DEFS_BY_MAJOR plus this '
- + 'entry are the declaration. ADR-0049, ADR-0078, #6414.',
+ 'The `joins` array was declared-but-inert: no engine or driver read `query.joins` '
+ + 'anywhere on the query path, so a query carrying it behaved exactly as if the key were '
+ + 'absent — while the name squatted on the reserved REST parameter set. Related-record '
+ + 'retrieval already has a live spelling (`expand`, resolved by the engine via batch '
+ + '`$in` queries), so the removal deletes the second, broken spelling rather than the '
+ + 'capability, and the orphaned `JoinNode`/`JoinType`/`JoinStrategy` cluster goes with '
+ + 'the key. A REQUEST surface — `QueryAST` is never stored in stack metadata — so there '
+ + 'is no source for the chain to rewrite; callers move their own queries. '
+ + 'ADR-0049 / ADR-0078, #4286.',
acceptanceCriteria:
- 'No source imports `ETLPipeline`, `ETLPipelineParsed`, `ETLPipelineSchema`, '
- + '`ETLPipelineRun(Schema)`, `ETLSource(Schema)`, `ETLDestination(Schema)`, '
- + '`ETLTransformation(Schema)`, `ETLEndpointType(Schema)`, '
- + '`ETLTransformationType(Schema)`, `ETLSyncMode(Schema)`, `ETLRunStatus(Schema)` or '
- + 'the `ETL` factory from `@objectstack/spec/automation`; `tsc` reports TS2724/TS2305 '
- + 'on any that survives. Every author who was pointed at L2 has been re-pointed by '
- + 'name: SYNC_ARCHITECTURE.md no longer lists an L2 row, no longer recommends '
- + '`ETLPipeline` as L1\'s destination and no longer advertises a transformation-type '
- + 'table. The surviving layers still parse unchanged — a connector declaring '
- + '`syncConfig` and an import declaring `mapping.transform` both behave exactly as they '
- + 'did in 16.x.',
+ 'No caller sends `joins`; related records are read through `expand` and single related '
+ + 'columns through dotted `fields` paths. A query that still carries `joins` fails to '
+ + 'parse with the removal prescription (even as an empty array), and authoring it is a '
+ + '`tsc` error at the call site.',
},
{
- id: 'action-descriptor-resume-authority-default-flip',
- // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code
- // span AND a table cell (see the note on `spec-type-alias-input-suffix-retired`).
- surface:
- 'automation.ActionDescriptor.resumeAuthority — an OMITTED value on a pausing '
- + 'node descriptor (supportsPause: true, or any executor whose execute() returns '
- + 'suspend: true)',
+ id: 'query-window-functions-retired',
+ surface: 'data.query.windowFunctions',
replacement:
- "an explicit resumeAuthority: 'any' on the descriptor, for a pausing node whose "
- + 'pauses really are meant to be continued through the generic resume route '
- + '(POST /automation/:name/runs/:runId/resume) — a screen-style collected-input '
- + "pause, or a signal wait an external producer resumes. Declare 'service' instead "
- + 'if continuing is the tail of a decision your own service must authorize and '
- + 'record first. Either value is a one-line addition; only the silence changed '
- + 'meaning',
+ '`aggregations` + `groupBy` for request-level analytics; '
+ + '`SqlDriver.findWithWindowFunctions(object, query)` for embedders on a SQL datasource',
reason:
- 'A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as '
- + "protocol 12's `rest-requireauth-default-flip`, and it is registered here for the "
- + 'same reason: whether a given pause is genuinely open to the generic route is a '
- + 'trust judgment no transform can make. The #3801 resume gate keys on the SUSPENDED '
- + "NODE, and `ActionDescriptor.resumeAuthority` used to default to `'any'`, so a "
- + 'pausing node type shipped raw-resumable unless its author remembered the field. '
- + "It now resolves to `'service'` when absent: an unclaimed pause is refused on the "
- + 'generic route with `PERMISSION_DENIED` / 403 until its descriptor states who may '
- + 'continue it. #3823 is the incident that decided the direction — ADR-0044 pointed '
- + "an approval's revise edge at a generic `wait`, `wait` is legitimately `'any'`, and "
- + 'the pause standing in a service-owned position inherited a fail-open value nobody '
- + 'chose; the demonstrated cost was an unaudited resubmit plus a destroyed remote '
- + 'run. The two possible mistakes are asymmetric, which is the whole argument: '
- + "guessing `'any'` walks past a decision nothing recorded and is silent, while "
- + "guessing `'service'` returns a refusal naming the missing field. ⚠️ The surface "
- + 'is a DESCRIPTOR FIELD set in plugin CODE, never stack metadata, so there is no '
- + 'source for a D2 conversion to rewrite and deliberately no schema tombstone — the '
- + 'disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` '
- + '(#5540) and `actor-user-roles-to-positions` (#6011) already carry. It differs from '
- + 'those in one way a reader should not have to infer: nothing is REMOVED, so tsc '
- + 'reports nothing at all — the field was already optional after step one and an '
- + 'omission still compiles. The enforced channels are all run-time: a registration '
- + 'warning naming the node type (once per type per engine), the refusal message on '
- + 'the resume itself, and `check:resume-authority-declared` for executors living in '
- + 'this repo. For a third-party plugin the generated upgrade guide is the only '
- + 'channel that arrives BEFORE a user hits a run that will not continue. In-tree the '
- + 'flip moves nothing: all six shipped pausing types (screen, wait, subflow, map, '
- + 'approval, approval_revise) declare their authority explicitly. ADR-0044 amendment '
- + '(2026-07-28) and its 2026-08-08 landing section, ADR-0019 #3801 addendum, #5561.',
+ 'The `windowFunctions` array was declared-but-inert on the query path: `find()` never '
+ + 'applied a window function, so every OVER clause a caller declared was silently '
+ + 'dropped. The capability only ever ran behind `SqlDriver.findWithWindowFunctions()`, '
+ + 'a driver-level door that is not on the `IDataDriver` contract and whose flat input '
+ + 'shape (`{ function, alias, partitionBy?, orderBy? }`) the spec vocabulary never '
+ + 'matched — `WindowFunctionNodeSchema` declared `field`/`over`/`frame` members the door '
+ + 'never read, so that cluster is removed with the key rather than left as a false '
+ + 'affordance. A REQUEST surface, never stored; no source to rewrite. '
+ + 'ADR-0049 / ADR-0078, #4286.',
acceptanceCriteria:
- 'Every action descriptor your plugin registers for a node type that can suspend '
- + 'declares `resumeAuthority`. Booting the stack logs no `declares supportsPause but '
- + 'never declares resumeAuthority` warning naming one of your types, and a run parked '
- + 'on each of your pausing nodes can still be continued the way you intend: a resume '
- + "through the generic route succeeds for the ones you declared `'any'`, and answers "
- + "403 (`PERMISSION_DENIED`) for the ones you declared `'service'`, which continue "
- + 'through your own service API instead. ⚠️ `supportsPause` is no longer the '
- + 'declaration nothing enforced (#5703, closed by #6667): an executor whose '
- + '`execute()` returns `suspend: true` while leaving `supportsPause` false is still '
- + 'warned about by neither warning channel, but '
- + '`AutomationEngine.refuseUndeclaredSuspension` now refuses that suspension at the '
- + 'one seam every suspension passes through — a guard-class failure no `fault` edge '
- + 'routes — so it needs no hand-check. The residue that does: an executor registering '
- + 'NO descriptor declares nothing for either warning or the refusal to read, so its '
- + 'pauses are still created and refused only later, on the resume route (#5561).',
+ 'No caller sends `windowFunctions` in a query; request-level analytics use '
+ + '`aggregations` + `groupBy`, and embedders needing OVER-clause SQL call the SQL '
+ + "driver's `findWithWindowFunctions` door directly. A query that still carries the key "
+ + 'fails to parse with the removal prescription naming that door.',
},
{
- id: 'export-field-meta-constraints-retired',
- surface:
- '@objectstack/rest: ExportFieldMeta.required / .system / .readonly / .hasDefault / '
- + '.min / .max / .minLength / .maxLength (the map built by `buildFieldMetaMap`, '
- + 'reached as `PreparedImport.metaMap` from `prepareImportRequest`)',
+ id: 'rest-server-openapi31-block-removed',
+ surface: 'restServer.openApi31',
replacement:
- 'the object schema you already hold — read `fields[name].required` / `.system` / '
- + '`.readonly` / `.defaultValue` / `.min` / `.max` / `.minLength` / `.maxLength` off '
- + 'the same `ObjectSchema` you passed to `buildFieldMetaMap`, which is where the '
- + 'ENGINE reads them and therefore the only copy that cannot drift',
+ '(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)',
reason:
- 'ADR-0049 enforce-or-remove. These eight were never a source of truth: '
- + '`buildFieldMetaMap(schema)` DERIVED each one from the very `schema` its caller '
- + 'passed in, so the map carried a second copy of facts the caller already held. '
- + "They existed for exactly one consumer — the import dry run's hand-copied "
- + 'pre-check mirror (`firstMissingRequiredField` / `firstConstraintViolation`, '
- + 'framework#3956) — and #4633 ruling D retired that mirror (PR #6532): the dry run '
- + "now asks `DataProtocol.validateData` for the engine's verdict, which reads the "
- + "object's own schema. That left all eight computed on every import and read by "
- + 'NOTHING, which is the declared-and-unread shape ADR-0049 exists for; a constraint '
- + 'vocabulary standing next to the presentation one with no enforcer behind it is '
- + 'precisely the thing an AI-authored consumer mistakes for a contract. Verified '
- + 'zero-reader before removal, per key and by type, across this repo (`packages/rest` '
- + 'itself, and all five in-repo dependents of `@objectstack/rest`: runtime, cli, '
- + "verify, plugin-auth, plugin-dev) and the `objectui` sibling; plugin-auth's "
- + 'identity import forwards `prepared.metaMap` into `runImport` but reads only the '
- + 'presentation keys through `coerceRow`. '
- + 'Why this needs a ledger entry despite that sweep: it is the `findStream` (#4484) / '
- + '`IStorageService.list` (#5540) / `actor-user-roles-to-positions` (#6011) '
- + 'disposition — a published TS surface with NO spec schema, so there is no '
- + '`retiredKey()` tombstone and no parse rejection that could carry a prescription, '
- + 'and the ledger is the only channel that reaches an upgrader. It is if anything '
- + 'blinder than those three: the keys shipped in a FINAL release (`@objectstack/rest` '
- + '14.5.0) and have been published in every release since, and because they were '
- + 'OPTIONAL keys on an interface that itself survives, a JavaScript consumer reading '
- + '`meta.required` after the upgrade gets `undefined` with no error at all — tsc '
- + 'reports at the read site only for a typed consumer. '
- + 'Why D3 semantic and not a D2 conversion: there is nothing to convert. No authored '
- + 'or stored metadata changes shape — `required` / `min` / `maxLength` and the rest '
- + 'remain fully authorable on a field definition and fully enforced by the engine, '
- + 'which is where they always lived. The only place these eight are ever spelled is '
- + "inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can "
- + 'reach them. ADR-0049 / ADR-0087, #6536 (the sweep PR #6532 deliberately deferred).',
+ '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.',
acceptanceCriteria:
- 'No code of yours reads any of the eight off a `buildFieldMetaMap` / '
- + '`prepareImportRequest` result. Grep your sources for `.required` / `.hasDefault` / '
- + '`.minLength` / `.maxLength` / `.min` / `.max` / `.system` / `.readonly` on an '
- + '`ExportFieldMeta`-typed value; each hit moves to the object schema you already '
- + 'passed in. ⚠️ Prove it against a RUN, not against tsc: these were optional keys, '
- + 'so an untyped or `any`-typed read compiles clean and silently becomes `undefined` '
- + '— assert that the constraint your code acts on is still observed on a real import, '
- + 'not merely that the build is green. Note `hasDefault` has no one-to-one '
- + "replacement key: it was the derived predicate `defaultValue != null`, mirroring the "
- + "engine's `applyFieldDefaults` gate, so read `fields[name].defaultValue` and apply "
- + 'that same `!= null` test yourself.',
+ '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.',
},
{
- id: 'action-descriptor-is-async-retired',
- surface: 'ActionDescriptor.isAsync (the descriptor an executor publishes via `registerNodeExecutor` / `defineActionDescriptor`)',
+ id: 'sharing-execution-context-retired',
+ 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:
- 'nothing to re-declare — delete the key. Suspension is `execute()` RETURNING '
- + '`suspend: true`, and permission to suspend is `supportsPause: true` on the same '
- + 'descriptor (with the `resumeAuthority` its pauses need)',
+ '`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',
reason:
- 'ADR-0049 enforce-or-remove. `isAsync` declared "this action suspends the flow '
- + 'awaiting an external reply" and NOTHING read it: a fresh three-repo measurement '
- + '(#6748, re-run at pickup) found zero property reads across objectstack, objectui '
- + 'and cloud — every hit was the declaration itself, a generated baseline, one of '
- + 'five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. '
- + 'So declaring it never made a node suspend and omitting it never stopped one, '
- + 'which is the silently-inert declaration ADR-0049 exists to end. It was always a '
- + 'second, weaker spelling of the capability `supportsPause` states, and the two '
- + 'diverged in exactly the way a duplicated declaration does: `screen` declared '
- + 'both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing '
- + 'anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling in '
- + '#6667 — `AutomationEngine` now refuses a suspension whose type does not declare '
- + '`supportsPause: true` — so the capability this key gestured at is now a real, '
- + 'enforced fact under one name. This one had no consumer to grow into and takes '
- + 'the remove leg. '
- + 'Why D3 semantic and not a D2 conversion: an ActionDescriptor is published from '
- + "an executor's TypeScript, never stored in stack metadata — no stack, example or "
- + 'template carries the key — so there is no source for the chain to rewrite and '
- + '`os migrate meta` cannot reach it. The schema tombstones it via `retiredKey()` '
- + 'and descriptor authors delete the key themselves; that rejection (a `tsc` error '
- + 'at the authoring site, and a parse error inside `defineActionDescriptor`) is the '
- + 'channel a third-party plugin author actually meets. The '
- + '`EnhancedApiError.fieldErrors` disposition, one layer down.',
+ '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.',
acceptanceCriteria:
- 'No descriptor declares `isAsync` — not the five that shipped it (`screen`, `map`, '
- + '`wait`, `approval`, `approval_revise`), not a plugin\'s. Every node type that '
- + 'returns `suspend: true` from `execute()` declares `supportsPause: true` on its '
- + 'descriptor together with a `resumeAuthority`, and its runs still pause and resume '
- + 'as before: the behaviour never depended on `isAsync`, so deleting the key changes '
- + 'no run. Authoring `isAsync` fails `tsc` at the descriptor literal and fails '
- + '`defineActionDescriptor()` at runtime with the prescription, instead of parsing '
- + 'clean and being stripped.',
+ '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.',
},
{
- id: 'notification-list-cursor-retired',
- // No backticks in `surface` — build-upgrade-guide.ts renders it inside a
- // code span AND a table cell (see the note on `spec-type-alias-input-suffix-retired`).
+ id: 'spec-type-alias-input-suffix-retired',
+ // Plain text, no markdown: build-upgrade-guide.ts renders this field inside a
+ // code span AND inside a table cell, so backticks here break both.
surface:
- 'api.listNotifications cursor — the key on BOTH halves of GET /api/v1/notifications '
- + '(ListNotificationsRequestSchema and ListNotificationsResponseSchema) and the cursor '
- + 'argument of the client SDK call client.notifications.list(). The same entry covers '
- + 'the limit default: the request schema no longer declares default(20)',
+ 'type alias: the 102 XInput names of @objectstack/spec '
+ + '(ConnectorInput, AppInput, PageInput, ActionInput, ServiceObjectInput, '
+ + 'ExecutionContextInput, TaskInput, … — 52 files across api/ automation/ data/ '
+ + 'identity/ integration/ kernel/ security/ system/ ui/)',
replacement:
- 'a larger `limit` — the route answers the newest N notifications and has no page 2. '
- + 'There is no replacement for `cursor`, deliberately: nothing ever minted one, so no '
- + 'caller holds a value to carry over. Callers that looped on it were re-reading the '
- + 'first window and should read one window sized to what they display (the Console '
- + 'bell polls exactly this way). For the removed `limit` default, send the number you '
- + 'want explicitly if you were relying on 20 — omitting it takes the server window, '
- + 'which is 50 on the platform inbox and clamped into 1..200, and has been since '
- + 'before the declaration existed',
+ 'the BARE name. ADR-0122 phase 2 moved the author state onto `X`, which makes `XInput` '
+ + 'a character-for-character synonym of it — the permanent synonym D3 forbids. Drop the '
+ + '`Input` suffix: `ConnectorInput` -> `Connector`. Symmetrically, a consumer that held '
+ + 'a PARSE RESULT under the bare name moves to `XParsed`, which phase 1 (16.x) already '
+ + 'declared for every schema whose two shapes differ, so the target name has existed for '
+ + 'a release. NINE `*Input` names are NOT retired and need no edit: `ExpressionInput`, '
+ + '`CronExpressionInput`, `TemplateExpressionInput` and `PredicateInput` are the bare '
+ + 'aliases of their own `…InputSchema`, and `FormFieldInput`, `QueryInput`, `FieldInput`, '
+ + '`ObjectStackDefinitionInput` and `NavigationItemInput` are composed (recursive or '
+ + '`Partial`-shaped) types no bare alias denotes.',
reason:
- '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.',
+ 'This entry exists for the reason `data-driver-find-stream-retired` (#4484), '
+ + '`storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) '
+ + 'exist, and it is the same disposition: the surface is a TYPESCRIPT NAME, never stack '
+ + 'metadata, so there is no source for a D2 conversion to rewrite and deliberately no '
+ + 'schema tombstone — an `XInput` alias never had a carrier key, never emitted a def, '
+ + 'and no `.parse()` ever saw it. Measured and verified rather than assumed: '
+ + '`json-schema/`, `json-schema.manifest/` and `authorable-surface/` are BYTE-IDENTICAL '
+ + 'across this change, because those generators enumerate runtime `z.ZodType` exports '
+ + 'and never read a type alias. So nothing left the published metadata surface and '
+ + 'RETIRED_DEFS_BY_MAJOR is deliberately untouched — an entry there would falsely claim '
+ + 'the metadata contract shrank. The enforced channel is tsc: the name is gone, so every '
+ + 'consumer gets TS2724/TS2305 naming the import. That is loud but MUTE about the '
+ + 'replacement — a compile error says `ConnectorInput` does not exist, not that '
+ + '`Connector` now means what it meant. The generated upgrade guide is the only channel '
+ + 'that carries the second half, which is precisely the #6048 gap ADR-0087 registration '
+ + 'exists to close. ⚠️ Deliberately NOT registered alongside it: the 1384 bare aliases '
+ + 'the same change FLIPPED from `z.infer` to `z.input`. Those names all still exist and '
+ + 'still resolve; what moved is which of a schema\'s two shapes they denote, and only '
+ + 'where the two differ (663 of 1384 — the rest are isomorphic and the flip is a no-op '
+ + 'there, pinned as such). A consumer holding an authored literal is made MORE correct '
+ + 'by it, silently; one holding a parse result gets a tsc error at the first defaulted '
+ + 'key it reads. Registering that as a rename would misdescribe it — no name was '
+ + 'retired — and the changeset carries its own FROM -> TO for it. ADR-0122 D8/D9, '
+ + '#6083 (PR #6279).',
+ acceptanceCriteria:
+ 'No source imports a name ending `Input` from `@objectstack/spec` except the nine listed '
+ + 'above: `rg "\\b\\w+Input\\b" --type ts` over consumer code resolves only to those. A '
+ + 'literal annotated with a bare spec type compiles while listing ONLY the keys the '
+ + 'author means — `const c: Connector = { name, label, type }` type-checks, which it did '
+ + 'not in 16.x — and a value read out of `XSchema.parse()` annotated with the bare name '
+ + 'no longer compiles at the first defaulted key it reads (TS18048/TS2532), the signal '
+ + 'that the annotation should be `XParsed`. `pnpm check:spec-parsed-alias` reports every '
+ + 'bare alias as `z.input` and refuses both a bare `z.infer` alias and a reintroduced '
+ + '`XInput` synonym.',
+ },
+ {
+ id: 'storage-service-list-retired',
+ surface: 'contracts.IStorageService.list',
+ replacement:
+ 'track the keys you wrote (sys_file / file-reference records, queryable through '
+ + 'ObjectQL with real pagination) instead of enumerating the bucket — and where no '
+ + 'such record exists, the cursor-shaped `list(prefix, { cursor, limit })` this '
+ + 'entry reserved, restored in #6781',
+ reason:
+ '`list(prefix)` was an OPTIONAL contract method documented as "List files in a '
+ + 'directory/prefix", and the two shipped adapters answered the same call with two '
+ + 'different semantics — both of them silently incomplete. `LocalStorageAdapter.list` '
+ + 'was a single-level `readdir`, so a nested key `a/b/c` was invisible under '
+ + '`list(\'a\')` (only `a/b` came back), and a subdirectory that `stat` succeeded on '
+ + 'was pushed into the result as a file, yielding a `StorageFileInfo` whose `size` is '
+ + 'a directory inode and which cannot be downloaded at all. `S3StorageAdapter.list` '
+ + 'was RECURSIVE (`ListObjectsV2` matches the whole key) and read neither '
+ + '`IsTruncated` nor `ContinuationToken`, so past 1000 objects the "all files" a '
+ + 'caller received was the first page, with no signal. One contract method, two '
+ + 'dialects, both quietly incomplete — and the first feature that genuinely needed to '
+ + 'enumerate a prefix (backup, orphan sweep, migration audit) would have got two '
+ + 'different answers on two deployments without an error on either. #5172 was nearly '
+ + 'that feature: it planned to drive attachment reclamation off '
+ + '`list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one '
+ + 'level down, and switched to queue-driven deferred work instead. Nothing consumed '
+ + 'it afterwards: the only in-repo call site was the `SwappableStorageService` '
+ + 'pass-through (which itself rejects when the active adapter has no `list`), and '
+ + 'REST, CLI and the storage routes never called it. Remove was chosen over '
+ + 'align-and-tighten (maintainer ruling, 2026-08-05, #5266): aligning would grow a '
+ + 'conformance surface nobody walks, while a prefix listing that cannot paginate is '
+ + 'the wrong signature to inherit — when a real caller needs enumeration it returns '
+ + 'cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases '
+ + '(nested keys, directory entries, >1000 objects) proving both backends agree. This '
+ + 'is a TS/API contract surface — a storage adapter is CODE, never stack metadata — '
+ + 'so there is no source for the chain to rewrite, and deliberately no schema '
+ + 'tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription '
+ + 'there would reach no one. The enforced channel is tsc, and it reports at the call '
+ + 'site. Same disposition, and the same reason, as '
+ + '`data-driver-find-stream-retired` (#4484). ADR-0049 / ADR-0087, #5540 '
+ + '(analysis #5266).',
acceptanceCriteria:
- '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.',
+ 'No code calls `storage.list(...)` on the `file-storage` service or on any '
+ + '`IStorageService` value. Code that needed "which files are under this prefix" '
+ + 'reads the records it wrote — `sys_file` / file-reference rows carry the storage '
+ + 'key and page deterministically through ObjectQL — rather than asking the bucket, '
+ + 'which is also the only form that stays correct past 1000 objects and across both '
+ + 'adapters. An adapter that still IMPLEMENTS `list` keeps compiling (an extra '
+ + 'method is not an error on a class) and is simply unreachable through the '
+ + 'contract, so deleting it is cleanup that can follow. The break is on the CALLER '
+ + 'side: `storage.list(...)` no longer type-checks, and a PROXY typed against '
+ + '`IStorageService` that forwards to `inner.list` is exactly such a caller — the '
+ + 'one in `@objectstack/service-storage` goes with the adapters (#5541). '
+ + '⚠️ AMENDED 2026-08-09 (#6781, maintainer ruling on cloud#1203, option B): the '
+ + 'RESERVED route in the paragraph above was taken. `list` exists again on the '
+ + 'contract, cursor-shaped — `list(prefix, { cursor, limit })` returning '
+ + '`{ items, nextCursor }` — because cloud had two first-party callers this repo '
+ + 'could not see when the measurement said "nothing calls it" (tenant attachment '
+ + 'reclamation, marketplace snapshot GC). This does NOT un-retire anything and the '
+ + 'acceptance criterion above is unchanged for what it actually governs: the '
+ + 'single-argument `list(prefix): StorageFileInfo[]` is gone for good, a call written '
+ + 'against it still fails to compile, and the two dialects it had are now pinned '
+ + 'against each other in `storage-adapter-list.conformance.test.ts` rather than left '
+ + 'to diverge. What changed for an upgrader is only the destination: prefer the '
+ + 'records you wrote, and reach for the restored member when there are none.',
},
{
- id: 'plugin-manifest-loading-retired',
+ id: 'ui-interaction-config-family-retired',
surface:
- 'manifest.loading (the whole block: strategy / preload / codeSplitting / dynamicImport / '
- + 'initialization / dependencyResolution / hotReload / caching / sandboxing / monitoring)',
+ 'ui.touchInteraction / ui.gestureConfig / ui.dndConfig / ui.keyboardNavigationConfig '
+ + '/ ui.componentAnimation / ui.motionConfig / ui.pageTransition / ui.offlineConfig '
+ + '(the whole export surface of ui/touch.zod.ts, ui/dnd.zod.ts, ui/keyboard.zod.ts, '
+ + 'ui/animation.zod.ts and ui/offline.zod.ts — 32 defs, 64 exported names)',
replacement:
- 'nothing to re-declare — delete the key. Plugins are composed at boot: `defineStack` '
- + 'registers them and the kernel runs `init` then `start` in an order topologically '
- + "resolved from each composed plugin's own `dependencies` / `optionalDependencies` "
- + '(`resolvePluginOrder` in `packages/core/src/plugin-order.ts`). For the isolation '
- + '`loading.sandboxing` appeared to configure, use the plugin trust tier '
- + '(`manifest.runtime`, ADR-0025 §3.6) and the manifest permission declarations, which '
- + 'are the surfaces the platform actually enforces',
+ '(removed — there is no replacement key, because there was never a key. Touch targets, '
+ + 'drag-and-drop, focus management, keyboard shortcuts and motion are RENDERER BUILT-IN '
+ + 'behaviour: the component library decides them, not a per-page metadata author. '
+ + 'Offline is a platform capability, and its vocabulary belongs on the sync engine that '
+ + 'owns the queue, the conflict policy and the cache — none of which exists yet. Delete '
+ + 'the import and the value. Whichever of these earns real product pull returns WITH its '
+ + 'own vocabulary and its executor, the #4910 way, not by un-retiring a declaration)',
reason:
- 'ADR-0049 enforce-or-remove; maintainer ruling 2026-08-04 on #4914. The block declared a '
- + 'complete plugin loading policy and NOTHING read it. A bare-name scan of all three '
- + 'repos — objectstack, cloud (measured 2026-08-09) and objectui (measured at pickup), '
- + 'each with a control probe proving the scan saw the tree — put every hit inside '
- + '`packages/spec` itself: this module\'s own declaration, its own unit tests, the '
- + '`Manifest.loading` embed and the generated artifacts. `manifest.loading.*` had zero '
- + 'readers in `packages/core`, `packages/runtime` and `packages/metadata`. So the key '
- + 'parsed, entered the manifest, and changed nothing — #3950, at the scale of a whole '
- + 'block. What made it outrank ordinary inert-key cleanup is `sandboxing`: it declared '
- + 'process / vm / iframe / web-worker isolation, IPC transports and an `allowedServices` '
- + 'ACL, so an AI author (ADR-0033) reading that vocabulary concluded the platform '
- + 'isolates plugins, wrote the config, and received a clean parse and zero isolation. An '
- + 'inert security control is worse than an absent one because it is believed. Hot reload '
- + 'was additionally a TWO-SOURCE defect: the docs pointed at this dead '
- + '`PluginHotReloadSchema` while the only implementation body, `HotReloadManager` '
- + '(`packages/core/src/hot-reload.ts`), reads a different vocabulary — '
- + '`HotReloadConfigSchema` in `plugin-lifecycle-advanced.zod.ts`. Ruling §2 converges on '
- + 'the surviving side: that schema is KEPT as the starting point for a future enforce '
- + 'decision (it has an implementation body but no runtime composes it yet), and '
- + 'enforcing it is deliberately a separate decision, not this retirement. '
- + 'Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and '
- + '`applyConversionsToStoredItem` maps a metadata type onto one of its collections. A '
- + 'package manifest is neither — `PLURAL_TO_SINGULAR` has no `packages` / `plugins` '
- + 'entry, so a manifest is not a stack collection member and a stored manifest row '
- + 'passes that seam through unchanged. A conversion would be a transform with no seam '
- + 'that ever runs.',
+ 'Five `@objectstack/spec/ui` modules declared a full interaction-configuration '
+ + 'vocabulary — 22 `z.object` sites across touch/gesture, drag-and-drop, '
+ + 'focus/keyboard, animation/motion and offline/sync — and NOTHING in the protocol '
+ + 'carried them. This is the ADR-0049 false-compliance shape in its most inviting form '
+ + 'for an AI author (ADR-0033), and worse than the ordinary declared-but-unread defect: '
+ + '`authorable-surface.json` listed 109 keys under these defs and '
+ + '`content/docs/references/ui/{touch,dnd,keyboard,animation,offline}.mdx` rendered them '
+ + 'as authoring tables, so the published documentation advertised a vocabulary with no '
+ + 'carrier key anywhere. An author following `dnd.mdx` and writing a `dnd:` block onto a '
+ + 'page component was rejected by `PageComponentSchema` for an unrecognized key — the '
+ + 'docs and the schema disagreeing about the platform (Prime Directive #10). Three '
+ + 'independent measurements, each with its controls passing in the same run: (1) no '
+ + 'module under `packages/spec/src` imported any of the five except the `ui/index.ts` '
+ + 'barrel, so no schema declared a carrier key; (2) a BFS over the in-memory Zod graph '
+ + 'from all 24 metadata-type roots plus `defineStack`\'s `ObjectStackSchema` (25 roots, '
+ + '4742 nodes) reached none of the 21 named object shapes, while `PageSchema`, '
+ + '`WebhookSchema` and `StateMachineSchema` all resolved `direct` and a synthetic '
+ + 'carrier flipped all 21 — so unreachability was a fact about the graph, not a broken '
+ + 'walker; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud '
+ + 'outside these modules\' own unit tests. objectui holds TYPE re-exports and parity '
+ + 'ratchets, never validators, and says so (#2561). The 2026-08-04 ruling weighed '
+ + 'wiring a carrier key (option B) and rejected it: that is a feature with a renderer '
+ + 'behind it, not ledger clean-up. It also weighed tightening the shapes to '
+ + '`strictObject` and rejected that explicitly — strictness is a property of a PARSE and '
+ + 'there is no parse, so it would spend a breaking change to leave "a precisely '
+ + 'validated dead slot, the more convincing lie" (#4583). Because there was no carrier '
+ + 'key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 '
+ + 'conversion to rewrite: this entry is the D3 record, the same route 3 as #4834 (kernel '
+ + 'plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ Not to be confused with '
+ + '#5021, which retired the THEME `animation` block — a different file, different defs, '
+ + 'and that one did have a carrier key and therefore a tombstone. ADR-0049, #4988.',
acceptanceCriteria:
- 'No `objectstack.plugin.json` and no stored package manifest carries a `loading` key. '
- + 'The enforced channel is the one place a manifest is parsed with an author present: '
- + '`os plugin build` runs `ManifestSchema.safeParse` and exits non-zero, printing the '
- + 'tombstone prescription, so a manifest still declaring `loading` fails its build '
- + 'rather than shipping. TypeScript authors get it earlier still — `loading` is typed '
- + '`never`, so assigning it is a `tsc` error. ⚠️ Runtime behaviour is deliberately '
- + 'UNCHANGED and must be verified as such: nothing ever read the block, so removing it '
- + 'removes no behaviour. A package ALREADY INSTALLED whose stored manifest carries '
- + '`loading` keeps working — the registry\'s `validate()` is an explicit diagnostic and '
- + 'not a gate (it catches, logs `[metadata_spec_invalid]`, and registers the item '
- + 'anyway, deliberately, so bad metadata is never a data outage), so such a row '
- + 'degrades to one log line at registration rather than a boot failure. Clear it by '
- + 'deleting the key from the source manifest and reinstalling.',
+ 'No code imports any of the 64 retired names from `@objectstack/spec` or '
+ + '`@objectstack/spec/ui` — `TouchTargetConfig(Schema)`, `GestureType(Schema)`, '
+ + '`SwipeDirection(Schema)`, `SwipeGestureConfig(Schema)`, `PinchGestureConfig(Schema)`, '
+ + '`LongPressGestureConfig(Schema)`, `GestureConfig(Schema)`, `TouchInteraction(Schema)`, '
+ + '`TransitionPreset(Schema)`, `EasingFunction(Schema)`, `TransitionConfig(Schema)`, '
+ + '`AnimationTrigger(Schema)`, `ComponentAnimation(Schema)`, `PageTransition(Schema)`, '
+ + '`MotionConfig(Schema)`, `DragHandle(Schema)`, `DropEffect(Schema)`, '
+ + '`DragConstraint(Schema)`, `DropZone(Schema)`, `DragItem(Schema)`, `DndConfig(Schema)`, '
+ + '`FocusTrapConfig(Schema)`, `KeyboardShortcut(Schema)`, `FocusManagement(Schema)`, '
+ + '`KeyboardNavigationConfig(Schema)`, `OfflineStrategy(Schema)`, '
+ + '`ConflictResolution(Schema)`, `SyncConfig(Schema)`, `PersistStorage(Schema)`, '
+ + '`EvictionPolicy(Schema)`, `OfflineCacheConfig(Schema)`, `OfflineConfig(Schema)` — '
+ + 'every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol '
+ + 'identity in `ui/interaction-config-retirement.test.ts`). No metadata document needs '
+ + 'editing, because none could ever carry one of these blocks: a stack that parsed '
+ + 'before parses byte-for-byte the same after. If you consumed the bare '
+ + '`ConflictResolution` from `@objectstack/spec/ui` as a TYPE for your own offline code, '
+ + 'declare that union locally — it is your client\'s policy, not the platform\'s. '
+ + '`@objectstack/spec/integration`\'s `ConnectorConflictResolution` (connector sync) and '
+ + '`@objectstack/spec/api`\'s `ConflictResolutionStrategy` (route merge policy) are '
+ + 'different concepts and are untouched.',
},
{
- id: 'api-runtime-create-withdrawn',
- surface: 'PUT /api/v1/meta/api/{name} (runtime-authored `api` endpoints, draft and active alike)',
+ id: 'ui-notification-action-embed-config-retired',
+ surface: 'ui.notificationAction / ui.embedConfig',
replacement:
- 'Declare the endpoint as a stack artifact (`**/*.api.ts`, or `defineStack({ apis })`) '
- + 'and ship it through `publishPackage`',
+ '(removed — there is no replacement shape, because there was never a key to write '
+ + 'either into. Delete the import and the value. Notification presentation is still '
+ + 'described by the surviving `NotificationType` / `NotificationSeverity` / '
+ + '`NotificationPosition` vocabulary; public access to a form is granted by the LIVE '
+ + '`FormView.sharing` block (`SharingConfig`), which is untouched. Notification action '
+ + 'buttons as metadata, and iframe embedding, return via the enforce route of ADR-0049 '
+ + 'through a new ADR — carrier key and renderer first, vocabulary second)',
reason:
- 'The `api` registry entry declared `allowRuntimeCreate: true` and the runtime never '
- + 'honoured it. Measured on a real showcase boot (#5488): `PUT /api/v1/meta/api/'
- + 'e8_backdoor` answered 200 with `{"success":true,…,"message":"Saved …"}`, and the '
- + 'declared route then answered 404 forever — with NO `[EndpointMatcher] … EXCLUDED` '
- + 'line, because the endpoint was never in the index to be excluded from. The serving '
- + 'criterion belongs to `IMetadataService.matchEndpoint` -> `EndpointMatcher` -> '
- + "`MetadataManager.listForIndex('api')`, which reads the manager's registry plus its "
- + 'registered loaders (`["filesystem","memory"]` on dev/serve); a runtime write lands '
- + 'in `sys_metadata`, which is in neither. A declared capability the runtime does not '
- + 'honour is ADR-0049 false compliance, and a write that answers "Saved" and then 404s '
- + 'forever is its most dangerous shape for the AI authors ADR-0033 targets. The '
- + 'maintainer ruled REMOVE on 2026-08-07 rather than converge the read path, because '
- + 'making the matcher read `sys_metadata` re-opens cache, invalidation, tenancy and '
- + "the ADR-0110 D3 miss-vs-outage distinction on a new read path, and there is no "
- + 'business pull for Studio-authored endpoints today (zero `.api.*` artifacts author '
- + 'them at runtime; showcase uses the artifact route, #5040 E8 LIVE). '
- + 'There is NO D2 conversion, for the reason this list exists: nothing in an authored '
- + 'source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an '
- + 'authorable one, and the artifact route it points authors toward is untouched — a '
- + '`**/*.api.ts` file valid before this change is valid after it, byte for byte. What '
- + 'changed is a runtime HTTP verdict, so it is one semantic TODO for operators and '
- + 'Studio callers rather than a stack conversion — the same disposition '
- + '`BatchOptions.validateOnly` (#4052) takes. Consequently `gateApiDraftsForPublish` '
- + '(PR #5279) is retired with it: it gated a promotion into a state the matcher can '
- + 'never read, and with the inlet closed no `api` draft can exist for it to judge. '
- + 'Re-entry is recorded in the ruling: if #2657 Part B promotes `apis` to a registered '
- + 'type WITH A REAL CONSUMPTION PATH, the flag flips back then — implementation first, '
- + 'declaration second. ADR-0049 / ADR-0121, #5488 (subsumes #5311).',
+ 'Both shapes were published `@objectstack/spec/ui` vocabulary with NO AUTHORING DOOR. '
+ + '#4001 批 14 measured them three ways on 2026-08-03 and this retirement re-ran all '
+ + 'three against `origin/main` before removing anything, each with a positive control '
+ + 'that passed in the same run: (1) CARRIER — no schema in `packages/spec/src` declared '
+ + 'a key of either type (`ui/notification.zod`\'s only non-test importer was the '
+ + 'barrel; `ui/sharing.zod`\'s were the barrel and `ui/view.zod.ts`, which names its '
+ + 'SIBLING `SharingConfigSchema`), measured by resolving specifiers rather than '
+ + 'substring-matching, because the repo holds two `sharing.zod` modules and a substring '
+ + 'test miscredits `stack.zod.ts` to the UI one; (2) REACHABILITY — a BFS from the 24 '
+ + 'metadata-type roots plus `defineStack`\'s `ObjectStackSchema`, over '
+ + '`build-schemas.ts`\'s own walk including its derived-clone bridge, never reached '
+ + 'either, while `Page` / `Action` / `DashboardWidget` / `Webhook` and `SharingConfig` '
+ + 'itself all resolved `root-graph` in the same run and an injected synthetic carrier '
+ + 'flipped both; (3) PARSE — zero `.parse()` in objectstack, cloud or objectui outside '
+ + 'their own unit tests. So nobody could author one and nothing ever validated one: '
+ + 'the #3950 shape, an exported schema with no consumer read as a capability, and the '
+ + 'ADR-0033 trap where an AI author takes `EmbedConfigSchema` in the published bundle '
+ + 'as proof the platform serves iframes. Neither is stored metadata and neither has a '
+ + 'carrier, so no `sys_metadata` row can hold one and there is no source for the D2 '
+ + 'chain to rewrite; this entry is the D3 record. 批 14 deliberately did NOT close them '
+ + 'with `.strict()` — strictness is a property of a PARSE, and closing a shape nothing '
+ + 'parses buys only "a precisely-validated dead slot, the more convincing lie" (#4583) '
+ + '— and filed the disposition as #5015, ruled REMOVE on 2026-08-04. Each was orphaned '
+ + 'by an earlier retirement one level up: `NotificationAction` lost its wrappers at '
+ + '#4610 (`NotificationSchema` / `NotificationConfigSchema`, the #4535 C3 dual-source '
+ + 'cleanup — that retirement\'s published "zero consumers" evidence was later falsified '
+ + 'for objectui and is corrected on `ui/notification.zod`\'s tombstone; the removal '
+ + 'itself stands, #5781), and `EmbedConfig` lost its key at 17.0.0 when the 2026-06 '
+ + 'liveness audit retired `App.embed` (no iframe route ever read it) — that key still '
+ + 'stands as a `retiredKey()` tombstone in `app.zod.ts`, so an author who wrote the KEY already '
+ + 'meets a prescription; this removes the value shape that outlived it. ⚠️ The '
+ + 'retirement is per SCHEMA, not per file: `ui/sharing.zod` KEEPS `SharingConfigSchema`, '
+ + 'a live door carried by `FormViewSchema.sharing` and read by `rest-server.ts` to mount '
+ + 'the anonymous form routes, and `ui/notification.zod` keeps its three presentation '
+ + 'enums. objectui consumed `NotificationActionSchema.shape.variant` as a VOCABULARY '
+ + '(never a parse) to pin its own hand-written `NotificationActionButton` interface — '
+ + 'which is exactly why "has a consumer" never meant "has an authoring door" here; that '
+ + 'pin is adapted objectui-side when it refreshes this dependency. ADR-0049, #5015.',
acceptanceCriteria:
- 'No caller creates or updates an `api` item through the runtime metadata API. '
- + '`PUT /api/v1/meta/api/{name}` answers 403 with `code: "NOT_CREATABLE"` and a body '
- + 'naming both flags (`allowRuntimeCreate=false, allowOrgOverride=false`) and the '
- + 'prescription `Declare it in source (**/*.api.ts) and redeploy` — in `?mode=draft` '
- + 'as well as direct-active, because the gate runs before the draft/publish branch and '
- + 'does not read `mode`. ⚠️ Verify the artifact route is UNAFFECTED, which is the whole '
- + 'point of the change: a stack declaring `apis:` still compiles, still passes '
- + '`validateApiEndpointDeclarations` at publish (`publishPackage`, #5189) and at load '
- + '(`buildEndpointIndex`, PR #5203), and its endpoints still SERVE — that route was '
- + 'always the only one that served. An operator who genuinely needs the runtime door '
- + 'back on one deployment sets `OS_METADATA_WRITABLE=api`, the same single escape '
- + 'hatch `job` / `agent` / `capability` use; note that this unlocks the WRITE only, and '
- + 'the endpoint still will not be served, which is why it is a diagnostic and not a '
- + 'workaround. Any `api` rows already sitting in `sys_metadata` from before this change '
- + 'were never served either; they can be deleted (`deleteMetaItem` is deliberately not '
- + 'gated by this refusal, so repair stays possible).',
+ 'No code imports `NotificationActionSchema`, `NotificationAction`, `EmbedConfigSchema` '
+ + 'or `EmbedConfig` from `@objectstack/spec` or `@objectstack/spec/ui` — both are '
+ + 'TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in '
+ + '`notification-embed-retirement.test.ts`). The same pin asserts the SURVIVORS in the '
+ + 'same run, and that half is equally load-bearing: `NotificationTypeSchema` / '
+ + '`NotificationSeveritySchema` / `NotificationPositionSchema` and `SharingConfigSchema` '
+ + 'must still be exported from `./ui`, and both modules must still load — a retirement '
+ + 'that deleted either file would satisfy the absence half while destroying working '
+ + 'surface. Nothing regresses at runtime, because nothing ever ran: no notification '
+ + 'action was ever parsed from metadata and no iframe route ever read an embed config. '
+ + 'Public form sharing is unaffected — `FormView.sharing` still gates the anonymous '
+ + 'endpoints on `allowAnonymous` + `publicLink`.',
},
{
- id: 'import-run-automations-declared-default-corrected',
- // No backticks in `surface` — build-upgrade-guide.ts renders it inside a
- // code span AND a table cell (see the note on `spec-type-alias-input-suffix-retired`).
+ id: 'ui-widget-i18n-family-retired',
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',
+ 'ui.widgetManifest / ui.widgetLifecycle / ui.widgetEvent / ui.widgetProperty '
+ + '/ ui.widgetSource / ui.i18nObject / ui.pluralRule / ui.numberFormat / ui.dateFormat '
+ + '/ ui.localeConfig (the widget-registration vocabulary of ui/widget.zod.ts, and the '
+ + 'five doorless shapes of ui/i18n.zod.ts — 10 defs, 26 exported names)',
replacement:
- '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',
+ '(removed — there is no replacement key, because there was never a key. A custom field '
+ + 'widget is still named the same way it always was: `field.widget` is a plain string '
+ + 'naming a component the RENDERER has registered, and objectui\'s registry has always '
+ + 'carried its own runtime manifest for that (`RuntimeWidgetManifest` / '
+ + '`RuntimeWidgetSource` in `@object-ui/types`, objectui#3161 / #4115), which models '
+ + 'different keys and never derived from these. For localisation: write the '
+ + 'default-language string on `label` / `description` — the framework generates the '
+ + 'translation key at registration time from the naming convention — and put '
+ + 'translations in translation files, which is the LIVE `system/translation.zod.ts` '
+ + 'surface. Widget registration and locale formatting as authorable protocol metadata '
+ + 'return via the ENFORCE route of ADR-0049 through a new ADR — the registry / loader / '
+ + 'formatter first, the vocabulary second)',
reason:
- '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.',
+ '`ui/widget.zod.ts` published a complete widget-registration vocabulary — a manifest '
+ + 'with lifecycle hooks, custom events, configurable properties and an '
+ + 'npm/remote/inline implementation-source union — and `ui/i18n.zod.ts` published a '
+ + 'structured-label, plural-rule and locale-formatting vocabulary. NOTHING in the '
+ + 'protocol carried either. Three independent measurements, re-run on `origin/main` '
+ + 'immediately before the removal with their controls passing in the SAME run: (1) no '
+ + 'module under `packages/spec/src` imported `widget.zod` at all, and the only imports '
+ + 'of `i18n.zod` anywhere name `I18nLabelSchema` / `AriaPropsSchema` (both KEPT), so no '
+ + 'schema declared a carrier key — `field.widget` is a `z.string()` naming a registered '
+ + 'component and has never referenced `WidgetManifest`; (2) a BFS over the in-memory '
+ + 'Zod graph from all 24 metadata-type roots plus `defineStack`\'s `ObjectStackSchema` '
+ + 'reached none of them, while `PageSchema` / `ObjectListViewSchema` resolved `direct` '
+ + 'in the same run and a synthetic carrier flipped every one of them; (3) zero '
+ + '`.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these files\' '
+ + 'own unit tests. `NumberFormat` / `DateFormat` DID have a carrier key '
+ + '(`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier was itself doorless, '
+ + 'so the subtree was `no door` rather than `no gate` and goes whole — leaving the two '
+ + 'leaves behind would strand exported schemas with no consumer (#3950). '
+ + '`I18nObjectSchema` was additionally superseded by its own file-neighbour: '
+ + '`I18nLabelSchema`\'s documentation already says translation keys are generated at '
+ + 'registration time and translations live in translation files, and the live '
+ + 'translation surface is `system/translation.zod.ts`, which uses none of these shapes. '
+ + 'The 2026-08-06 ruling weighed giving them a carrier (option B) and rejected it: that '
+ + 'is a feature with a registry and a renderer behind it, not ledger clean-up. '
+ + 'Tightening them to `strictObject` was rejected earlier and explicitly (#4001 批 16) '
+ + '— strictness is a property of a PARSE and there is no parse, so it would spend a '
+ + 'breaking change to leave "a precisely validated dead slot, the more convincing lie" '
+ + '(#4583). With no carrier key there is nothing to tombstone and no `sys_metadata` row '
+ + 'or source file for a D2 conversion to rewrite: this entry is the D3 record, route 3, '
+ + 'the same shape as #4988 (the ui/ interaction config family), #4834 (kernel '
+ + 'plugin-runtime family) and #4938 (`HttpServerConfig`). '
+ + '⚠️ `WidgetManifest.performance`\'s own `retiredKey()` tombstone (#3896 close-out) is '
+ + 'SUBSUMED here, the #4657/#4834 way: it goes with the shape that carried it, which is '
+ + 'strictly stronger than the tombstone, because there is no longer a manifest to '
+ + 'author the key INTO. '
+ + '⚠️ One of the nine widget sites is deliberately NOT retired. '
+ + '`FieldWidgetPropsSchema` survives: it is a REACT PROPS CONTRACT rather than '
+ + 'authorable metadata (it never appeared in `authorable-surface/` or '
+ + '`json-schema.manifest/` — its `onChange` is a `z.function()`), so "zero parse" is its '
+ + 'design and not its defect, and it acquired a live cross-repo compile-time consumer '
+ + 'one day before 批 16 measured: objectui PR #3289 (2026-08-03) renamed '
+ + '`@object-ui/fields`\' validation slot onto the spec\'s `error` with no alias, the '
+ + 'form renderer began producing it, and '
+ + '`packages/fields/src/__tests__/spec-symbol-batch7.test.ts` pins the shape against '
+ + '`import type { FieldWidgetProps } from \'@objectstack/spec/ui\'` as an intentional '
+ + 'tripwire. Re-verified on objectui `origin/main` 2026-08-07. ADR-0049, #5055.',
acceptanceCriteria:
- 'Every import request of yours that must NOT fire triggers sends `runAutomations: '
- + 'false` explicitly, rather than omitting the key and trusting the old declared '
- + 'default. The check is worth doing precisely where it looks unnecessary: if you '
- + 'build the body by parsing it through `ImportRequestSchema` (or the published JSON '
- + 'Schema) and then send the PARSED object, your bulk loads were running with '
- + 'automations OFF and will now run with them ON — that is the only class whose '
- + 'behaviour changes, and it changes toward what an unvalidated caller always got. '
- + '⚠️ Behaviour on the wire is deliberately UNCHANGED and should be verified as '
- + 'such: a body that omits `runAutomations` fired triggers before this change and '
- + 'fires them after, and `runAutomations: false` turns them off before and after. '
- + 'Nothing starts being refused — the route never validated this body against the '
- + 'schema and does not begin to. `dryRun` is unaffected and still runs NO automations '
- + 'whatever the flag says (#6037).',
+ 'No code imports `WidgetManifest(Schema|Parsed)`, `WidgetLifecycle(Schema)`, '
+ + '`WidgetEvent(Schema|Parsed)`, `WidgetProperty(Schema|Parsed)`, '
+ + '`WidgetSource(Schema|Parsed)`, `I18nObject(Schema)`, `PluralRule(Schema)`, '
+ + '`NumberFormat(Schema|Parsed)`, `DateFormat(Schema)` or '
+ + '`LocaleConfig(Schema|Parsed)` from `@objectstack/spec` or `@objectstack/spec/ui` — '
+ + 'every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol '
+ + 'identity in `ui/widget-i18n-retirement.test.ts`). No metadata document needs '
+ + 'editing, because none could ever carry one of these shapes: a stack that parsed '
+ + 'before parses byte-for-byte the same after, and a `field.widget: "my_picker"` string '
+ + 'is untouched. `FieldWidgetProps` / `FieldWidgetPropsSchema` / '
+ + '`FieldWidgetPropsParsed`, `I18nLabel(Schema)` and `AriaProps(Schema)` all still '
+ + 'resolve on `@objectstack/spec/ui` and are asserted to. ⚠️ objectui needs a companion '
+ + 'PR in the same window: `packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts` '
+ + 'asserts the spec STILL owns `WidgetManifest` / `WidgetSource` (it is the '
+ + '"a workaround should not outlive its reason" half of the objectui#3169 tripwire, '
+ + 'designed to go red exactly here), and `packages/types/src/widget.ts`\'s '
+ + '"Renamed off the spec\'s `WidgetManifest` name" comments now point at names that no '
+ + 'longer exist. Both are prescribed responses to this removal, not collateral damage.',
},
{
id: 'view-filter-rule-value-shaped-by-operator',
// No backticks in `surface` — build-upgrade-guide.ts renders it inside a
- // code span (see the note on the entry above).
+ // code span already, and a nested backtick would close it.
surface:
'ui.ViewFilterRule value — the third key of a view filter rule, on every carrier of '
+ 'ViewFilterRuleSchema: ListView.filter, a list view tab filter, Page.filterBy, a '
@@ -3382,166 +3520,89 @@ const step17: MigrationStep = {
+ 'was correct.',
},
{
- id: 'hook-register-empty-object-target-refused',
- surface:
- "engine.registerHook(event, handler, { object: '' | [] | [''] }), and a scope whose "
- + '`excludeObjects` cancels its `object` entirely',
- replacement:
- "name the object(s) — `object: 'account'` / `object: ['account', 'contact']` — or, for "
- + "a global hook, `object: '*'` or no `object` key at all; for a cancelled scope, widen "
- + '`object` or drop the overlapping names from `excludeObjects`',
- reason:
- '#4281 ruled that an empty hook target is not "no target" and closed the shape at the '
- + "two METADATA doors — `HookSchema.object`'s refine and `hook-binder.ts`'s "
- + '`normalizeObjects`. `engine.registerHook`, the CODE door, goes through neither, so '
- + 'all three spellings still registered, each producing a defect the author did not '
- + "write: `''` is FALSY, so the allow face was skipped entirely and the entry became a "
- + "GLOBAL hook (#4281's headline failure mode — blank intent taking the broadest "
- + "possible blast radius); `[]` and `['']` are truthy but admit no object name, so the "
- + 'entry could never fire. #5928 then added the `excludeObjects` face, which brought a '
- + 'fourth shape reached by arithmetic rather than by one bad name: an `object` list '
- + 'every member of which is also excluded admits nothing, so that entry can never fire '
- + 'either. All four are ADR-0078 silently-inert declarations, and all four are now '
- + 'refused at REGISTRATION.\n\n'
- + 'No mechanical rewrite exists, in either direction. The refused values carry no '
- + "recoverable intent — `object: ''` could have meant `'*'` (what it actually did) or a "
- + 'specific object name the author forgot to fill in, and those are opposite '
- + 'registrations; choosing between them is a judgment the chain cannot make. Nor could '
- + "the MATCHING read be changed instead: teaching the matcher that `''` is an "
- + 'unmatchable name would silently convert a hook firing on every object into one '
- + 'firing on none — the same class of defect pointing the other way, which is why '
- + '#5928 declined to do it in passing.\n\n'
- + '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. #6573, #4281, #4001, '
- + '#5928, ADR-0078.',
- acceptanceCriteria:
- '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.',
- },
- {
- id: 'engine-find-formula-order-by-refused',
+ id: 'view-management-protocol-retired',
surface:
- 'engine.find(object, { orderBy }) and engine.findOne(object, { orderBy }) naming a '
- + '`formula` field — the direct engine path, not the REST ingress',
+ '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:
- 'denormalise the value onto the object (a stored field, written when the source '
- + 'changes) and sort by that — the same remedy the REST ingress has prescribed since '
- + '#6924 / #6994; a `summary` field is unaffected and still sorts, because it gets a '
- + 'real maintained column',
+ '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',
reason:
- '#4226 / #4256 / #6994 closed the SORT axis at the REST ingress '
- + '(`assertSortFieldsExist`, `400 INVALID_SORT`), which covers everything reaching '
- + '`findData`: the list route, `POST /data/:object/query`, the export route and the '
- + 'RPC dispatcher. A caller reaching `engine.find()` / `engine.findOne()` DIRECTLY '
- + 'passed through none of it, and a `formula` ORDER BY there was dropped in silence. '
- + 'Measured on a real driver: `asc` and `desc` came back BYTE-IDENTICAL, in insertion '
- + 'order, under a success, with the rows carrying the very values they were asked to '
- + 'be ordered by. No column exists to order by (a formula is computed on read, so no '
- + 'driver materialises one), so the ORDER BY reached the driver, found nothing, and '
- + 'the unknown-column backstop returned the rows unordered.\n\n'
- + 'Ruled 2026-08-10 on #7095: an ORDER BY the engine cannot apply is a 4xx with '
- + 'guidance prose at the public boundary, never a silent drop — the same direction as '
- + 'the analytics dataset refusal envelope and the #6924 sort-hint prescription. The '
- + "engine's documented internal-caller tolerance (`assertProjectionFieldsExist`'s "
- + 'docblock) was to survive only behind a pinned internal path, and only if a MEASURED '
- + 'internal call site relied on it. The #7095 sweep of every in-tree `orderBy` reaching '
- + 'the engine directly — hooks, flows, reports, queue/job adapters, sharing, metadata '
- + 'loaders, expand sub-reads — found NONE: every hardcoded internal sort names a real '
- + 'stored column (`created_at`, `updated_at`, `version`, `priority`, `scheduled_for`, '
- + '`started_at`, `next_run_at`, `recorded_at`, `id`), and no shipped object in the repo '
- + 'declares a `formula` field at all. So no internal path shipped, and there is no flag '
- + 'to opt back into the drop.\n\n'
- + 'This is a CODE-path API, not stored metadata, so — like '
- + '`hook-register-empty-object-target-refused` at this step — there is no `sys_metadata` '
- + 'row for the D2 chain to rewrite and the ledger entry is the notification channel. '
- + 'No mechanical rewrite exists in either direction: the platform cannot invent the '
- + 'stored column the remedy prescribes, and it must not sort post-hoc instead — '
- + '`driver.find` has already applied `limit` / `offset`, so re-sorting after the '
- + 'formulas are evaluated would reorder an ARBITRARY PAGE, which looks correct on small '
- + 'result sets and is wrong the moment pagination is involved.\n\n'
- + 'ONE AUTHOR-REACHABLE SURFACE reaches this indirectly and is why it is not purely a '
- + "code-side note: a saved report's `query.orderBy` (`sys_saved_report`) is forwarded "
- + 'verbatim into `engine.find` by `plugin-reports`, bypassing the ingress gate. A '
- + 'report authored to sort by a formula field used to run and return rows in an '
- + 'arbitrary order; it now fails loudly, with the remedy in the message. One further '
- + 'path is deliberately NOT a refusal: a nested `expand` sort raises this refusal '
- + 'inside `expandRelatedRecords`, whose pre-existing graceful-degradation `catch` '
- + 'swallows every expand failure and retains the raw foreign keys — so that path moves '
- + 'from silent to OBSERVABLE (a warning naming the field and the fix) rather than '
- + 'refusing. Reversing that backstop is a separate decision on all expand failure '
- + 'modes. #7095, #6994, #6924, #4226, #4256, #3821, ADR-0112.',
+ '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.',
acceptanceCriteria:
- 'No `engine.find` / `engine.findOne` call site sorts by a `formula` field, and no saved '
- + "report's `query.orderBy` names one — grep your report definitions for an `orderBy` "
- + 'field whose object declares it as a `formula`, and denormalise it onto a stored '
- + 'column written when the source changes. A `summary` / rollup field needs no action: '
- + 'it has a real maintained column and sorts correctly. Reads complete with no '
- + '`INVALID_SORT` naming a formula field, and no "Failed to expand relationship field" '
- + 'warning whose error text names one.',
+ '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.',
},
{
- id: 'sharing-execution-context-retired',
+ id: 'workflow-service-slot-retired',
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',
+ "CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / "
+ + 'discovery routes.workflow / RestApiRouteCategory workflow',
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',
+ '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',
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 '
- + '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.',
+ '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.',
acceptanceCriteria:
- '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.',
+ '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).',
},
+ //
],
};
@@ -3659,32 +3720,10 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly>
// shapes and `authorable-surface.json` marks each `[RETIRED]` separately.
// Registered per key, as the gate reads them — nothing radiates from the base.
17: [
- 'data/ExternalFieldMapping:transform',
- 'integration/ConnectorFieldMapping:transform',
- 'shared/FieldMapping:transform',
- // #5775 — the SDUI component-props reconciliation. Three keys on the record
- // picker (`displayField` was the REQUIRED one, and the synonym of the key
- // the renderer actually reads) and the card's second spelling of the
- // composition slot every other container calls `children`.
- 'ui/ElementRecordPickerProps:displayField',
- 'ui/ElementRecordPickerProps:multiple',
- 'ui/ElementRecordPickerProps:searchFields',
- 'ui/PageCardProps:body',
- // #6776 — #5775's count was incomplete. The tab strip's visual style is the
- // one prop whose declared spelling collides with the page component's own
- // dispatch key, so `type` could never be authored in a flat or JSX carrier
- // and was skipped unvalidated by `sdui-parser`'s `BASE_PROPS`. Renamed to
- // the `tabStyle` every carrier can express and the renderer already reads.
- 'ui/PageTabsProps:type',
- // #6748 — ADR-0049 enforce-or-remove on the action-descriptor capability
- // block. `isAsync` was a second spelling of `supportsPause` with ZERO
- // readers on a fresh three-repo measurement; its sibling took the enforce
- // leg in #6667 and this one takes the remove leg. Descriptors are published
- // from executor TypeScript, not from stack metadata, so the D2 side is a D3
- // `SemanticMigration` (`action-descriptor-is-async-retired`) rather than a
- // MetadataConversion — there is no stored source for `os migrate meta` to
- // rewrite. The `EnhancedApiError.fieldErrors` precedent.
- 'automation/ActionDescriptor:isAsync',
+ // One file per entry under `entries/retired-keys/`, concatenated here sorted by
+ // entry id by `gen:migration-registry` (#7297). Add an entry by adding a
+ // FILE — never by editing between the markers, which is generated.
+ //
// #6361 — the notification-inbox pagination key, tombstoned on BOTH halves
// of `GET /api/v1/notifications` because one capability is never half-
// deleted (maintainer ruling 2026-08-07, ruled jointly with #6363). Two
@@ -3702,6 +3741,39 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly>
// callers rather than a stack conversion").
'api/ListNotificationsRequest:cursor',
'api/ListNotificationsResponse:cursor',
+ // #6748 — ADR-0049 enforce-or-remove on the action-descriptor capability
+ // block. `isAsync` was a second spelling of `supportsPause` with ZERO
+ // readers on a fresh three-repo measurement; its sibling took the enforce
+ // leg in #6667 and this one takes the remove leg. Descriptors are published
+ // from executor TypeScript, not from stack metadata, so the D2 side is a D3
+ // `SemanticMigration` (`action-descriptor-is-async-retired`) rather than a
+ // MetadataConversion — there is no stored source for `os migrate meta` to
+ // rewrite. The `EnhancedApiError.fieldErrors` precedent.
+ 'automation/ActionDescriptor:isAsync',
+ // #6815 — the per-aggregation DISTINCT flag, retired under ADR-0049 by
+ // maintainer ruling 2026-08-09. ONE key, and one entry, because
+ // `AggregationNodeSchema` is reused BY REFERENCE rather than `.extend()`ed:
+ // `QuerySchema.aggregations` and `EngineAggregateOptionsSchema.
+ // aggregations` are both `z.array(AggregationNodeSchema)`, so the walked
+ // shape has a single `data/AggregationNode` def and the baseline marks one
+ // line `[RETIRED]`. Contrast the `shared/FieldMapping:transform` trio in
+ // this same table, where two `.extend()`s copied the property into three
+ // walked shapes and each needed its own registration.
+ //
+ // Registered here but NOT in `src/conversions/registry.ts`, for the same
+ // reason as the `api/ListNotifications{Request,Response}:cursor` pair:
+ // `QueryAST` is a REQUEST surface —
+ // the client SDK builder's output and the `POST /data/:object/query` body
+ // — never stored in stack metadata, so there is no authored source or
+ // `sys_metadata` row for a D2 conversion to rewrite. The prescription
+ // reaches consumers as the D3 semantic entry
+ // `aggregation-node-distinct-retired` plus this tombstone, which is the
+ // disposition every other `data.query.*` retirement in this major already
+ // takes (`query-joins-retired` / `query-cursor-retired` /
+ // `query-distinct-retired` / `query-window-functions-retired`, #4286).
+ 'data/AggregationNode:distinct',
+ 'data/ExternalFieldMapping:transform',
+ 'integration/ConnectorFieldMapping:transform',
// #4914 — ADR-0049 enforce-or-remove on the plugin manifest's whole
// `loading` block (maintainer ruling 2026-08-04). ONE tombstoned key here,
// because `loading` was the single carrier: every schema underneath it
@@ -3710,7 +3782,7 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly>
// `RETIRED_DEFS_BY_MAJOR` below, not as ~27 individual key entries.
//
// Registered here but NOT in `src/conversions/registry.ts`, for the reason
- // `automation/ActionDescriptor:isAsync` above gives: the conversion chain
+ // `automation/ActionDescriptor:isAsync` gives: the conversion chain
// walks a normalized STACK (`mapCollection(stack, 'objects' | 'views' | …)`)
// and `applyConversionsToStoredItem` maps a metadata type onto one of those
// collections. A package manifest is neither — there is no `packages` /
@@ -3722,27 +3794,14 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly>
// (`os plugin build` → `ManifestSchema.safeParse`, which exits non-zero),
// and through the D3 semantic entry `plugin-manifest-loading-retired`.
'kernel/Manifest:loading',
- // #6815 — the per-aggregation DISTINCT flag, retired under ADR-0049 by
- // maintainer ruling 2026-08-09. ONE key, and one entry, because
- // `AggregationNodeSchema` is reused BY REFERENCE rather than `.extend()`ed:
- // `QuerySchema.aggregations` and `EngineAggregateOptionsSchema.
- // aggregations` are both `z.array(AggregationNodeSchema)`, so the walked
- // shape has a single `data/AggregationNode` def and the baseline marks one
- // line `[RETIRED]`. Contrast the `shared/FieldMapping:transform` trio at
- // the top of this list, where two `.extend()`s copied the property into
- // three walked shapes and each needed its own registration.
- //
- // Registered here but NOT in `src/conversions/registry.ts`, for the same
- // reason as the notification pair above: `QueryAST` is a REQUEST surface —
- // the client SDK builder's output and the `POST /data/:object/query` body
- // — never stored in stack metadata, so there is no authored source or
- // `sys_metadata` row for a D2 conversion to rewrite. The prescription
- // reaches consumers as the D3 semantic entry
- // `aggregation-node-distinct-retired` plus this tombstone, which is the
- // disposition every other `data.query.*` retirement in this major already
- // takes (`query-joins-retired` / `query-cursor-retired` /
- // `query-distinct-retired` / `query-window-functions-retired`, #4286).
- 'data/AggregationNode:distinct',
+ 'shared/FieldMapping:transform',
+ // #5775 — the SDUI component-props reconciliation. Three keys on the record
+ // picker (`displayField` was the REQUIRED one, and the synonym of the key
+ // the renderer actually reads) and the card's second spelling of the
+ // composition slot every other container calls `children`.
+ 'ui/ElementRecordPickerProps:displayField',
+ 'ui/ElementRecordPickerProps:multiple',
+ 'ui/ElementRecordPickerProps:searchFields',
// #6946 — three SDUI page-component props, retired by maintainer ruling
// 2026-08-09 (decision-inbox round, 「全部接受」): objectui#3829 route (c)
// for the first two, objectui#3818 for the third. Registered per key, as
@@ -3755,13 +3814,21 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly>
// title/bordered/children/footer and has no actions area), and carried in
// that repo's own `UNPUBLISHED_EXEMPTIONS` map as exactly that.
'ui/PageCardProps:actions',
+ 'ui/PageCardProps:body',
'ui/PageHeaderProps:icon',
+ // #6776 — #5775's count was incomplete. The tab strip's visual style is the
+ // one prop whose declared spelling collides with the page component's own
+ // dispatch key, so `type` could never be authored in a flat or JSX carrier
+ // and was skipped unvalidated by `sdui-parser`'s `BASE_PROPS`. Renamed to
+ // the `tabStyle` every carrier can express and the renderer already reads.
+ 'ui/PageTabsProps:type',
// The third is a sharper shape: `layout` IS read, but only against
// `inline`/`compact` — values its `auto | custom` enum never permitted — so
// both legal values took the same branch. Declared on BOTH sides with the
// same enum, which is why the declaration-parity ratchet (two declarations,
// never a declaration vs an implementation) reported agreement over it.
'ui/RecordDetailsProps:layout',
+ //
],
};
@@ -3874,48 +3941,36 @@ export const RETIRED_DEFS_BY_MAJOR: Readonly>
// LIVE server surface #5006 admitted, with an executor. Prefix adjacency is
// not evidence.
17: [
- 'shared/FieldMappingTransform',
- 'ui/WidgetManifest',
- 'ui/WidgetLifecycle',
- 'ui/WidgetEvent',
- 'ui/WidgetProperty',
- 'ui/WidgetSource',
- 'ui/I18nObject',
- 'ui/PluralRule',
- 'ui/NumberFormat',
- 'ui/DateFormat',
- 'ui/LocaleConfig',
- // #5295 — system/http-server.zod.ts runtime vocabulary
- 'system/ServerEvent',
- 'system/ServerEventType',
- 'system/ServerCapabilities',
- 'system/ServerStatus',
+ // One file per entry under `entries/retired-defs/`, concatenated here sorted by
+ // entry id by `gen:migration-registry` (#7297). Add an entry by adding a
+ // FILE — never by editing between the markers, which is generated.
+ //
// #6239 — api/protocol.zod.ts view-management operations
- 'api/ListViewsRequest',
- 'api/ListViewsResponse',
- 'api/GetViewRequest',
- 'api/GetViewResponse',
'api/CreateViewRequest',
'api/CreateViewResponse',
- 'api/UpdateViewRequest',
- 'api/UpdateViewResponse',
'api/DeleteViewRequest',
'api/DeleteViewResponse',
+ 'api/GetViewRequest',
+ 'api/GetViewResponse',
+ 'api/ListViewsRequest',
+ 'api/ListViewsResponse',
+ 'api/UpdateViewRequest',
+ 'api/UpdateViewResponse',
// #6414 — automation/etl.zod.ts, the whole L2 layer
+ 'automation/ETLDestination',
+ 'automation/ETLEndpointType',
'automation/ETLPipeline',
'automation/ETLPipelineRun',
+ 'automation/ETLRunStatus',
'automation/ETLSource',
- 'automation/ETLDestination',
+ 'automation/ETLSyncMode',
'automation/ETLTransformation',
- 'automation/ETLEndpointType',
'automation/ETLTransformationType',
- 'automation/ETLSyncMode',
- 'automation/ETLRunStatus',
// #4914 — the plugin manifest's `loading` block (ADR-0049 enforce-or-remove,
// maintainer ruling 2026-08-04). `PluginLoadingConfig` was reachable from
// authored metadata ONLY through `Manifest.loading`, and the ten members
- // below were embedded only by it, so retiring the carrier key unpublishes
- // the whole closure. The carrier itself is a `retiredKey()` tombstone
+ // registered with it were embedded only by it, so retiring the carrier key
+ // unpublishes the whole closure. The carrier itself is a `retiredKey()` tombstone
// registered one level up in `RETIRED_KEYS_BY_MAJOR`.
//
// ⚠️ `kernel/PluginLoadingEvent` and `kernel/PluginLoadingState` are
@@ -3924,16 +3979,33 @@ export const RETIRED_DEFS_BY_MAJOR: Readonly>
// observational half (a lifecycle event and a per-plugin state), they are
// not authorable, and they still emit. Module adjacency is not evidence,
// the `system/ServerRateLimitConfig` note above applies verbatim.
- 'kernel/PluginLoadingConfig',
- 'kernel/PluginLoadingStrategy',
- 'kernel/PluginPreloadConfig',
+ 'kernel/PluginCaching',
'kernel/PluginCodeSplitting',
- 'kernel/PluginDynamicImport',
- 'kernel/PluginInitialization',
'kernel/PluginDependencyResolution',
+ 'kernel/PluginDynamicImport',
'kernel/PluginHotReload',
- 'kernel/PluginCaching',
- 'kernel/PluginSandboxing',
+ 'kernel/PluginInitialization',
+ 'kernel/PluginLoadingConfig',
+ 'kernel/PluginLoadingStrategy',
'kernel/PluginPerformanceMonitoring',
+ 'kernel/PluginPreloadConfig',
+ 'kernel/PluginSandboxing',
+ 'shared/FieldMappingTransform',
+ // #5295 — system/http-server.zod.ts runtime vocabulary
+ 'system/ServerCapabilities',
+ 'system/ServerEvent',
+ 'system/ServerEventType',
+ 'system/ServerStatus',
+ 'ui/DateFormat',
+ 'ui/I18nObject',
+ 'ui/LocaleConfig',
+ 'ui/NumberFormat',
+ 'ui/PluralRule',
+ 'ui/WidgetEvent',
+ 'ui/WidgetLifecycle',
+ 'ui/WidgetManifest',
+ 'ui/WidgetProperty',
+ 'ui/WidgetSource',
+ //
],
};
diff --git a/scripts/regen-artifacts.mjs b/scripts/regen-artifacts.mjs
index 6ff233f5ce..31ba38087b 100644
--- a/scripts/regen-artifacts.mjs
+++ b/scripts/regen-artifacts.mjs
@@ -146,12 +146,24 @@ export const NOT_DRIVER_MANAGED = Object.freeze([
{
path: 'packages/spec/src/migrations/registry.ts',
why:
- 'hand-written source. Conflicts here are two retirements appended at the same spot — '
- + 'keeping both is usually right, but "usually" is a human judgement, not a merge rule.',
+ 'a MIXED file since #7297, and the mix is exactly why the driver must not own it. Its three '
+ + 'append tables are now generated into marked regions from `src/migrations/entries/` (one file '
+ + 'per entry), so a conflict INSIDE a region is resolved by `gen:migration-registry` and nothing '
+ + 'else — `check:migration-registry` fails if it was resolved any other way, which is what stops '
+ + "a resolution from silently dropping one side's retirement (#6957). But everything OUTSIDE the "
+ + 'markers — the tables\' load-bearing doc comments and each step\'s `rationale` — is still '
+ + 'hand-written, and the driver defers the WHOLE file to one side. Routing it here would let a '
+ + "regeneration launder away a sibling's prose edit, trading the silent drop this change removed "
+ + 'for a quieter one. So the prose conflict stays a human\'s, as it always was.',
},
{
path: 'packages/spec/src/conversions/registry.ts',
- why: 'hand-written source, same as the migrations registry.',
+ why:
+ 'hand-written source. Conflicts here are two conversions appended at the same spot — keeping '
+ + 'both is usually right, but "usually" is a human judgement, not a merge rule. Deliberately NOT '
+ + 'split per-entry alongside the migrations registry by #7297: the #6957 ruling names two append '
+ + 'registries and the other one is `scripts/adr-anchors.json` (#7301). Splitting this one too is '
+ + 'a follow-up with its own measurement, not a rider.',
},
{
path: 'docs/audits/**',