From 2d52025e09fdc408f572a0f9526a48d6f8210cb2 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:45:18 +0800 Subject: [PATCH] fix(spec)!: the retirement prescriptions name protocol 17, the major this train actually ships (#4350) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten tombstones told authors a key "was removed in `@objectstack/spec` 18", and the machine agreed: a whole `step18` chain step plus two `toMajor: 18` conversions were wired for a major the release train cannot reach. 17 is what ships. `latest` is 16.1.0, `rc` is 17.0.0-rc.0 (17.0.0 never published), and `.changeset/pre.json` records spec at initialVersion 16.1.0 — changesets computes a pre-mode bump from the last *published* version, so 16.1.0 + major = 17.0.0. `changeset status` confirms 17.0.0-rc.0 -> 17.0.0-rc.1. `PROTOCOL_VERSION` is '17.0.0' and `protocol-version.test.ts` pins it to the package major, so it cannot unilaterally become 18 either. The "18" came from counting up from the in-flight rc instead of from 16.1.0. The prose was the smaller half. `composeMigrationChain(from, to = PROTOCOL_MAJOR)` filters `m <= toMajor`, so a step keyed 18 was unreachable: `os migrate meta --from 16` walked 11-17 and stepped over it. The same ceiling applies to `composeSpecChanges`, so the committed `spec-changes.json` carried none of these seven retirements — `query.joins`, `windowFunctions` and `validateOnly` appeared zero times, and the upgrade guide contained no "18" at all. Authors would have hit a tombstone with no chain hop to run and no guide row to read. - Fold `step18` into `step17` (rationale, both `conversionIds`, all six semantic migrations); drop `MIGRATIONS_BY_MAJOR[18]`; retarget both conversions to `toMajor: 17` (`migrations.test.ts` requires conversion major == step major) and merge `CONVERSIONS_BY_MAJOR[18]` into `[17]`. - Retarget all 30 hand-written "18" references: the ten prescriptions, the `query.test.ts` regex that pinned the wrong number, internal comments, the liveness ledger + README, and the seven unconsumed changesets. - Regenerate `spec-changes.json` (+108), `protocol-upgrade-guide.md` (+32) and the five reference pages. - Write the seven retirements into the v17 release notes and upgrade checklist, where they had no entry at all — there is no v18.mdx for them to land in. No behaviour is added or withdrawn: every key retired by #3963, #4052, #4158, prescription names the version that will carry it, and `os migrate meta` applies the two stack conversions instead of stepping over them. Co-Authored-By: Claude Opus 5 --- .changeset/drop-require-auth.md | 2 +- .changeset/query-ast-inert-request-surface.md | 2 +- .changeset/query-cursor-removed.md | 2 +- .changeset/query-distinct-removed.md | 2 +- .../query-field-node-object-form-removed.md | 2 +- .changeset/retire-batch-validate-only.md | 2 +- ...tirement-prescriptions-name-protocol-17.md | 48 +++++++ .changeset/wait-timeout-keys-retired.md | 4 +- .../docs/protocol/objectql/query-syntax.mdx | 2 +- content/docs/references/api/contract.mdx | 8 +- content/docs/references/api/protocol.mdx | 2 +- content/docs/references/api/rest-server.mdx | 2 +- content/docs/references/data/data-engine.mdx | 4 +- content/docs/references/data/query.mdx | 8 +- content/docs/releases/v17.mdx | 70 ++++++++++ docs/protocol-upgrade-guide.md | 32 +++++ packages/client/src/query-builder.ts | 2 +- packages/metadata-protocol/src/protocol.ts | 4 +- .../src/builtin/wait-node.ts | 2 +- packages/spec/liveness/README.md | 2 +- packages/spec/liveness/query.json | 10 +- packages/spec/spec-changes.json | 108 +++++++++++++++ packages/spec/src/api/rest-server.zod.ts | 2 +- packages/spec/src/automation/flow.zod.ts | 4 +- packages/spec/src/conversions/registry.ts | 12 +- packages/spec/src/data/query.test.ts | 2 +- packages/spec/src/data/query.zod.ts | 12 +- packages/spec/src/migrations/registry.ts | 131 +++++++++--------- packages/spec/src/stack.zod.ts | 2 +- 29 files changed, 367 insertions(+), 118 deletions(-) create mode 100644 .changeset/retirement-prescriptions-name-protocol-17.md diff --git a/.changeset/drop-require-auth.md b/.changeset/drop-require-auth.md index c635da6c6f..c915cb9ef7 100644 --- a/.changeset/drop-require-auth.md +++ b/.changeset/drop-require-auth.md @@ -32,7 +32,7 @@ switch: `RestApiConfigSchema` and the stack `api` block, so authoring it now fails with a fix-it message rather than being silently stripped (the ADR-0104 / #3733 quiet-failure this whole line of work has been closing). `os migrate meta` - drops it via the protocol-18 conversion `stack-api-require-auth-removed`. + drops it via the protocol-17 conversion `stack-api-require-auth-removed`. - `shouldDenyAnonymous` (@objectstack/core) no longer takes a `requireAuth` input; it denies any anonymous, non-system caller outside the control-plane allowlist. diff --git a/.changeset/query-ast-inert-request-surface.md b/.changeset/query-ast-inert-request-surface.md index 4c74675ccd..18031af8eb 100644 --- a/.changeset/query-ast-inert-request-surface.md +++ b/.changeset/query-ast-inert-request-surface.md @@ -41,7 +41,7 @@ non-strict `BaseQuerySchema`, so authoring either fails `tsc` (input type `never`) and a query still carrying one — even as an empty array — fails to parse with the prescription itself. `QueryAST` is a request shape, never stored in stack metadata, so there is no `os migrate meta` step: the removals are -registered as protocol-18 **semantic** migrations (`query-joins-retired`, +registered as protocol-17 **semantic** migrations (`query-joins-retired`, `query-window-functions-retired`), the #4196 precedent. Compat note for the REST boundary: both names remain **reserved** list-query diff --git a/.changeset/query-cursor-removed.md b/.changeset/query-cursor-removed.md index ba9e9caa99..5483e2a125 100644 --- a/.changeset/query-cursor-removed.md +++ b/.changeset/query-cursor-removed.md @@ -25,7 +25,7 @@ Mechanics: `retiredKey()` tombstones on both declaration sites (`QuerySchema.cursor` and `EngineQueryOptionsSchema.cursor`, one shared prescription), so authoring the key fails `tsc` and a query still carrying it fails to parse with the fix. `QueryBuilder.cursor()` is deleted. Registered as -the protocol-18 semantic migration `query-cursor-retired` (request surface — +the protocol-17 semantic migration `query-cursor-retired` (request surface — nothing stored to rewrite). The caller-built `Record` shape would not survive a real keyset design anyway: a first-class cursor, if ever built, will be a response-minted opaque token (the pattern the diff --git a/.changeset/query-distinct-removed.md b/.changeset/query-distinct-removed.md index ef6bd153f1..d55f57395b 100644 --- a/.changeset/query-distinct-removed.md +++ b/.changeset/query-distinct-removed.md @@ -29,7 +29,7 @@ The one-line fix: **delete the key**; deduplicate with `groupBy` / Mechanics: `retiredKey()` tombstones on both declaration sites (`QuerySchema.distinct` and `EngineQueryOptionsSchema.distinct`, one shared prescription); `QueryBuilder.distinct()` is deleted; registered as the -protocol-18 semantic migration `query-distinct-retired`. **Observable REST +protocol-17 semantic migration `query-distinct-retired`. **Observable REST change (`@objectstack/metadata-protocol`):** the count-suppression branch is deleted — a list request that used to carry `distinct` now gets a real `total`/`hasMore` again (that restoration is the point, not a side effect). diff --git a/.changeset/query-field-node-object-form-removed.md b/.changeset/query-field-node-object-form-removed.md index 9426795432..333ed2f09c 100644 --- a/.changeset/query-field-node-object-form-removed.md +++ b/.changeset/query-field-node-object-form-removed.md @@ -50,7 +50,7 @@ depth 3). There is no `os migrate meta` step, and deliberately so: `QueryAST` is a request shape, never stored in stack metadata, so the chain has no source to rewrite. It is registered as an ADR-0087 D3 **semantic** migration -(`query-field-node-object-form-retired`) on the protocol-18 step instead — the +(`query-field-node-object-form-retired`) on the protocol-17 step instead — the `EnhancedApiError.fieldErrors` / `BatchOptions.validateOnly` precedent. Callers move their own select lists, and both channels tell them how: diff --git a/.changeset/retire-batch-validate-only.md b/.changeset/retire-batch-validate-only.md index a36fe4c685..e87463a174 100644 --- a/.changeset/retire-batch-validate-only.md +++ b/.changeset/retire-batch-validate-only.md @@ -24,7 +24,7 @@ deliberately when there is a real need. rather than being silently stripped (the ADR-0104 / #3733 quiet-failure class). The `BatchOptions` type's `validateOnly` becomes `never`. - The retirement is HTTP-only (the key never appeared in stored stack metadata), - so it is recorded as a semantic migration on the protocol-18 chain step + so it is recorded as a semantic migration on the protocol-17 chain step (`batch-options-validate-only-retired`) — a TODO for API callers, not a stack conversion. diff --git a/.changeset/retirement-prescriptions-name-protocol-17.md b/.changeset/retirement-prescriptions-name-protocol-17.md new file mode 100644 index 0000000000..f2da101f37 --- /dev/null +++ b/.changeset/retirement-prescriptions-name-protocol-17.md @@ -0,0 +1,48 @@ +--- +'@objectstack/spec': major +'@objectstack/metadata-protocol': patch +'@objectstack/client': patch +'@objectstack/service-automation': patch +--- + +fix(spec)!: the #3963 / #4052 / #4158 / #4196 / #4286 retirements land in protocol **17**, not a protocol 18 that this train cannot produce (#4350) + +Ten tombstone prescriptions told authors a key "was removed in `@objectstack/spec` **18**", +and — worse — the machine agreed with them: a whole `step18` chain step and two +`toMajor: 18` conversions were wired for a major the release train does not reach. + +**17 is what ships.** `latest` is 16.1.0 and `rc` is `17.0.0-rc.0` — 17.0.0 has never been +published. `.changeset/pre.json` records `@objectstack/spec` at initialVersion 16.1.0, and +changesets computes a pre-mode bump from the last *published* version: 16.1.0 + `major` = +**17.0.0**, released as `17.0.0-rc.N`. `PROTOCOL_VERSION` is `'17.0.0'`, and +`protocol-version.test.ts` pins it to the package major, so it cannot unilaterally become 18 +either. The "18" came from counting up from the in-flight `17.0.0-rc.0` instead of from +16.1.0. + +**The prose was the smaller half.** `composeMigrationChain(from, to = PROTOCOL_MAJOR)` +filters `m <= toMajor`, so a step keyed 18 was **unreachable**: `os migrate meta --from 16` +walked steps 11–17 and silently skipped 18. The same ceiling applies to `composeSpecChanges`, +so the generated `spec-changes.json`, `docs/protocol-upgrade-guide.md` and the `spec_changes` +MCP tool — the ADR-0087 D4 primary channel — carried **none** of these seven retirements: +`query.joins`, `query.windowFunctions` and `BatchOptions.validateOnly` appeared zero times in +the committed manifest, and the upgrade guide contained no "18" at all. Authors would have hit +the tombstones with no chain hop to run and no upgrade-guide row to read. + +What changed: + +- `step18` is folded into `step17` — its rationale, both `conversionIds` + (`stack-api-require-auth-removed`, `flow-node-wait-timeout-keys-removed`) and all six + semantic migrations move across, and `MIGRATIONS_BY_MAJOR[18]` is gone. Both conversions + become `toMajor: 17` (`migrations.test.ts` requires a conversion's `toMajor` to equal its + step's major), and `CONVERSIONS_BY_MAJOR[18]` merges into `[17]`. +- All 30 hand-written "18" references become "17": the ten tombstone prescriptions + (`query.zod.ts`, `flow.zod.ts`, `rest-server.zod.ts`, `stack.zod.ts`, `protocol.ts`), the + `query.test.ts` pin regex that was holding the wrong number in place, the internal comments, + the `liveness/query.json` + `liveness/README.md` notes, and the seven unconsumed changesets. +- The seven retirements are written into the v17 release notes and upgrade checklist, where + they had no entry at all — there is no `v18.mdx` for them to have landed in. + +No behaviour is added or withdrawn: every key retired by #3963, #4052, #4158, #4196 and #4286 +stays retired, on exactly the terms those changesets describe. What changes is that the +prescription now names the version that will actually carry it, and `os migrate meta` actually +applies the two stack conversions instead of stepping over them. diff --git a/.changeset/wait-timeout-keys-retired.md b/.changeset/wait-timeout-keys-retired.md index 73f0cf08a8..b560253192 100644 --- a/.changeset/wait-timeout-keys-retired.md +++ b/.changeset/wait-timeout-keys-retired.md @@ -5,7 +5,7 @@ feat(spec)!: retire `waitEventConfig.timeoutMs` / `.onTimeout` — `wait` never had a timeout (#4158) -Both keys described a timeout and neither delivered one, so protocol 18 removes the pair +Both keys described a timeout and neither delivered one, so protocol 17 removes the pair rather than leaving a promise the runtime does not keep (PD #10). - **`onTimeout`** had **zero** readers. No path ever inspected it, so neither `'fail'` nor @@ -38,5 +38,5 @@ tombstones name the replacement. One fixture interaction worth recording: the #4045 lift fixture used `waitEventConfig.timeoutMs` to demonstrate its fourth ledger entry, and the fixture -harness replays the whole table — so its `after` described an end state protocol 18 makes +harness replays the whole table — so its `after` described an end state protocol 17 makes unreachable. It now lifts `eventType` instead. The harness caught this itself. diff --git a/content/docs/protocol/objectql/query-syntax.mdx b/content/docs/protocol/objectql/query-syntax.mdx index 2b8168a8e1..b5f3a2863a 100644 --- a/content/docs/protocol/objectql/query-syntax.mdx +++ b/content/docs/protocol/objectql/query-syntax.mdx @@ -130,7 +130,7 @@ interface AggregationNode { // `expand`, not from inside this list. // // The `{ field, fields, alias }` nested-select member this union used to carry -// was REMOVED in protocol 18 (#4196): nothing produced it and nothing read +// was REMOVED in protocol 17 (#4196): nothing produced it and nothing read // `.fields`/`.alias`, so it was dropped by the SQL and memory drivers, // projected as a column named "[object Object]" by MongoDB, and refused as an // unknown field by the REST ingress. `expand` is the one spelling. diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 2722ff2045..1bd4433ffa 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -155,13 +155,13 @@ const result = ApiError.parse(data); | **limit** | `number` | optional | Max records to return (LIMIT) | | **offset** | `number` | optional | Records to skip (OFFSET) | | **top** | `number` | optional | Alias for limit (OData compatibility) | -| **cursor** | `any` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 18 (#4286, ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | -| **joins** | `any` | optional | [REMOVED] `query.joins` was removed in @objectstack/spec 18 (#4286, ADR-0049) — no engine or driver ever read it: a query carrying `joins` behaved exactly as if the key were absent, while its name squatted on the reserved REST parameter set. Delete the key. Related records are read through `expand` — `expand: { owner: { object: 'user', fields: ['name'] } }` — which the engine resolves via batch $in queries, and a single related column is a dotted `fields` path (`fields: ['owner.name']`). | +| **cursor** | `any` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | +| **joins** | `any` | optional | [REMOVED] `query.joins` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no engine or driver ever read it: a query carrying `joins` behaved exactly as if the key were absent, while its name squatted on the reserved REST parameter set. Delete the key. Related records are read through `expand` — `expand: { owner: { object: 'user', fields: ['name'] } }` — which the engine resolves via batch $in queries, and a single related column is a dotted `fields` path (`fields: ['owner.name']`). | | **aggregations** | `{ function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'array_agg' \| 'string_agg'>; field?: string; alias: string; distinct?: boolean; … }[]` | optional | Aggregation functions | | **groupBy** | `string \| { field: string; dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; alias?: string }[]` | optional | GROUP BY targets (strings or `{field, dateGranularity?}` objects for date bucketing) | | **having** | `any` | optional | HAVING — filter over the AGGREGATED rows (aggregation aliases + groupBy projections); applied engine-side after aggregation | -| **windowFunctions** | `any` | optional | [REMOVED] `query.windowFunctions` was removed in @objectstack/spec 18 (#4286, ADR-0049) — `find()` never applied it: no engine or driver read the key on the query path, so every OVER clause it declared was silently dropped. Delete the key. Window functions are a SQL-driver capability behind `SqlDriver.findWithWindowFunctions(object, query)` (embedder-level; not on the `IDataDriver` contract or the REST surface); request-level analytics are `aggregations` + `groupBy`. | -| **distinct** | `any` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 18 (#4286, ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | +| **windowFunctions** | `any` | optional | [REMOVED] `query.windowFunctions` was removed in @objectstack/spec 17 (#4286, ADR-0049) — `find()` never applied it: no engine or driver read the key on the query path, so every OVER clause it declared was silently dropped. Delete the key. Window functions are a SQL-driver capability behind `SqlDriver.findWithWindowFunctions(object, query)` (embedder-level; not on the `IDataDriver` contract or the REST surface); request-level analytics are `aggregations` + `groupBy`. | +| **distinct** | `any` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (#4286, ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | | **expand** | `Record; … }; … }>` | optional | Recursive relation loading map. Keys are lookup/master_detail field names; values are nested QueryAST objects that control select (`fields`) and filter (`where`, AND-merged with the batch $in), plus further expansion on the related object. The engine resolves expand via batch $in queries (driver-agnostic) with a default max depth of 3; per-parent `limit`/`offset`/`orderBy` are NOT applied on this path. | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 8b9ffc283b..0c185f4f2b 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -988,7 +988,7 @@ const result = AiAgentCapabilities.parse(data); | **skip** | `number` | optional | Records to skip (offset). | | **expand** | `string` | optional | Comma-separated list of lookup/master_detail field names to expand. Resolved to populate array and passed to the engine for batch $in expansion. | | **search** | `string` | optional | Full-text search query. | -| **distinct** | `any` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 18 (#4286, ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | +| **distinct** | `any` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (#4286, ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | | **count** | `boolean` | optional | Include total count in response. | diff --git a/content/docs/references/api/rest-server.mdx b/content/docs/references/api/rest-server.mdx index be30370d07..585a824f62 100644 --- a/content/docs/references/api/rest-server.mdx +++ b/content/docs/references/api/rest-server.mdx @@ -192,7 +192,7 @@ const result = BatchEndpointsConfig.parse(data); | **enableOpenApi** | `boolean` | ✅ | Enable OpenAPI 3.1 spec & docs viewer endpoints | | **enableProjectScoping** | `boolean` | ✅ | Enable project-scoped routing for data/meta/AI APIs | | **projectResolution** | `Enum<'required' \| 'optional' \| 'auto'>` | ✅ | Project ID resolution strategy | -| **requireAuth** | `any` | optional | [REMOVED] `api.requireAuth` was removed in @objectstack/spec 18 (#3963). Anonymous access to object data is now always denied — auth is a kernel concern, not a deployment posture. Delete the key. To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of opening the whole data plane. | +| **requireAuth** | `any` | optional | [REMOVED] `api.requireAuth` was removed in @objectstack/spec 17 (#3963). Anonymous access to object data is now always denied — auth is a kernel concern, not a deployment posture. Delete the key. To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of opening the whole data plane. | | **documentation** | `{ enabled: boolean; title: string; description?: string; version?: string; … }` | optional | OpenAPI/Swagger documentation config | | **responseFormat** | `{ envelope: boolean; includeMetadata: boolean; includePagination: boolean }` | optional | Response format options | diff --git a/content/docs/references/data/data-engine.mdx b/content/docs/references/data/data-engine.mdx index 7e3cfae753..4c319ca97a 100644 --- a/content/docs/references/data/data-engine.mdx +++ b/content/docs/references/data/data-engine.mdx @@ -546,10 +546,10 @@ QueryAST-aligned query options for IDataEngine.find() operations | **limit** | `number` | optional | | | **offset** | `number` | optional | | | **top** | `number` | optional | | -| **cursor** | `any` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 18 (#4286, ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | +| **cursor** | `any` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | | **search** | `{ query: string; fields?: string[]; fuzzy: boolean; operator: Enum<'and' \| 'or'>; … }` | optional | | | **expand** | `Record; … }; … }>` | optional | | -| **distinct** | `any` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 18 (#4286, ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | +| **distinct** | `any` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (#4286, ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | --- diff --git a/content/docs/references/data/query.mdx b/content/docs/references/data/query.mdx index 4f21c1286f..abd69bf9e4 100644 --- a/content/docs/references/data/query.mdx +++ b/content/docs/references/data/query.mdx @@ -132,13 +132,13 @@ Type: `string` | **limit** | `number` | optional | Max records to return (LIMIT) | | **offset** | `number` | optional | Records to skip (OFFSET) | | **top** | `number` | optional | Alias for limit (OData compatibility) | -| **cursor** | `any` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 18 (#4286, ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | -| **joins** | `any` | optional | [REMOVED] `query.joins` was removed in @objectstack/spec 18 (#4286, ADR-0049) — no engine or driver ever read it: a query carrying `joins` behaved exactly as if the key were absent, while its name squatted on the reserved REST parameter set. Delete the key. Related records are read through `expand` — `expand: { owner: { object: 'user', fields: ['name'] } }` — which the engine resolves via batch $in queries, and a single related column is a dotted `fields` path (`fields: ['owner.name']`). | +| **cursor** | `any` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | +| **joins** | `any` | optional | [REMOVED] `query.joins` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no engine or driver ever read it: a query carrying `joins` behaved exactly as if the key were absent, while its name squatted on the reserved REST parameter set. Delete the key. Related records are read through `expand` — `expand: { owner: { object: 'user', fields: ['name'] } }` — which the engine resolves via batch $in queries, and a single related column is a dotted `fields` path (`fields: ['owner.name']`). | | **aggregations** | `{ function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'array_agg' \| 'string_agg'>; field?: string; alias: string; distinct?: boolean; … }[]` | optional | Aggregation functions | | **groupBy** | `string \| { field: string; dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; alias?: string }[]` | optional | GROUP BY targets (strings or `{field, dateGranularity?}` objects for date bucketing) | | **having** | `any` | optional | HAVING — filter over the AGGREGATED rows (aggregation aliases + groupBy projections); applied engine-side after aggregation | -| **windowFunctions** | `any` | optional | [REMOVED] `query.windowFunctions` was removed in @objectstack/spec 18 (#4286, ADR-0049) — `find()` never applied it: no engine or driver read the key on the query path, so every OVER clause it declared was silently dropped. Delete the key. Window functions are a SQL-driver capability behind `SqlDriver.findWithWindowFunctions(object, query)` (embedder-level; not on the `IDataDriver` contract or the REST surface); request-level analytics are `aggregations` + `groupBy`. | -| **distinct** | `any` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 18 (#4286, ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | +| **windowFunctions** | `any` | optional | [REMOVED] `query.windowFunctions` was removed in @objectstack/spec 17 (#4286, ADR-0049) — `find()` never applied it: no engine or driver read the key on the query path, so every OVER clause it declared was silently dropped. Delete the key. Window functions are a SQL-driver capability behind `SqlDriver.findWithWindowFunctions(object, query)` (embedder-level; not on the `IDataDriver` contract or the REST surface); request-level analytics are `aggregations` + `groupBy`. | +| **distinct** | `any` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (#4286, ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | | **expand** | `Record` | optional | Recursive relation loading map. Keys are lookup/master_detail field names; values are nested QueryAST objects that control select (`fields`) and filter (`where`, AND-merged with the batch $in), plus further expansion on the related object. The engine resolves expand via batch $in queries (driver-agnostic) with a default max depth of 3; per-parent `limit`/`offset`/`orderBy` are NOT applied on this path. | diff --git a/content/docs/releases/v17.mdx b/content/docs/releases/v17.mdx index 99bc1c7d53..24e1a708b5 100644 --- a/content/docs/releases/v17.mdx +++ b/content/docs/releases/v17.mdx @@ -869,6 +869,63 @@ somewhere else and did, in three different somewhere-elses: `error.details.code` the opposite of `ApiErrorSchema` for the same field, which is what let the deviation stand. +### Anonymous access is denied unconditionally — `api.requireAuth` is gone (#3963) + +`api.requireAuth` was a deployment-wide opt-out: one boolean that let a stack +serve its **entire** data plane to unauthenticated callers. Auth is a kernel +concern, not a deployment posture, so the key is retired and anonymous access to +object data is now denied on every HTTP surface. A stack that mounts no auth at +all fails at boot when it would serve a data API, instead of receiving an +implicit fail-open. + +Publish public surfaces by **declaration** instead — each derives its own narrow +authorization rather than opening the whole data plane: a public form view +(`sharing.allowAnonymous`), a share-link token (read as SYSTEM), or +`book.audience: 'public'` (ADR-0046 §6.7). `os migrate meta` drops the key and +emits a notice telling you where public access has to be re-declared. + +### `wait` never had a timeout — `timeoutMs` / `onTimeout` are retired (#4158) + +Both keys described a timeout and neither delivered one. +`waitEventConfig.onTimeout` had **zero** readers — no path ever inspected it, so +neither `fail` nor `continue` ever happened, while its `.default('fail')` +stamped a decision nothing made onto every wait node. +`waitEventConfig.timeoutMs` said "maximum wait time before timeout", but its +only reader used it as the timer *duration* when `timerDuration` was absent: it +did something, just not what it claimed. A wait resumes when its timer elapses +or its signal arrives — never on a deadline. + +`os migrate meta` converts `timeoutMs` → `timerDuration` (stringified — a bare +numeric string reads as milliseconds, so the wait is unchanged) and drops it +outright when `timerDuration` was already set. `onTimeout` is deleted; there is +no replacement. Real timeout semantics are left to be built to a requirement +rather than retrofitted onto two keys that happened to be declared. + +### The query request surface sheds five inert keys (#4196, #4286) + +`QueryAST` is a **request** shape — never stored in stack metadata — so these +are caller-side edits, not a source rewrite: `os migrate meta` has nothing to +convert. Each key now fails to parse with its prescription, and authoring one is +a `tsc` error at the call site. + +| Removed | Why | Use instead | +|---|---|---| +| `fields[]` object form `{ field, fields, alias }` | inert end to end — every reader treats the list as `string[]`, so it was dropped by the SQL and memory drivers, projected as a column named `"[object Object]"` by MongoDB, and refused by the REST ingress | `expand`, or a dotted path for a single related column (`fields: ['owner.name']`) | +| `query.joins` | no engine or driver ever read it; the name squatted on the reserved REST parameter set (the `JoinNode`/`JoinType`/`JoinStrategy` cluster goes with it) | `expand` — the engine resolves it via batch `$in` queries | +| `query.windowFunctions` | `find()` never applied one, so every OVER clause was silently dropped; `WindowFunctionNode` declared `field`/`over`/`frame` members the live door never read | `aggregations` + `groupBy`; embedders on a SQL datasource call `SqlDriver.findWithWindowFunctions()` | +| `query.cursor` | no driver implemented keyset pagination — the cursor was accepted and ignored, so every page came back identical and a caller looping "until `hasMore` is false" never terminates | a `where` predicate on your sort key (`{ created_at: { $gt: last.created_at } }`) with the matching `orderBy` | +| `query.distinct` | **mis-wired**, not merely dead: no driver rendered SELECT DISTINCT, and its only observable effect was suppressing the REST list count — callers got duplicate rows *and* a degraded `total`/`hasMore` | `groupBy`, the `count_distinct` aggregation, or the drivers' `distinct(object, field)` door | + +`QueryBuilder.cursor()` and `QueryBuilder.distinct()` are deleted with their +keys. The count suppression goes too, so `total` is truthful again for queries +that used to send `distinct`. + +`BatchOptions.validateOnly` (#4052) is retired on the same grounds: it promised +a dry-run ("validate records without persisting") that no batch surface ever +honoured — `updateManyData`, `deleteManyData` and `batchData` persisted +regardless, so a caller sending it to *preview* a mutation got it executed. It +is HTTP-only; stop sending it. + ### Dead spec clusters removed **App shell (2026-06 liveness audit, #4001 app step).** `App.version`, @@ -1389,6 +1446,19 @@ objectui commits on top of the pin 16.1.0 shipped. dashboard/widget `aria`/`performance`, `agent.knowledge` and `skill.triggerPhrases`. If you set `flow.active: false` expecting a flow to stop, set `status: 'obsolete'` — the flag never worked. +- **Anonymous access:** delete `api.requireAuth` (`os migrate meta` does it). If + you relied on `requireAuth: false` to serve something publicly, re-declare it + narrowly — a public form view, a share link, or `book.audience: 'public'` — + and make sure the stack mounts auth at all, or it now fails at boot. +- **Wait nodes:** `os migrate meta` moves `waitEventConfig.timeoutMs` onto + `timerDuration` and drops `onTimeout`. Anything that expected a wait to end on + a deadline never did — build the deadline explicitly if you need one. +- **Query callers:** these are request shapes, so the chain cannot fix them for + you. Drop `joins`, `windowFunctions`, `cursor` and `distinct` from queries and + the `{ field, fields, alias }` object form from `fields[]`; replace + `QueryBuilder.cursor()`/`.distinct()` call sites. Stop sending + `options.validateOnly` to `/batch`, `/updateMany` and `/deleteMany` — it never + previewed anything, it persisted. - **Required fields:** run `os migrate meta` — it stamps `storage: { notNull: true }` onto every pre-17 `required: true` field so your columns keep their exact constraints. New fields: `required: true` alone diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index f64700ec6f..c6b7cf8168 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -144,6 +144,18 @@ The close-out sweep finishes the enforce-or-remove worklist across the remaining One flow key changes WITHOUT a lossless target: `errorHandling.maxRetries` (#4247). It carried two defaults — `.default(0)` in FlowSchema and `maxRetries ?? 3` in the engine's retryExecution — and because `??` fires only on `undefined`, an unstated count meant 0 retries for a flow parsed by the schema and 3 for a definition handed to the engine directly: the retry count was a function of the route in, not of the authored flow. The engine's copy is deleted (it reads the parsed block, no fallback), which makes an unstated count unambiguously 0 — and `strategy: 'retry'` that retries zero times is `strategy: 'fail'` under another name, the declared-not-delivered shape ADR-0049 exists to close. The schema therefore requires `maxRetries` at least 1 under `'retry'`, in both spellings (omitted, and an explicit 0). This is the one v17 flow change the chain cannot apply for you: choosing the count is a judgment about re-running the WHOLE flow with its side effects, so it is a semantic TODO rather than a rewrite. +It also retires `api.requireAuth` (#3963): the deployment-wide opt-out that let a stack serve its ENTIRE data plane anonymously with one boolean. Auth is a kernel concern, not a deployment posture — anonymous access to object data is now denied unconditionally on every HTTP surface. Every surface that legitimately serves a session-less caller derives its own narrow authorization from a DECLARATION instead: the control-plane allowlist, `publicFormGrant` (public form views), share-link tokens (read as SYSTEM), and `book.audience: 'public'` (ADR-0046 §6.7). The key is dropped with a notice rather than mapped — there is no replacement value, only a different way to publish (by declaration). A stack that mounts no auth at all now fails at boot when it would serve a data API, instead of receiving an implicit fail-open. + +The same major retires `BatchOptions.validateOnly` (#4052): a batch "dry-run" flag that was declared but never implemented — every batch surface (`updateManyData` / `deleteManyData` / `batchData`) persisted regardless, so a caller sending it to PREVIEW a mutation got it executed. That is the dangerous direction of declared ≠ enforced: a flag lying about a data-safety guarantee. No dry-run exists today; the schema tombstones the key with the prescription. It is HTTP-only (never stored in stack metadata), so the change is one semantic TODO for API callers rather than a stack conversion. + +It also narrows `QueryAST.fields` to field names (#4196): the `FieldNode` union carried a second `{ field, fields, alias }` nested-select member that nothing produced and nothing consumed — every reader on the path treats the list as `string[]`, so the object form was dropped by the SQL and memory drivers, projected as a column named "[object Object]" by MongoDB, and refused by the REST ingress as an unknown field of that name. `expand` is the one spelling for nested selection (ADR-0049 enforce-or-remove; Prime Directive #12: one capability, one contract). Like the two above it is a request shape, never stored, so the chain has no source to rewrite. + +The #4286 sweep applies the same method to the rest of the request surface: `query.joins` and `query.windowFunctions` are tombstoned — no engine or driver ever read either on the query path, so every join and OVER clause a caller declared was silently dropped. Joins were the second, broken spelling of related-record retrieval (`expand` is the live one; the whole JoinNode cluster goes with the key), and window functions only ever ran behind `SqlDriver.findWithWindowFunctions()`, a driver-level door whose flat input shape the spec vocabulary never matched (it declared `field`/`over`/`frame` members the door never read — that cluster goes too). Request shapes again: two semantic TODOs, no source rewrite. + +The #4286 close-out settles the remaining three. `having` is ENFORCED, not removed — the engine applies it after aggregation on both paths, so the clause every SQL-literate author expects now works (no migration; queries that carried it were silently returning every group and now filter as written). `cursor` and `distinct` are tombstoned WITH their shipped SDK producers (`QueryBuilder.cursor()` / `.distinct()` are deleted): no driver ever implemented keyset pagination or SELECT DISTINCT, `cursor` re-served page 1 forever, and `distinct`'s only observable effect was mis-wired — it suppressed the REST list count, which is now truthful again. Both are request shapes; two more semantic TODOs, no source rewrite. + +The same kind of retirement covers `wait`'s timeout pair (#4158). `waitEventConfig.onTimeout` had ZERO readers — no path ever inspected it, so neither `fail` nor `continue` ever happened, while its `.default('fail')` stamped a decision nothing made onto every wait node. `waitEventConfig.timeoutMs` said "maximum wait time before timeout" and its only reader used it as the timer DURATION when `timerDuration` was absent: it did something, just not what it said. Together they declared a timeout `wait` does not have — the run resumes when its timer elapses or its signal arrives, never on a deadline. Rather than retrofit an implementation to fit two keys that happened to be declared, the pair is retired and real timeout semantics are left to be built to a requirement. `timeoutMs` converts to `timerDuration` (stringified — the target is `z.string()` and `parseIsoDuration` reads a bare numeric string as milliseconds, so the wait is unchanged); with `timerDuration` already set it is dropped, having been dead metadata. Like the other keys retired for MISDESCRIBING themselves rather than for being renamed, both leave the load path: absorbing them silently would let an author keep believing they configured a timeout. + ### Mechanical (applied for you) | Conversion | Surface | Change | Load window | @@ -169,6 +181,8 @@ One flow key changes WITHOUT a lossless target: `errorHandling.maxRetries` (#424 | `dashboard-inert-keys-removed` | `dashboard.aria / dashboard.performance / dashboard.widgets[].performance` | dashboard keys 'aria'/'performance' and widget 'performance' removed (#3896 close-out — no renderer applied any of them) | retired — `migrate meta` only | | `agent-knowledge-removed` | `agent.knowledge` | agent key 'knowledge' removed (#3896 close-out — declaring sources/indexes never scoped retrieval; restrict at the knowledge-service level) | retired — `migrate meta` only | | `skill-trigger-phrases-removed` | `skill.triggerPhrases` | skill key 'triggerPhrases' removed (#3896 close-out — activation is triggerConditions + the agent's skills[] allowlist; phrases were a dead-end projection) | retired — `migrate meta` only | +| `stack-api-require-auth-removed` | `stack.api.requireAuth` | stack key 'api.requireAuth' removed — anonymous access is always denied; publish public surfaces by declaration (#3963) | retired — `migrate meta` only | +| `flow-node-wait-timeout-keys-removed` | `flow.node.waitEventConfig` | waitEventConfig keys 'timeoutMs' (→ 'timerDuration', stringified — its only reader used it as the duration) and 'onTimeout' (removed — zero readers, so no timeout ever fired) (#4158) | retired — `migrate meta` only | ### Semantic (delegated to you, with acceptance criteria) @@ -184,6 +198,24 @@ One flow key changes WITHOUT a lossless target: `errorHandling.maxRetries` (#424 - **`analytics-query-request-format-retired`** — `api.analyticsQueryRequest.format` → (removed — responses are always the JSON envelope; use the export surface for CSV/XLSX) - Why not automatic: The `format` key was declared but never implemented (declared ≠ enforced): every response is the JSON envelope regardless of the requested value, so there is no behaviour to preserve and nothing stored to rewrite. - Done when: No /analytics/query or /analytics/sql call sends `format`; exports go through the export surface. +- **`query-field-node-object-form-retired`** — `data.query.fields` → expand (`expand: { owner: { object: 'user', fields: ['name'] } }`), or a dotted path for a single related column (`fields: ['owner.name']`) + - Why not automatic: The `FieldNode` union declared a nested-select object form `{ field, fields, alias }` that was inert end to end: no producer emitted it, and no consumer read `.fields` or `.alias` — objectql's formula projection and known-field filters, driver-sql's `select()` and driver-memory's projection all treat the list as `string[]`, driver-mongodb keyed its projection with the entry itself, and the REST ingress stringified it. Nested selection is `expand`, which the engine resolves via batch `$in` queries. This is a REQUEST surface — `QueryAST` is never stored in stack metadata (no view, dataset or report authors one), so there is no source for the chain to rewrite: the schema narrows to `z.string()` and callers move their own select lists. ADR-0049 / ADR-0078, #4196. + - Done when: No caller puts an object in `fields[]`; related records are read through `expand` and single related columns through dotted paths. A `fields` entry that is not a string fails to parse with the removal prescription, and the list/query/export routes answer 400 INVALID_FIELD naming the retired form instead of the field `"[object Object]"`. +- **`batch-options-validate-only-retired`** — `api.batchOptions.validateOnly` → (removed — no dry-run today; open an issue to design a no-commit batch preview) + - Why not automatic: The `validateOnly` key promised a dry-run ("validate records without persisting") but no batch surface ever read it — updateManyData / deleteManyData / batchData persist regardless. There is no behaviour to preserve and nothing stored to rewrite (it only ever appeared in an HTTP request body). Callers must stop sending it. + - Done when: No /batch, /updateMany or /deleteMany call sends `options.validateOnly`; a request that includes it answers 400 VALIDATION_FAILED with the retirement prescription. +- **`query-joins-retired`** — `data.query.joins` → expand (`expand: { owner: { object: 'user', fields: ['name'] } }`), or a dotted `fields` path for a single related column (`fields: ['owner.name']`) + - Why not automatic: The `joins` array was declared-but-inert: no engine or driver read `query.joins` anywhere on the query path, so a query carrying it behaved exactly as if the key were absent — while the name squatted on the reserved REST parameter set. Related-record retrieval already has a live spelling (`expand`, resolved by the engine via batch `$in` queries), so the removal deletes the second, broken spelling rather than the capability, and the orphaned `JoinNode`/`JoinType`/`JoinStrategy` cluster goes with the key. A REQUEST surface — `QueryAST` is never stored in stack metadata — so there is no source for the chain to rewrite; callers move their own queries. ADR-0049 / ADR-0078, #4286. + - Done when: No caller sends `joins`; related records are read through `expand` and single related columns through dotted `fields` paths. A query that still carries `joins` fails to parse with the removal prescription (even as an empty array), and authoring it is a `tsc` error at the call site. +- **`query-window-functions-retired`** — `data.query.windowFunctions` → `aggregations` + `groupBy` for request-level analytics; `SqlDriver.findWithWindowFunctions(object, query)` for embedders on a SQL datasource + - Why not automatic: The `windowFunctions` array was declared-but-inert on the query path: `find()` never applied a window function, so every OVER clause a caller declared was silently dropped. The capability only ever ran behind `SqlDriver.findWithWindowFunctions()`, a driver-level door that is not on the `IDataDriver` contract and whose flat input shape (`{ function, alias, partitionBy?, orderBy? }`) the spec vocabulary never matched — `WindowFunctionNodeSchema` declared `field`/`over`/`frame` members the door never read, so that cluster is removed with the key rather than left as a false affordance. A REQUEST surface, never stored; no source to rewrite. ADR-0049 / ADR-0078, #4286. + - Done when: No caller sends `windowFunctions` in a query; request-level analytics use `aggregations` + `groupBy`, and embedders needing OVER-clause SQL call the SQL driver's `findWithWindowFunctions` door directly. A query that still carries the key fails to parse with the removal prescription naming that door. +- **`query-cursor-retired`** — `data.query.cursor` → a `where` predicate on the sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` (the documented manual-keyset pattern) + - Why not automatic: The `cursor` key promised keyset pagination and no driver implemented it: the cursor was accepted and ignored, so every page came back identical — a caller looping "until hasMore is false" never terminates. Worse than inert, it had a shipped public producer (`QueryBuilder.cursor()`, removed with the key). The caller-built `Record` shape also leaks sort/storage detail and squats on the reserved REST parameter set; a first-class cursor, if ever designed, will be a response-minted opaque token — a different API, so keeping this one preserved a wrong design rather than a roadmap. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286. + - Done when: No caller sends `cursor` and no SDK call site uses `QueryBuilder.cursor()`; deep pagination expresses the keyset as a `where` predicate on the sort key. A query still carrying `cursor` fails to parse with the removal prescription, and authoring it is a `tsc` error. +- **`query-distinct-retired`** — `data.query.distinct` → `groupBy` for unique combinations; the `count_distinct` aggregation for deduplicated counts; the SQL/memory drivers' `distinct(object, field)` door for one column's values + - Why not automatic: The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND worse pagination metadata, and a side effect that "confirmed" the flag was doing something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with the key). The count suppression is deleted in the same change — `total` is truthful for those queries again. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286. + - Done when: No caller sends `distinct` and no SDK call site uses `QueryBuilder.distinct()`; deduplication goes through `groupBy` / `count_distinct` / the drivers' `distinct()` door. A query still carrying the key fails to parse with the removal prescription, and the REST list response reports a real `total` for queries that used to send it. --- diff --git a/packages/client/src/query-builder.ts b/packages/client/src/query-builder.ts index 83bda8d009..a93c6159de 100644 --- a/packages/client/src/query-builder.ts +++ b/packages/client/src/query-builder.ts @@ -289,7 +289,7 @@ export class QueryBuilder { } // `cursor()` and `distinct()` were REMOVED with `query.cursor` / - // `query.distinct` (#4286, spec 18): no driver ever implemented keyset + // `query.distinct` (#4286, spec 17): no driver ever implemented keyset // pagination or SELECT DISTINCT, so both minted keys the engine ignored — // `cursor` re-served page 1 forever, `distinct`'s only effect was silently // degrading the REST count. Express a keyset as a `where` predicate on the diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 2898244a86..ceaad3fb1b 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -3561,7 +3561,7 @@ export class ObjectStackProtocolImplementation implements `'${param}' entry #${badShape + 1} on object '${object}' is not a field name.` + (retiredForm ? ' The nested-select object form `{ field, fields, alias }` was removed in ' - + '@objectstack/spec 18 (#4196) — no engine or driver ever read it.' + + '@objectstack/spec 17 (#4196) — no engine or driver ever read it.' : '') + " Select related records with `expand` (`expand=owner` / `{ expand: { owner: " + "{ object: 'user', fields: ['name'] } } }`), or name one related column with a " @@ -4452,7 +4452,7 @@ export class ObjectStackProtocolImplementation implements // `distinct` used to suppress the count here too — #4286 finding 2: // the flag's ONLY observable effect platform-wide, on a capability // that never deduplicated a row. Removed with `query.distinct` - // (tombstoned in spec 18); `total`/`hasMore` are truthful again. + // (tombstoned in spec 17); `total`/`hasMore` are truthful again. const countable = options.search == null; if (countable) { try { diff --git a/packages/services/service-automation/src/builtin/wait-node.ts b/packages/services/service-automation/src/builtin/wait-node.ts index 35d8c658ec..ff6aae71a3 100644 --- a/packages/services/service-automation/src/builtin/wait-node.ts +++ b/packages/services/service-automation/src/builtin/wait-node.ts @@ -65,7 +65,7 @@ export function registerWaitNode(engine: AutomationEngine, ctx: PluginContext): if (eventType === 'timer') { // One source for the wait length. `timeoutMs` used to double as this - // when `timerDuration` was absent — it was retired in protocol 18 (#4158) + // when `timerDuration` was absent — it was retired in protocol 17 (#4158) // precisely because that is not what it said it did, and the conversion // rewrites it to `timerDuration`, so there is one spelling left. const durationMs = parseIsoDuration(wec.timerDuration); diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index b7ea453ac6..5b048788c6 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -502,7 +502,7 @@ EOF | report | 13 | 0 | 0 | – | dataset-bound (ADR-0021); the aria/performance LEDGER entries were stale — the keys left the schema in the report-liveness close-out; deleted 2026-07-30 as hygiene. Audit-era `chart` DEAD superseded (framework#1890 / #3441) | | dashboard | 10 | 0 | 2 | – | ADR-0021 dataset widgets (#3251; DashboardWidgetSchema `.strict()`); `aria`/`performance` (and widget `performance` + PerformanceConfigSchema) REMOVED 2026-07-30 (#3896 close-out sweep — no renderer applied any of them); audit-era `globalFilters`/`dateRange` DEAD superseded (framework#2501) | -| query | 16 | 7 | 4 | – | **not a metadata type** — the REQUEST surface (`QuerySchema`: client SDK QueryBuilder output; the `POST /data/:object/query` body), governed via `SPEC_ONLY_SCHEMAS` (#4286). The 7 experimental resolve from `[EXPERIMENTAL — not enforced]` describe markers, not ledger entries (search `fuzzy`/`operator`/`boost`/`minScore`/`language`/`highlight` + `aggregations[].filter` — declared engine affordances no executor receives). The #4286 sweep closed out same-release: `having` ENFORCED 2026-07-31 (engine-side post-aggregation filter, both paths; was finding 1); dead 4 = the tombstoned removals `joins`/`windowFunctions`/`cursor`/`distinct` — REMOVED 2026-07-31 (retiredKey keeps each in the walked shape so the rows stay; protocol-18 semantic migrations; the JoinNode + WindowFunctionNode clusters and the `QueryBuilder.cursor()`/`.distinct()` producers deleted with their keys; `distinct`'s mis-wired REST count suppression deleted too — finding 2) | +| query | 16 | 7 | 4 | – | **not a metadata type** — the REQUEST surface (`QuerySchema`: client SDK QueryBuilder output; the `POST /data/:object/query` body), governed via `SPEC_ONLY_SCHEMAS` (#4286). The 7 experimental resolve from `[EXPERIMENTAL — not enforced]` describe markers, not ledger entries (search `fuzzy`/`operator`/`boost`/`minScore`/`language`/`highlight` + `aggregations[].filter` — declared engine affordances no executor receives). The #4286 sweep closed out same-release: `having` ENFORCED 2026-07-31 (engine-side post-aggregation filter, both paths; was finding 1); dead 4 = the tombstoned removals `joins`/`windowFunctions`/`cursor`/`distinct` — REMOVED 2026-07-31 (retiredKey keeps each in the walked shape so the rows stay; protocol-17 semantic migrations; the JoinNode + WindowFunctionNode clusters and the `QueryBuilder.cursor()`/`.distinct()` producers deleted with their keys; `distinct`'s mis-wired REST count suppression deleted too — finding 2) | | webhook | 0 | 1 | 16 | – | **not a registered metadata type** — governed via the gate's spec-only schema override (`SPEC_ONLY_SCHEMAS`), not `getMetadataTypeSchema` (#3461/#3462). The ENTIRE authoring surface is dead: nothing materializes an authored `webhooks:` entry into a `sys_webhook` dispatcher row (#3461, enforce-or-remove pending). `url` carries the single per-webhook `authorWarn` (one no-op heads-up per artifact, not per-prop); `authentication` experimental (HMAC-`secret`-only); `isActive` unmarked (default(true)). Notes cite the sys_webhook column map as the future materializer's mapping table | The `dead` set across types is the enforce-or-remove worklist (ADR-0049); every diff --git a/packages/spec/liveness/query.json b/packages/spec/liveness/query.json index 324796edbf..7ee2f81eae 100644 --- a/packages/spec/liveness/query.json +++ b/packages/spec/liveness/query.json @@ -1,6 +1,6 @@ { "type": "query", - "_note": "QuerySchema (BaseQuerySchema + expand) — packages/spec/src/data/query.zod.ts. NOT a registered metadata type: QueryAST is the REQUEST surface (the client SDK QueryBuilder's output; the POST /data/:object/query body), governed via the gate's SPEC_ONLY_SCHEMAS override like webhook (#4286). It is authorable by every API caller yet never stored as stack metadata, which is why no other gate reached it: this ledger read the metadata-type registry, check:authorable-surface walks the same registry, and check:exported-any is a type-resolution gate. Seeded 2026-07-31 from the #4286 sweep; closed out same day — joins/windowFunctions/cursor/distinct REMOVED (tombstoned; protocol-18 semantic migrations; the cursor/distinct SDK producers deleted with their keys), having ENFORCED engine-side, search sub-flags + aggregations.filter marked experimental at the schema. The main executor evidence below: objectql engine find/aggregate and the drivers' find paths.", + "_note": "QuerySchema (BaseQuerySchema + expand) — packages/spec/src/data/query.zod.ts. NOT a registered metadata type: QueryAST is the REQUEST surface (the client SDK QueryBuilder's output; the POST /data/:object/query body), governed via the gate's SPEC_ONLY_SCHEMAS override like webhook (#4286). It is authorable by every API caller yet never stored as stack metadata, which is why no other gate reached it: this ledger read the metadata-type registry, check:authorable-surface walks the same registry, and check:exported-any is a type-resolution gate. Seeded 2026-07-31 from the #4286 sweep; closed out same day — joins/windowFunctions/cursor/distinct REMOVED (tombstoned; protocol-17 semantic migrations; the cursor/distinct SDK producers deleted with their keys), having ENFORCED engine-side, search sub-flags + aggregations.filter marked experimental at the schema. The main executor evidence below: objectql engine find/aggregate and the drivers' find paths.", "props": { "object": { "status": "live", @@ -52,12 +52,12 @@ "cursor": { "status": "dead", "verifiedAt": "2026-07-31", - "note": "REMOVED 2026-07-31 (#4286 step 4, resolved to remove) — tombstoned at the schema (retiredKey; also on EngineQueryOptionsSchema, same prescription) and the shipped producer QueryBuilder.cursor() deleted with it: no driver implemented keyset pagination, so the cursor re-served page 1 forever. The protocol-18 semantic migration `query-cursor-retired` carries it (request surface, no source to rewrite). The entry stays because retiredKey keeps the key in the walked shape. Manual keyset (a `where` predicate on the sort key) is the documented pattern; a first-class cursor, if ever designed, will be a response-minted opaque token. NOTE: the `cursor: string` params on listRevisions / flow-runs / notifications in client/src/index.ts are those endpoints' own server-minted tokens — live, unrelated, untouched." + "note": "REMOVED 2026-07-31 (#4286 step 4, resolved to remove) — tombstoned at the schema (retiredKey; also on EngineQueryOptionsSchema, same prescription) and the shipped producer QueryBuilder.cursor() deleted with it: no driver implemented keyset pagination, so the cursor re-served page 1 forever. The protocol-17 semantic migration `query-cursor-retired` carries it (request surface, no source to rewrite). The entry stays because retiredKey keeps the key in the walked shape. Manual keyset (a `where` predicate on the sort key) is the documented pattern; a first-class cursor, if ever designed, will be a response-minted opaque token. NOTE: the `cursor: string` params on listRevisions / flow-runs / notifications in client/src/index.ts are those endpoints' own server-minted tokens — live, unrelated, untouched." }, "joins": { "status": "dead", "verifiedAt": "2026-07-31", - "note": "REMOVED 2026-07-31 (#4286) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error). No conversion strips it: QueryAST is a request surface, never stored in stack metadata, so the removal is the protocol-18 semantic migration `query-joins-retired`. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). Related records are read through `expand` (batch $in resolution); the orphaned JoinNode/JoinType/JoinStrategy cluster left with the key." + "note": "REMOVED 2026-07-31 (#4286) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error). No conversion strips it: QueryAST is a request surface, never stored in stack metadata, so the removal is the protocol-17 semantic migration `query-joins-retired`. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). Related records are read through `expand` (batch $in resolution); the orphaned JoinNode/JoinType/JoinStrategy cluster left with the key." }, "aggregations": { "note": "Container. `filter` resolves `experimental` from its `[EXPERIMENTAL — not enforced]` describe marker (#4286 — a SQL FILTER (WHERE …) affordance neither the SQL builders nor the in-memory fallback applies; the FLS predicate guard walks it, predicate-guard.ts:89).", @@ -98,12 +98,12 @@ "windowFunctions": { "status": "dead", "verifiedAt": "2026-07-31", - "note": "REMOVED 2026-07-31 (#4286) — tombstoned at the schema; the protocol-18 semantic migration `query-window-functions-retired` carries it (request surface, no source to rewrite). The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). find() never applied a window function; the live door is SqlDriver.findWithWindowFunctions() (driver-level, its own flat input shape), and the WindowFunctionNode cluster — which declared field/over/frame members that door never read — left with the key. The FLS guard's window walk was pruned in the same change." + "note": "REMOVED 2026-07-31 (#4286) — tombstoned at the schema; the protocol-17 semantic migration `query-window-functions-retired` carries it (request surface, no source to rewrite). The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). find() never applied a window function; the live door is SqlDriver.findWithWindowFunctions() (driver-level, its own flat input shape), and the WindowFunctionNode cluster — which declared field/over/frame members that door never read — left with the key. The FLS guard's window walk was pruned in the same change." }, "distinct": { "status": "dead", "verifiedAt": "2026-07-31", - "note": "REMOVED 2026-07-31 (#4286 step 4, resolved to remove) — tombstoned at the schema (retiredKey; also on EngineQueryOptionsSchema, same prescription), the shipped producer QueryBuilder.distinct() deleted, AND the mis-wiring deleted with it: finding 2 was that the flag's only observable effect was suppressing the REST list count (protocol.ts `countable`), so callers got duplicate rows plus degraded total/hasMore — worse than dead, a side effect that 'confirmed' a capability that never ran. `total` is truthful for those queries again (the observable REST change, called out in the changeset). The protocol-18 semantic migration `query-distinct-retired` carries it. The entry stays because retiredKey keeps the key in the walked shape. Live spellings: groupBy, count_distinct, the drivers' distinct(object, field) door. AggregationNode.distinct (per-aggregation dedupe) is a different, live member — see aggregations.children." + "note": "REMOVED 2026-07-31 (#4286 step 4, resolved to remove) — tombstoned at the schema (retiredKey; also on EngineQueryOptionsSchema, same prescription), the shipped producer QueryBuilder.distinct() deleted, AND the mis-wiring deleted with it: finding 2 was that the flag's only observable effect was suppressing the REST list count (protocol.ts `countable`), so callers got duplicate rows plus degraded total/hasMore — worse than dead, a side effect that 'confirmed' a capability that never ran. `total` is truthful for those queries again (the observable REST change, called out in the changeset). The protocol-17 semantic migration `query-distinct-retired` carries it. The entry stays because retiredKey keeps the key in the walked shape. Live spellings: groupBy, count_distinct, the drivers' distinct(object, field) door. AggregationNode.distinct (per-aggregation dedupe) is a different, live member — see aggregations.children." }, "expand": { "status": "live", diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index a99193c14d..1116fc4573 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -193,6 +193,18 @@ "to": "skill key 'triggerPhrases' removed (#3896 close-out — activation is triggerConditions + the agent's skills[] allowlist; phrases were a dead-end projection)", "conversionId": "skill-trigger-phrases-removed", "toMajor": 17 + }, + { + "surface": "stack.api.requireAuth", + "to": "stack key 'api.requireAuth' removed — anonymous access is always denied; publish public surfaces by declaration (#3963)", + "conversionId": "stack-api-require-auth-removed", + "toMajor": 17 + }, + { + "surface": "flow.node.waitEventConfig", + "to": "waitEventConfig keys 'timeoutMs' (→ 'timerDuration', stringified — its only reader used it as the duration) and 'onTimeout' (removed — zero readers, so no timeout ever fired) (#4158)", + "conversionId": "flow-node-wait-timeout-keys-removed", + "toMajor": 17 } ], "migrated": [ @@ -293,6 +305,48 @@ "migrationId": "analytics-query-request-format-retired", "toMajor": 17, "rationale": "The `format` key was declared but never implemented (declared ≠ enforced): every response is the JSON envelope regardless of the requested value, so there is no behaviour to preserve and nothing stored to rewrite." + }, + { + "surface": "data.query.fields", + "replacement": "expand (`expand: { owner: { object: 'user', fields: ['name'] } }`), or a dotted path for a single related column (`fields: ['owner.name']`)", + "migrationId": "query-field-node-object-form-retired", + "toMajor": 17, + "rationale": "The `FieldNode` union declared a nested-select object form `{ field, fields, alias }` that was inert end to end: no producer emitted it, and no consumer read `.fields` or `.alias` — objectql's formula projection and known-field filters, driver-sql's `select()` and driver-memory's projection all treat the list as `string[]`, driver-mongodb keyed its projection with the entry itself, and the REST ingress stringified it. Nested selection is `expand`, which the engine resolves via batch `$in` queries. This is a REQUEST surface — `QueryAST` is never stored in stack metadata (no view, dataset or report authors one), so there is no source for the chain to rewrite: the schema narrows to `z.string()` and callers move their own select lists. ADR-0049 / ADR-0078, #4196." + }, + { + "surface": "api.batchOptions.validateOnly", + "replacement": "(removed — no dry-run today; open an issue to design a no-commit batch preview)", + "migrationId": "batch-options-validate-only-retired", + "toMajor": 17, + "rationale": "The `validateOnly` key promised a dry-run (\"validate records without persisting\") but no batch surface ever read it — updateManyData / deleteManyData / batchData persist regardless. There is no behaviour to preserve and nothing stored to rewrite (it only ever appeared in an HTTP request body). Callers must stop sending it." + }, + { + "surface": "data.query.joins", + "replacement": "expand (`expand: { owner: { object: 'user', fields: ['name'] } }`), or a dotted `fields` path for a single related column (`fields: ['owner.name']`)", + "migrationId": "query-joins-retired", + "toMajor": 17, + "rationale": "The `joins` array was declared-but-inert: no engine or driver read `query.joins` anywhere on the query path, so a query carrying it behaved exactly as if the key were absent — while the name squatted on the reserved REST parameter set. Related-record retrieval already has a live spelling (`expand`, resolved by the engine via batch `$in` queries), so the removal deletes the second, broken spelling rather than the capability, and the orphaned `JoinNode`/`JoinType`/`JoinStrategy` cluster goes with the key. A REQUEST surface — `QueryAST` is never stored in stack metadata — so there is no source for the chain to rewrite; callers move their own queries. ADR-0049 / ADR-0078, #4286." + }, + { + "surface": "data.query.windowFunctions", + "replacement": "`aggregations` + `groupBy` for request-level analytics; `SqlDriver.findWithWindowFunctions(object, query)` for embedders on a SQL datasource", + "migrationId": "query-window-functions-retired", + "toMajor": 17, + "rationale": "The `windowFunctions` array was declared-but-inert on the query path: `find()` never applied a window function, so every OVER clause a caller declared was silently dropped. The capability only ever ran behind `SqlDriver.findWithWindowFunctions()`, a driver-level door that is not on the `IDataDriver` contract and whose flat input shape (`{ function, alias, partitionBy?, orderBy? }`) the spec vocabulary never matched — `WindowFunctionNodeSchema` declared `field`/`over`/`frame` members the door never read, so that cluster is removed with the key rather than left as a false affordance. A REQUEST surface, never stored; no source to rewrite. ADR-0049 / ADR-0078, #4286." + }, + { + "surface": "data.query.cursor", + "replacement": "a `where` predicate on the sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` (the documented manual-keyset pattern)", + "migrationId": "query-cursor-retired", + "toMajor": 17, + "rationale": "The `cursor` key promised keyset pagination and no driver implemented it: the cursor was accepted and ignored, so every page came back identical — a caller looping \"until hasMore is false\" never terminates. Worse than inert, it had a shipped public producer (`QueryBuilder.cursor()`, removed with the key). The caller-built `Record` shape also leaks sort/storage detail and squats on the reserved REST parameter set; a first-class cursor, if ever designed, will be a response-minted opaque token — a different API, so keeping this one preserved a wrong design rather than a roadmap. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286." + }, + { + "surface": "data.query.distinct", + "replacement": "`groupBy` for unique combinations; the `count_distinct` aggregation for deduplicated counts; the SQL/memory drivers' `distinct(object, field)` door for one column's values", + "migrationId": "query-distinct-retired", + "toMajor": 17, + "rationale": "The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND worse pagination metadata, and a side effect that \"confirmed\" the flag was doing something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with the key). The count suppression is deleted in the same change — `total` is truthful for those queries again. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286." } ], "removed": [] @@ -615,6 +669,18 @@ "to": "skill key 'triggerPhrases' removed (#3896 close-out — activation is triggerConditions + the agent's skills[] allowlist; phrases were a dead-end projection)", "conversionId": "skill-trigger-phrases-removed", "toMajor": 17 + }, + { + "surface": "stack.api.requireAuth", + "to": "stack key 'api.requireAuth' removed — anonymous access is always denied; publish public surfaces by declaration (#3963)", + "conversionId": "stack-api-require-auth-removed", + "toMajor": 17 + }, + { + "surface": "flow.node.waitEventConfig", + "to": "waitEventConfig keys 'timeoutMs' (→ 'timerDuration', stringified — its only reader used it as the duration) and 'onTimeout' (removed — zero readers, so no timeout ever fired) (#4158)", + "conversionId": "flow-node-wait-timeout-keys-removed", + "toMajor": 17 } ], "migrated": [ @@ -645,6 +711,48 @@ "migrationId": "analytics-query-request-format-retired", "toMajor": 17, "rationale": "The `format` key was declared but never implemented (declared ≠ enforced): every response is the JSON envelope regardless of the requested value, so there is no behaviour to preserve and nothing stored to rewrite." + }, + { + "surface": "data.query.fields", + "replacement": "expand (`expand: { owner: { object: 'user', fields: ['name'] } }`), or a dotted path for a single related column (`fields: ['owner.name']`)", + "migrationId": "query-field-node-object-form-retired", + "toMajor": 17, + "rationale": "The `FieldNode` union declared a nested-select object form `{ field, fields, alias }` that was inert end to end: no producer emitted it, and no consumer read `.fields` or `.alias` — objectql's formula projection and known-field filters, driver-sql's `select()` and driver-memory's projection all treat the list as `string[]`, driver-mongodb keyed its projection with the entry itself, and the REST ingress stringified it. Nested selection is `expand`, which the engine resolves via batch `$in` queries. This is a REQUEST surface — `QueryAST` is never stored in stack metadata (no view, dataset or report authors one), so there is no source for the chain to rewrite: the schema narrows to `z.string()` and callers move their own select lists. ADR-0049 / ADR-0078, #4196." + }, + { + "surface": "api.batchOptions.validateOnly", + "replacement": "(removed — no dry-run today; open an issue to design a no-commit batch preview)", + "migrationId": "batch-options-validate-only-retired", + "toMajor": 17, + "rationale": "The `validateOnly` key promised a dry-run (\"validate records without persisting\") but no batch surface ever read it — updateManyData / deleteManyData / batchData persist regardless. There is no behaviour to preserve and nothing stored to rewrite (it only ever appeared in an HTTP request body). Callers must stop sending it." + }, + { + "surface": "data.query.joins", + "replacement": "expand (`expand: { owner: { object: 'user', fields: ['name'] } }`), or a dotted `fields` path for a single related column (`fields: ['owner.name']`)", + "migrationId": "query-joins-retired", + "toMajor": 17, + "rationale": "The `joins` array was declared-but-inert: no engine or driver read `query.joins` anywhere on the query path, so a query carrying it behaved exactly as if the key were absent — while the name squatted on the reserved REST parameter set. Related-record retrieval already has a live spelling (`expand`, resolved by the engine via batch `$in` queries), so the removal deletes the second, broken spelling rather than the capability, and the orphaned `JoinNode`/`JoinType`/`JoinStrategy` cluster goes with the key. A REQUEST surface — `QueryAST` is never stored in stack metadata — so there is no source for the chain to rewrite; callers move their own queries. ADR-0049 / ADR-0078, #4286." + }, + { + "surface": "data.query.windowFunctions", + "replacement": "`aggregations` + `groupBy` for request-level analytics; `SqlDriver.findWithWindowFunctions(object, query)` for embedders on a SQL datasource", + "migrationId": "query-window-functions-retired", + "toMajor": 17, + "rationale": "The `windowFunctions` array was declared-but-inert on the query path: `find()` never applied a window function, so every OVER clause a caller declared was silently dropped. The capability only ever ran behind `SqlDriver.findWithWindowFunctions()`, a driver-level door that is not on the `IDataDriver` contract and whose flat input shape (`{ function, alias, partitionBy?, orderBy? }`) the spec vocabulary never matched — `WindowFunctionNodeSchema` declared `field`/`over`/`frame` members the door never read, so that cluster is removed with the key rather than left as a false affordance. A REQUEST surface, never stored; no source to rewrite. ADR-0049 / ADR-0078, #4286." + }, + { + "surface": "data.query.cursor", + "replacement": "a `where` predicate on the sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` (the documented manual-keyset pattern)", + "migrationId": "query-cursor-retired", + "toMajor": 17, + "rationale": "The `cursor` key promised keyset pagination and no driver implemented it: the cursor was accepted and ignored, so every page came back identical — a caller looping \"until hasMore is false\" never terminates. Worse than inert, it had a shipped public producer (`QueryBuilder.cursor()`, removed with the key). The caller-built `Record` shape also leaks sort/storage detail and squats on the reserved REST parameter set; a first-class cursor, if ever designed, will be a response-minted opaque token — a different API, so keeping this one preserved a wrong design rather than a roadmap. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286." + }, + { + "surface": "data.query.distinct", + "replacement": "`groupBy` for unique combinations; the `count_distinct` aggregation for deduplicated counts; the SQL/memory drivers' `distinct(object, field)` door for one column's values", + "migrationId": "query-distinct-retired", + "toMajor": 17, + "rationale": "The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND worse pagination metadata, and a side effect that \"confirmed\" the flag was doing something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with the key). The count suppression is deleted in the same change — `total` is truthful for those queries again. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286." } ], "removed": [] diff --git a/packages/spec/src/api/rest-server.zod.ts b/packages/spec/src/api/rest-server.zod.ts index 01cb04de95..713cbf859d 100644 --- a/packages/spec/src/api/rest-server.zod.ts +++ b/packages/spec/src/api/rest-server.zod.ts @@ -123,7 +123,7 @@ export const RestApiConfigSchema = lazySchema(() => z.object({ * Which is the exact failure mode this key's removal is about. */ requireAuth: retiredKey( - '`api.requireAuth` was removed in @objectstack/spec 18 (#3963). Anonymous access to object data ' + '`api.requireAuth` was removed in @objectstack/spec 17 (#3963). Anonymous access to object data ' + 'is now always denied — auth is a kernel concern, not a deployment posture. Delete the key. ' + 'To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a ' + "share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of " diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index 3f37367140..7dc6a1b1c1 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -234,14 +234,14 @@ export const FlowNodeSchema = lazySchema(() => z.object({ * happened to be declared. */ timeoutMs: retiredKey( - '`waitEventConfig.timeoutMs` was removed in @objectstack/spec 18 (#4158). It documented a ' + '`waitEventConfig.timeoutMs` was removed in @objectstack/spec 17 (#4158). It documented a ' + 'timeout guard that never existed: nothing ever failed or resumed a wait on a deadline. Its ' + 'only reader treated it as the timer DURATION when `timerDuration` was absent, so use ' + '`timerDuration` — it accepts a bare number as milliseconds, making `timeoutMs: 60000` and ' + "`timerDuration: 60000` the same wait. Stored flows are converted automatically.", ), onTimeout: retiredKey( - '`waitEventConfig.onTimeout` was removed in @objectstack/spec 18 (#4158). It had no readers at ' + '`waitEventConfig.onTimeout` was removed in @objectstack/spec 17 (#4158). It had no readers at ' + 'all — no code path ever inspected it, so neither `fail` nor `continue` ever happened. Delete ' + 'the key. There is no replacement: `wait` has no timeout, and a wait node resumes only when ' + 'its timer elapses or its signal arrives.', diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 2c6d7c70a0..70402f7e30 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -1198,7 +1198,7 @@ const flowNodeWaitEventConfigLift: MetadataConversion = { { id: 'n3', type: 'wait', config: { eventType: 'signal', signal: 'order_paid' } }, // Partially shadowed: `timerDuration` is already declared, so the // loose `duration` stays put untouched; only `eventType` lifts. - // (Deliberately not `timeoutMs` — protocol 18 retires that key + // (Deliberately not `timeoutMs` — protocol 17 retires that key // (#4158), and the fixture harness replays the WHOLE table, so an // `after` naming it would describe an end state that no longer // exists.) @@ -2081,7 +2081,7 @@ const skillTriggerPhrasesRemoved: MetadataConversion = { * shape. Newest majors last; ordering within a major is application order. */ /** - * Stack `api.requireAuth` → dropped (protocol 18, #3963). + * Stack `api.requireAuth` → dropped (protocol 17, #3963). * * NOT a rename — there is no key to move the value to. The deployment-wide * anonymous-access opt-out is retired: auth is a kernel concern, and anonymous @@ -2099,7 +2099,7 @@ const skillTriggerPhrasesRemoved: MetadataConversion = { */ const stackApiRequireAuthRemoved: MetadataConversion = { id: 'stack-api-require-auth-removed', - toMajor: 18, + toMajor: 17, retiredFromLoadPath: true, surface: 'stack.api.requireAuth', summary: "stack key 'api.requireAuth' removed — anonymous access is always denied; publish public surfaces by declaration (#3963)", @@ -2124,7 +2124,7 @@ const stackApiRequireAuthRemoved: MetadataConversion = { /** * `waitEventConfig.timeoutMs` / `.onTimeout` removed — `wait` never had a timeout - * (protocol 18, #4158). + * (protocol 17, #4158). * * Both keys described a timeout and neither delivered one. `onTimeout` had **zero** * readers: no path ever inspected it, so neither `'fail'` nor `'continue'` ever @@ -2179,7 +2179,7 @@ function removeWaitTimeoutKeys(stack: Dict, emit: Emit): Dict { const flowNodeWaitTimeoutKeysRemoved: MetadataConversion = { id: 'flow-node-wait-timeout-keys-removed', - toMajor: 18, + toMajor: 17, retiredFromLoadPath: true, surface: 'flow.node.waitEventConfig', summary: @@ -2255,8 +2255,6 @@ export const CONVERSIONS_BY_MAJOR: Readonly it('rejects the object form with the removal prescription, not a type mismatch', () => { expect(() => FieldNodeSchema.parse({ field: 'owner', fields: ['name'] })) - .toThrow(/nested-select object form.*removed in @objectstack\/spec 18.*expand/s); + .toThrow(/nested-select object form.*removed in @objectstack\/spec 17.*expand/s); // …and through the query it is reached from, since that is where an author // writes it. expect(() => QuerySchema.parse({ diff --git a/packages/spec/src/data/query.zod.ts b/packages/spec/src/data/query.zod.ts index 32eec59cdd..de5dfd1fe4 100644 --- a/packages/spec/src/data/query.zod.ts +++ b/packages/spec/src/data/query.zod.ts @@ -174,7 +174,7 @@ export type FieldNode = string; */ const FIELD_NODE_OBJECT_FORM_REMOVED = 'A `fields[]` entry is a field name (string). The nested-select object form ' - + '`{ field, fields, alias }` was removed in @objectstack/spec 18 (#4196, ADR-0049) — nothing ' + + '`{ field, fields, alias }` was removed in @objectstack/spec 17 (#4196, ADR-0049) — nothing ' + 'ever produced it and nothing ever read `.fields`/`.alias`: every consumer on this path ' + 'treats the list as `string[]`, so the object form was dropped by the SQL and memory drivers, ' + 'projected as a column literally named "[object Object]" by MongoDB, and refused as an unknown ' @@ -209,11 +209,11 @@ export const FieldNodeSchema = z.string({ * {@link FIELD_NODE_OBJECT_FORM_REMOVED}, the rejection is where an author * meets a retirement, so each message carries the FROM → TO mapping. `QueryAST` * is a request shape, never stored in stack metadata, so there is no - * `os migrate meta` step — callers rewrite their own queries (the protocol-18 + * `os migrate meta` step — callers rewrite their own queries (the protocol-17 * semantic migrations `query-joins-retired` / `query-window-functions-retired`). */ const QUERY_JOINS_REMOVED = - '`query.joins` was removed in @objectstack/spec 18 (#4286, ADR-0049) — no engine or driver ' + '`query.joins` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no engine or driver ' + 'ever read it: a query carrying `joins` behaved exactly as if the key were absent, while ' + 'its name squatted on the reserved REST parameter set. Delete the key. Related records are ' + "read through `expand` — `expand: { owner: { object: 'user', fields: ['name'] } }` — which " @@ -226,7 +226,7 @@ const QUERY_JOINS_REMOVED = * same prescription — one string, two rejection sites. */ export const QUERY_CURSOR_REMOVED = - '`query.cursor` was removed in @objectstack/spec 18 (#4286, ADR-0049) — no driver ever ' + '`query.cursor` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no driver ever ' + 'implemented keyset pagination, so the cursor was accepted and ignored and every page came ' + 'back identical (a caller looping "until hasMore is false" never terminates). Delete the ' + 'key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary ' @@ -237,7 +237,7 @@ export const QUERY_CURSOR_REMOVED = /** See {@link QUERY_CURSOR_REMOVED} for why this one is exported. */ export const QUERY_DISTINCT_REMOVED = - '`query.distinct` was removed in @objectstack/spec 18 (#4286, ADR-0049 / ADR-0078) — no ' + '`query.distinct` was removed in @objectstack/spec 17 (#4286, ADR-0049 / ADR-0078) — no ' + "driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: " + 'the REST list path treated a distinct query as not countable and silently degraded ' + '`total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete ' @@ -247,7 +247,7 @@ export const QUERY_DISTINCT_REMOVED = + 'count, the `count_distinct` aggregation.'; const QUERY_WINDOW_FUNCTIONS_REMOVED = - '`query.windowFunctions` was removed in @objectstack/spec 18 (#4286, ADR-0049) — `find()` ' + '`query.windowFunctions` was removed in @objectstack/spec 17 (#4286, ADR-0049) — `find()` ' + 'never applied it: no engine or driver read the key on the query path, so every OVER ' + 'clause it declared was silently dropped. Delete the key. Window functions are a ' + 'SQL-driver capability behind `SqlDriver.findWithWindowFunctions(object, query)` ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 0fd5055037..20d57c4c5f 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -470,7 +470,65 @@ const step17: MigrationStep = { "at least 1 under `'retry'`, in both spellings (omitted, and an explicit 0). " + 'This is the one v17 flow change the chain cannot apply for you: choosing the ' + 'count is a judgment about re-running the WHOLE flow with its side effects, ' + - 'so it is a semantic TODO rather than a rewrite.', + 'so it is a semantic TODO rather than a rewrite.\n\n' + + 'It also retires `api.requireAuth` (#3963): the deployment-wide opt-out that let a ' + + 'stack serve its ENTIRE data plane anonymously with one boolean. Auth is a kernel ' + + 'concern, not a deployment posture — anonymous access to object data is now denied ' + + 'unconditionally on every HTTP surface. Every surface that legitimately serves a ' + + 'session-less caller derives its own narrow authorization from a DECLARATION instead: ' + + 'the control-plane allowlist, `publicFormGrant` (public form views), share-link tokens ' + + "(read as SYSTEM), and `book.audience: 'public'` (ADR-0046 §6.7). The key is dropped " + + 'with a notice rather than mapped — there is no replacement value, only a different ' + + 'way to publish (by declaration). A stack that mounts no auth at all now fails at boot ' + + 'when it would serve a data API, instead of receiving an implicit fail-open.\n\n' + + 'The same major retires `BatchOptions.validateOnly` (#4052): a batch "dry-run" flag that ' + + 'was declared but never implemented — every batch surface (`updateManyData` / ' + + '`deleteManyData` / `batchData`) persisted regardless, so a caller sending it to PREVIEW a ' + + 'mutation got it executed. That is the dangerous direction of declared ≠ enforced: a flag ' + + 'lying about a data-safety guarantee. No dry-run exists today; the schema tombstones the ' + + 'key with the prescription. It is HTTP-only (never stored in stack metadata), so the ' + + 'change is one semantic TODO for API callers rather than a stack conversion.\n\n' + + 'It also narrows `QueryAST.fields` to field names (#4196): the `FieldNode` union carried a ' + + 'second `{ field, fields, alias }` nested-select member that nothing produced and nothing ' + + 'consumed — every reader on the path treats the list as `string[]`, so the object form was ' + + 'dropped by the SQL and memory drivers, projected as a column named "[object Object]" by ' + + 'MongoDB, and refused by the REST ingress as an unknown field of that name. `expand` is the ' + + 'one spelling for nested selection (ADR-0049 enforce-or-remove; Prime Directive #12: one ' + + 'capability, one contract). Like the two above it is a request shape, never stored, so the ' + + 'chain has no source to rewrite.\n\n' + + 'The #4286 sweep applies the same method to the rest of the request surface: `query.joins` ' + + 'and `query.windowFunctions` are tombstoned — no engine or driver ever read either on the ' + + 'query path, so every join and OVER clause a caller declared was silently dropped. Joins ' + + 'were the second, broken spelling of related-record retrieval (`expand` is the live one; ' + + 'the whole JoinNode cluster goes with the key), and window functions only ever ran behind ' + + '`SqlDriver.findWithWindowFunctions()`, a driver-level door whose flat input shape the ' + + 'spec vocabulary never matched (it declared `field`/`over`/`frame` members the door never ' + + 'read — that cluster goes too). Request shapes again: two semantic TODOs, no source ' + + 'rewrite.\n\n' + + 'The #4286 close-out settles the remaining three. `having` is ENFORCED, not removed — ' + + 'the engine applies it after aggregation on both paths, so the clause every SQL-literate ' + + 'author expects now works (no migration; queries that carried it were silently returning ' + + 'every group and now filter as written). `cursor` and `distinct` are tombstoned WITH ' + + 'their shipped SDK producers (`QueryBuilder.cursor()` / `.distinct()` are deleted): no ' + + 'driver ever implemented keyset pagination or SELECT DISTINCT, `cursor` re-served page 1 ' + + "forever, and `distinct`'s only observable effect was mis-wired — it suppressed the REST " + + 'list count, which is now truthful again. Both are request shapes; two more semantic ' + + 'TODOs, no source rewrite.\n\n' + + 'The same kind of retirement covers `wait`\'s timeout pair (#4158). `waitEventConfig.onTimeout` ' + + 'had ZERO readers — no path ever inspected it, so neither `fail` nor `continue` ever ' + + 'happened, while its `.default(\'fail\')` stamped a decision nothing made onto every wait ' + + 'node. `waitEventConfig.timeoutMs` said "maximum wait time before timeout" and its only ' + + 'reader used it as the timer DURATION when `timerDuration` was absent: it did something, ' + + 'just not what it said. Together they declared a timeout `wait` does not have — the run ' + + 'resumes when its timer elapses or its signal arrives, never on a deadline. Rather than ' + + 'retrofit an implementation to fit two keys that happened to be declared, the pair is ' + + 'retired and real timeout semantics are left to be built to a requirement. `timeoutMs` ' + + 'converts to `timerDuration` (stringified — the target is `z.string()` and ' + + '`parseIsoDuration` reads a bare numeric string as milliseconds, so the wait is unchanged); ' + + 'with `timerDuration` already set it is dropped, having been dead metadata. Like the other ' + + 'keys retired for MISDESCRIBING themselves rather than for being renamed, both leave the ' + + 'load path: absorbing them silently would let an author keep believing they configured a ' + + 'timeout.', conversionIds: [ 'action-execute-to-target', 'field-conditionalRequired-to-requiredWhen', @@ -493,6 +551,8 @@ const step17: MigrationStep = { 'dashboard-inert-keys-removed', 'agent-knowledge-removed', 'skill-trigger-phrases-removed', + 'stack-api-require-auth-removed', + 'flow-node-wait-timeout-keys-removed', ], semantic: [ { @@ -557,73 +617,6 @@ const step17: MigrationStep = { 'No /analytics/query or /analytics/sql call sends `format`; exports go through the ' + 'export surface.', }, - ], -}; - -/** All migration steps, keyed by the major they migrate into. */ -const step18: MigrationStep = { - toMajor: 18, - rationale: - 'Protocol 18 retires `api.requireAuth` (#3963): the deployment-wide opt-out that let a ' - + 'stack serve its ENTIRE data plane anonymously with one boolean. Auth is a kernel ' - + 'concern, not a deployment posture — anonymous access to object data is now denied ' - + 'unconditionally on every HTTP surface. Every surface that legitimately serves a ' - + 'session-less caller derives its own narrow authorization from a DECLARATION instead: ' - + 'the control-plane allowlist, `publicFormGrant` (public form views), share-link tokens ' - + "(read as SYSTEM), and `book.audience: 'public'` (ADR-0046 §6.7). The key is dropped " - + 'with a notice rather than mapped — there is no replacement value, only a different ' - + 'way to publish (by declaration). A stack that mounts no auth at all now fails at boot ' - + 'when it would serve a data API, instead of receiving an implicit fail-open.\n\n' - + 'The same major retires `BatchOptions.validateOnly` (#4052): a batch "dry-run" flag that ' - + 'was declared but never implemented — every batch surface (`updateManyData` / ' - + '`deleteManyData` / `batchData`) persisted regardless, so a caller sending it to PREVIEW a ' - + 'mutation got it executed. That is the dangerous direction of declared ≠ enforced: a flag ' - + 'lying about a data-safety guarantee. No dry-run exists today; the schema tombstones the ' - + 'key with the prescription. It is HTTP-only (never stored in stack metadata), so the ' - + 'change is one semantic TODO for API callers rather than a stack conversion.\n\n' - + 'It also narrows `QueryAST.fields` to field names (#4196): the `FieldNode` union carried a ' - + 'second `{ field, fields, alias }` nested-select member that nothing produced and nothing ' - + 'consumed — every reader on the path treats the list as `string[]`, so the object form was ' - + 'dropped by the SQL and memory drivers, projected as a column named "[object Object]" by ' - + 'MongoDB, and refused by the REST ingress as an unknown field of that name. `expand` is the ' - + 'one spelling for nested selection (ADR-0049 enforce-or-remove; Prime Directive #12: one ' - + 'capability, one contract). Like the two above it is a request shape, never stored, so the ' - + 'chain has no source to rewrite.\n\n' - + 'The #4286 sweep applies the same method to the rest of the request surface: `query.joins` ' - + 'and `query.windowFunctions` are tombstoned — no engine or driver ever read either on the ' - + 'query path, so every join and OVER clause a caller declared was silently dropped. Joins ' - + 'were the second, broken spelling of related-record retrieval (`expand` is the live one; ' - + 'the whole JoinNode cluster goes with the key), and window functions only ever ran behind ' - + '`SqlDriver.findWithWindowFunctions()`, a driver-level door whose flat input shape the ' - + 'spec vocabulary never matched (it declared `field`/`over`/`frame` members the door never ' - + 'read — that cluster goes too). Request shapes again: two semantic TODOs, no source ' - + 'rewrite.\n\n' - + 'The #4286 close-out settles the remaining three. `having` is ENFORCED, not removed — ' - + 'the engine applies it after aggregation on both paths, so the clause every SQL-literate ' - + 'author expects now works (no migration; queries that carried it were silently returning ' - + 'every group and now filter as written). `cursor` and `distinct` are tombstoned WITH ' - + 'their shipped SDK producers (`QueryBuilder.cursor()` / `.distinct()` are deleted): no ' - + 'driver ever implemented keyset pagination or SELECT DISTINCT, `cursor` re-served page 1 ' - + "forever, and `distinct`'s only observable effect was mis-wired — it suppressed the REST " - + 'list count, which is now truthful again. Both are request shapes; two more semantic ' - + 'TODOs, no source rewrite.\n\n' - + 'The same kind of retirement covers `wait`\'s timeout pair (#4158). `waitEventConfig.onTimeout` ' - + 'had ZERO readers — no path ever inspected it, so neither `fail` nor `continue` ever ' - + 'happened, while its `.default(\'fail\')` stamped a decision nothing made onto every wait ' - + 'node. `waitEventConfig.timeoutMs` said "maximum wait time before timeout" and its only ' - + 'reader used it as the timer DURATION when `timerDuration` was absent: it did something, ' - + 'just not what it said. Together they declared a timeout `wait` does not have — the run ' - + 'resumes when its timer elapses or its signal arrives, never on a deadline. Rather than ' - + 'retrofit an implementation to fit two keys that happened to be declared, the pair is ' - + 'retired and real timeout semantics are left to be built to a requirement. `timeoutMs` ' - + 'converts to `timerDuration` (stringified — the target is `z.string()` and ' - + '`parseIsoDuration` reads a bare numeric string as milliseconds, so the wait is unchanged); ' - + 'with `timerDuration` already set it is dropped, having been dead metadata. Like the other ' - + 'keys retired for MISDESCRIBING themselves rather than for being renamed, both leave the ' - + 'load path: absorbing them silently would let an author keep believing they configured a ' - + 'timeout.', - conversionIds: ['stack-api-require-auth-removed', 'flow-node-wait-timeout-keys-removed'], - semantic: [ { id: 'query-field-node-object-form-retired', surface: 'data.query.fields', @@ -748,6 +741,7 @@ const step18: MigrationStep = { ], }; +/** All migration steps, keyed by the major they migrate into. */ export const MIGRATIONS_BY_MAJOR: Readonly> = { 11: step11, 12: step12, @@ -756,7 +750,6 @@ export const MIGRATIONS_BY_MAJOR: Readonly> = { 15: step15, 16: step16, 17: step17, - 18: step18, }; /** The majors that have a step, ascending. */ diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 9b85b2e2f2..c9cb34a99e 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -280,7 +280,7 @@ export const ObjectStackDefinitionSchema = lazySchema(() => z.object({ * silently strip it and the author's intent would vanish without a word. */ requireAuth: retiredKey( - '`api.requireAuth` was removed in @objectstack/spec 18 (#3963). Anonymous access to object data ' + '`api.requireAuth` was removed in @objectstack/spec 17 (#3963). Anonymous access to object data ' + 'is now always denied. Delete the key; publish public surfaces by declaration instead — a public ' + "form view, a share link, or `book.audience: 'public'`. A stack that mounts no auth at all now " + 'fails at boot rather than silently serving object data to anonymous callers.',