Skip to content

Commit f50a8b9

Browse files
committed
feat(spec)!: retire BatchOptions.validateOnly — a dry-run flag never implemented (#4052)
`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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TzLE9cw4gZKNyPN2ZP4iTt
1 parent b3a2318 commit f50a8b9

8 files changed

Lines changed: 100 additions & 15 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
feat(spec)!: retire `BatchOptions.validateOnly` — a dry-run flag that was never implemented (#4052)
6+
7+
`BatchOptions.validateOnly` promised a dry-run — "validate records without
8+
persisting changes" — but no batch surface ever read it. `updateManyData`,
9+
`deleteManyData` and `batchData` all persist regardless, so a caller sending
10+
`options.validateOnly: true` to PREVIEW a mutation got it executed. That is the
11+
dangerous direction of "declared ≠ enforced": a flag lying about a data-safety
12+
guarantee, not merely an inert no-op.
13+
14+
There is no dry-run today. Rather than back-fill an implementation to match a
15+
promise nothing kept — a real no-commit batch has its own design space (cascade
16+
and constraint semantics under rollback, a response contract that reports each
17+
row's would-succeed verdict) — the key is retired so it can be reintroduced
18+
deliberately when there is a real need.
19+
20+
**Breaking change.**
21+
22+
- `BatchOptions.validateOnly` is a retired key. It is tombstoned (`retiredKey`)
23+
in `BatchOptionsSchema`, so authoring it now fails with a fix-it prescription
24+
rather than being silently stripped (the ADR-0104 / #3733 quiet-failure class).
25+
The `BatchOptions` type's `validateOnly` becomes `never`.
26+
- The retirement is HTTP-only (the key never appeared in stored stack metadata),
27+
so it is recorded as a semantic migration on the protocol-18 chain step
28+
(`batch-options-validate-only-retired`) — a TODO for API callers, not a stack
29+
conversion.
30+
31+
**Migration.** Stop sending `options.validateOnly` on `/batch`, `/updateMany`
32+
and `/deleteMany`. It never previewed anything; removing it changes no behaviour.
33+
If you need to validate a batch without writing, follow #4052 so a real
34+
no-commit preview can be designed.
35+
36+
Also fixes a dangling documentation reference: the `/createMany` route
37+
registration named `requestSchema: 'CreateManyRequestSchema'`, a schema no
38+
module ever exported — pointed at the real `CreateManyDataRequestSchema`.

‎content/docs/references/api/batch.mdx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ const result = BatchConfig.parse(data);
4747
| :--- | :--- | :--- | :--- |
4848
| **enabled** | `boolean` | ✅ | Enable batch operations |
4949
| **maxRecordsPerBatch** | `integer` | ✅ | Maximum records per batch |
50-
| **defaultOptions** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly: boolean }` | optional | Default batch options |
50+
| **defaultOptions** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly?: any }` | optional | Default batch options |
5151

5252

5353
---
@@ -89,7 +89,7 @@ const result = BatchConfig.parse(data);
8989
| **atomic** | `boolean` | ✅ | If true, rollback entire batch on any failure (transaction mode) |
9090
| **returnRecords** | `boolean` | ✅ | If true, return full record data in response |
9191
| **continueOnError** | `boolean` | ✅ | If true (and atomic=false), continue processing remaining records after errors |
92-
| **validateOnly** | `boolean` | ✅ | If true, validate records without persisting changes (dry-run mode) |
92+
| **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. |
9393

9494

9595
---
@@ -115,7 +115,7 @@ const result = BatchConfig.parse(data);
115115
| :--- | :--- | :--- | :--- |
116116
| **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | ✅ | Type of batch operation |
117117
| **records** | `{ id?: string; data?: Record<string, any>; externalId?: string }[]` | ✅ | Array of records to process (server caps the count — see batch.maxBatchSize) |
118-
| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly: boolean }` | optional | Batch operation options |
118+
| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly?: any }` | optional | Batch operation options |
119119

120120

121121
---
@@ -199,7 +199,7 @@ A cross-object batch strip event: dropped fields plus the operation index
199199
| Property | Type | Required | Description |
200200
| :--- | :--- | :--- | :--- |
201201
| **ids** | `string[]` | ✅ | Array of record IDs to delete (server caps the count — see batch.maxBatchSize) |
202-
| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly: boolean }` | optional | Delete options |
202+
| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly?: any }` | optional | Delete options |
203203

204204

205205
---
@@ -223,7 +223,7 @@ A cross-object batch strip event: dropped fields plus the operation index
223223
| Property | Type | Required | Description |
224224
| :--- | :--- | :--- | :--- |
225225
| **records** | `{ id: string; data: Record<string, any> }[]` | ✅ | Array of records to update (server caps the count — see batch.maxBatchSize) |
226-
| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly: boolean }` | optional | Update options |
226+
| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly?: any }` | optional | Update options |
227227

228228

229229
---

‎content/docs/references/api/protocol.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,7 @@ const result = AiAgentCapabilities.parse(data);
448448
| Property | Type | Required | Description |
449449
| :--- | :--- | :--- | :--- |
450450
| **ids** | `string[]` | ✅ | Array of record IDs to delete (server caps the count — see batch.maxBatchSize) |
451-
| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly: boolean }` | optional | Delete options |
451+
| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly?: any }` | optional | Delete options |
452452
| **object** | `string` | ✅ | Object name |
453453

454454

@@ -1404,7 +1404,7 @@ const result = AiAgentCapabilities.parse(data);
14041404
| Property | Type | Required | Description |
14051405
| :--- | :--- | :--- | :--- |
14061406
| **records** | `{ id: string; data: Record<string, any> }[]` | ✅ | Array of records to update (server caps the count — see batch.maxBatchSize) |
1407-
| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly: boolean }` | optional | Update options |
1407+
| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly?: any }` | optional | Update options |
14081408
| **object** | `string` | ✅ | Object name |
14091409

14101410

‎packages/spec/authorable-surface.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -682,7 +682,7 @@
682682
"api/BatchOptions:atomic",
683683
"api/BatchOptions:continueOnError",
684684
"api/BatchOptions:returnRecords",
685-
"api/BatchOptions:validateOnly",
685+
"api/BatchOptions:validateOnly [RETIRED]",
686686
"api/BatchRecord:data",
687687
"api/BatchRecord:externalId",
688688
"api/BatchRecord:id",

‎packages/spec/src/api/batch.test.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,21 +57,28 @@ describe('BatchOptionsSchema', () => {
5757
expect(options.atomic).toBe(true);
5858
expect(options.returnRecords).toBe(false);
5959
expect(options.continueOnError).toBe(false);
60-
expect(options.validateOnly).toBe(false);
6160
});
6261

6362
it('should accept custom options', () => {
6463
const options = BatchOptionsSchema.parse({
6564
atomic: false,
6665
returnRecords: true,
6766
continueOnError: true,
68-
validateOnly: true,
6967
});
7068

7169
expect(options.atomic).toBe(false);
7270
expect(options.returnRecords).toBe(true);
7371
expect(options.continueOnError).toBe(true);
74-
expect(options.validateOnly).toBe(true);
72+
});
73+
74+
it('rejects the retired `validateOnly` key with its prescription (#3963 follow-up)', () => {
75+
// Never implemented — a "dry-run" that silently persisted. Tombstoned so
76+
// writing it is audible rather than silently stripped (ADR-0104 / PD #10).
77+
const result = BatchOptionsSchema.safeParse({ validateOnly: true });
78+
expect(result.success).toBe(false);
79+
if (!result.success) {
80+
expect(result.error.issues[0]?.message).toMatch(/validateOnly.*removed|never implemented/i);
81+
}
7582
});
7683
});
7784

‎packages/spec/src/api/batch.zod.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { z } from 'zod';
44
import { ApiErrorSchema, BaseResponseSchema, RecordDataSchema } from './contract.zod';
55
import { DroppedFieldsEventSchema } from '../data/data-engine.zod';
6+
import { retiredKey } from '../shared/retired-key';
67

78
/**
89
* Batch Operations API
@@ -61,7 +62,22 @@ export const BatchOptionsSchema = lazySchema(() => z.object({
6162
atomic: z.boolean().optional().default(true).describe('If true, rollback entire batch on any failure (transaction mode)'),
6263
returnRecords: z.boolean().optional().default(false).describe('If true, return full record data in response'),
6364
continueOnError: z.boolean().optional().default(false).describe('If true (and atomic=false), continue processing remaining records after errors'),
64-
validateOnly: z.boolean().optional().default(false).describe('If true, validate records without persisting changes (dry-run mode)'),
65+
// `validateOnly` promised a dry-run — "validate records without persisting" —
66+
// but no batch surface ever read it (`updateManyData` / `deleteManyData` /
67+
// `batchData` all persist regardless). A caller sending `validateOnly: true`
68+
// to preview a mutation got it EXECUTED: a declared flag actively lying about
69+
// a data-safety guarantee, the worst shape of "declared ≠ enforced" (PD #10).
70+
// Retired rather than half-implemented: a real dry-run has its own design
71+
// space (cascade / constraint semantics under no-commit, response contract)
72+
// and should be reintroduced deliberately, not back-filled to match a promise
73+
// nothing kept. Tombstoned so writing it is audible, not silently stripped.
74+
validateOnly: retiredKey(
75+
'`options.validateOnly` was removed from BatchOptions in @objectstack/spec (#4052). '
76+
+ 'It was never implemented: the batch surfaces persisted regardless, so a "dry-run" would have '
77+
+ 'silently executed. There is no dry-run today — drop the key. If you need to preview a batch '
78+
+ 'without writing, open an issue so it can be designed (no-commit cascade / constraint semantics) '
79+
+ 'and reintroduced as a flag that actually holds.',
80+
),
6581
}));
6682

6783
export type BatchOptions = z.infer<typeof BatchOptionsSchema>;

‎packages/spec/src/api/plugin-rest-api.zod.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -915,7 +915,10 @@ export const DEFAULT_BATCH_ROUTES: RestApiRouteRegistration = {
915915
summary: 'Batch create',
916916
description: 'Create multiple records in a single operation',
917917
tags: ['Batch'],
918-
requestSchema: 'CreateManyRequestSchema',
918+
// Was 'CreateManyRequestSchema' — a name no schema ever exported (the real
919+
// request contract is CreateManyDataRequestSchema in protocol.zod.ts). A
920+
// dangling documentation reference; point it at the schema that exists.
921+
requestSchema: 'CreateManyDataRequestSchema',
919922
responseSchema: 'BatchUpdateResponseSchema',
920923
permissions: ['data.create', 'data.batch'],
921924
timeout: 60000,

‎packages/spec/src/migrations/registry.ts‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -467,9 +467,30 @@ const step18: MigrationStep = {
467467
+ "(read as SYSTEM), and `book.audience: 'public'` (ADR-0046 §6.7). The key is dropped "
468468
+ 'with a notice rather than mapped — there is no replacement value, only a different '
469469
+ 'way to publish (by declaration). A stack that mounts no auth at all now fails at boot '
470-
+ 'when it would serve a data API, instead of receiving an implicit fail-open.',
470+
+ 'when it would serve a data API, instead of receiving an implicit fail-open.\n\n'
471+
+ 'The same major retires `BatchOptions.validateOnly` (#4052): a batch "dry-run" flag that '
472+
+ 'was declared but never implemented — every batch surface (`updateManyData` / '
473+
+ '`deleteManyData` / `batchData`) persisted regardless, so a caller sending it to PREVIEW a '
474+
+ 'mutation got it executed. That is the dangerous direction of declared ≠ enforced: a flag '
475+
+ 'lying about a data-safety guarantee. No dry-run exists today; the schema tombstones the '
476+
+ 'key with the prescription. It is HTTP-only (never stored in stack metadata), so the '
477+
+ 'change is one semantic TODO for API callers rather than a stack conversion.',
471478
conversionIds: ['stack-api-require-auth-removed'],
472-
semantic: [],
479+
semantic: [
480+
{
481+
id: 'batch-options-validate-only-retired',
482+
surface: 'api.batchOptions.validateOnly',
483+
replacement: '(removed — no dry-run today; open an issue to design a no-commit batch preview)',
484+
reason:
485+
'The `validateOnly` key promised a dry-run ("validate records without persisting") but no '
486+
+ 'batch surface ever read it — updateManyData / deleteManyData / batchData persist '
487+
+ 'regardless. There is no behaviour to preserve and nothing stored to rewrite (it only '
488+
+ 'ever appeared in an HTTP request body). Callers must stop sending it.',
489+
acceptanceCriteria:
490+
'No /batch, /updateMany or /deleteMany call sends `options.validateOnly`; a request that '
491+
+ 'includes it answers 400 VALIDATION_FAILED with the retirement prescription.',
492+
},
493+
],
473494
};
474495

475496
export const MIGRATIONS_BY_MAJOR: Readonly<Record<number, MigrationStep>> = {

0 commit comments

Comments
 (0)