diff --git a/.changeset/adr-0087-stock-reconciliation-backfill.md b/.changeset/adr-0087-stock-reconciliation-backfill.md new file mode 100644 index 0000000000..1daef170ac --- /dev/null +++ b/.changeset/adr-0087-stock-reconciliation-backfill.md @@ -0,0 +1,43 @@ +--- +"@objectstack/spec": minor +--- + +chore(spec): backfill nine ADR-0087 ledger entries the v17 stock was missing (#6350) + +The #6148 completeness gate is deliberately **diff-only** — it judges what a PR +adds, never the inventory, because auditing stock in CI would turn the repo red on +adoption day and bill main's history to current authors (#6129). The cost of that +choice is that every breaking changeset already in the v17 train was never compared +against the ledger. This is the one-time manual reconciliation the maintainer ruled +for, not a change to the gate: `check-adr-0087-registration.mjs` still judges diffs +only, and nothing in CI starts auditing inventory. + +Measured today over the stock: **274 declared-breaking changesets**, 106 in the +residue (published break + a real FROM → TO prescription, so only `registered` or +`already-registered` remain), **61 of them flagged** as never having touched a +ledger file. Judging those 61 by hand found nine genuinely missing entries, now +registered as D3 semantic migrations: + +| entry | face | issue | +| --- | --- | --- | +| `runtime-httpserver-wrapper-retired` | the exported `HttpServer` delegating wrapper | #5122 | +| `record-details-sections-object-form` | `RecordDetailsProps.sections` shape + `hideFields` | #5611 | +| `data-driver-query-omit-object` | `IDataDriver`'s query parameter contract | #5181 | +| `sort-node-direction-rejected` | `orderBy[].direction` → `order` | #4721 | +| `tool-requires-confirmation-retired` | `tool.requiresConfirmation` | #3715 | +| `export-axis-opt-in` | `allowExport` unset flips to deny | #3544 | +| `apimethod-enum-shrink` | `enable.apiMethods` legacy values | #3543 | +| `sharing-rule-recipient-reconcile` | sharing-rule `group` / `guest` / owner-type rules | #1878 | +| `client-delete-result-success` | `DeleteDataResult.deleted` → `success` | #5638 | + +Two of them had already shipped **half** a retirement: `tool.requiresConfirmation` +carries a live `retiredKey()` tombstone in `ai/tool.zod.ts` and `SortNodeSchema` +carries `aliases: { direction: 'order' }`, but neither had the ledger half. A +tombstone is the proof the removal was declared; the ledger entry is what +`spec-changes.json`, the upgrade guide and `os migrate meta` project to consumers, +and a retirement needs both. + +Each of the nine stock changesets now carries its `` +disposition marker, so the judgement is recorded where the next auditor reads +rather than only in a PR body. `spec-changes.json` and +`docs/protocol-upgrade-guide.md` are regenerated from the registry. diff --git a/.changeset/apimethod-enum-shrink.md b/.changeset/apimethod-enum-shrink.md index a3dc84cf80..dee2576ef5 100644 --- a/.changeset/apimethod-enum-shrink.md +++ b/.changeset/apimethod-enum-shrink.md @@ -79,3 +79,5 @@ passing through Zod; the parse-time strip warning carries no object name). primitives = default-open). Read-only and deny-all whitelists are unchanged; the seven `[]` declarations are deliberately KEPT as defense-in-depth alongside `apiEnabled: false`. + + diff --git a/.changeset/client-delete-result-success.md b/.changeset/client-delete-result-success.md index 296523c501..c4e50f9631 100644 --- a/.changeset/client-delete-result-success.md +++ b/.changeset/client-delete-result-success.md @@ -96,3 +96,5 @@ server suite never had: a real DELETE over HTTP whose body is read as `deleted.success` and whose key set is asserted literally — `z.object` strips unknown keys, so a passing parse alone cannot prove no stray `deleted` rode along. + + diff --git a/.changeset/data-driver-query-omit-object.md b/.changeset/data-driver-query-omit-object.md index 5556b45409..ca8571866f 100644 --- a/.changeset/data-driver-query-omit-object.md +++ b/.changeset/data-driver-query-omit-object.md @@ -27,3 +27,5 @@ await driver.find('account', { where: { status: 'open' } }); `QueryAST` 的 zod 形状(`data/query.zod.ts` 的 `BaseQuerySchema`)**没有动**:`object` 在引擎与 hook 那一层是被读的,改的只是驱动契约的参数类型。`expand` 条目里的 `object` 同样保留 —— 那里它命名的是**关联对象**,没有任何实参携带这个事实,不是冗余。 标 major 是因为这是**源码级破坏性**变更(调用点字面量),运行时行为零变化。注意 `check:api-surface` 只看得见新增的 `DriverQuery` 导出、看不见参数类型的收窄(它记录导出存在与否,不记录签名),所以这条迁移说明是该变更唯一的下游载体。 + + diff --git a/.changeset/export-axis-opt-in.md b/.changeset/export-axis-opt-in.md index f6a47715b3..86029020bc 100644 --- a/.changeset/export-axis-opt-in.md +++ b/.changeset/export-axis-opt-in.md @@ -73,3 +73,5 @@ is the segregation-of-duties case the axis exists for. revoked. `html_table` stays a read — it is a rendered view, not a bulk copy. Deployments without `plugin-security` are unaffected (no permission sets exist, so the axis does not apply). + + diff --git a/.changeset/record-details-sections-object-form.md b/.changeset/record-details-sections-object-form.md index 8eac674a88..089a4d8b38 100644 --- a/.changeset/record-details-sections-object-form.md +++ b/.changeset/record-details-sections-object-form.md @@ -54,3 +54,5 @@ of silently stripping a live page's hidden-field list. `fields` (required), `label`, `columns` (1-4), and `name` — the i18n anchor that resolves `objects.._sections..label`, which `packages/lint`'s `translation-section-name-missing` rule tells authors to add. + + diff --git a/.changeset/runtime-httpserver-wrapper-retired.md b/.changeset/runtime-httpserver-wrapper-retired.md index a164e59980..abbf5c27fd 100644 --- a/.changeset/runtime-httpserver-wrapper-retired.md +++ b/.changeset/runtime-httpserver-wrapper-retired.md @@ -58,3 +58,5 @@ and re-audited every time `IHttpServer` gains an optional member — it gained o as recently as #5080. The 2026-08-06 maintainer ruling took the #4939 (`ApiRegistry`) precedent instead — retiring a part that was never assembled beats repairing it — under ADR-0049's remove side. + + diff --git a/.changeset/sharing-rule-recipient-reconcile.md b/.changeset/sharing-rule-recipient-reconcile.md index b823b0d49f..44c5fedce5 100644 --- a/.changeset/sharing-rule-recipient-reconcile.md +++ b/.changeset/sharing-rule-recipient-reconcile.md @@ -38,3 +38,5 @@ validates and then silently does nothing (ADR-0078). rows by field values (see the migrated examples: `share_open_tasks_with_manager` in app-showcase, `share_active_leads_with_manager` in app-crm), or use a scope-depth grant. + + diff --git a/.changeset/sort-node-direction-rejected.md b/.changeset/sort-node-direction-rejected.md index 6b478cdc35..6299ea647f 100644 --- a/.changeset/sort-node-direction-rejected.md +++ b/.changeset/sort-node-direction-rejected.md @@ -63,3 +63,5 @@ nonsenseKey: 1 }).success === true`). That is tracked in the #4001 campaign map for its own batch, not smuggled in here. Related: #4674, #4720, #4363, #4371, #4001, ADR-0049. + + diff --git a/.changeset/tool-requires-confirmation-removed.md b/.changeset/tool-requires-confirmation-removed.md index 8e21c8c30f..8b4f18f7b5 100644 --- a/.changeset/tool-requires-confirmation-removed.md +++ b/.changeset/tool-requires-confirmation-removed.md @@ -48,3 +48,5 @@ liveness-ledger entry, and the generated reference-doc row. objectui's `ToolPreview.tsx` reads the field via `!!d.requiresConfirmation`, so it degrades to "not shown" with no error; removing that badge is a follow-up in that repo. + + diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 31cc8df101..4d6c8a4d30 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -325,6 +325,9 @@ The action LOCATION vocabulary loses `global_nav` in this step (#6888, ADR-0049, - **`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). +- **`apimethod-enum-shrink`** — `data.object.enable.apiMethods (the eight legacy non-primitive values)` → the six primitives only — `get` / `list` / `create` / `update` / `delete` / `bulk`: replace each legacy value with the primitives it derives from, de-duplicate, and delete the key entirely if the result names all six + - Why not automatic: The authored `enable.apiMethods` enum is now exactly the six primitives. The eight legacy values — `upsert`, `aggregate`, `history`, `search`, `restore`, `purge`, `import`, `export` — are no longer authorable, because they are DERIVED effective operations resolved by the server's single derivation table, and an enum that lets an author name both a primitive and something derived from it has two spellings for one fact. The FROM → TO is a table rather than a rename: `upsert` → `create` + `update`; `import` → `create` + `update`; `export`, `aggregate` and `search` → `list`; `history` → `get`; and `restore` / `purge` map to NOTHING — they never derived, because `enable.trash` was retired in #2377, so the value is deleted outright. That last row is why this is a semantic entry and not a mechanical conversion, and the reason is a security one: the mapping WIDENS. An allowlist naming `history` was granting read of one record's audit trail; rewritten to `get` it grants ordinary record reads, and an allowlist naming `search` becomes a grant of full `list`. A transform that applied the table silently would broaden real API permissions without anyone reading the diff, so the rewrite is delegated to the author with the widening flagged. The reporter codemod exists for exactly that shape: `node scripts/codemod/apimethods-legacy-to-primitives.mjs` scans, reports the exact replacement per site, and FLAGS the allowlists the mapping would widen so the edit stays reviewable — it reports, it does not rewrite. Stored metadata keeps parsing (permanent tolerance, narrowing only), so nothing breaks at rest; what changes is what an author may newly write. Registered by the #6350 stock reconciliation; #3543 (P2 of #3391) predates the #6148 completeness gate. ADR-0087, #3543 (backfilled #6350). + - Done when: No authored `enable.apiMethods` array names a legacy value; `objectstack validate` passes. Run the reporter codemod first and read its widening flags before applying anything — ⚠️ the migration is only correct if each widened grant was INTENDED. For every object where `history` became `get` or `search` became `list`, confirm the broader operation is one the API should genuinely expose; where it is not, the answer is not a different value in this enum but a permission set that withholds the operation. Where the six primitives are all present, prefer deleting the key: that is equivalent to default-open and it tracks future primitives, whereas a hand-listed six silently stops granting anything added later. `restore` / `purge` are deleted with no replacement — if trash-like behaviour was being relied on, that capability left in #2377 and this entry is not where it returns. - **`auth-config-unadvertised-reserved-features`** — `api.authConfig.features.passkeys / api.authConfig.features.magicLink` → (removed — no replacement flag; the capabilities are not advertised) - Why not automatic: Both flags were served by `GET /api/v1/auth/config` from introduction and read by no client: no login UI anywhere renders a passkey or magic-link affordance off them, so the payload advertised two sign-in methods a user could never reach, and a deployer setting `plugins.passkeys` / `plugins.magicLink` flipped a switch with no observable effect (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-11 on #7481 chose remove over keep-as-reserved). The two are not equally empty: nothing at all is wired behind `passkeys`, whereas `magicLink`'s better-auth endpoints are live and only their advertisement was withdrawn. This is a RESPONSE surface — nobody authors or persists an `AuthFeaturesConfig` — so there is no source for the chain to rewrite; the schema tombstones both keys via retiredKey() and consumers drop their read. The withdrawal is conditional: both return to the payload in the change that ships the login UI (objectui#4179). ADR-0049, #7481. - Done when: No client reads `features.passkeys` or `features.magicLink` off `/api/v1/auth/config`; a client that gated UI on either now treats the capability as absent rather than reading `undefined` as false by accident, and constructing an `AuthFeaturesConfig` with either key fails to parse with its own prescription instead of being silently stripped. Magic-link deployments keep working: `plugins.magicLink` still mounts `/api/v1/auth/magic-link/send` and `/magic-link/verify`, which a custom UI may call directly. @@ -334,12 +337,18 @@ The action LOCATION vocabulary loses `global_nav` in this step (#6888, ADR-0049, - **`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. +- **`client-delete-result-success`** — `client.DeleteDataResult.deleted (the return of `client.data.delete()`)` → `success` — `r.deleted` → `r.success`. Same call, same wire body, declared name + - Why not automatic: `DeleteDataResult` carried the comment `Spec: DeleteDataResponseSchema` above a declaration that contradicted it: the interface declared `deleted: boolean` while `DeleteDataResponseSchema` declares `{ object, id, success }`. `deleted` has never been declared by any schema and no server path has ever returned it on `/data/:object/:id`. Both delete surfaces — `client.data.delete()` and the project-scoped `client.project(id).data.delete()` — are pure `unwrapResponse` / `_unwrap` passthroughs, so the interface is a CLAIM about the wire, never a rewrite of it, and the claim was false in the one direction that matters: the compiler endorsed the wrong spelling. `if (r.deleted)` compiled, read `undefined` at runtime, and the branch was never taken; `if (r.success)` was rejected by the compiler and correct on the wire. So this rename REVEALS a defect rather than breaking working code — every reader of the old key was already reading `undefined`, on every deployment and not just some, because the protocol path has always answered `success`. It is registered as a semantic entry rather than a mechanical conversion for the reason the rewrite itself does not capture: the key is one token, but a call site that branched on `r.deleted` has been taking the FALSE branch unconditionally since it was written, and whatever that branch did — or skipped — is what actually has to be re-read. There is no authored source for the chain to rewrite either; this is a published TypeScript surface whose enforced channel is tsc at the call site, and for an untyped JS caller there is no constrained channel at all, which is why the ledger entry is the only notification that reaches them. ⛔ Do not write `r.success ?? r.deleted`: there is one producer shape, and a consumer accepting two spellings is what contract-first exists to prevent (the same ruling #5581 applied on the producer side). No deprecated `deleted?: boolean` transition key ships, for the same reason — a transition period is for keys that WORKED, and this one never did. Registered by the #6350 stock reconciliation. ADR-0087, #5638 (backfilled #6350). + - Done when: No code reads `.deleted` off a `client.data.delete()` / `client.project(id).data.delete()` result; `tsc` names every site for a typed caller, and an untyped JS caller must be swept by hand because nothing will report it. Nothing about the request, the route, the status codes or the error shapes changes, and no server needs upgrading — the value you may now read is the one that was already arriving. ⚠️ The real work is behavioural: every `if (r.deleted)` has been false since it was written, so re-read what each of those branches was supposed to do. Post-delete cleanup, cache invalidation, audit writes and UI refreshes guarded that way have never run, and switching to `r.success` turns them ON for the first time — verify that is what you want rather than assuming it restores prior behaviour. Any test that passed while asserting on `deleted` was asserting on `undefined` and needs rewriting, not renaming. - **`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-driver-query-omit-object`** — `contracts.IDataDriver query parameter — find / findOne / count / updateMany / deleteMany / explain` → `DriverQuery` (`Omit`): delete the redundant `object:` key from the query literal at the call site — the object name is already the FIRST argument + - Why not automatic: Every one of these methods takes the object name as its first argument, and then required a `QueryAST` that lists `object` as mandatory — the same fact demanded twice, with two places for it to disagree. The layers above had already paid for that ambiguity: the objectql engine deliberately writes its key order as `{ ...query, object }` so a smuggled `query.object` cannot override the resolved name, and the wire layer spends a named 400 (`QUERY_OBJECT_MISMATCH`) refusing the inconsistency. The driver side paid in blanket casts: a direct caller holding only a `where` could not name the type, wrote `as any`, and switched off checking for `where` / `orderBy` / `fields` along with it — 20 such sites measured in cloud#1053, and cloud#1030's `$like` reached runtime through exactly that hole. This is a TS contract surface with no authored source for the chain to rewrite, which is why it is a semantic entry and not a D2 conversion; for a typed caller the compiler names every site (TS2353 `'object' does not exist in type 'DriverQuery'`), and for an untyped JS caller there is no constrained channel at all, which is exactly why this ledger entry must exist. Neither side is forced to move: a caller holding a `QueryAST` VALUE passes it unchanged (excess properties are only rejected on fresh literals), and an implementation still declaring `query: QueryAST` keeps compiling under parameter bivariance. What an implementation may no longer do is READ `query.object` — callers are now entitled to omit it. Registered by the #6350 stock reconciliation. #5181 was the audit's CONTROL sample, drawn to show that not every flagged candidate is an omission, and it was one: the seven `IDataDriver` hits in the ledger are all prose inside other entries, and the only subject-level hit on this interface is `data-driver-find-stream-retired` — a DIFFERENT member. Two later, smaller driver call-parameter changes (#6321, #6083) both registered, and both cite #5181 as background; the larger sibling they derive from never got its own entry. ADR-0087, #5181 (backfilled #6350). + - Done when: No `IDataDriver` call site passes an inline literal carrying `object:` — `driver.find("account", { object: "account", where: … })` becomes `driver.find("account", { where: … })`. `tsc` is the verify loop for typed callers and reports every remaining site by name; an untyped JS caller must be swept by hand, because nothing will report it. Any driver IMPLEMENTATION that read `query.object` is rewritten to use the object-name argument instead — that read now yields `undefined` whenever a caller exercises its new right to omit the key, and it fails at runtime rather than at compile time, so it is the one change on this surface a type check cannot find for you. - **`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. @@ -382,6 +391,9 @@ ONE AUTHOR-REACHABLE SURFACE reaches this indirectly and is why it is not purely - **`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. +- **`export-axis-opt-in`** — `security.permissionSet.objects[].allowExport (ABSENT — a permission set that never declared the key)` → an explicit `allowExport: true` on the object entry (or the `*` wildcard) of every permission set whose holders are meant to keep exporting + - Why not automatic: A secure-default FLIP, not a shape change — the same class as `rest-requireauth-default-flip` (ADR-0056 D2, protocol 12) and `action-descriptor-resume-authority-default-flip`, and it is registered for the same reason those are: the metadata is UNCHANGED and still parses, so no gate anywhere will tell an upgrader that what it MEANS has inverted. Before 17, `allowExport` unset inherited read; from 17 it denies. Reading a record and taking a bulk machine-readable copy of the whole table are different privileges — Salesforce "Export Reports", Dynamics "Export to Excel", NetSuite "Export Lists" and SAP `S_GUI` 61 all separate them — and the axis now says so. This cannot be a mechanical conversion in either direction: writing `allowExport: true` wherever the key is absent would preserve today's behaviour while silently defeating the entire point of the flip, and writing `false` would revoke a capability the deployment may legitimately want. Whether a given set's holders SHOULD be able to take a bulk copy is exactly the segregation-of-duties judgement the axis exists to make explicit, and it belongs to the operator. Two details decide who is actually affected: package-shipped sets are re-seeded on upgrade, so the built-ins are handled — `admin_full_access` and `organization_admin` now carry the grant explicitly — while ENVIRONMENT-AUTHORED sets are not and must be edited by hand. `member_default` deliberately does NOT carry the grant, so ordinary authenticated users lose export until an admin grants it; that is the point of the flip, not an oversight. Merge semantics are unchanged and most-permissive, exactly like the CRUD bits: any set granting `true` grants export, and `false` is authoring intent rather than a veto, because permission sets are additive capability containers (ADR-0090). The super-user bits no longer confer it: `viewAllRecords` / `modifyAllRecords` are "may see all data", not "may take a bulk copy". Registered by the #6350 stock reconciliation; #3544 / #3710 predate the #6148 completeness gate. ADR-0087, #3544 / #3710 (backfilled #6350). + - Done when: Every environment-authored permission set has been READ and decided, not just parsed: each object entry whose holders should keep exporting carries `allowExport: true`, and each one that should not is knowingly left without it. The verify loop is behavioural, because nothing fails at parse time — sign in as a holder of each affected set and confirm an export either succeeds or is refused as intended, including the report export path, which this change brought under the same axis. ⚠️ Silence is not success here: a deployment that upgrades without editing anything is VALID metadata whose ordinary users have quietly lost export, and the first sign will be a user report rather than an error. Check `member_default` explicitly — it is the set most likely to have been carrying export by inheritance. - **`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. @@ -440,18 +452,33 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-conte - **`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. +- **`record-details-sections-object-form`** — `ui.RecordDetailsProps.sections (the `record:details` page component)` → an OBJECT array — `sections: [{ label, columns, fields: [...] }]` — replacing the string-ID list; `label` gives the heading, `columns` its grid width, `name` makes the heading translatable, and the new sibling `hideFields` omits named fields from the body + - Why not automatic: `record:details` declared `sections` as a list of section IDs — `["overview", "financials"]` — a shape nothing produced and nothing consumed, while every real page authored the object form. This is authorable metadata on the publish/parse path, so it is the D2 class exactly; it is registered as a SEMANTIC step rather than a mechanical conversion because a string ID carries no field list and the chain cannot invent one — only the author knows which fields the section named `overview` was meant to render. The measurement that made the type change safe is also what makes the prescription unambiguous: the ID-list form had zero read paths and zero producers. objectui's `RecordDetailsRenderer` maps every entry as an object (`s.name` / `s.label` / `s.title` / `s.fields`) with no string branch at all — a string entry spreads into a character map and renders nothing; `@object-ui/types`' `RecordDetailsComponentProps` mirror already declared `Array<{ name?, label?, fields, ... }>`; the Studio block designer can only author `{ label, columns, fields }`; `packages/lint` has modelled it as `nestedSections` all along; and every page in this repo — three showcase pages plus the `sys_user` platform page — authors the object form. So the break lands only on stored metadata written against a declaration nothing ever honoured, and it lands at publish time rather than rewriting data at rest. The same change DECLARED `hideFields`, which the `sys_user` platform page had been authoring undeclared. Registered by the #6350 stock reconciliation: #5611 predates the #6148 completeness gate, so nothing asked it what it had done about the ledger, and the sibling key on the same def — `ui/RecordDetailsProps:layout`, retired by #6350's neighbour — carries a tombstone while this face carried none. ADR-0087, #5611 (backfilled #6350). + - Done when: Every `record:details` component in authored metadata spells `sections` as an object array: each entry names the fields it renders (`fields: [...]`), optionally with `label` / `name` / `columns`. `objectstack validate` passes and each detail page renders the same sections, in the same order, with the same fields as before the upgrade — a page whose sections silently render EMPTY is the signature of an ID list left in place. A section that existed only as an ID, with no field list recoverable from the page it belonged to, is a judgement for the author: name the fields it was meant to show, or delete the entry. Fields previously hidden by a convention outside the schema move onto the declared `hideFields`. - **`rest-server-openapi31-block-removed`** — `restServer.openApi31` → (removed — no replacement key exists. Delete the key; for a real outbound webhook use `Webhook` from `@objectstack/spec/automation`. Config-driven OpenAPI 3.1 webhooks/callbacks documentation returns, if ever, via the enforce route of ADR-0049 through a new ADR) - Why not automatic: The `openApi31` block (`webhooks` / `callbacks` / `jsonSchemaDialect` / `pathItemReferences`, typed by `OpenApi31ExtensionsSchema` with `OpenApiWebhookEventSchema` and `CallbackSchema` under it) promised OpenAPI 3.1 document synthesis nothing delivered: the REST server's `normalizeConfig` forwards only `api`/`crud`/`metadata`/`batch`/`routes`, and the served /openapi.json is the pre-generated @objectstack/spec contract enriched with the live server URL and the registered objects — a webhook declared here never appeared in any served document (ADR-0049; the #3197 connector-webhook shape one layer up). There is no behaviour to preserve and nothing stored to rewrite: `RestServerConfig` is plugin TS configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), never a `sys_metadata` shape — the stack tree's `api` block declares only its four scoping/auth knobs. The three schemas are removed with the key (zero import-level consumers in objectstack / cloud / objectui); the key itself is tombstoned because the schema is not `.strict()` and a plain delete would strip it silently. #4579. - Done when: No `RestServerConfig` value passed to the REST plugin (or `plugin-hono-server` `restConfig`) carries `openApi31` — a config that includes it now fails the parse with the retirement prescription instead of being silently stripped. No code imports `OpenApi31Extensions(Schema)`, `Callback(Schema)` or `OpenApiWebhookEvent(Schema)` from `@objectstack/spec/api` (TS2305 after upgrade). The served /openapi.json is byte-identical before and after — the block never reached it. +- **`runtime-httpserver-wrapper-retired`** — `runtime.HttpServer (the exported delegating wrapper class)` → register an `IHttpServer` ADAPTER INSTANCE directly as the `http.server` service — `HonoHttpServer` or whatever adapter the host already builds — instead of wrapping one + - Why not automatic: `@objectstack/runtime` exported an `HttpServer` class that took an `IHttpServer` in its constructor, declared `implements IHttpServer`, and forwarded only the contract's REQUIRED members (`get` / `post` / `put` / `delete` / `patch` / `use` / `listen` / `close`). It forwarded none of the OPTIONAL ones — `getPort?()`, `getRawApp?()`, `setFallbackHandler?()`. `packages/spec/src/contracts/http-server.ts` instructs consumers to feature-detect exactly those members with `typeof server.X === "function"` and to degrade when absent, so wrapping a capable adapter made every probe answer false and the capability vanish with the adapter underneath providing it the whole time. The sharpest consequence is worth writing down before anyone reaches for a wrapper of the same shape: a host that wrapped `HonoHttpServer` and registered the wrapper as `http.server` would answer 404 to every endpoint its metadata declared, because `setFallbackHandler` — since #5111 the ONLY entry path for declarative `apis:` endpoints — was never forwarded. This is a TS/API contract surface: an HTTP server adapter is CODE, never stack metadata, so there is no authored source for the chain to rewrite and deliberately no schema tombstone — nothing ever ran an adapter through a `.parse()`. That is precisely why this entry must exist: for an untyped JS host the ledger is the only notification channel there is, and for a typed one tsc reports at the construction site. Same disposition, and the same reason, as `storage-service-list-retired` (#5540) and `data-driver-find-stream-retired` (#4484). Registered by the #6350 stock reconciliation, not by the original change: #5122 landed before the #6148 completeness gate existed, so nothing ever asked it what it had done about the ledger. ADR-0049 / ADR-0087, #5122 (backfilled #6350). + - Done when: No code constructs `new HttpServer(...)` from `@objectstack/runtime`, and no import of the name resolves — the export is gone, so a typed caller fails to compile at the construction site. A host that was wrapping an adapter registers the ADAPTER INSTANCE as `http.server` instead, and then proves the capability came back: `getPort()` returns the real bound port after `listen(0)`, `getRawApp()` returns the framework-native app, and a declarative `apis:` endpoint declared in metadata answers its route rather than 404 — the last of which is the failure a wrapper produced silently. An adapter that genuinely needs to intercept calls implements `IHttpServer` in full, forwarding the optional members too, rather than declaring `implements` and dropping them. - **`sharing-execution-context-retired`** — `@objectstack/spec: the exported type `SharingExecutionContext` (`contracts/sharing-service`), and its re-export from @objectstack/plugin-sharing — the six-field context shape (`userId` / `tenantId` / `positions` / `permissions` / `systemPermissions` / `isSystem`) that sharing, approval and report enforcement signatures used to name` → `ExecutionContext` from `@objectstack/spec` — the complete `resolveAuthzContext` envelope the contracts have declared since #6523. Every one of the retired type's six fields exists on it under the same name and type, so a value that satisfied the old type already satisfies the envelope: only the annotation is rewritten, never the value - Why not automatic: ADR-0049 enforce-or-remove, completing the #6206 ruling (2026-08-07: enforcement adjudicates on the WHOLE envelope, never a per-site subset). This type was the declared context parameter of 36 signatures across three contracts — `ISharingService` / `ISharingRuleService`, `IApprovalService`, `IReportService` — and it omitted four fields those gates need: `accessible_org_ids` (under the `group` tenancy posture this IS the Layer 0 wall, ADR-0105 D2), `org_user_ids`, `posture` (ADR-0095 D2) and `tabPermissions`. Its damage ran in the MIRROR direction of the share-link twin (#6430 / PR #6511): nothing trimmed the VALUES — the engine middleware always handed the whole context down — it was the declared TYPE that was narrow, so an implementation could not READ what it had been given without casting out of its own contract (`const posture = (context as any).posture` in plugin-approvals' privileged-override gate). #6523 / PR #7068 converged the contracts, PR #7140 and PR #7206 re-annotated the four implementations, and this card removes the now-unreferenced declaration (#7070, #7218). Why this needs a ledger entry despite nothing in-repo referencing it: it is the `export-field-meta-constraints-retired` / `hook-context-session-roles-retired` disposition — a PUBLISHED TypeScript surface with no spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry the prescription, and the ledger is the only channel that reaches an upgrader. Why D3 semantic and not a D2 conversion: nothing authored or stored changes shape. The name is only ever spelled inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach it, and no `sys_metadata` row carries it. ADR-0049 / ADR-0087, #7218. - Done when: No source of yours imports `SharingExecutionContext` from `@objectstack/spec` or `@objectstack/plugin-sharing`; each such import becomes `ExecutionContext` from `@objectstack/spec` and the build is green. tsc IS a sufficient detector here, unlike the optional-key retirements at this step: the name is gone outright, so every remaining reference is a hard resolution error rather than a silent `undefined`. ⚠️ Then check the direction tsc CANNOT see: widening an annotation never rejects a value, so an enforcement path that only ever received a hand-built six-field object still compiles and still under-adjudicates. Confirm each caller passes the context it was HANDED, unchanged, rather than a literal it assembled — and that any gate of yours reading `posture`, `accessible_org_ids`, `org_user_ids` or `tabPermissions` now reads them declared, with no `as any` in the path. +- **`sharing-rule-recipient-reconcile`** — `security.sharingRule.sharedWith.type `group` / `guest`, and owner-type rules (`type: owner` + `ownedBy`)` → `group` → `team` (the enforced runtime vocabulary); `guest` → delete the rule and expose the records through a public form or a share link; `type: owner` → rewrite as a `type: criteria` rule. `business_unit` is newly authorable for the single-unit case + - Why not automatic: The authoring `ShareRecipientType` enum had drifted behind both the ADR-0090 D3 rename and the enforced runtime, in both directions at once. It still offered the pre-rename `group`, which the seed path silently SKIPPED, while omitting two recipients the runtime and bootstrap already enforced (`team` via `sys_team` / `sys_team_member`, and `business_unit`). It also offered `guest`, which had no runtime recipient mapping at all. Each of those is a rule that validated and then materialised nothing — the ADR-0078 shape, and on a SECURITY surface, where the failure is silent under-sharing: the author sees a valid rule and believes a set of people can reach the records, and no error ever contradicts them. Owner-type rules go for a different and sharper reason: they depend on live team / position membership, which the static materialiser cannot track, so they could not be made to work by fixing a name. They return as an enforced form only if membership-reactive re-materialisation is designed. This is a semantic entry rather than a mechanical conversion because only one of the three rewrites is a rename: `group` → `team` is mechanical, but `guest` and `type: owner` have no target — the author has to decide who was actually meant to reach those records and say so in a form the runtime enforces, and a transform that guessed would be inventing an access grant. After this change every authorable recipient and rule type on the SharingRule surface is enforced; the `queue` recipient stays runtime-reserved and deliberately non-authorable (there is no `sys_queue` yet). Note the two neighbouring conversions cover DIFFERENT faces of this schema and not this one: `sharing-recipient-role-to-position` is the ADR-0090 role → position rename and `sharing-rule-access-level-full-to-edit` is the access-level vocabulary. Registered by the #6350 stock reconciliation. ADR-0078 / ADR-0090 D3 / ADR-0087, #1878 (backfilled #6350). + - Done when: No sharing rule names `group` or `guest`, and none carries `type: owner`; stale definitions now FAIL parse with the valid options listed, so the sweep is "fix until nothing raises". ⚠️ Parsing clean is the weaker half — verify the SHARES, because a rule that was silently materialising nothing looked exactly like one that worked. For every rule that named `group`, confirm the `sys_team` it now resolves to has the membership you expected, and that records reach the people the rule was written for. Each former `guest` rule needs an explicit decision about anonymous access — a public form grant or a share link, or knowingly no access at all — and each former owner-type rule needs a `criteria` predicate that names the same population, checked against a representative record. Where a single business unit was meant, use `business_unit`; `unit_and_subordinates` is the subtree and grants strictly more. +- **`sort-node-direction-rejected`** — `data.query.orderBy[].direction (SortNode)` → `order` — `orderBy: [{ field: "updated_at", order: "desc" }]`. One word, same values (`asc` / `desc`) + - Why not automatic: `SortNodeSchema` was a plain `z.object`, so zod's default `.strip` applied and a sort node spelling its direction `direction` lost the key silently. Measured on `main` before the change: `SortNodeSchema.parse({ field: "updated_at", direction: "desc" })` returned `{ field: "updated_at", order: "asc" }` — the key discarded and `order` falling back to its `asc` default, so the sort ran in the OPPOSITE direction and the request succeeded. Paired with `limit`, which is how a caller asks for "the latest N", that is not a reordered page but a DIFFERENT SET OF ROWS, returned under an ordinary 200 with nothing in the response to distinguish it from the answer that was asked for. `direction` is not a typo: it is the live vocabulary of a neighbouring contract, `IReportService.orderBy`, and `plugin-auth/objectql-adapter.ts` already translated between the two by hand — a translation known to be necessary and enforced nowhere, the ADR-0049 shape. Both doors closed in one change: `SortNodeSchema` is now a `strictObject` carrying `aliases: { direction: "order" }`, and `normalizeSortNodes` in `metadata-protocol` refuses `{ field, direction }` with `400 INVALID_SORT`. The alias is deliberate rather than left to the edit-distance fallback, because no edit distance bridges `direction` → `order` and a bare "unrecognized key" would leave the caller exactly where the silent strip did. This is registered as a semantic entry rather than a mechanical conversion for one reason worth stating: the rewrite itself is trivially mechanical, but a stored `direction: "asc"` is ambiguous evidence — the author may have written it meaning ascending and been silently GIVEN ascending, so the visible behaviour never contradicted them, and only they can say whether the sort they have been reading was the sort they asked for. Registered by the #6350 stock reconciliation: the in-code alias tombstone shipped with #4721, but the ledger half never did, and a retirement needs both — the tombstone is the proof the removal was declared, the ledger entry is what `spec-changes.json`, the upgrade guide and `os migrate meta` project to consumers. ADR-0049 / ADR-0087, #4721 (backfilled #6350). + - Done when: No authored `orderBy` entry — in metadata, in a saved view's `sort[]`, or in a REST / RPC request body — spells the key `direction`. The upgrade's own verify loop is that the failure is now LOUD: a stale `direction` raises a named parse error (or `400 INVALID_SORT` at ingress) quoting `order`, so a sweep is "fix until nothing raises" rather than a search. ⚠️ Check the RESULTS, not just the parse: every list, report and paged query that carried `direction: "desc"` has been silently serving ASCENDING order and, wherever it was paired with `limit`, a different set of rows. After the rename those pages change what they return — that is the defect being corrected, not a regression, and any downstream expectation baked against the old output has to be re-read rather than restored. - **`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. +- **`tool-requires-confirmation-retired`** — `ai.tool.requiresConfirmation` → put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — that is the flag the HITL approval queue actually reads, and the only path that stops execution + - Why not automatic: `ToolSchema.requiresConfirmation` accepted `true` and no execution path ever read it: not the LLM tool set (a tool reaches the model as name / description / parameters only), not `ToolRegistry.execute`, not `POST /ai/tools/:name/execute`, and not the MCP bridge, which derives `destructiveHint` from a hardcoded name list. Setting it on a destructive tool produced NO PAUSE. For an ordinary dead property that is untidy; for a SAFETY property it is false compliance, the case ADR-0049 exists for — an author gates a destructive tool, sees the flag accepted, and ships believing a human is in the loop. It is made worse by the near-miss: `action.ai.requiresConfirmation` carries the same name and DOES work, so the mistake reads as correct in review. This is registered as a semantic entry rather than a mechanical conversion because the rewrite is not a rename at all — the replacement lives on a different metadata object at a different layer, and deciding which action should carry the gate (or whether the operation should be an action at all) is a judgement the chain cannot make. Deleting the key mechanically would be the worst possible transform here: it would leave the metadata parsing green while silently completing the removal of a safety gate the author believed was in place. `ToolSchema` was made `.strict()` in the same change, which is load-bearing rather than tidying — removing a key from a non-strict schema swaps one silent no-op for another, so the retired key now REJECTS and the parse error carries the prescription, that being the one channel every consumer bumping `@objectstack/spec` is guaranteed to hit. Registered by the #6350 stock reconciliation: the `retiredKey()` tombstone shipped with #3715 and still stands in `ai/tool.zod.ts`, but the ledger half never did. A retirement needs both — the tombstone is the proof the removal was declared, this entry is what `spec-changes.json`, the upgrade guide and `os migrate meta` project to consumers. ADR-0033 §2 / ADR-0049 / ADR-0087, #3715 (backfilled #6350). + - Done when: No tool definition carries `requiresConfirmation`; the key now raises a located parse error naming the replacement, so the sweep is "fix until nothing raises". ⚠️ The load-bearing half is what happens NEXT, and no gate can check it for you: for every tool that carried the flag, decide whether that operation genuinely needs a human in the loop. If it does, move it behind an action carrying `ai.requiresConfirmation: true` and prove the pause exists — invoke it and observe the approval queue hold it, rather than assuming the declaration. If it does not, delete the key knowingly. Deleting it without that decision leaves exactly the state the retirement exists to end: a destructive tool nobody is approving, now without even the false flag to show that somebody once meant to. - **`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. diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index a3d64057f5..72c08f7ec2 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -532,6 +532,13 @@ "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)." }, + { + "surface": "data.object.enable.apiMethods (the eight legacy non-primitive values)", + "replacement": "the six primitives only — `get` / `list` / `create` / `update` / `delete` / `bulk`: replace each legacy value with the primitives it derives from, de-duplicate, and delete the key entirely if the result names all six", + "migrationId": "apimethod-enum-shrink", + "toMajor": 17, + "rationale": "The authored `enable.apiMethods` enum is now exactly the six primitives. The eight legacy values — `upsert`, `aggregate`, `history`, `search`, `restore`, `purge`, `import`, `export` — are no longer authorable, because they are DERIVED effective operations resolved by the server's single derivation table, and an enum that lets an author name both a primitive and something derived from it has two spellings for one fact. The FROM → TO is a table rather than a rename: `upsert` → `create` + `update`; `import` → `create` + `update`; `export`, `aggregate` and `search` → `list`; `history` → `get`; and `restore` / `purge` map to NOTHING — they never derived, because `enable.trash` was retired in #2377, so the value is deleted outright. That last row is why this is a semantic entry and not a mechanical conversion, and the reason is a security one: the mapping WIDENS. An allowlist naming `history` was granting read of one record's audit trail; rewritten to `get` it grants ordinary record reads, and an allowlist naming `search` becomes a grant of full `list`. A transform that applied the table silently would broaden real API permissions without anyone reading the diff, so the rewrite is delegated to the author with the widening flagged. The reporter codemod exists for exactly that shape: `node scripts/codemod/apimethods-legacy-to-primitives.mjs` scans, reports the exact replacement per site, and FLAGS the allowlists the mapping would widen so the edit stays reviewable — it reports, it does not rewrite. Stored metadata keeps parsing (permanent tolerance, narrowing only), so nothing breaks at rest; what changes is what an author may newly write. Registered by the #6350 stock reconciliation; #3543 (P2 of #3391) predates the #6148 completeness gate. ADR-0087, #3543 (backfilled #6350)." + }, { "surface": "api.authConfig.features.passkeys / api.authConfig.features.magicLink", "replacement": "(removed — no replacement flag; the capabilities are not advertised)", @@ -553,6 +560,13 @@ "toMajor": 17, "rationale": "The rows the three bulk-write endpoints emitted had drifted from the schema that declared them: `BatchOperationResultSchema`, the client SDK's exported `BatchOperationResult` type and the reference docs all said `errors: ApiError[]` / `data` / `index`, while the wire carried `error: string` / `record` and never sent `index` at all. A TypeScript consumer written against the published type compiled, validated and read `undefined` at runtime — the declared-but-not-delivered shape this registry exists to close, on the response envelope (ADR-0119 D4 deferred the reconciliation off a bug fix; this is that tracked change, shipped in the 17 major window). The ADR-0119/#4620 rollback marking is structured in the same move: the `ROLLED_BACK:` / `NOT_ATTEMPTED:` message-string prefixes become registered `ApiError.code` values (message keeps the human-readable cause and causal row index), so \"attempted and undone\" vs \"never ran\" is machine-readable instead of a regex convention. A RESPONSE surface — nothing stored in stack metadata carries a batch row, so there is no source for the chain to rewrite; consumers of the legacy keys move their reads themselves. Off-contract readers only: the legacy keys were never in the schema or the SDK types, so a typed consumer needs no change. #4793." }, + { + "surface": "client.DeleteDataResult.deleted (the return of `client.data.delete()`)", + "replacement": "`success` — `r.deleted` → `r.success`. Same call, same wire body, declared name", + "migrationId": "client-delete-result-success", + "toMajor": 17, + "rationale": "`DeleteDataResult` carried the comment `Spec: DeleteDataResponseSchema` above a declaration that contradicted it: the interface declared `deleted: boolean` while `DeleteDataResponseSchema` declares `{ object, id, success }`. `deleted` has never been declared by any schema and no server path has ever returned it on `/data/:object/:id`. Both delete surfaces — `client.data.delete()` and the project-scoped `client.project(id).data.delete()` — are pure `unwrapResponse` / `_unwrap` passthroughs, so the interface is a CLAIM about the wire, never a rewrite of it, and the claim was false in the one direction that matters: the compiler endorsed the wrong spelling. `if (r.deleted)` compiled, read `undefined` at runtime, and the branch was never taken; `if (r.success)` was rejected by the compiler and correct on the wire. So this rename REVEALS a defect rather than breaking working code — every reader of the old key was already reading `undefined`, on every deployment and not just some, because the protocol path has always answered `success`. It is registered as a semantic entry rather than a mechanical conversion for the reason the rewrite itself does not capture: the key is one token, but a call site that branched on `r.deleted` has been taking the FALSE branch unconditionally since it was written, and whatever that branch did — or skipped — is what actually has to be re-read. There is no authored source for the chain to rewrite either; this is a published TypeScript surface whose enforced channel is tsc at the call site, and for an untyped JS caller there is no constrained channel at all, which is why the ledger entry is the only notification that reaches them. ⛔ Do not write `r.success ?? r.deleted`: there is one producer shape, and a consumer accepting two spellings is what contract-first exists to prevent (the same ruling #5581 applied on the producer side). No deprecated `deleted?: boolean` transition key ships, for the same reason — a transition period is for keys that WORKED, and this one never did. Registered by the #6350 stock reconciliation. ADR-0087, #5638 (backfilled #6350)." + }, { "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`", @@ -567,6 +581,13 @@ "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.IDataDriver query parameter — find / findOne / count / updateMany / deleteMany / explain", + "replacement": "`DriverQuery` (`Omit`): delete the redundant `object:` key from the query literal at the call site — the object name is already the FIRST argument", + "migrationId": "data-driver-query-omit-object", + "toMajor": 17, + "rationale": "Every one of these methods takes the object name as its first argument, and then required a `QueryAST` that lists `object` as mandatory — the same fact demanded twice, with two places for it to disagree. The layers above had already paid for that ambiguity: the objectql engine deliberately writes its key order as `{ ...query, object }` so a smuggled `query.object` cannot override the resolved name, and the wire layer spends a named 400 (`QUERY_OBJECT_MISMATCH`) refusing the inconsistency. The driver side paid in blanket casts: a direct caller holding only a `where` could not name the type, wrote `as any`, and switched off checking for `where` / `orderBy` / `fields` along with it — 20 such sites measured in cloud#1053, and cloud#1030's `$like` reached runtime through exactly that hole. This is a TS contract surface with no authored source for the chain to rewrite, which is why it is a semantic entry and not a D2 conversion; for a typed caller the compiler names every site (TS2353 `'object' does not exist in type 'DriverQuery'`), and for an untyped JS caller there is no constrained channel at all, which is exactly why this ledger entry must exist. Neither side is forced to move: a caller holding a `QueryAST` VALUE passes it unchanged (excess properties are only rejected on fresh literals), and an implementation still declaring `query: QueryAST` keeps compiling under parameter bivariance. What an implementation may no longer do is READ `query.object` — callers are now entitled to omit it. Registered by the #6350 stock reconciliation. #5181 was the audit's CONTROL sample, drawn to show that not every flagged candidate is an omission, and it was one: the seven `IDataDriver` hits in the ledger are all prose inside other entries, and the only subject-level hit on this interface is `data-driver-find-stream-retired` — a DIFFERENT member. Two later, smaller driver call-parameter changes (#6321, #6083) both registered, and both cite #5181 as background; the larger sibling they derive from never got its own entry. ADR-0087, #5181 (backfilled #6350)." + }, { "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", @@ -637,6 +658,13 @@ "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." }, + { + "surface": "security.permissionSet.objects[].allowExport (ABSENT — a permission set that never declared the key)", + "replacement": "an explicit `allowExport: true` on the object entry (or the `*` wildcard) of every permission set whose holders are meant to keep exporting", + "migrationId": "export-axis-opt-in", + "toMajor": 17, + "rationale": "A secure-default FLIP, not a shape change — the same class as `rest-requireauth-default-flip` (ADR-0056 D2, protocol 12) and `action-descriptor-resume-authority-default-flip`, and it is registered for the same reason those are: the metadata is UNCHANGED and still parses, so no gate anywhere will tell an upgrader that what it MEANS has inverted. Before 17, `allowExport` unset inherited read; from 17 it denies. Reading a record and taking a bulk machine-readable copy of the whole table are different privileges — Salesforce \"Export Reports\", Dynamics \"Export to Excel\", NetSuite \"Export Lists\" and SAP `S_GUI` 61 all separate them — and the axis now says so. This cannot be a mechanical conversion in either direction: writing `allowExport: true` wherever the key is absent would preserve today's behaviour while silently defeating the entire point of the flip, and writing `false` would revoke a capability the deployment may legitimately want. Whether a given set's holders SHOULD be able to take a bulk copy is exactly the segregation-of-duties judgement the axis exists to make explicit, and it belongs to the operator. Two details decide who is actually affected: package-shipped sets are re-seeded on upgrade, so the built-ins are handled — `admin_full_access` and `organization_admin` now carry the grant explicitly — while ENVIRONMENT-AUTHORED sets are not and must be edited by hand. `member_default` deliberately does NOT carry the grant, so ordinary authenticated users lose export until an admin grants it; that is the point of the flip, not an oversight. Merge semantics are unchanged and most-permissive, exactly like the CRUD bits: any set granting `true` grants export, and `false` is authoring intent rather than a veto, because permission sets are additive capability containers (ADR-0090). The super-user bits no longer confer it: `viewAllRecords` / `modifyAllRecords` are \"may see all data\", not \"may take a bulk copy\". Registered by the #6350 stock reconciliation; #3544 / #3710 predate the #6148 completeness gate. ADR-0087, #3544 / #3710 (backfilled #6350)." + }, { "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", @@ -763,6 +791,13 @@ "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": "ui.RecordDetailsProps.sections (the `record:details` page component)", + "replacement": "an OBJECT array — `sections: [{ label, columns, fields: [...] }]` — replacing the string-ID list; `label` gives the heading, `columns` its grid width, `name` makes the heading translatable, and the new sibling `hideFields` omits named fields from the body", + "migrationId": "record-details-sections-object-form", + "toMajor": 17, + "rationale": "`record:details` declared `sections` as a list of section IDs — `[\"overview\", \"financials\"]` — a shape nothing produced and nothing consumed, while every real page authored the object form. This is authorable metadata on the publish/parse path, so it is the D2 class exactly; it is registered as a SEMANTIC step rather than a mechanical conversion because a string ID carries no field list and the chain cannot invent one — only the author knows which fields the section named `overview` was meant to render. The measurement that made the type change safe is also what makes the prescription unambiguous: the ID-list form had zero read paths and zero producers. objectui's `RecordDetailsRenderer` maps every entry as an object (`s.name` / `s.label` / `s.title` / `s.fields`) with no string branch at all — a string entry spreads into a character map and renders nothing; `@object-ui/types`' `RecordDetailsComponentProps` mirror already declared `Array<{ name?, label?, fields, ... }>`; the Studio block designer can only author `{ label, columns, fields }`; `packages/lint` has modelled it as `nestedSections` all along; and every page in this repo — three showcase pages plus the `sys_user` platform page — authors the object form. So the break lands only on stored metadata written against a declaration nothing ever honoured, and it lands at publish time rather than rewriting data at rest. The same change DECLARED `hideFields`, which the `sys_user` platform page had been authoring undeclared. Registered by the #6350 stock reconciliation: #5611 predates the #6148 completeness gate, so nothing asked it what it had done about the ledger, and the sibling key on the same def — `ui/RecordDetailsProps:layout`, retired by #6350's neighbour — carries a tombstone while this face carried none. ADR-0087, #5611 (backfilled #6350)." + }, { "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)", @@ -770,6 +805,13 @@ "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." }, + { + "surface": "runtime.HttpServer (the exported delegating wrapper class)", + "replacement": "register an `IHttpServer` ADAPTER INSTANCE directly as the `http.server` service — `HonoHttpServer` or whatever adapter the host already builds — instead of wrapping one", + "migrationId": "runtime-httpserver-wrapper-retired", + "toMajor": 17, + "rationale": "`@objectstack/runtime` exported an `HttpServer` class that took an `IHttpServer` in its constructor, declared `implements IHttpServer`, and forwarded only the contract's REQUIRED members (`get` / `post` / `put` / `delete` / `patch` / `use` / `listen` / `close`). It forwarded none of the OPTIONAL ones — `getPort?()`, `getRawApp?()`, `setFallbackHandler?()`. `packages/spec/src/contracts/http-server.ts` instructs consumers to feature-detect exactly those members with `typeof server.X === \"function\"` and to degrade when absent, so wrapping a capable adapter made every probe answer false and the capability vanish with the adapter underneath providing it the whole time. The sharpest consequence is worth writing down before anyone reaches for a wrapper of the same shape: a host that wrapped `HonoHttpServer` and registered the wrapper as `http.server` would answer 404 to every endpoint its metadata declared, because `setFallbackHandler` — since #5111 the ONLY entry path for declarative `apis:` endpoints — was never forwarded. This is a TS/API contract surface: an HTTP server adapter is CODE, never stack metadata, so there is no authored source for the chain to rewrite and deliberately no schema tombstone — nothing ever ran an adapter through a `.parse()`. That is precisely why this entry must exist: for an untyped JS host the ledger is the only notification channel there is, and for a typed one tsc reports at the construction site. Same disposition, and the same reason, as `storage-service-list-retired` (#5540) and `data-driver-find-stream-retired` (#4484). Registered by the #6350 stock reconciliation, not by the original change: #5122 landed before the #6148 completeness gate existed, so nothing ever asked it what it had done about the ledger. ADR-0049 / ADR-0087, #5122 (backfilled #6350)." + }, { "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", @@ -777,6 +819,20 @@ "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." }, + { + "surface": "security.sharingRule.sharedWith.type `group` / `guest`, and owner-type rules (`type: owner` + `ownedBy`)", + "replacement": "`group` → `team` (the enforced runtime vocabulary); `guest` → delete the rule and expose the records through a public form or a share link; `type: owner` → rewrite as a `type: criteria` rule. `business_unit` is newly authorable for the single-unit case", + "migrationId": "sharing-rule-recipient-reconcile", + "toMajor": 17, + "rationale": "The authoring `ShareRecipientType` enum had drifted behind both the ADR-0090 D3 rename and the enforced runtime, in both directions at once. It still offered the pre-rename `group`, which the seed path silently SKIPPED, while omitting two recipients the runtime and bootstrap already enforced (`team` via `sys_team` / `sys_team_member`, and `business_unit`). It also offered `guest`, which had no runtime recipient mapping at all. Each of those is a rule that validated and then materialised nothing — the ADR-0078 shape, and on a SECURITY surface, where the failure is silent under-sharing: the author sees a valid rule and believes a set of people can reach the records, and no error ever contradicts them. Owner-type rules go for a different and sharper reason: they depend on live team / position membership, which the static materialiser cannot track, so they could not be made to work by fixing a name. They return as an enforced form only if membership-reactive re-materialisation is designed. This is a semantic entry rather than a mechanical conversion because only one of the three rewrites is a rename: `group` → `team` is mechanical, but `guest` and `type: owner` have no target — the author has to decide who was actually meant to reach those records and say so in a form the runtime enforces, and a transform that guessed would be inventing an access grant. After this change every authorable recipient and rule type on the SharingRule surface is enforced; the `queue` recipient stays runtime-reserved and deliberately non-authorable (there is no `sys_queue` yet). Note the two neighbouring conversions cover DIFFERENT faces of this schema and not this one: `sharing-recipient-role-to-position` is the ADR-0090 role → position rename and `sharing-rule-access-level-full-to-edit` is the access-level vocabulary. Registered by the #6350 stock reconciliation. ADR-0078 / ADR-0090 D3 / ADR-0087, #1878 (backfilled #6350)." + }, + { + "surface": "data.query.orderBy[].direction (SortNode)", + "replacement": "`order` — `orderBy: [{ field: \"updated_at\", order: \"desc\" }]`. One word, same values (`asc` / `desc`)", + "migrationId": "sort-node-direction-rejected", + "toMajor": 17, + "rationale": "`SortNodeSchema` was a plain `z.object`, so zod's default `.strip` applied and a sort node spelling its direction `direction` lost the key silently. Measured on `main` before the change: `SortNodeSchema.parse({ field: \"updated_at\", direction: \"desc\" })` returned `{ field: \"updated_at\", order: \"asc\" }` — the key discarded and `order` falling back to its `asc` default, so the sort ran in the OPPOSITE direction and the request succeeded. Paired with `limit`, which is how a caller asks for \"the latest N\", that is not a reordered page but a DIFFERENT SET OF ROWS, returned under an ordinary 200 with nothing in the response to distinguish it from the answer that was asked for. `direction` is not a typo: it is the live vocabulary of a neighbouring contract, `IReportService.orderBy`, and `plugin-auth/objectql-adapter.ts` already translated between the two by hand — a translation known to be necessary and enforced nowhere, the ADR-0049 shape. Both doors closed in one change: `SortNodeSchema` is now a `strictObject` carrying `aliases: { direction: \"order\" }`, and `normalizeSortNodes` in `metadata-protocol` refuses `{ field, direction }` with `400 INVALID_SORT`. The alias is deliberate rather than left to the edit-distance fallback, because no edit distance bridges `direction` → `order` and a bare \"unrecognized key\" would leave the caller exactly where the silent strip did. This is registered as a semantic entry rather than a mechanical conversion for one reason worth stating: the rewrite itself is trivially mechanical, but a stored `direction: \"asc\"` is ambiguous evidence — the author may have written it meaning ascending and been silently GIVEN ascending, so the visible behaviour never contradicted them, and only they can say whether the sort they have been reading was the sort they asked for. Registered by the #6350 stock reconciliation: the in-code alias tombstone shipped with #4721, but the ledger half never did, and a retirement needs both — the tombstone is the proof the removal was declared, the ledger entry is what `spec-changes.json`, the upgrade guide and `os migrate meta` project to consumers. ADR-0049 / ADR-0087, #4721 (backfilled #6350)." + }, { "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.", @@ -791,6 +847,13 @@ "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": "ai.tool.requiresConfirmation", + "replacement": "put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — that is the flag the HITL approval queue actually reads, and the only path that stops execution", + "migrationId": "tool-requires-confirmation-retired", + "toMajor": 17, + "rationale": "`ToolSchema.requiresConfirmation` accepted `true` and no execution path ever read it: not the LLM tool set (a tool reaches the model as name / description / parameters only), not `ToolRegistry.execute`, not `POST /ai/tools/:name/execute`, and not the MCP bridge, which derives `destructiveHint` from a hardcoded name list. Setting it on a destructive tool produced NO PAUSE. For an ordinary dead property that is untidy; for a SAFETY property it is false compliance, the case ADR-0049 exists for — an author gates a destructive tool, sees the flag accepted, and ships believing a human is in the loop. It is made worse by the near-miss: `action.ai.requiresConfirmation` carries the same name and DOES work, so the mistake reads as correct in review. This is registered as a semantic entry rather than a mechanical conversion because the rewrite is not a rename at all — the replacement lives on a different metadata object at a different layer, and deciding which action should carry the gate (or whether the operation should be an action at all) is a judgement the chain cannot make. Deleting the key mechanically would be the worst possible transform here: it would leave the metadata parsing green while silently completing the removal of a safety gate the author believed was in place. `ToolSchema` was made `.strict()` in the same change, which is load-bearing rather than tidying — removing a key from a non-strict schema swaps one silent no-op for another, so the retired key now REJECTS and the parse error carries the prescription, that being the one channel every consumer bumping `@objectstack/spec` is guaranteed to hit. Registered by the #6350 stock reconciliation: the `retiredKey()` tombstone shipped with #3715 and still stands in `ai/tool.zod.ts`, but the ledger half never did. A retirement needs both — the tombstone is the proof the removal was declared, this entry is what `spec-changes.json`, the upgrade guide and `os migrate meta` project to consumers. ADR-0033 §2 / ADR-0049 / ADR-0087, #3715 (backfilled #6350)." + }, { "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)", @@ -1423,6 +1486,13 @@ "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)." }, + { + "surface": "data.object.enable.apiMethods (the eight legacy non-primitive values)", + "replacement": "the six primitives only — `get` / `list` / `create` / `update` / `delete` / `bulk`: replace each legacy value with the primitives it derives from, de-duplicate, and delete the key entirely if the result names all six", + "migrationId": "apimethod-enum-shrink", + "toMajor": 17, + "rationale": "The authored `enable.apiMethods` enum is now exactly the six primitives. The eight legacy values — `upsert`, `aggregate`, `history`, `search`, `restore`, `purge`, `import`, `export` — are no longer authorable, because they are DERIVED effective operations resolved by the server's single derivation table, and an enum that lets an author name both a primitive and something derived from it has two spellings for one fact. The FROM → TO is a table rather than a rename: `upsert` → `create` + `update`; `import` → `create` + `update`; `export`, `aggregate` and `search` → `list`; `history` → `get`; and `restore` / `purge` map to NOTHING — they never derived, because `enable.trash` was retired in #2377, so the value is deleted outright. That last row is why this is a semantic entry and not a mechanical conversion, and the reason is a security one: the mapping WIDENS. An allowlist naming `history` was granting read of one record's audit trail; rewritten to `get` it grants ordinary record reads, and an allowlist naming `search` becomes a grant of full `list`. A transform that applied the table silently would broaden real API permissions without anyone reading the diff, so the rewrite is delegated to the author with the widening flagged. The reporter codemod exists for exactly that shape: `node scripts/codemod/apimethods-legacy-to-primitives.mjs` scans, reports the exact replacement per site, and FLAGS the allowlists the mapping would widen so the edit stays reviewable — it reports, it does not rewrite. Stored metadata keeps parsing (permanent tolerance, narrowing only), so nothing breaks at rest; what changes is what an author may newly write. Registered by the #6350 stock reconciliation; #3543 (P2 of #3391) predates the #6148 completeness gate. ADR-0087, #3543 (backfilled #6350)." + }, { "surface": "api.authConfig.features.passkeys / api.authConfig.features.magicLink", "replacement": "(removed — no replacement flag; the capabilities are not advertised)", @@ -1444,6 +1514,13 @@ "toMajor": 17, "rationale": "The rows the three bulk-write endpoints emitted had drifted from the schema that declared them: `BatchOperationResultSchema`, the client SDK's exported `BatchOperationResult` type and the reference docs all said `errors: ApiError[]` / `data` / `index`, while the wire carried `error: string` / `record` and never sent `index` at all. A TypeScript consumer written against the published type compiled, validated and read `undefined` at runtime — the declared-but-not-delivered shape this registry exists to close, on the response envelope (ADR-0119 D4 deferred the reconciliation off a bug fix; this is that tracked change, shipped in the 17 major window). The ADR-0119/#4620 rollback marking is structured in the same move: the `ROLLED_BACK:` / `NOT_ATTEMPTED:` message-string prefixes become registered `ApiError.code` values (message keeps the human-readable cause and causal row index), so \"attempted and undone\" vs \"never ran\" is machine-readable instead of a regex convention. A RESPONSE surface — nothing stored in stack metadata carries a batch row, so there is no source for the chain to rewrite; consumers of the legacy keys move their reads themselves. Off-contract readers only: the legacy keys were never in the schema or the SDK types, so a typed consumer needs no change. #4793." }, + { + "surface": "client.DeleteDataResult.deleted (the return of `client.data.delete()`)", + "replacement": "`success` — `r.deleted` → `r.success`. Same call, same wire body, declared name", + "migrationId": "client-delete-result-success", + "toMajor": 17, + "rationale": "`DeleteDataResult` carried the comment `Spec: DeleteDataResponseSchema` above a declaration that contradicted it: the interface declared `deleted: boolean` while `DeleteDataResponseSchema` declares `{ object, id, success }`. `deleted` has never been declared by any schema and no server path has ever returned it on `/data/:object/:id`. Both delete surfaces — `client.data.delete()` and the project-scoped `client.project(id).data.delete()` — are pure `unwrapResponse` / `_unwrap` passthroughs, so the interface is a CLAIM about the wire, never a rewrite of it, and the claim was false in the one direction that matters: the compiler endorsed the wrong spelling. `if (r.deleted)` compiled, read `undefined` at runtime, and the branch was never taken; `if (r.success)` was rejected by the compiler and correct on the wire. So this rename REVEALS a defect rather than breaking working code — every reader of the old key was already reading `undefined`, on every deployment and not just some, because the protocol path has always answered `success`. It is registered as a semantic entry rather than a mechanical conversion for the reason the rewrite itself does not capture: the key is one token, but a call site that branched on `r.deleted` has been taking the FALSE branch unconditionally since it was written, and whatever that branch did — or skipped — is what actually has to be re-read. There is no authored source for the chain to rewrite either; this is a published TypeScript surface whose enforced channel is tsc at the call site, and for an untyped JS caller there is no constrained channel at all, which is why the ledger entry is the only notification that reaches them. ⛔ Do not write `r.success ?? r.deleted`: there is one producer shape, and a consumer accepting two spellings is what contract-first exists to prevent (the same ruling #5581 applied on the producer side). No deprecated `deleted?: boolean` transition key ships, for the same reason — a transition period is for keys that WORKED, and this one never did. Registered by the #6350 stock reconciliation. ADR-0087, #5638 (backfilled #6350)." + }, { "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`", @@ -1458,6 +1535,13 @@ "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.IDataDriver query parameter — find / findOne / count / updateMany / deleteMany / explain", + "replacement": "`DriverQuery` (`Omit`): delete the redundant `object:` key from the query literal at the call site — the object name is already the FIRST argument", + "migrationId": "data-driver-query-omit-object", + "toMajor": 17, + "rationale": "Every one of these methods takes the object name as its first argument, and then required a `QueryAST` that lists `object` as mandatory — the same fact demanded twice, with two places for it to disagree. The layers above had already paid for that ambiguity: the objectql engine deliberately writes its key order as `{ ...query, object }` so a smuggled `query.object` cannot override the resolved name, and the wire layer spends a named 400 (`QUERY_OBJECT_MISMATCH`) refusing the inconsistency. The driver side paid in blanket casts: a direct caller holding only a `where` could not name the type, wrote `as any`, and switched off checking for `where` / `orderBy` / `fields` along with it — 20 such sites measured in cloud#1053, and cloud#1030's `$like` reached runtime through exactly that hole. This is a TS contract surface with no authored source for the chain to rewrite, which is why it is a semantic entry and not a D2 conversion; for a typed caller the compiler names every site (TS2353 `'object' does not exist in type 'DriverQuery'`), and for an untyped JS caller there is no constrained channel at all, which is exactly why this ledger entry must exist. Neither side is forced to move: a caller holding a `QueryAST` VALUE passes it unchanged (excess properties are only rejected on fresh literals), and an implementation still declaring `query: QueryAST` keeps compiling under parameter bivariance. What an implementation may no longer do is READ `query.object` — callers are now entitled to omit it. Registered by the #6350 stock reconciliation. #5181 was the audit's CONTROL sample, drawn to show that not every flagged candidate is an omission, and it was one: the seven `IDataDriver` hits in the ledger are all prose inside other entries, and the only subject-level hit on this interface is `data-driver-find-stream-retired` — a DIFFERENT member. Two later, smaller driver call-parameter changes (#6321, #6083) both registered, and both cite #5181 as background; the larger sibling they derive from never got its own entry. ADR-0087, #5181 (backfilled #6350)." + }, { "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", @@ -1528,6 +1612,13 @@ "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." }, + { + "surface": "security.permissionSet.objects[].allowExport (ABSENT — a permission set that never declared the key)", + "replacement": "an explicit `allowExport: true` on the object entry (or the `*` wildcard) of every permission set whose holders are meant to keep exporting", + "migrationId": "export-axis-opt-in", + "toMajor": 17, + "rationale": "A secure-default FLIP, not a shape change — the same class as `rest-requireauth-default-flip` (ADR-0056 D2, protocol 12) and `action-descriptor-resume-authority-default-flip`, and it is registered for the same reason those are: the metadata is UNCHANGED and still parses, so no gate anywhere will tell an upgrader that what it MEANS has inverted. Before 17, `allowExport` unset inherited read; from 17 it denies. Reading a record and taking a bulk machine-readable copy of the whole table are different privileges — Salesforce \"Export Reports\", Dynamics \"Export to Excel\", NetSuite \"Export Lists\" and SAP `S_GUI` 61 all separate them — and the axis now says so. This cannot be a mechanical conversion in either direction: writing `allowExport: true` wherever the key is absent would preserve today's behaviour while silently defeating the entire point of the flip, and writing `false` would revoke a capability the deployment may legitimately want. Whether a given set's holders SHOULD be able to take a bulk copy is exactly the segregation-of-duties judgement the axis exists to make explicit, and it belongs to the operator. Two details decide who is actually affected: package-shipped sets are re-seeded on upgrade, so the built-ins are handled — `admin_full_access` and `organization_admin` now carry the grant explicitly — while ENVIRONMENT-AUTHORED sets are not and must be edited by hand. `member_default` deliberately does NOT carry the grant, so ordinary authenticated users lose export until an admin grants it; that is the point of the flip, not an oversight. Merge semantics are unchanged and most-permissive, exactly like the CRUD bits: any set granting `true` grants export, and `false` is authoring intent rather than a veto, because permission sets are additive capability containers (ADR-0090). The super-user bits no longer confer it: `viewAllRecords` / `modifyAllRecords` are \"may see all data\", not \"may take a bulk copy\". Registered by the #6350 stock reconciliation; #3544 / #3710 predate the #6148 completeness gate. ADR-0087, #3544 / #3710 (backfilled #6350)." + }, { "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", @@ -1654,6 +1745,13 @@ "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": "ui.RecordDetailsProps.sections (the `record:details` page component)", + "replacement": "an OBJECT array — `sections: [{ label, columns, fields: [...] }]` — replacing the string-ID list; `label` gives the heading, `columns` its grid width, `name` makes the heading translatable, and the new sibling `hideFields` omits named fields from the body", + "migrationId": "record-details-sections-object-form", + "toMajor": 17, + "rationale": "`record:details` declared `sections` as a list of section IDs — `[\"overview\", \"financials\"]` — a shape nothing produced and nothing consumed, while every real page authored the object form. This is authorable metadata on the publish/parse path, so it is the D2 class exactly; it is registered as a SEMANTIC step rather than a mechanical conversion because a string ID carries no field list and the chain cannot invent one — only the author knows which fields the section named `overview` was meant to render. The measurement that made the type change safe is also what makes the prescription unambiguous: the ID-list form had zero read paths and zero producers. objectui's `RecordDetailsRenderer` maps every entry as an object (`s.name` / `s.label` / `s.title` / `s.fields`) with no string branch at all — a string entry spreads into a character map and renders nothing; `@object-ui/types`' `RecordDetailsComponentProps` mirror already declared `Array<{ name?, label?, fields, ... }>`; the Studio block designer can only author `{ label, columns, fields }`; `packages/lint` has modelled it as `nestedSections` all along; and every page in this repo — three showcase pages plus the `sys_user` platform page — authors the object form. So the break lands only on stored metadata written against a declaration nothing ever honoured, and it lands at publish time rather than rewriting data at rest. The same change DECLARED `hideFields`, which the `sys_user` platform page had been authoring undeclared. Registered by the #6350 stock reconciliation: #5611 predates the #6148 completeness gate, so nothing asked it what it had done about the ledger, and the sibling key on the same def — `ui/RecordDetailsProps:layout`, retired by #6350's neighbour — carries a tombstone while this face carried none. ADR-0087, #5611 (backfilled #6350)." + }, { "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)", @@ -1661,6 +1759,13 @@ "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." }, + { + "surface": "runtime.HttpServer (the exported delegating wrapper class)", + "replacement": "register an `IHttpServer` ADAPTER INSTANCE directly as the `http.server` service — `HonoHttpServer` or whatever adapter the host already builds — instead of wrapping one", + "migrationId": "runtime-httpserver-wrapper-retired", + "toMajor": 17, + "rationale": "`@objectstack/runtime` exported an `HttpServer` class that took an `IHttpServer` in its constructor, declared `implements IHttpServer`, and forwarded only the contract's REQUIRED members (`get` / `post` / `put` / `delete` / `patch` / `use` / `listen` / `close`). It forwarded none of the OPTIONAL ones — `getPort?()`, `getRawApp?()`, `setFallbackHandler?()`. `packages/spec/src/contracts/http-server.ts` instructs consumers to feature-detect exactly those members with `typeof server.X === \"function\"` and to degrade when absent, so wrapping a capable adapter made every probe answer false and the capability vanish with the adapter underneath providing it the whole time. The sharpest consequence is worth writing down before anyone reaches for a wrapper of the same shape: a host that wrapped `HonoHttpServer` and registered the wrapper as `http.server` would answer 404 to every endpoint its metadata declared, because `setFallbackHandler` — since #5111 the ONLY entry path for declarative `apis:` endpoints — was never forwarded. This is a TS/API contract surface: an HTTP server adapter is CODE, never stack metadata, so there is no authored source for the chain to rewrite and deliberately no schema tombstone — nothing ever ran an adapter through a `.parse()`. That is precisely why this entry must exist: for an untyped JS host the ledger is the only notification channel there is, and for a typed one tsc reports at the construction site. Same disposition, and the same reason, as `storage-service-list-retired` (#5540) and `data-driver-find-stream-retired` (#4484). Registered by the #6350 stock reconciliation, not by the original change: #5122 landed before the #6148 completeness gate existed, so nothing ever asked it what it had done about the ledger. ADR-0049 / ADR-0087, #5122 (backfilled #6350)." + }, { "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", @@ -1668,6 +1773,20 @@ "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." }, + { + "surface": "security.sharingRule.sharedWith.type `group` / `guest`, and owner-type rules (`type: owner` + `ownedBy`)", + "replacement": "`group` → `team` (the enforced runtime vocabulary); `guest` → delete the rule and expose the records through a public form or a share link; `type: owner` → rewrite as a `type: criteria` rule. `business_unit` is newly authorable for the single-unit case", + "migrationId": "sharing-rule-recipient-reconcile", + "toMajor": 17, + "rationale": "The authoring `ShareRecipientType` enum had drifted behind both the ADR-0090 D3 rename and the enforced runtime, in both directions at once. It still offered the pre-rename `group`, which the seed path silently SKIPPED, while omitting two recipients the runtime and bootstrap already enforced (`team` via `sys_team` / `sys_team_member`, and `business_unit`). It also offered `guest`, which had no runtime recipient mapping at all. Each of those is a rule that validated and then materialised nothing — the ADR-0078 shape, and on a SECURITY surface, where the failure is silent under-sharing: the author sees a valid rule and believes a set of people can reach the records, and no error ever contradicts them. Owner-type rules go for a different and sharper reason: they depend on live team / position membership, which the static materialiser cannot track, so they could not be made to work by fixing a name. They return as an enforced form only if membership-reactive re-materialisation is designed. This is a semantic entry rather than a mechanical conversion because only one of the three rewrites is a rename: `group` → `team` is mechanical, but `guest` and `type: owner` have no target — the author has to decide who was actually meant to reach those records and say so in a form the runtime enforces, and a transform that guessed would be inventing an access grant. After this change every authorable recipient and rule type on the SharingRule surface is enforced; the `queue` recipient stays runtime-reserved and deliberately non-authorable (there is no `sys_queue` yet). Note the two neighbouring conversions cover DIFFERENT faces of this schema and not this one: `sharing-recipient-role-to-position` is the ADR-0090 role → position rename and `sharing-rule-access-level-full-to-edit` is the access-level vocabulary. Registered by the #6350 stock reconciliation. ADR-0078 / ADR-0090 D3 / ADR-0087, #1878 (backfilled #6350)." + }, + { + "surface": "data.query.orderBy[].direction (SortNode)", + "replacement": "`order` — `orderBy: [{ field: \"updated_at\", order: \"desc\" }]`. One word, same values (`asc` / `desc`)", + "migrationId": "sort-node-direction-rejected", + "toMajor": 17, + "rationale": "`SortNodeSchema` was a plain `z.object`, so zod's default `.strip` applied and a sort node spelling its direction `direction` lost the key silently. Measured on `main` before the change: `SortNodeSchema.parse({ field: \"updated_at\", direction: \"desc\" })` returned `{ field: \"updated_at\", order: \"asc\" }` — the key discarded and `order` falling back to its `asc` default, so the sort ran in the OPPOSITE direction and the request succeeded. Paired with `limit`, which is how a caller asks for \"the latest N\", that is not a reordered page but a DIFFERENT SET OF ROWS, returned under an ordinary 200 with nothing in the response to distinguish it from the answer that was asked for. `direction` is not a typo: it is the live vocabulary of a neighbouring contract, `IReportService.orderBy`, and `plugin-auth/objectql-adapter.ts` already translated between the two by hand — a translation known to be necessary and enforced nowhere, the ADR-0049 shape. Both doors closed in one change: `SortNodeSchema` is now a `strictObject` carrying `aliases: { direction: \"order\" }`, and `normalizeSortNodes` in `metadata-protocol` refuses `{ field, direction }` with `400 INVALID_SORT`. The alias is deliberate rather than left to the edit-distance fallback, because no edit distance bridges `direction` → `order` and a bare \"unrecognized key\" would leave the caller exactly where the silent strip did. This is registered as a semantic entry rather than a mechanical conversion for one reason worth stating: the rewrite itself is trivially mechanical, but a stored `direction: \"asc\"` is ambiguous evidence — the author may have written it meaning ascending and been silently GIVEN ascending, so the visible behaviour never contradicted them, and only they can say whether the sort they have been reading was the sort they asked for. Registered by the #6350 stock reconciliation: the in-code alias tombstone shipped with #4721, but the ledger half never did, and a retirement needs both — the tombstone is the proof the removal was declared, the ledger entry is what `spec-changes.json`, the upgrade guide and `os migrate meta` project to consumers. ADR-0049 / ADR-0087, #4721 (backfilled #6350)." + }, { "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.", @@ -1682,6 +1801,13 @@ "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": "ai.tool.requiresConfirmation", + "replacement": "put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — that is the flag the HITL approval queue actually reads, and the only path that stops execution", + "migrationId": "tool-requires-confirmation-retired", + "toMajor": 17, + "rationale": "`ToolSchema.requiresConfirmation` accepted `true` and no execution path ever read it: not the LLM tool set (a tool reaches the model as name / description / parameters only), not `ToolRegistry.execute`, not `POST /ai/tools/:name/execute`, and not the MCP bridge, which derives `destructiveHint` from a hardcoded name list. Setting it on a destructive tool produced NO PAUSE. For an ordinary dead property that is untidy; for a SAFETY property it is false compliance, the case ADR-0049 exists for — an author gates a destructive tool, sees the flag accepted, and ships believing a human is in the loop. It is made worse by the near-miss: `action.ai.requiresConfirmation` carries the same name and DOES work, so the mistake reads as correct in review. This is registered as a semantic entry rather than a mechanical conversion because the rewrite is not a rename at all — the replacement lives on a different metadata object at a different layer, and deciding which action should carry the gate (or whether the operation should be an action at all) is a judgement the chain cannot make. Deleting the key mechanically would be the worst possible transform here: it would leave the metadata parsing green while silently completing the removal of a safety gate the author believed was in place. `ToolSchema` was made `.strict()` in the same change, which is load-bearing rather than tidying — removing a key from a non-strict schema swaps one silent no-op for another, so the retired key now REJECTS and the parse error carries the prescription, that being the one channel every consumer bumping `@objectstack/spec` is guaranteed to hit. Registered by the #6350 stock reconciliation: the `retiredKey()` tombstone shipped with #3715 and still stands in `ai/tool.zod.ts`, but the ledger half never did. A retirement needs both — the tombstone is the proof the removal was declared, this entry is what `spec-changes.json`, the upgrade guide and `os migrate meta` project to consumers. ADR-0033 §2 / ADR-0049 / ADR-0087, #3715 (backfilled #6350)." + }, { "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)", diff --git a/packages/spec/src/migrations/entries/semantic/17.apimethod-enum-shrink.ts b/packages/spec/src/migrations/entries/semantic/17.apimethod-enum-shrink.ts new file mode 100644 index 0000000000..dba47176b3 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.apimethod-enum-shrink.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: 'apimethod-enum-shrink', + surface: 'data.object.enable.apiMethods (the eight legacy non-primitive values)', + replacement: + 'the six primitives only — `get` / `list` / `create` / `update` / `delete` / `bulk`: ' + + 'replace each legacy value with the primitives it derives from, de-duplicate, and ' + + 'delete the key entirely if the result names all six', + reason: + 'The authored `enable.apiMethods` enum is now exactly the six primitives. The eight ' + + 'legacy values — `upsert`, `aggregate`, `history`, `search`, `restore`, `purge`, ' + + '`import`, `export` — are no longer authorable, because they are DERIVED effective ' + + 'operations resolved by the server\'s single derivation table, and an enum that lets an ' + + 'author name both a primitive and something derived from it has two spellings for one ' + + 'fact. The FROM → TO is a table rather than a rename: `upsert` → `create` + `update`; ' + + '`import` → `create` + `update`; `export`, `aggregate` and `search` → `list`; ' + + '`history` → `get`; and `restore` / `purge` map to NOTHING — they never derived, ' + + 'because `enable.trash` was retired in #2377, so the value is deleted outright. That ' + + 'last row is why this is a semantic entry and not a mechanical conversion, and the ' + + 'reason is a security one: the mapping WIDENS. An allowlist naming `history` was ' + + 'granting read of one record\'s audit trail; rewritten to `get` it grants ordinary ' + + 'record reads, and an allowlist naming `search` becomes a grant of full `list`. A ' + + 'transform that applied the table silently would broaden real API permissions without ' + + 'anyone reading the diff, so the rewrite is delegated to the author with the widening ' + + 'flagged. The reporter codemod exists for exactly that shape: `node ' + + 'scripts/codemod/apimethods-legacy-to-primitives.mjs` scans, reports the exact ' + + 'replacement per site, and FLAGS the allowlists the mapping would widen so the edit ' + + 'stays reviewable — it reports, it does not rewrite. Stored metadata keeps parsing ' + + '(permanent tolerance, narrowing only), so nothing breaks at rest; what changes is what ' + + 'an author may newly write. Registered by the #6350 stock reconciliation; #3543 (P2 of ' + + '#3391) predates the #6148 completeness gate. ADR-0087, #3543 (backfilled #6350).', + acceptanceCriteria: + 'No authored `enable.apiMethods` array names a legacy value; `objectstack validate` ' + + 'passes. Run the reporter codemod first and read its widening flags before applying ' + + 'anything — ⚠️ the migration is only correct if each widened grant was INTENDED. For ' + + 'every object where `history` became `get` or `search` became `list`, confirm the ' + + 'broader operation is one the API should genuinely expose; where it is not, the answer ' + + 'is not a different value in this enum but a permission set that withholds the ' + + 'operation. Where the six primitives are all present, prefer deleting the key: that is ' + + 'equivalent to default-open and it tracks future primitives, whereas a hand-listed six ' + + 'silently stops granting anything added later. `restore` / `purge` are deleted with no ' + + 'replacement — if trash-like behaviour was being relied on, that capability left in ' + + '#2377 and this entry is not where it returns.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.client-delete-result-success.ts b/packages/spec/src/migrations/entries/semantic/17.client-delete-result-success.ts new file mode 100644 index 0000000000..53051b34b5 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.client-delete-result-success.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'client-delete-result-success', + surface: 'client.DeleteDataResult.deleted (the return of `client.data.delete()`)', + replacement: '`success` — `r.deleted` → `r.success`. Same call, same wire body, declared name', + reason: + '`DeleteDataResult` carried the comment `Spec: DeleteDataResponseSchema` above a ' + + 'declaration that contradicted it: the interface declared `deleted: boolean` while ' + + '`DeleteDataResponseSchema` declares `{ object, id, success }`. `deleted` has never ' + + 'been declared by any schema and no server path has ever returned it on ' + + '`/data/:object/:id`. Both delete surfaces — `client.data.delete()` and the ' + + 'project-scoped `client.project(id).data.delete()` — are pure `unwrapResponse` / ' + + '`_unwrap` passthroughs, so the interface is a CLAIM about the wire, never a rewrite of ' + + 'it, and the claim was false in the one direction that matters: the compiler endorsed ' + + 'the wrong spelling. `if (r.deleted)` compiled, read `undefined` at runtime, and the ' + + 'branch was never taken; `if (r.success)` was rejected by the compiler and correct on ' + + 'the wire. So this rename REVEALS a defect rather than breaking working code — every ' + + 'reader of the old key was already reading `undefined`, on every deployment and not ' + + 'just some, because the protocol path has always answered `success`. It is registered ' + + 'as a semantic entry rather than a mechanical conversion for the reason the rewrite ' + + 'itself does not capture: the key is one token, but a call site that branched on ' + + '`r.deleted` has been taking the FALSE branch unconditionally since it was written, and ' + + 'whatever that branch did — or skipped — is what actually has to be re-read. There is ' + + 'no authored source for the chain to rewrite either; this is a published TypeScript ' + + 'surface whose enforced channel is tsc at the call site, and for an untyped JS caller ' + + 'there is no constrained channel at all, which is why the ledger entry is the only ' + + 'notification that reaches them. ⛔ Do not write `r.success ?? r.deleted`: there is one ' + + 'producer shape, and a consumer accepting two spellings is what contract-first exists ' + + 'to prevent (the same ruling #5581 applied on the producer side). No deprecated ' + + '`deleted?: boolean` transition key ships, for the same reason — a transition period is ' + + 'for keys that WORKED, and this one never did. Registered by the #6350 stock ' + + 'reconciliation. ADR-0087, #5638 (backfilled #6350).', + acceptanceCriteria: + 'No code reads `.deleted` off a `client.data.delete()` / `client.project(id).data.' + + 'delete()` result; `tsc` names every site for a typed caller, and an untyped JS caller ' + + 'must be swept by hand because nothing will report it. Nothing about the request, the ' + + 'route, the status codes or the error shapes changes, and no server needs upgrading — ' + + 'the value you may now read is the one that was already arriving. ⚠️ The real work is ' + + 'behavioural: every `if (r.deleted)` has been false since it was written, so re-read ' + + 'what each of those branches was supposed to do. Post-delete cleanup, cache ' + + 'invalidation, audit writes and UI refreshes guarded that way have never run, and ' + + 'switching to `r.success` turns them ON for the first time — verify that is what you ' + + 'want rather than assuming it restores prior behaviour. Any test that passed while ' + + 'asserting on `deleted` was asserting on `undefined` and needs rewriting, not renaming.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.data-driver-query-omit-object.ts b/packages/spec/src/migrations/entries/semantic/17.data-driver-query-omit-object.ts new file mode 100644 index 0000000000..86ef709dd8 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.data-driver-query-omit-object.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: 'data-driver-query-omit-object', + surface: + 'contracts.IDataDriver query parameter — find / findOne / count / updateMany / ' + + 'deleteMany / explain', + replacement: + '`DriverQuery` (`Omit`): delete the redundant `object:` key from the ' + + 'query literal at the call site — the object name is already the FIRST argument', + reason: + 'Every one of these methods takes the object name as its first argument, and then ' + + 'required a `QueryAST` that lists `object` as mandatory — the same fact demanded twice, ' + + 'with two places for it to disagree. The layers above had already paid for that ' + + 'ambiguity: the objectql engine deliberately writes its key order as `{ ...query, ' + + 'object }` so a smuggled `query.object` cannot override the resolved name, and the wire ' + + 'layer spends a named 400 (`QUERY_OBJECT_MISMATCH`) refusing the inconsistency. The ' + + 'driver side paid in blanket casts: a direct caller holding only a `where` could not ' + + 'name the type, wrote `as any`, and switched off checking for `where` / `orderBy` / ' + + '`fields` along with it — 20 such sites measured in cloud#1053, and cloud#1030\'s ' + + '`$like` reached runtime through exactly that hole. This is a TS contract surface with ' + + 'no authored source for the chain to rewrite, which is why it is a semantic entry and ' + + 'not a D2 conversion; for a typed caller the compiler names every site (TS2353 ' + + '`\'object\' does not exist in type \'DriverQuery\'`), and for an untyped JS caller ' + + 'there is no constrained channel at all, which is exactly why this ledger entry must ' + + 'exist. Neither side is forced to move: a caller holding a `QueryAST` VALUE passes it ' + + 'unchanged (excess properties are only rejected on fresh literals), and an ' + + 'implementation still declaring `query: QueryAST` keeps compiling under parameter ' + + 'bivariance. What an implementation may no longer do is READ `query.object` — callers ' + + 'are now entitled to omit it. Registered by the #6350 stock reconciliation. #5181 was ' + + 'the audit\'s CONTROL sample, drawn to show that not every flagged candidate is an ' + + 'omission, and it was one: the seven `IDataDriver` hits in the ledger are all prose ' + + 'inside other entries, and the only subject-level hit on this interface is ' + + '`data-driver-find-stream-retired` — a DIFFERENT member. Two later, smaller driver ' + + 'call-parameter changes (#6321, #6083) both registered, and both cite #5181 as ' + + 'background; the larger sibling they derive from never got its own entry. ADR-0087, ' + + '#5181 (backfilled #6350).', + acceptanceCriteria: + 'No `IDataDriver` call site passes an inline literal carrying `object:` — `driver.find(' + + '"account", { object: "account", where: … })` becomes `driver.find("account", { where: ' + + '… })`. `tsc` is the verify loop for typed callers and reports every remaining site by ' + + 'name; an untyped JS caller must be swept by hand, because nothing will report it. Any ' + + 'driver IMPLEMENTATION that read `query.object` is rewritten to use the object-name ' + + 'argument instead — that read now yields `undefined` whenever a caller exercises its ' + + 'new right to omit the key, and it fails at runtime rather than at compile time, so it ' + + 'is the one change on this surface a type check cannot find for you.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.export-axis-opt-in.ts b/packages/spec/src/migrations/entries/semantic/17.export-axis-opt-in.ts new file mode 100644 index 0000000000..bb4141780e --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.export-axis-opt-in.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: 'export-axis-opt-in', + surface: + 'security.permissionSet.objects[].allowExport (ABSENT — a permission set that never ' + + 'declared the key)', + replacement: + 'an explicit `allowExport: true` on the object entry (or the `*` wildcard) of every ' + + 'permission set whose holders are meant to keep exporting', + reason: + 'A secure-default FLIP, not a shape change — the same class as ' + + '`rest-requireauth-default-flip` (ADR-0056 D2, protocol 12) and ' + + '`action-descriptor-resume-authority-default-flip`, and it is registered for the same ' + + 'reason those are: the metadata is UNCHANGED and still parses, so no gate anywhere will ' + + 'tell an upgrader that what it MEANS has inverted. Before 17, `allowExport` unset ' + + 'inherited read; from 17 it denies. Reading a record and taking a bulk ' + + 'machine-readable copy of the whole table are different privileges — Salesforce ' + + '"Export Reports", Dynamics "Export to Excel", NetSuite "Export Lists" and SAP `S_GUI` ' + + '61 all separate them — and the axis now says so. This cannot be a mechanical ' + + 'conversion in either direction: writing `allowExport: true` wherever the key is absent ' + + 'would preserve today\'s behaviour while silently defeating the entire point of the ' + + 'flip, and writing `false` would revoke a capability the deployment may legitimately ' + + 'want. Whether a given set\'s holders SHOULD be able to take a bulk copy is exactly the ' + + 'segregation-of-duties judgement the axis exists to make explicit, and it belongs to ' + + 'the operator. Two details decide who is actually affected: package-shipped sets are ' + + 're-seeded on upgrade, so the built-ins are handled — `admin_full_access` and ' + + '`organization_admin` now carry the grant explicitly — while ENVIRONMENT-AUTHORED sets ' + + 'are not and must be edited by hand. `member_default` deliberately does NOT carry the ' + + 'grant, so ordinary authenticated users lose export until an admin grants it; that is ' + + 'the point of the flip, not an oversight. Merge semantics are unchanged and ' + + 'most-permissive, exactly like the CRUD bits: any set granting `true` grants export, ' + + 'and `false` is authoring intent rather than a veto, because permission sets are ' + + 'additive capability containers (ADR-0090). The super-user bits no longer confer it: ' + + '`viewAllRecords` / `modifyAllRecords` are "may see all data", not "may take a bulk ' + + 'copy". Registered by the #6350 stock reconciliation; #3544 / #3710 predate the #6148 ' + + 'completeness gate. ADR-0087, #3544 / #3710 (backfilled #6350).', + acceptanceCriteria: + 'Every environment-authored permission set has been READ and decided, not just parsed: ' + + 'each object entry whose holders should keep exporting carries `allowExport: true`, and ' + + 'each one that should not is knowingly left without it. The verify loop is behavioural, ' + + 'because nothing fails at parse time — sign in as a holder of each affected set and ' + + 'confirm an export either succeeds or is refused as intended, including the report ' + + 'export path, which this change brought under the same axis. ⚠️ Silence is not success ' + + 'here: a deployment that upgrades without editing anything is VALID metadata whose ' + + 'ordinary users have quietly lost export, and the first sign will be a user report ' + + 'rather than an error. Check `member_default` explicitly — it is the set most likely to ' + + 'have been carrying export by inheritance.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.record-details-sections-object-form.ts b/packages/spec/src/migrations/entries/semantic/17.record-details-sections-object-form.ts new file mode 100644 index 0000000000..768c90d860 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.record-details-sections-object-form.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: 'record-details-sections-object-form', + surface: 'ui.RecordDetailsProps.sections (the `record:details` page component)', + replacement: + 'an OBJECT array — `sections: [{ label, columns, fields: [...] }]` — replacing the ' + + 'string-ID list; `label` gives the heading, `columns` its grid width, `name` makes the ' + + 'heading translatable, and the new sibling `hideFields` omits named fields from the body', + reason: + '`record:details` declared `sections` as a list of section IDs — `["overview", ' + + '"financials"]` — a shape nothing produced and nothing consumed, while every real page ' + + 'authored the object form. This is authorable metadata on the publish/parse path, so it ' + + 'is the D2 class exactly; it is registered as a SEMANTIC step rather than a mechanical ' + + 'conversion because a string ID carries no field list and the chain cannot invent one — ' + + 'only the author knows which fields the section named `overview` was meant to render. ' + + 'The measurement that made the type change safe is also what makes the prescription ' + + 'unambiguous: the ID-list form had zero read paths and zero producers. objectui\'s ' + + '`RecordDetailsRenderer` maps every entry as an object (`s.name` / `s.label` / `s.title` ' + + '/ `s.fields`) with no string branch at all — a string entry spreads into a character ' + + 'map and renders nothing; `@object-ui/types`\' `RecordDetailsComponentProps` mirror ' + + 'already declared `Array<{ name?, label?, fields, ... }>`; the Studio block designer can ' + + 'only author `{ label, columns, fields }`; `packages/lint` has modelled it as ' + + '`nestedSections` all along; and every page in this repo — three showcase pages plus the ' + + '`sys_user` platform page — authors the object form. So the break lands only on stored ' + + 'metadata written against a declaration nothing ever honoured, and it lands at publish ' + + 'time rather than rewriting data at rest. The same change DECLARED `hideFields`, which ' + + 'the `sys_user` platform page had been authoring undeclared. Registered by the #6350 ' + + 'stock reconciliation: #5611 predates the #6148 completeness gate, so nothing asked it ' + + 'what it had done about the ledger, and the sibling key on the same def — ' + + '`ui/RecordDetailsProps:layout`, retired by #6350\'s neighbour — carries a tombstone ' + + 'while this face carried none. ADR-0087, #5611 (backfilled #6350).', + acceptanceCriteria: + 'Every `record:details` component in authored metadata spells `sections` as an object ' + + 'array: each entry names the fields it renders (`fields: [...]`), optionally with ' + + '`label` / `name` / `columns`. `objectstack validate` passes and each detail page ' + + 'renders the same sections, in the same order, with the same fields as before the ' + + 'upgrade — a page whose sections silently render EMPTY is the signature of an ID list ' + + 'left in place. A section that existed only as an ID, with no field list recoverable ' + + 'from the page it belonged to, is a judgement for the author: name the fields it was ' + + 'meant to show, or delete the entry. Fields previously hidden by a convention outside ' + + 'the schema move onto the declared `hideFields`.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.runtime-httpserver-wrapper-retired.ts b/packages/spec/src/migrations/entries/semantic/17.runtime-httpserver-wrapper-retired.ts new file mode 100644 index 0000000000..75bea16ae8 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.runtime-httpserver-wrapper-retired.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'runtime-httpserver-wrapper-retired', + surface: 'runtime.HttpServer (the exported delegating wrapper class)', + replacement: + 'register an `IHttpServer` ADAPTER INSTANCE directly as the `http.server` service — ' + + '`HonoHttpServer` or whatever adapter the host already builds — instead of wrapping one', + reason: + '`@objectstack/runtime` exported an `HttpServer` class that took an `IHttpServer` in its ' + + 'constructor, declared `implements IHttpServer`, and forwarded only the contract\'s ' + + 'REQUIRED members (`get` / `post` / `put` / `delete` / `patch` / `use` / `listen` / ' + + '`close`). It forwarded none of the OPTIONAL ones — `getPort?()`, `getRawApp?()`, ' + + '`setFallbackHandler?()`. `packages/spec/src/contracts/http-server.ts` instructs ' + + 'consumers to feature-detect exactly those members with `typeof server.X === ' + + '"function"` and to degrade when absent, so wrapping a capable adapter made every ' + + 'probe answer false and the capability vanish with the adapter underneath providing it ' + + 'the whole time. The sharpest consequence is worth writing down before anyone reaches ' + + 'for a wrapper of the same shape: a host that wrapped `HonoHttpServer` and registered ' + + 'the wrapper as `http.server` would answer 404 to every endpoint its metadata declared, ' + + 'because `setFallbackHandler` — since #5111 the ONLY entry path for declarative `apis:` ' + + 'endpoints — was never forwarded. This is a TS/API contract surface: an HTTP server ' + + 'adapter is CODE, never stack metadata, so there is no authored source for the chain to ' + + 'rewrite and deliberately no schema tombstone — nothing ever ran an adapter through a ' + + '`.parse()`. That is precisely why this entry must exist: for an untyped JS host the ' + + 'ledger is the only notification channel there is, and for a typed one tsc reports at ' + + 'the construction site. Same disposition, and the same reason, as ' + + '`storage-service-list-retired` (#5540) and `data-driver-find-stream-retired` (#4484). ' + + 'Registered by the #6350 stock reconciliation, not by the original change: #5122 landed ' + + 'before the #6148 completeness gate existed, so nothing ever asked it what it had done ' + + 'about the ledger. ADR-0049 / ADR-0087, #5122 (backfilled #6350).', + acceptanceCriteria: + 'No code constructs `new HttpServer(...)` from `@objectstack/runtime`, and no import of ' + + 'the name resolves — the export is gone, so a typed caller fails to compile at the ' + + 'construction site. A host that was wrapping an adapter registers the ADAPTER INSTANCE ' + + 'as `http.server` instead, and then proves the capability came back: `getPort()` returns ' + + 'the real bound port after `listen(0)`, `getRawApp()` returns the framework-native app, ' + + 'and a declarative `apis:` endpoint declared in metadata answers its route rather than ' + + '404 — the last of which is the failure a wrapper produced silently. An adapter that ' + + 'genuinely needs to intercept calls implements `IHttpServer` in full, forwarding the ' + + 'optional members too, rather than declaring `implements` and dropping them.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts b/packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts new file mode 100644 index 0000000000..7ede3147ae --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.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: 'sharing-rule-recipient-reconcile', + surface: + 'security.sharingRule.sharedWith.type `group` / `guest`, and owner-type rules ' + + '(`type: owner` + `ownedBy`)', + replacement: + '`group` → `team` (the enforced runtime vocabulary); `guest` → delete the rule and expose ' + + 'the records through a public form or a share link; `type: owner` → rewrite as a ' + + '`type: criteria` rule. `business_unit` is newly authorable for the single-unit case', + reason: + 'The authoring `ShareRecipientType` enum had drifted behind both the ADR-0090 D3 rename ' + + 'and the enforced runtime, in both directions at once. It still offered the pre-rename ' + + '`group`, which the seed path silently SKIPPED, while omitting two recipients the ' + + 'runtime and bootstrap already enforced (`team` via `sys_team` / `sys_team_member`, and ' + + '`business_unit`). It also offered `guest`, which had no runtime recipient mapping at ' + + 'all. Each of those is a rule that validated and then materialised nothing — the ' + + 'ADR-0078 shape, and on a SECURITY surface, where the failure is silent under-sharing: ' + + 'the author sees a valid rule and believes a set of people can reach the records, and ' + + 'no error ever contradicts them. Owner-type rules go for a different and sharper ' + + 'reason: they depend on live team / position membership, which the static materialiser ' + + 'cannot track, so they could not be made to work by fixing a name. They return as an ' + + 'enforced form only if membership-reactive re-materialisation is designed. This is a ' + + 'semantic entry rather than a mechanical conversion because only one of the three ' + + 'rewrites is a rename: `group` → `team` is mechanical, but `guest` and `type: owner` ' + + 'have no target — the author has to decide who was actually meant to reach those ' + + 'records and say so in a form the runtime enforces, and a transform that guessed would ' + + 'be inventing an access grant. After this change every authorable recipient and rule ' + + 'type on the SharingRule surface is enforced; the `queue` recipient stays ' + + 'runtime-reserved and deliberately non-authorable (there is no `sys_queue` yet). Note ' + + 'the two neighbouring conversions cover DIFFERENT faces of this schema and not this ' + + 'one: `sharing-recipient-role-to-position` is the ADR-0090 role → position rename and ' + + '`sharing-rule-access-level-full-to-edit` is the access-level vocabulary. Registered by ' + + 'the #6350 stock reconciliation. ADR-0078 / ADR-0090 D3 / ADR-0087, #1878 (backfilled ' + + '#6350).', + acceptanceCriteria: + 'No sharing rule names `group` or `guest`, and none carries `type: owner`; stale ' + + 'definitions now FAIL parse with the valid options listed, so the sweep is "fix until ' + + 'nothing raises". ⚠️ Parsing clean is the weaker half — verify the SHARES, because a ' + + 'rule that was silently materialising nothing looked exactly like one that worked. For ' + + 'every rule that named `group`, confirm the `sys_team` it now resolves to has the ' + + 'membership you expected, and that records reach the people the rule was written for. ' + + 'Each former `guest` rule needs an explicit decision about anonymous access — a public ' + + 'form grant or a share link, or knowingly no access at all — and each former owner-type ' + + 'rule needs a `criteria` predicate that names the same population, checked against a ' + + 'representative record. Where a single business unit was meant, use `business_unit`; ' + + '`unit_and_subordinates` is the subtree and grants strictly more.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.sort-node-direction-rejected.ts b/packages/spec/src/migrations/entries/semantic/17.sort-node-direction-rejected.ts new file mode 100644 index 0000000000..4542d063a0 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.sort-node-direction-rejected.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: 'sort-node-direction-rejected', + surface: 'data.query.orderBy[].direction (SortNode)', + replacement: + '`order` — `orderBy: [{ field: "updated_at", order: "desc" }]`. One word, same values ' + + '(`asc` / `desc`)', + reason: + '`SortNodeSchema` was a plain `z.object`, so zod\'s default `.strip` applied and a sort ' + + 'node spelling its direction `direction` lost the key silently. Measured on `main` ' + + 'before the change: `SortNodeSchema.parse({ field: "updated_at", direction: "desc" })` ' + + 'returned `{ field: "updated_at", order: "asc" }` — the key discarded and `order` ' + + 'falling back to its `asc` default, so the sort ran in the OPPOSITE direction and the ' + + 'request succeeded. Paired with `limit`, which is how a caller asks for "the latest N", ' + + 'that is not a reordered page but a DIFFERENT SET OF ROWS, returned under an ordinary ' + + '200 with nothing in the response to distinguish it from the answer that was asked for. ' + + '`direction` is not a typo: it is the live vocabulary of a neighbouring contract, ' + + '`IReportService.orderBy`, and `plugin-auth/objectql-adapter.ts` already translated ' + + 'between the two by hand — a translation known to be necessary and enforced nowhere, ' + + 'the ADR-0049 shape. Both doors closed in one change: `SortNodeSchema` is now a ' + + '`strictObject` carrying `aliases: { direction: "order" }`, and `normalizeSortNodes` ' + + 'in `metadata-protocol` refuses `{ field, direction }` with `400 INVALID_SORT`. The ' + + 'alias is deliberate rather than left to the edit-distance fallback, because no edit ' + + 'distance bridges `direction` → `order` and a bare "unrecognized key" would leave the ' + + 'caller exactly where the silent strip did. This is registered as a semantic entry ' + + 'rather than a mechanical conversion for one reason worth stating: the rewrite itself ' + + 'is trivially mechanical, but a stored `direction: "asc"` is ambiguous evidence — the ' + + 'author may have written it meaning ascending and been silently GIVEN ascending, so ' + + 'the visible behaviour never contradicted them, and only they can say whether the ' + + 'sort they have been reading was the sort they asked for. Registered by the #6350 stock ' + + 'reconciliation: the in-code alias tombstone shipped with #4721, but the ledger half ' + + 'never did, and a retirement needs both — the tombstone is the proof the removal was ' + + 'declared, the ledger entry is what `spec-changes.json`, the upgrade guide and ' + + '`os migrate meta` project to consumers. ADR-0049 / ADR-0087, #4721 (backfilled #6350).', + acceptanceCriteria: + 'No authored `orderBy` entry — in metadata, in a saved view\'s `sort[]`, or in a REST / ' + + 'RPC request body — spells the key `direction`. The upgrade\'s own verify loop is that ' + + 'the failure is now LOUD: a stale `direction` raises a named parse error (or `400 ' + + 'INVALID_SORT` at ingress) quoting `order`, so a sweep is "fix until nothing raises" ' + + 'rather than a search. ⚠️ Check the RESULTS, not just the parse: every list, report and ' + + 'paged query that carried `direction: "desc"` has been silently serving ASCENDING order ' + + 'and, wherever it was paired with `limit`, a different set of rows. After the rename ' + + 'those pages change what they return — that is the defect being corrected, not a ' + + 'regression, and any downstream expectation baked against the old output has to be ' + + 're-read rather than restored.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts b/packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts new file mode 100644 index 0000000000..9ff8b528ce --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.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: 'tool-requires-confirmation-retired', + surface: 'ai.tool.requiresConfirmation', + replacement: + 'put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — that ' + + 'is the flag the HITL approval queue actually reads, and the only path that stops ' + + 'execution', + reason: + '`ToolSchema.requiresConfirmation` accepted `true` and no execution path ever read it: ' + + 'not the LLM tool set (a tool reaches the model as name / description / parameters ' + + 'only), not `ToolRegistry.execute`, not `POST /ai/tools/:name/execute`, and not the MCP ' + + 'bridge, which derives `destructiveHint` from a hardcoded name list. Setting it on a ' + + 'destructive tool produced NO PAUSE. For an ordinary dead property that is untidy; for ' + + 'a SAFETY property it is false compliance, the case ADR-0049 exists for — an author ' + + 'gates a destructive tool, sees the flag accepted, and ships believing a human is in ' + + 'the loop. It is made worse by the near-miss: `action.ai.requiresConfirmation` carries ' + + 'the same name and DOES work, so the mistake reads as correct in review. This is ' + + 'registered as a semantic entry rather than a mechanical conversion because the rewrite ' + + 'is not a rename at all — the replacement lives on a different metadata object at a ' + + 'different layer, and deciding which action should carry the gate (or whether the ' + + 'operation should be an action at all) is a judgement the chain cannot make. Deleting ' + + 'the key mechanically would be the worst possible transform here: it would leave the ' + + 'metadata parsing green while silently completing the removal of a safety gate the ' + + 'author believed was in place. `ToolSchema` was made `.strict()` in the same change, ' + + 'which is load-bearing rather than tidying — removing a key from a non-strict schema ' + + 'swaps one silent no-op for another, so the retired key now REJECTS and the parse error ' + + 'carries the prescription, that being the one channel every consumer bumping ' + + '`@objectstack/spec` is guaranteed to hit. Registered by the #6350 stock ' + + 'reconciliation: the `retiredKey()` tombstone shipped with #3715 and still stands in ' + + '`ai/tool.zod.ts`, but the ledger half never did. A retirement needs both — the ' + + 'tombstone is the proof the removal was declared, this entry is what `spec-changes.json`' + + ', the upgrade guide and `os migrate meta` project to consumers. ADR-0033 §2 / ' + + 'ADR-0049 / ADR-0087, #3715 (backfilled #6350).', + acceptanceCriteria: + 'No tool definition carries `requiresConfirmation`; the key now raises a located parse ' + + 'error naming the replacement, so the sweep is "fix until nothing raises". ⚠️ The ' + + 'load-bearing half is what happens NEXT, and no gate can check it for you: for every ' + + 'tool that carried the flag, decide whether that operation genuinely needs a human in ' + + 'the loop. If it does, move it behind an action carrying `ai.requiresConfirmation: ' + + 'true` and prove the pause exists — invoke it and observe the approval queue hold it, ' + + 'rather than assuming the declaration. If it does not, delete the key knowingly. ' + + 'Deleting it without that decision leaves exactly the state the retirement exists to ' + + 'end: a destructive tool nobody is approving, now without even the false flag to show ' + + 'that somebody once meant to.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index c402ec9739..93dce96546 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -1761,6 +1761,49 @@ const step17: MigrationStep = { + 'were never served either; they can be deleted (`deleteMetaItem` is deliberately not ' + 'gated by this refusal, so repair stays possible).', }, + { + id: 'apimethod-enum-shrink', + surface: 'data.object.enable.apiMethods (the eight legacy non-primitive values)', + replacement: + 'the six primitives only — `get` / `list` / `create` / `update` / `delete` / `bulk`: ' + + 'replace each legacy value with the primitives it derives from, de-duplicate, and ' + + 'delete the key entirely if the result names all six', + reason: + 'The authored `enable.apiMethods` enum is now exactly the six primitives. The eight ' + + 'legacy values — `upsert`, `aggregate`, `history`, `search`, `restore`, `purge`, ' + + '`import`, `export` — are no longer authorable, because they are DERIVED effective ' + + 'operations resolved by the server\'s single derivation table, and an enum that lets an ' + + 'author name both a primitive and something derived from it has two spellings for one ' + + 'fact. The FROM → TO is a table rather than a rename: `upsert` → `create` + `update`; ' + + '`import` → `create` + `update`; `export`, `aggregate` and `search` → `list`; ' + + '`history` → `get`; and `restore` / `purge` map to NOTHING — they never derived, ' + + 'because `enable.trash` was retired in #2377, so the value is deleted outright. That ' + + 'last row is why this is a semantic entry and not a mechanical conversion, and the ' + + 'reason is a security one: the mapping WIDENS. An allowlist naming `history` was ' + + 'granting read of one record\'s audit trail; rewritten to `get` it grants ordinary ' + + 'record reads, and an allowlist naming `search` becomes a grant of full `list`. A ' + + 'transform that applied the table silently would broaden real API permissions without ' + + 'anyone reading the diff, so the rewrite is delegated to the author with the widening ' + + 'flagged. The reporter codemod exists for exactly that shape: `node ' + + 'scripts/codemod/apimethods-legacy-to-primitives.mjs` scans, reports the exact ' + + 'replacement per site, and FLAGS the allowlists the mapping would widen so the edit ' + + 'stays reviewable — it reports, it does not rewrite. Stored metadata keeps parsing ' + + '(permanent tolerance, narrowing only), so nothing breaks at rest; what changes is what ' + + 'an author may newly write. Registered by the #6350 stock reconciliation; #3543 (P2 of ' + + '#3391) predates the #6148 completeness gate. ADR-0087, #3543 (backfilled #6350).', + acceptanceCriteria: + 'No authored `enable.apiMethods` array names a legacy value; `objectstack validate` ' + + 'passes. Run the reporter codemod first and read its widening flags before applying ' + + 'anything — ⚠️ the migration is only correct if each widened grant was INTENDED. For ' + + 'every object where `history` became `get` or `search` became `list`, confirm the ' + + 'broader operation is one the API should genuinely expose; where it is not, the answer ' + + 'is not a different value in this enum but a permission set that withholds the ' + + 'operation. Where the six primitives are all present, prefer deleting the key: that is ' + + 'equivalent to default-open and it tracks future primitives, whereas a hand-listed six ' + + 'silently stops granting anything added later. `restore` / `purge` are deleted with no ' + + 'replacement — if trash-like behaviour was being relied on, that capability left in ' + + '#2377 and this entry is not where it returns.', + }, { id: 'auth-config-unadvertised-reserved-features', surface: 'api.authConfig.features.passkeys / api.authConfig.features.magicLink', @@ -1833,6 +1876,50 @@ const step17: MigrationStep = { + 'correlate to the request via `row.index`. Every row the three endpoints emit parses ' + 'under `BatchOperationResultSchema` with those keys present.', }, + { + id: 'client-delete-result-success', + surface: 'client.DeleteDataResult.deleted (the return of `client.data.delete()`)', + replacement: '`success` — `r.deleted` → `r.success`. Same call, same wire body, declared name', + reason: + '`DeleteDataResult` carried the comment `Spec: DeleteDataResponseSchema` above a ' + + 'declaration that contradicted it: the interface declared `deleted: boolean` while ' + + '`DeleteDataResponseSchema` declares `{ object, id, success }`. `deleted` has never ' + + 'been declared by any schema and no server path has ever returned it on ' + + '`/data/:object/:id`. Both delete surfaces — `client.data.delete()` and the ' + + 'project-scoped `client.project(id).data.delete()` — are pure `unwrapResponse` / ' + + '`_unwrap` passthroughs, so the interface is a CLAIM about the wire, never a rewrite of ' + + 'it, and the claim was false in the one direction that matters: the compiler endorsed ' + + 'the wrong spelling. `if (r.deleted)` compiled, read `undefined` at runtime, and the ' + + 'branch was never taken; `if (r.success)` was rejected by the compiler and correct on ' + + 'the wire. So this rename REVEALS a defect rather than breaking working code — every ' + + 'reader of the old key was already reading `undefined`, on every deployment and not ' + + 'just some, because the protocol path has always answered `success`. It is registered ' + + 'as a semantic entry rather than a mechanical conversion for the reason the rewrite ' + + 'itself does not capture: the key is one token, but a call site that branched on ' + + '`r.deleted` has been taking the FALSE branch unconditionally since it was written, and ' + + 'whatever that branch did — or skipped — is what actually has to be re-read. There is ' + + 'no authored source for the chain to rewrite either; this is a published TypeScript ' + + 'surface whose enforced channel is tsc at the call site, and for an untyped JS caller ' + + 'there is no constrained channel at all, which is why the ledger entry is the only ' + + 'notification that reaches them. ⛔ Do not write `r.success ?? r.deleted`: there is one ' + + 'producer shape, and a consumer accepting two spellings is what contract-first exists ' + + 'to prevent (the same ruling #5581 applied on the producer side). No deprecated ' + + '`deleted?: boolean` transition key ships, for the same reason — a transition period is ' + + 'for keys that WORKED, and this one never did. Registered by the #6350 stock ' + + 'reconciliation. ADR-0087, #5638 (backfilled #6350).', + acceptanceCriteria: + 'No code reads `.deleted` off a `client.data.delete()` / `client.project(id).data.' + + 'delete()` result; `tsc` names every site for a typed caller, and an untyped JS caller ' + + 'must be swept by hand because nothing will report it. Nothing about the request, the ' + + 'route, the status codes or the error shapes changes, and no server needs upgrading — ' + + 'the value you may now read is the one that was already arriving. ⚠️ The real work is ' + + 'behavioural: every `if (r.deleted)` has been false since it was written, so re-read ' + + 'what each of those branches was supposed to do. Post-delete cleanup, cache ' + + 'invalidation, audit writes and UI refreshes guarded that way have never run, and ' + + 'switching to `r.success` turns them ON for the first time — verify that is what you ' + + 'want rather than assuming it restores prior behaviour. Any test that passed while ' + + 'asserting on `deleted` was asserting on `undefined` and needs rewriting, not renaming.', + }, { id: 'dashboard-widget-compareto-offset', surface: "dashboard.widgets[].compareTo: { offset: '7d' | '1M' | … } (every duration except '1y')", @@ -1893,6 +1980,51 @@ 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-driver-query-omit-object', + surface: + 'contracts.IDataDriver query parameter — find / findOne / count / updateMany / ' + + 'deleteMany / explain', + replacement: + '`DriverQuery` (`Omit`): delete the redundant `object:` key from the ' + + 'query literal at the call site — the object name is already the FIRST argument', + reason: + 'Every one of these methods takes the object name as its first argument, and then ' + + 'required a `QueryAST` that lists `object` as mandatory — the same fact demanded twice, ' + + 'with two places for it to disagree. The layers above had already paid for that ' + + 'ambiguity: the objectql engine deliberately writes its key order as `{ ...query, ' + + 'object }` so a smuggled `query.object` cannot override the resolved name, and the wire ' + + 'layer spends a named 400 (`QUERY_OBJECT_MISMATCH`) refusing the inconsistency. The ' + + 'driver side paid in blanket casts: a direct caller holding only a `where` could not ' + + 'name the type, wrote `as any`, and switched off checking for `where` / `orderBy` / ' + + '`fields` along with it — 20 such sites measured in cloud#1053, and cloud#1030\'s ' + + '`$like` reached runtime through exactly that hole. This is a TS contract surface with ' + + 'no authored source for the chain to rewrite, which is why it is a semantic entry and ' + + 'not a D2 conversion; for a typed caller the compiler names every site (TS2353 ' + + '`\'object\' does not exist in type \'DriverQuery\'`), and for an untyped JS caller ' + + 'there is no constrained channel at all, which is exactly why this ledger entry must ' + + 'exist. Neither side is forced to move: a caller holding a `QueryAST` VALUE passes it ' + + 'unchanged (excess properties are only rejected on fresh literals), and an ' + + 'implementation still declaring `query: QueryAST` keeps compiling under parameter ' + + 'bivariance. What an implementation may no longer do is READ `query.object` — callers ' + + 'are now entitled to omit it. Registered by the #6350 stock reconciliation. #5181 was ' + + 'the audit\'s CONTROL sample, drawn to show that not every flagged candidate is an ' + + 'omission, and it was one: the seven `IDataDriver` hits in the ledger are all prose ' + + 'inside other entries, and the only subject-level hit on this interface is ' + + '`data-driver-find-stream-retired` — a DIFFERENT member. Two later, smaller driver ' + + 'call-parameter changes (#6321, #6083) both registered, and both cite #5181 as ' + + 'background; the larger sibling they derive from never got its own entry. ADR-0087, ' + + '#5181 (backfilled #6350).', + acceptanceCriteria: + 'No `IDataDriver` call site passes an inline literal carrying `object:` — `driver.find(' + + '"account", { object: "account", where: … })` becomes `driver.find("account", { where: ' + + '… })`. `tsc` is the verify loop for typed callers and reports every remaining site by ' + + 'name; an untyped JS caller must be swept by hand, because nothing will report it. Any ' + + 'driver IMPLEMENTATION that read `query.object` is rewritten to use the object-name ' + + 'argument instead — that read now yields `undefined` whenever a caller exercises its ' + + 'new right to omit the key, and it fails at runtime rather than at compile time, so it ' + + 'is the one change on this surface a type check cannot find for you.', + }, { id: 'data-engine-batch-retired', surface: 'contracts.IDataEngine.batch / data.DataEngineBatchRequestSchema', @@ -2390,6 +2522,53 @@ const step17: MigrationStep = { + '`syncConfig` and an import declaring `mapping.transform` both behave exactly as they ' + 'did in 16.x.', }, + { + id: 'export-axis-opt-in', + surface: + 'security.permissionSet.objects[].allowExport (ABSENT — a permission set that never ' + + 'declared the key)', + replacement: + 'an explicit `allowExport: true` on the object entry (or the `*` wildcard) of every ' + + 'permission set whose holders are meant to keep exporting', + reason: + 'A secure-default FLIP, not a shape change — the same class as ' + + '`rest-requireauth-default-flip` (ADR-0056 D2, protocol 12) and ' + + '`action-descriptor-resume-authority-default-flip`, and it is registered for the same ' + + 'reason those are: the metadata is UNCHANGED and still parses, so no gate anywhere will ' + + 'tell an upgrader that what it MEANS has inverted. Before 17, `allowExport` unset ' + + 'inherited read; from 17 it denies. Reading a record and taking a bulk ' + + 'machine-readable copy of the whole table are different privileges — Salesforce ' + + '"Export Reports", Dynamics "Export to Excel", NetSuite "Export Lists" and SAP `S_GUI` ' + + '61 all separate them — and the axis now says so. This cannot be a mechanical ' + + 'conversion in either direction: writing `allowExport: true` wherever the key is absent ' + + 'would preserve today\'s behaviour while silently defeating the entire point of the ' + + 'flip, and writing `false` would revoke a capability the deployment may legitimately ' + + 'want. Whether a given set\'s holders SHOULD be able to take a bulk copy is exactly the ' + + 'segregation-of-duties judgement the axis exists to make explicit, and it belongs to ' + + 'the operator. Two details decide who is actually affected: package-shipped sets are ' + + 're-seeded on upgrade, so the built-ins are handled — `admin_full_access` and ' + + '`organization_admin` now carry the grant explicitly — while ENVIRONMENT-AUTHORED sets ' + + 'are not and must be edited by hand. `member_default` deliberately does NOT carry the ' + + 'grant, so ordinary authenticated users lose export until an admin grants it; that is ' + + 'the point of the flip, not an oversight. Merge semantics are unchanged and ' + + 'most-permissive, exactly like the CRUD bits: any set granting `true` grants export, ' + + 'and `false` is authoring intent rather than a veto, because permission sets are ' + + 'additive capability containers (ADR-0090). The super-user bits no longer confer it: ' + + '`viewAllRecords` / `modifyAllRecords` are "may see all data", not "may take a bulk ' + + 'copy". Registered by the #6350 stock reconciliation; #3544 / #3710 predate the #6148 ' + + 'completeness gate. ADR-0087, #3544 / #3710 (backfilled #6350).', + acceptanceCriteria: + 'Every environment-authored permission set has been READ and decided, not just parsed: ' + + 'each object entry whose holders should keep exporting carries `allowExport: true`, and ' + + 'each one that should not is knowingly left without it. The verify loop is behavioural, ' + + 'because nothing fails at parse time — sign in as a holder of each affected set and ' + + 'confirm an export either succeeds or is refused as intended, including the report ' + + 'export path, which this change brought under the same axis. ⚠️ Silence is not success ' + + 'here: a deployment that upgrades without editing anything is VALID metadata whose ' + + 'ordinary users have quietly lost export, and the first sign will be a user report ' + + 'rather than an error. Check `member_default` explicitly — it is the set most likely to ' + + 'have been carrying export by inheritance.', + }, { id: 'export-field-meta-constraints-retired', surface: @@ -3120,6 +3299,47 @@ const step17: MigrationStep = { + "driver's `findWithWindowFunctions` door directly. A query that still carries the key " + 'fails to parse with the removal prescription naming that door.', }, + { + id: 'record-details-sections-object-form', + surface: 'ui.RecordDetailsProps.sections (the `record:details` page component)', + replacement: + 'an OBJECT array — `sections: [{ label, columns, fields: [...] }]` — replacing the ' + + 'string-ID list; `label` gives the heading, `columns` its grid width, `name` makes the ' + + 'heading translatable, and the new sibling `hideFields` omits named fields from the body', + reason: + '`record:details` declared `sections` as a list of section IDs — `["overview", ' + + '"financials"]` — a shape nothing produced and nothing consumed, while every real page ' + + 'authored the object form. This is authorable metadata on the publish/parse path, so it ' + + 'is the D2 class exactly; it is registered as a SEMANTIC step rather than a mechanical ' + + 'conversion because a string ID carries no field list and the chain cannot invent one — ' + + 'only the author knows which fields the section named `overview` was meant to render. ' + + 'The measurement that made the type change safe is also what makes the prescription ' + + 'unambiguous: the ID-list form had zero read paths and zero producers. objectui\'s ' + + '`RecordDetailsRenderer` maps every entry as an object (`s.name` / `s.label` / `s.title` ' + + '/ `s.fields`) with no string branch at all — a string entry spreads into a character ' + + 'map and renders nothing; `@object-ui/types`\' `RecordDetailsComponentProps` mirror ' + + 'already declared `Array<{ name?, label?, fields, ... }>`; the Studio block designer can ' + + 'only author `{ label, columns, fields }`; `packages/lint` has modelled it as ' + + '`nestedSections` all along; and every page in this repo — three showcase pages plus the ' + + '`sys_user` platform page — authors the object form. So the break lands only on stored ' + + 'metadata written against a declaration nothing ever honoured, and it lands at publish ' + + 'time rather than rewriting data at rest. The same change DECLARED `hideFields`, which ' + + 'the `sys_user` platform page had been authoring undeclared. Registered by the #6350 ' + + 'stock reconciliation: #5611 predates the #6148 completeness gate, so nothing asked it ' + + 'what it had done about the ledger, and the sibling key on the same def — ' + + '`ui/RecordDetailsProps:layout`, retired by #6350\'s neighbour — carries a tombstone ' + + 'while this face carried none. ADR-0087, #5611 (backfilled #6350).', + acceptanceCriteria: + 'Every `record:details` component in authored metadata spells `sections` as an object ' + + 'array: each entry names the fields it renders (`fields: [...]`), optionally with ' + + '`label` / `name` / `columns`. `objectstack validate` passes and each detail page ' + + 'renders the same sections, in the same order, with the same fields as before the ' + + 'upgrade — a page whose sections silently render EMPTY is the signature of an ID list ' + + 'left in place. A section that existed only as an ID, with no field list recoverable ' + + 'from the page it belonged to, is a judgement for the author: name the fields it was ' + + 'meant to show, or delete the entry. Fields previously hidden by a convention outside ' + + 'the schema move onto the declared `hideFields`.', + }, { id: 'rest-server-openapi31-block-removed', surface: 'restServer.openApi31', @@ -3152,6 +3372,46 @@ const step17: MigrationStep = { + 'The served /openapi.json is byte-identical before and after — the block never ' + 'reached it.', }, + { + id: 'runtime-httpserver-wrapper-retired', + surface: 'runtime.HttpServer (the exported delegating wrapper class)', + replacement: + 'register an `IHttpServer` ADAPTER INSTANCE directly as the `http.server` service — ' + + '`HonoHttpServer` or whatever adapter the host already builds — instead of wrapping one', + reason: + '`@objectstack/runtime` exported an `HttpServer` class that took an `IHttpServer` in its ' + + 'constructor, declared `implements IHttpServer`, and forwarded only the contract\'s ' + + 'REQUIRED members (`get` / `post` / `put` / `delete` / `patch` / `use` / `listen` / ' + + '`close`). It forwarded none of the OPTIONAL ones — `getPort?()`, `getRawApp?()`, ' + + '`setFallbackHandler?()`. `packages/spec/src/contracts/http-server.ts` instructs ' + + 'consumers to feature-detect exactly those members with `typeof server.X === ' + + '"function"` and to degrade when absent, so wrapping a capable adapter made every ' + + 'probe answer false and the capability vanish with the adapter underneath providing it ' + + 'the whole time. The sharpest consequence is worth writing down before anyone reaches ' + + 'for a wrapper of the same shape: a host that wrapped `HonoHttpServer` and registered ' + + 'the wrapper as `http.server` would answer 404 to every endpoint its metadata declared, ' + + 'because `setFallbackHandler` — since #5111 the ONLY entry path for declarative `apis:` ' + + 'endpoints — was never forwarded. This is a TS/API contract surface: an HTTP server ' + + 'adapter is CODE, never stack metadata, so there is no authored source for the chain to ' + + 'rewrite and deliberately no schema tombstone — nothing ever ran an adapter through a ' + + '`.parse()`. That is precisely why this entry must exist: for an untyped JS host the ' + + 'ledger is the only notification channel there is, and for a typed one tsc reports at ' + + 'the construction site. Same disposition, and the same reason, as ' + + '`storage-service-list-retired` (#5540) and `data-driver-find-stream-retired` (#4484). ' + + 'Registered by the #6350 stock reconciliation, not by the original change: #5122 landed ' + + 'before the #6148 completeness gate existed, so nothing ever asked it what it had done ' + + 'about the ledger. ADR-0049 / ADR-0087, #5122 (backfilled #6350).', + acceptanceCriteria: + 'No code constructs `new HttpServer(...)` from `@objectstack/runtime`, and no import of ' + + 'the name resolves — the export is gone, so a typed caller fails to compile at the ' + + 'construction site. A host that was wrapping an adapter registers the ADAPTER INSTANCE ' + + 'as `http.server` instead, and then proves the capability came back: `getPort()` returns ' + + 'the real bound port after `listen(0)`, `getRawApp()` returns the framework-native app, ' + + 'and a declarative `apis:` endpoint declared in metadata answers its route rather than ' + + '404 — the last of which is the failure a wrapper produced silently. An adapter that ' + + 'genuinely needs to intercept calls implements `IHttpServer` in full, forwarding the ' + + 'optional members too, rather than declaring `implements` and dropping them.', + }, { id: 'sharing-execution-context-retired', surface: @@ -3203,6 +3463,98 @@ const step17: MigrationStep = { + '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: 'sharing-rule-recipient-reconcile', + surface: + 'security.sharingRule.sharedWith.type `group` / `guest`, and owner-type rules ' + + '(`type: owner` + `ownedBy`)', + replacement: + '`group` → `team` (the enforced runtime vocabulary); `guest` → delete the rule and expose ' + + 'the records through a public form or a share link; `type: owner` → rewrite as a ' + + '`type: criteria` rule. `business_unit` is newly authorable for the single-unit case', + reason: + 'The authoring `ShareRecipientType` enum had drifted behind both the ADR-0090 D3 rename ' + + 'and the enforced runtime, in both directions at once. It still offered the pre-rename ' + + '`group`, which the seed path silently SKIPPED, while omitting two recipients the ' + + 'runtime and bootstrap already enforced (`team` via `sys_team` / `sys_team_member`, and ' + + '`business_unit`). It also offered `guest`, which had no runtime recipient mapping at ' + + 'all. Each of those is a rule that validated and then materialised nothing — the ' + + 'ADR-0078 shape, and on a SECURITY surface, where the failure is silent under-sharing: ' + + 'the author sees a valid rule and believes a set of people can reach the records, and ' + + 'no error ever contradicts them. Owner-type rules go for a different and sharper ' + + 'reason: they depend on live team / position membership, which the static materialiser ' + + 'cannot track, so they could not be made to work by fixing a name. They return as an ' + + 'enforced form only if membership-reactive re-materialisation is designed. This is a ' + + 'semantic entry rather than a mechanical conversion because only one of the three ' + + 'rewrites is a rename: `group` → `team` is mechanical, but `guest` and `type: owner` ' + + 'have no target — the author has to decide who was actually meant to reach those ' + + 'records and say so in a form the runtime enforces, and a transform that guessed would ' + + 'be inventing an access grant. After this change every authorable recipient and rule ' + + 'type on the SharingRule surface is enforced; the `queue` recipient stays ' + + 'runtime-reserved and deliberately non-authorable (there is no `sys_queue` yet). Note ' + + 'the two neighbouring conversions cover DIFFERENT faces of this schema and not this ' + + 'one: `sharing-recipient-role-to-position` is the ADR-0090 role → position rename and ' + + '`sharing-rule-access-level-full-to-edit` is the access-level vocabulary. Registered by ' + + 'the #6350 stock reconciliation. ADR-0078 / ADR-0090 D3 / ADR-0087, #1878 (backfilled ' + + '#6350).', + acceptanceCriteria: + 'No sharing rule names `group` or `guest`, and none carries `type: owner`; stale ' + + 'definitions now FAIL parse with the valid options listed, so the sweep is "fix until ' + + 'nothing raises". ⚠️ Parsing clean is the weaker half — verify the SHARES, because a ' + + 'rule that was silently materialising nothing looked exactly like one that worked. For ' + + 'every rule that named `group`, confirm the `sys_team` it now resolves to has the ' + + 'membership you expected, and that records reach the people the rule was written for. ' + + 'Each former `guest` rule needs an explicit decision about anonymous access — a public ' + + 'form grant or a share link, or knowingly no access at all — and each former owner-type ' + + 'rule needs a `criteria` predicate that names the same population, checked against a ' + + 'representative record. Where a single business unit was meant, use `business_unit`; ' + + '`unit_and_subordinates` is the subtree and grants strictly more.', + }, + { + id: 'sort-node-direction-rejected', + surface: 'data.query.orderBy[].direction (SortNode)', + replacement: + '`order` — `orderBy: [{ field: "updated_at", order: "desc" }]`. One word, same values ' + + '(`asc` / `desc`)', + reason: + '`SortNodeSchema` was a plain `z.object`, so zod\'s default `.strip` applied and a sort ' + + 'node spelling its direction `direction` lost the key silently. Measured on `main` ' + + 'before the change: `SortNodeSchema.parse({ field: "updated_at", direction: "desc" })` ' + + 'returned `{ field: "updated_at", order: "asc" }` — the key discarded and `order` ' + + 'falling back to its `asc` default, so the sort ran in the OPPOSITE direction and the ' + + 'request succeeded. Paired with `limit`, which is how a caller asks for "the latest N", ' + + 'that is not a reordered page but a DIFFERENT SET OF ROWS, returned under an ordinary ' + + '200 with nothing in the response to distinguish it from the answer that was asked for. ' + + '`direction` is not a typo: it is the live vocabulary of a neighbouring contract, ' + + '`IReportService.orderBy`, and `plugin-auth/objectql-adapter.ts` already translated ' + + 'between the two by hand — a translation known to be necessary and enforced nowhere, ' + + 'the ADR-0049 shape. Both doors closed in one change: `SortNodeSchema` is now a ' + + '`strictObject` carrying `aliases: { direction: "order" }`, and `normalizeSortNodes` ' + + 'in `metadata-protocol` refuses `{ field, direction }` with `400 INVALID_SORT`. The ' + + 'alias is deliberate rather than left to the edit-distance fallback, because no edit ' + + 'distance bridges `direction` → `order` and a bare "unrecognized key" would leave the ' + + 'caller exactly where the silent strip did. This is registered as a semantic entry ' + + 'rather than a mechanical conversion for one reason worth stating: the rewrite itself ' + + 'is trivially mechanical, but a stored `direction: "asc"` is ambiguous evidence — the ' + + 'author may have written it meaning ascending and been silently GIVEN ascending, so ' + + 'the visible behaviour never contradicted them, and only they can say whether the ' + + 'sort they have been reading was the sort they asked for. Registered by the #6350 stock ' + + 'reconciliation: the in-code alias tombstone shipped with #4721, but the ledger half ' + + 'never did, and a retirement needs both — the tombstone is the proof the removal was ' + + 'declared, the ledger entry is what `spec-changes.json`, the upgrade guide and ' + + '`os migrate meta` project to consumers. ADR-0049 / ADR-0087, #4721 (backfilled #6350).', + acceptanceCriteria: + 'No authored `orderBy` entry — in metadata, in a saved view\'s `sort[]`, or in a REST / ' + + 'RPC request body — spells the key `direction`. The upgrade\'s own verify loop is that ' + + 'the failure is now LOUD: a stale `direction` raises a named parse error (or `400 ' + + 'INVALID_SORT` at ingress) quoting `order`, so a sweep is "fix until nothing raises" ' + + 'rather than a search. ⚠️ Check the RESULTS, not just the parse: every list, report and ' + + 'paged query that carried `direction: "desc"` has been silently serving ASCENDING order ' + + 'and, wherever it was paired with `limit`, a different set of rows. After the rename ' + + 'those pages change what they return — that is the defect being corrected, not a ' + + 'regression, and any downstream expectation baked against the old output has to be ' + + 're-read rather than restored.', + }, { id: 'spec-type-alias-input-suffix-retired', // Plain text, no markdown: build-upgrade-guide.ts renders this field inside a @@ -3324,6 +3676,51 @@ const step17: MigrationStep = { + '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: 'tool-requires-confirmation-retired', + surface: 'ai.tool.requiresConfirmation', + replacement: + 'put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — that ' + + 'is the flag the HITL approval queue actually reads, and the only path that stops ' + + 'execution', + reason: + '`ToolSchema.requiresConfirmation` accepted `true` and no execution path ever read it: ' + + 'not the LLM tool set (a tool reaches the model as name / description / parameters ' + + 'only), not `ToolRegistry.execute`, not `POST /ai/tools/:name/execute`, and not the MCP ' + + 'bridge, which derives `destructiveHint` from a hardcoded name list. Setting it on a ' + + 'destructive tool produced NO PAUSE. For an ordinary dead property that is untidy; for ' + + 'a SAFETY property it is false compliance, the case ADR-0049 exists for — an author ' + + 'gates a destructive tool, sees the flag accepted, and ships believing a human is in ' + + 'the loop. It is made worse by the near-miss: `action.ai.requiresConfirmation` carries ' + + 'the same name and DOES work, so the mistake reads as correct in review. This is ' + + 'registered as a semantic entry rather than a mechanical conversion because the rewrite ' + + 'is not a rename at all — the replacement lives on a different metadata object at a ' + + 'different layer, and deciding which action should carry the gate (or whether the ' + + 'operation should be an action at all) is a judgement the chain cannot make. Deleting ' + + 'the key mechanically would be the worst possible transform here: it would leave the ' + + 'metadata parsing green while silently completing the removal of a safety gate the ' + + 'author believed was in place. `ToolSchema` was made `.strict()` in the same change, ' + + 'which is load-bearing rather than tidying — removing a key from a non-strict schema ' + + 'swaps one silent no-op for another, so the retired key now REJECTS and the parse error ' + + 'carries the prescription, that being the one channel every consumer bumping ' + + '`@objectstack/spec` is guaranteed to hit. Registered by the #6350 stock ' + + 'reconciliation: the `retiredKey()` tombstone shipped with #3715 and still stands in ' + + '`ai/tool.zod.ts`, but the ledger half never did. A retirement needs both — the ' + + 'tombstone is the proof the removal was declared, this entry is what `spec-changes.json`' + + ', the upgrade guide and `os migrate meta` project to consumers. ADR-0033 §2 / ' + + 'ADR-0049 / ADR-0087, #3715 (backfilled #6350).', + acceptanceCriteria: + 'No tool definition carries `requiresConfirmation`; the key now raises a located parse ' + + 'error naming the replacement, so the sweep is "fix until nothing raises". ⚠️ The ' + + 'load-bearing half is what happens NEXT, and no gate can check it for you: for every ' + + 'tool that carried the flag, decide whether that operation genuinely needs a human in ' + + 'the loop. If it does, move it behind an action carrying `ai.requiresConfirmation: ' + + 'true` and prove the pause exists — invoke it and observe the approval queue hold it, ' + + 'rather than assuming the declaration. If it does not, delete the key knowingly. ' + + 'Deleting it without that decision leaves exactly the state the retirement exists to ' + + 'end: a destructive tool nobody is approving, now without even the false flag to show ' + + 'that somebody once meant to.', + }, { id: 'ui-interaction-config-family-retired', surface: