From f50a8b9e4a936680bbf6e52fb4357abfbdc11a00 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 07:18:09 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(spec)!:=20retire=20BatchOptions.valida?= =?UTF-8?q?teOnly=20=E2=80=94=20a=20dry-run=20flag=20never=20implemented?= =?UTF-8?q?=20(#4052)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BatchOptions.validateOnly` promised a dry-run ("validate records without persisting changes") but no batch surface ever read it — updateManyData / deleteManyData / batchData all persist regardless. A caller sending `options.validateOnly: true` to PREVIEW a mutation got it executed: a declared flag lying about a data-safety guarantee, the dangerous direction of "declared ≠ enforced" (PD #10). Retired rather than half-implemented — a real no-commit batch has its own design space (cascade / constraint semantics under rollback, a per-row would-succeed response contract) and should be reintroduced deliberately, not back-filled to match a promise nothing kept. - Tombstone `validateOnly` with `retiredKey()` in BatchOptionsSchema so writing it fails with the prescription instead of being silently stripped (ADR-0104 / #3733). The BatchOptions type's key becomes `never`. - HTTP-only (never stored in stack metadata), so recorded as a semantic migration on the protocol-18 chain step (`batch-options-validate-only-retired`) — a TODO for API callers, not a stack conversion. - Regenerated authorable-surface, references docs; major changeset. Also fixes a dangling doc reference: the /createMany route named `requestSchema: 'CreateManyRequestSchema'`, a schema no module ever exported — pointed at the real `CreateManyDataRequestSchema`. Closes #4052. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TzLE9cw4gZKNyPN2ZP4iTt --- .changeset/retire-batch-validate-only.md | 38 ++++++++++++++++++++ content/docs/references/api/batch.mdx | 10 +++--- content/docs/references/api/protocol.mdx | 4 +-- packages/spec/authorable-surface.json | 2 +- packages/spec/src/api/batch.test.ts | 13 +++++-- packages/spec/src/api/batch.zod.ts | 18 +++++++++- packages/spec/src/api/plugin-rest-api.zod.ts | 5 ++- packages/spec/src/migrations/registry.ts | 25 +++++++++++-- 8 files changed, 100 insertions(+), 15 deletions(-) create mode 100644 .changeset/retire-batch-validate-only.md diff --git a/.changeset/retire-batch-validate-only.md b/.changeset/retire-batch-validate-only.md new file mode 100644 index 0000000000..a36fe4c685 --- /dev/null +++ b/.changeset/retire-batch-validate-only.md @@ -0,0 +1,38 @@ +--- +"@objectstack/spec": major +--- + +feat(spec)!: retire `BatchOptions.validateOnly` — a dry-run flag that was never implemented (#4052) + +`BatchOptions.validateOnly` promised a dry-run — "validate records without +persisting changes" — but no batch surface ever read it. `updateManyData`, +`deleteManyData` and `batchData` all persist regardless, so a caller sending +`options.validateOnly: true` to PREVIEW a mutation got it executed. That is the +dangerous direction of "declared ≠ enforced": a flag lying about a data-safety +guarantee, not merely an inert no-op. + +There is no dry-run today. Rather than back-fill an implementation to match a +promise nothing kept — a real no-commit batch has its own design space (cascade +and constraint semantics under rollback, a response contract that reports each +row's would-succeed verdict) — the key is retired so it can be reintroduced +deliberately when there is a real need. + +**Breaking change.** + +- `BatchOptions.validateOnly` is a retired key. It is tombstoned (`retiredKey`) + in `BatchOptionsSchema`, so authoring it now fails with a fix-it prescription + 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 + (`batch-options-validate-only-retired`) — a TODO for API callers, not a stack + conversion. + +**Migration.** Stop sending `options.validateOnly` on `/batch`, `/updateMany` +and `/deleteMany`. It never previewed anything; removing it changes no behaviour. +If you need to validate a batch without writing, follow #4052 so a real +no-commit preview can be designed. + +Also fixes a dangling documentation reference: the `/createMany` route +registration named `requestSchema: 'CreateManyRequestSchema'`, a schema no +module ever exported — pointed at the real `CreateManyDataRequestSchema`. diff --git a/content/docs/references/api/batch.mdx b/content/docs/references/api/batch.mdx index 9d46f17800..57b6d05ad7 100644 --- a/content/docs/references/api/batch.mdx +++ b/content/docs/references/api/batch.mdx @@ -47,7 +47,7 @@ const result = BatchConfig.parse(data); | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | ✅ | Enable batch operations | | **maxRecordsPerBatch** | `integer` | ✅ | Maximum records per batch | -| **defaultOptions** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly: boolean }` | optional | Default batch options | +| **defaultOptions** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly?: any }` | optional | Default batch options | --- @@ -89,7 +89,7 @@ const result = BatchConfig.parse(data); | **atomic** | `boolean` | ✅ | If true, rollback entire batch on any failure (transaction mode) | | **returnRecords** | `boolean` | ✅ | If true, return full record data in response | | **continueOnError** | `boolean` | ✅ | If true (and atomic=false), continue processing remaining records after errors | -| **validateOnly** | `boolean` | ✅ | If true, validate records without persisting changes (dry-run mode) | +| **validateOnly** | `any` | optional | [REMOVED] `options.validateOnly` was removed from BatchOptions in @objectstack/spec (#4052). It was never implemented: the batch surfaces persisted regardless, so a "dry-run" would have silently executed. There is no dry-run today — drop the key. If you need to preview a batch without writing, open an issue so it can be designed (no-commit cascade / constraint semantics) and reintroduced as a flag that actually holds. | --- @@ -115,7 +115,7 @@ const result = BatchConfig.parse(data); | :--- | :--- | :--- | :--- | | **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | ✅ | Type of batch operation | | **records** | `{ id?: string; data?: Record; externalId?: string }[]` | ✅ | Array of records to process (server caps the count — see batch.maxBatchSize) | -| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly: boolean }` | optional | Batch operation options | +| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly?: any }` | optional | Batch operation options | --- @@ -199,7 +199,7 @@ A cross-object batch strip event: dropped fields plus the operation index | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **ids** | `string[]` | ✅ | Array of record IDs to delete (server caps the count — see batch.maxBatchSize) | -| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly: boolean }` | optional | Delete options | +| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly?: any }` | optional | Delete options | --- @@ -223,7 +223,7 @@ A cross-object batch strip event: dropped fields plus the operation index | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **records** | `{ id: string; data: Record }[]` | ✅ | Array of records to update (server caps the count — see batch.maxBatchSize) | -| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly: boolean }` | optional | Update options | +| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly?: any }` | optional | Update options | --- diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 540d71b6b6..dcaef1b135 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -448,7 +448,7 @@ const result = AiAgentCapabilities.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **ids** | `string[]` | ✅ | Array of record IDs to delete (server caps the count — see batch.maxBatchSize) | -| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly: boolean }` | optional | Delete options | +| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly?: any }` | optional | Delete options | | **object** | `string` | ✅ | Object name | @@ -1404,7 +1404,7 @@ const result = AiAgentCapabilities.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **records** | `{ id: string; data: Record }[]` | ✅ | Array of records to update (server caps the count — see batch.maxBatchSize) | -| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly: boolean }` | optional | Update options | +| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly?: any }` | optional | Update options | | **object** | `string` | ✅ | Object name | diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 0caa8cc0d5..c958cf5aa5 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -682,7 +682,7 @@ "api/BatchOptions:atomic", "api/BatchOptions:continueOnError", "api/BatchOptions:returnRecords", - "api/BatchOptions:validateOnly", + "api/BatchOptions:validateOnly [RETIRED]", "api/BatchRecord:data", "api/BatchRecord:externalId", "api/BatchRecord:id", diff --git a/packages/spec/src/api/batch.test.ts b/packages/spec/src/api/batch.test.ts index 02e08d57ce..65c9f39b3a 100644 --- a/packages/spec/src/api/batch.test.ts +++ b/packages/spec/src/api/batch.test.ts @@ -57,7 +57,6 @@ describe('BatchOptionsSchema', () => { expect(options.atomic).toBe(true); expect(options.returnRecords).toBe(false); expect(options.continueOnError).toBe(false); - expect(options.validateOnly).toBe(false); }); it('should accept custom options', () => { @@ -65,13 +64,21 @@ describe('BatchOptionsSchema', () => { atomic: false, returnRecords: true, continueOnError: true, - validateOnly: true, }); expect(options.atomic).toBe(false); expect(options.returnRecords).toBe(true); expect(options.continueOnError).toBe(true); - expect(options.validateOnly).toBe(true); + }); + + it('rejects the retired `validateOnly` key with its prescription (#3963 follow-up)', () => { + // Never implemented — a "dry-run" that silently persisted. Tombstoned so + // writing it is audible rather than silently stripped (ADR-0104 / PD #10). + const result = BatchOptionsSchema.safeParse({ validateOnly: true }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toMatch(/validateOnly.*removed|never implemented/i); + } }); }); diff --git a/packages/spec/src/api/batch.zod.ts b/packages/spec/src/api/batch.zod.ts index 70607fff81..c4ad12029a 100644 --- a/packages/spec/src/api/batch.zod.ts +++ b/packages/spec/src/api/batch.zod.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { ApiErrorSchema, BaseResponseSchema, RecordDataSchema } from './contract.zod'; import { DroppedFieldsEventSchema } from '../data/data-engine.zod'; +import { retiredKey } from '../shared/retired-key'; /** * Batch Operations API @@ -61,7 +62,22 @@ export const BatchOptionsSchema = lazySchema(() => z.object({ atomic: z.boolean().optional().default(true).describe('If true, rollback entire batch on any failure (transaction mode)'), returnRecords: z.boolean().optional().default(false).describe('If true, return full record data in response'), continueOnError: z.boolean().optional().default(false).describe('If true (and atomic=false), continue processing remaining records after errors'), - validateOnly: z.boolean().optional().default(false).describe('If true, validate records without persisting changes (dry-run mode)'), + // `validateOnly` promised a dry-run — "validate records without persisting" — + // but no batch surface ever read it (`updateManyData` / `deleteManyData` / + // `batchData` all persist regardless). A caller sending `validateOnly: true` + // to preview a mutation got it EXECUTED: a declared flag actively lying about + // a data-safety guarantee, the worst shape of "declared ≠ enforced" (PD #10). + // Retired rather than half-implemented: a real dry-run has its own design + // space (cascade / constraint semantics under no-commit, response contract) + // and should be reintroduced deliberately, not back-filled to match a promise + // nothing kept. Tombstoned so writing it is audible, not silently stripped. + validateOnly: retiredKey( + '`options.validateOnly` was removed from BatchOptions in @objectstack/spec (#4052). ' + + 'It was never implemented: the batch surfaces persisted regardless, so a "dry-run" would have ' + + 'silently executed. There is no dry-run today — drop the key. If you need to preview a batch ' + + 'without writing, open an issue so it can be designed (no-commit cascade / constraint semantics) ' + + 'and reintroduced as a flag that actually holds.', + ), })); export type BatchOptions = z.infer; diff --git a/packages/spec/src/api/plugin-rest-api.zod.ts b/packages/spec/src/api/plugin-rest-api.zod.ts index eb1521ff9f..2e3ef4d018 100644 --- a/packages/spec/src/api/plugin-rest-api.zod.ts +++ b/packages/spec/src/api/plugin-rest-api.zod.ts @@ -915,7 +915,10 @@ export const DEFAULT_BATCH_ROUTES: RestApiRouteRegistration = { summary: 'Batch create', description: 'Create multiple records in a single operation', tags: ['Batch'], - requestSchema: 'CreateManyRequestSchema', + // Was 'CreateManyRequestSchema' — a name no schema ever exported (the real + // request contract is CreateManyDataRequestSchema in protocol.zod.ts). A + // dangling documentation reference; point it at the schema that exists. + requestSchema: 'CreateManyDataRequestSchema', responseSchema: 'BatchUpdateResponseSchema', permissions: ['data.create', 'data.batch'], timeout: 60000, diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index e03f446d36..c3fd748f0d 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -467,9 +467,30 @@ const step18: MigrationStep = { + "(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.', + + '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.', conversionIds: ['stack-api-require-auth-removed'], - semantic: [], + semantic: [ + { + id: 'batch-options-validate-only-retired', + surface: 'api.batchOptions.validateOnly', + replacement: '(removed — no dry-run today; open an issue to design a no-commit batch preview)', + reason: + 'The `validateOnly` key promised a dry-run ("validate records without persisting") but no ' + + 'batch surface ever read it — updateManyData / deleteManyData / batchData persist ' + + 'regardless. There is no behaviour to preserve and nothing stored to rewrite (it only ' + + 'ever appeared in an HTTP request body). Callers must stop sending it.', + acceptanceCriteria: + 'No /batch, /updateMany or /deleteMany call sends `options.validateOnly`; a request that ' + + 'includes it answers 400 VALIDATION_FAILED with the retirement prescription.', + }, + ], }; export const MIGRATIONS_BY_MAJOR: Readonly> = { From 7d70cc7885a5d24f2ff68b55c862245a4f5e37e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 07:22:41 +0000 Subject: [PATCH 2/2] docs(api): drop retired validateOnly from hand-written batch examples (#4052) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs-drift-check flagged four hand-written references that still presented `validateOnly` as a working dry-run option — a `data-api` / `wire-format` batch example, a `client-sdk` example, and the client-sdk Batch Options table row ("Dry-run mode — validate without persisting"). The key is retired and now 400s, so these advertised a feature that no longer exists. Removed the key from the three examples (fixing JSON trailing commas) and deleted the table row. The generated references already carry the [REMOVED] prescription. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TzLE9cw4gZKNyPN2ZP4iTt --- content/docs/api/client-sdk.mdx | 2 -- content/docs/api/data-api.mdx | 3 +-- content/docs/api/wire-format.mdx | 3 +-- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx index a3c4aab1f0..c90cb84e6c 100644 --- a/content/docs/api/client-sdk.mdx +++ b/content/docs/api/client-sdk.mdx @@ -225,7 +225,6 @@ const batchResult = await client.data.batch('account', { atomic: true, returnRecords: true, continueOnError: false, - validateOnly: false, }, }); @@ -499,7 +498,6 @@ The `find` method accepts an options object with **canonical** (recommended) fie | `atomic` | `boolean` | `true` | Rollback entire batch on any failure | | `returnRecords` | `boolean` | `false` | Include full records in response | | `continueOnError` | `boolean` | `false` | Continue after errors (when atomic=false) | -| `validateOnly` | `boolean` | `false` | Dry-run mode — validate without persisting | --- diff --git a/content/docs/api/data-api.mdx b/content/docs/api/data-api.mdx index a46690ba92..d13ef34667 100644 --- a/content/docs/api/data-api.mdx +++ b/content/docs/api/data-api.mdx @@ -92,8 +92,7 @@ Execute a batch operation (create / update / upsert / delete) on multiple record "options": { "atomic": true, "returnRecords": true, - "continueOnError": false, - "validateOnly": false + "continueOnError": false } } ``` diff --git a/content/docs/api/wire-format.mdx b/content/docs/api/wire-format.mdx index cd71343ae5..1ba1806f9d 100644 --- a/content/docs/api/wire-format.mdx +++ b/content/docs/api/wire-format.mdx @@ -484,8 +484,7 @@ Process many records of a **single** operation type in one request. The body car ], "options": { "atomic": true, - "continueOnError": false, - "validateOnly": false + "continueOnError": false } } ```