From 755896994d1b825e1a41905e8c7a75a1718326fb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 21:44:59 +0000 Subject: [PATCH 01/10] feat(meta): the /meta item read serves the version token; If-None-Match: * pins a first write; the 409 carries currentVersion (WIP) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 93 +++++++++++++++++-- packages/rest/src/rest-server.ts | 71 +++++++++++++- packages/spec/src/api/protocol.zod.ts | 84 ++++++++++++++++- .../types/src/data-error-classification.ts | 23 +++++ 4 files changed, 259 insertions(+), 12 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 82080e6b72e..fe4ae2aa24a 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -10298,6 +10298,9 @@ export class ObjectStackProtocolImplementation implements type: request.type, name: request.name, item: decorateMetadataItem(request.type, this.governServedObject(request.type, item)), + // [#22114] The draft row's version token — the one a draft save + // (`?mode=draft`) at this address accepts as `If-Match`. + version: await this.readVersionToken(request, orgId, 'draft'), }; } @@ -10556,6 +10559,16 @@ export class ObjectStackProtocolImplementation implements editable: lockState.editable, deletable: lockState.deletable, resettable: lockState.resettable, + // [#22114] The active row's version token ({@link readVersionToken}). + // No row was found at any candidate this read consults — which + // include the save's own address — so `null` needs no second read. + // ABSENT on a `previewDrafts` read: it serves a pending draft where + // one exists and the active item where none does, so no single + // lifecycle's token describes it, and it is a render path — an + // editor reads `state: 'draft'` or the plain read. + ...(request.previewDrafts + ? {} + : { version: storedRowServed ? await this.readVersionToken(request, orgId, 'active') : null }), }; } @@ -18023,6 +18036,63 @@ export class ObjectStackProtocolImplementation implements return this.storedHashDigest()(stored); } + /** + * [#22114] The stored head a `/meta` save at one address compares a + * caller's version token against: the `state` row at `ref`'s organization + * partition, bound to `packageId` (`null` = the package-unbound row), as + * the repository reads and serves it ({@link SysMetadataRepository.get}). + * `null` when no such row exists. + * + * The ONE head read both halves of the ADR-0008 chain make. The save door + * judges an inbound token against it ({@link storedParentForToken}), and + * the item read serves its keyed form as `version` + * ({@link readVersionToken}). So the token a read hands out is the token a + * save at the same address accepts, and the one a receipt serves for that + * row — ⛔ never a second derivation of either. + */ + private async storedHeadAt( + repo: SysMetadataRepository, + ref: Parameters[0], + state: 'active' | 'draft', + packageId: string | null, + ): Promise { + return (await repo.get(ref, { state, packageId }))?.hash ?? null; + } + + /** + * [#22114] The item read's `version`: the keyed form + * ({@link receiptVersion}) of the stored head a save to this item compares + * against ({@link storedHeadAt}), at the read's own scope — its + * organization partition and `packageId` — and its lifecycle, or `null` + * when no stored row is there. The `/meta` save door builds its address + * from the same three facts (`organizationIdForMetaWrite` has the body of + * `organizationIdForMetaRead`, and `?package=` names the binding on both), + * so a read followed by a save with the served token is accepted, and a + * `null` says that save is a create: the state `If-None-Match: *` asserts. + * + * ⚠️ The address of the SAVE, not of the served content. A read falls back + * from the caller's organization to the environment-wide row (ADR-0005) + * and from a package's own row to the package-less one (ADR-0048); a save + * does not, it writes its own partition. A token of a row the save would + * not overwrite would be refused by that save every time, so such a read + * serves `null` rather than the served row's token. + */ + private async readVersionToken( + request: { type: string; name: string; packageId?: string }, + orgId: string | undefined, + state: 'active' | 'draft', + ): Promise { + const organizationId = orgId ?? null; + const repo = this.getOverlayRepo(organizationId); + const ref = { + type: PLURAL_TO_SINGULAR[request.type] ?? request.type, + name: request.name, + org: organizationId ?? 'env', + } as Parameters[0]; + const stored = await this.storedHeadAt(repo, ref, state, request.packageId ?? null); + return stored === null ? null : this.receiptVersion(stored); + } + /** * Resolve a caller's version token to the STORED head it names, for a write * whose current stored head is `currentStored`: the token must equal the @@ -18057,6 +18127,14 @@ export class ObjectStackProtocolImplementation implements * - a caller's token naming no current head: the keyed current head. * * A side with no served form is `(withheld)`; an absent side is `null`. + * + * [#22114] The current side also rides as DATA, `currentVersion`: the + * value the sentence names (the keyed head, or `null` when no row is + * there), which the REST door serializes beside the sentence + * (`MetadataConflictErrorSchema` in `@objectstack/spec`). For the save + * door it is the version the next item read at the same address serves + * ({@link readVersionToken}), so a client re-pins from the refusal without + * parsing prose. A head with no served form sets neither attribute. */ private async metadataConflictRefusal(err: ConflictError, subject: string, prefix: string): Promise { const conflict: any = new Error(subject); @@ -18065,7 +18143,10 @@ export class ObjectStackProtocolImplementation implements const digest = this.storedHashDigest(); const show = (served: string | null | undefined) => (served === undefined ? '(withheld)' : served ?? 'null'); const current = await servedContentHash(err.actualHead, digest); - if (current !== undefined) conflict.actualHead = current; + if (current !== undefined) { + conflict.actualHead = current; + conflict.currentVersion = current; + } if (err instanceof InboundVersionConflictError) { conflict.message = `${prefix} The version token sent is not the current version (current is ${show(current)}).`; return conflict; @@ -20850,11 +20931,11 @@ export class ObjectStackProtocolImplementation implements // current published hash. ADR-0048 — scope to the same // package the upsert targets so a collision's other-package // row is never read as this item's parent. - const current = await repo.get(ref, { - state: mode === 'draft' ? 'draft' : 'active', - packageId: request.packageId ?? null, - }); - const currentStored = current?.hash ?? null; + // [#22114] The head the item read's `version` is the keyed form of + // — one read for both halves ({@link storedHeadAt}). + const currentStored = await this.storedHeadAt( + repo, ref, mode === 'draft' ? 'draft' : 'active', request.packageId ?? null, + ); if (request.parentVersion === undefined) { parentVersion = currentStored; } else { diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index bca1481694b..30dc3f1168c 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -1717,6 +1717,57 @@ function mayReadPendingDrafts(caller: unknown): boolean { return isObjectSchemaMaskExempt(caller); } +/** + * [#22114] The ADR-0008 pin `PUT /meta/:type/:name` reads off its request + * headers: `parentVersion` for `saveMetaItem` — the `If-Match` token (ETag-style + * quotes stripped), `null` for `If-None-Match: *` ("no row of this lifecycle + * is here", the first-write pin the protocol has always declared), or + * `undefined` for neither (unpinned, last-write-wins, as before) — or the + * sentence of a `400` for a pin that cannot be honoured. + * + * `If-None-Match` is read on this route from the day it lands with a CLOSED + * value set, `*` alone (AGENTS.md 〈Route & surface ownership〉 rule 5's reason, + * one carrier over): a header read for the values it knows and dropped + * otherwise would write a caller unguarded who asked for a guard. Measured + * before it landed: no first-party client sends `If-None-Match` on a `PUT` + * (the SDK sends it only on its cached `GET`, objectui's ETag hook has no + * caller), and nothing on this path read it. Two refusals, both `400`: + * + * - a value other than `*` — an entity-tag list asks "write unless the head is + * one of these", a condition no `/meta` client has and this door does not + * evaluate; + * - `If-None-Match` beside `If-Match` — the pair can never hold (RFC 9110 + * §13.2.2 evaluates both: `If-Match` true needs a current row, `*` true + * needs none), and a `409` would send the caller round a re-read that + * serves a token it would pair with `*` again. + */ +function metaSavePreconditionPin(headers: Record | undefined): + | { ok: true; parentVersion?: string | null } + | { ok: false; message: string } { + const ifMatch = headers?.['if-match'] ?? headers?.['If-Match']; + const ifNoneMatch = headers?.['if-none-match'] ?? headers?.['If-None-Match']; + if (ifNoneMatch !== undefined) { + if (ifMatch !== undefined) { + return { + ok: false, + message: 'Send If-Match or If-None-Match, not both. If-Match: saves only over that ' + + 'version; If-None-Match: * saves only where no row of this lifecycle exists. A row ' + + 'cannot both exist and not exist, so this pair can never be honoured.', + }; + } + if (typeof ifNoneMatch !== 'string' || ifNoneMatch.trim() !== '*') { + return { + ok: false, + message: 'If-None-Match on this route takes "*" alone (save only if no row of this lifecycle ' + + 'exists). To pin a save to the version you read, send it as If-Match: .', + }; + } + return { ok: true, parentVersion: null }; + } + if (typeof ifMatch === 'string') return { ok: true, parentVersion: ifMatch.replace(/^"|"$/g, '') }; + return { ok: true }; +} + /** * [#20378 · #20441] THE AUTHORING-DOOR REFUSAL — ruling 5865708652 (letter B), * carried to `/audit` by triage's grade 5871509797. Sends it and answers `true` @@ -6800,6 +6851,13 @@ export class RestServer { // carries no `lock` — it is the fast published-value path // and never consulted the lock resolver; a caller that // needs the ADR-0008 OCC carriers reads the uncached path. + // [#22114] That includes `version`, the read's token. Its + // ETag above stays the CACHE validator and is not the token: + // the validator varies by locale (#1319), by the caller's + // field visibility (ADR-0106 D3) and by the served bytes + // (`getMetaItemCached`, #16525), none of which moves the + // stored row's version, and an item with no stored row has + // a validator but no version at all. const cachedEnvelope = { type: metaType, name: req.params.name, @@ -7076,10 +7134,15 @@ export class RestServer { // The token is the crypto provider's keyed digest of the // stored content hash, never the hash itself; the protocol // compares it in that form, so it passes through here as sent. - const ifMatchHeader = req.headers?.['if-match'] ?? req.headers?.['If-Match']; - const parentVersion = typeof ifMatchHeader === 'string' - ? ifMatchHeader.replace(/^"|"$/g, '') // strip ETag-style quotes - : undefined; + // [#22114] The item read serves the same token as `version`, + // and `If-None-Match: *` pins a save that expects no row — + // see {@link metaSavePreconditionPin}. + const pin = metaSavePreconditionPin(req.headers); + if (!pin.ok) { + res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: pin.message } }); + return; + } + const parentVersion = pin.parentVersion; // [#7749 producer, #7941 precedence] The request's authenticated // identity — one producer, shared by every `/meta` write (see // resolveMetaWriteActor). `X-Actor` is not consulted. diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index b3cbec54dbd..6b518c6c6f4 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -418,6 +418,19 @@ const MetadataProtectionEnvelopeFields = { * protection keys — because it is computed from the served document itself, * never from the lock resolver). See `sortability.zod.ts` for the category * set and the consumer contract. + * + * [#22114] `version` is the READ half of the ADR-0008 optimistic-concurrency + * chain: the token a client sends back as `If-Match`, served by the read it + * edits from. ADR-0008 says clients pass `If-Match: `, and a client can + * only send a token it was served — before this member only a save receipt + * served one, so the first save after a load could not be pinned and two + * editors who each loaded and saved once overwrote each other. It is the + * keyed token of the stored row a SAVE to this item would compare against — + * the same producer and the same row as {@link SaveMetaItemResponseSchema}'s + * `version`, so a read after a save serves the receipt's token byte for byte. + * Like the protection keys it is published by the uncached branch only (the + * cached branch never consults the store's rows), and it is the one key whose + * `null` is a statement: no stored row is there, so the next save is a create. */ export const GetMetaItemResponseSchema = lazySchema(() => z.object({ type: z.string().describe('Metadata type name'), @@ -431,6 +444,23 @@ export const GetMetaItemResponseSchema = lazySchema(() => z.object({ + '`type`. See `ObjectSortabilitySchema` for the closed category set.', ), ...MetadataProtectionEnvelopeFields, + version: z.string().nullable().optional().describe( + 'ADR-0008 version token of the STORED row a save to this item compares ' + + 'against, at this read\'s scope (the caller\'s organization partition and ' + + '`?package=`) and lifecycle (`?state=draft` reads the draft row, the plain ' + + 'read the active row). The same keyed token a save receipt\'s `version` ' + + 'serves for that row: send it back as the `If-Match` request header on ' + + '`PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a ' + + 'concurrent edit is refused 409 `METADATA_CONFLICT` instead of ' + + 'overwritten. `null` when no stored row is there — an item served from ' + + 'code or a package artifact, or from a row in another scope (the ' + + 'environment-wide row an organization falls back to, a package-less row a ' + + 'package read falls back to) — so the next save creates one: pin it with ' + + '`If-None-Match: *`. ABSENT on the cached published-value branch and on a ' + + '`?preview=draft` read, which publish no version: absence means "not ' + + 'published here", never "no row". Opaque — echo it verbatim, never parse ' + + 'it.', + ), })); /** @@ -649,11 +679,14 @@ export const SaveMetaItemRequestSchema = lazySchema(() => z.object({ parentVersion: z.string().nullable().optional().describe( 'ADR-0008 optimistic-concurrency pin: the version token the caller ' + 'believes is current (on the REST door, the `If-Match` request ' - + 'header). Present as a string, a concurrent edit is reported as a 409 ' + + 'header; the item read\'s `version` and a save receipt\'s `version` ' + + 'serve it). Present as a string, a concurrent edit is reported as a 409 ' + 'conflict instead of silently overwritten. ⚠️ `null` is NOT the same ' + 'as absent: a present `null` asserts "no current row of this ' + 'lifecycle" — the first-write pin, refused 409 when a row already ' - + 'exists — while an ABSENT key is unpinned: the implementation adopts ' + + 'exists (on the REST door, `If-None-Match: *`; that header takes `*` ' + + 'alone and never beside `If-Match`, and any other spelling is refused ' + + '400) — while an ABSENT key is unpinned: the implementation adopts ' + 'the current row\'s hash as the parent (last-write-wins). Nullable ' + 'because that is the implementation\'s parameter type, and unlike the ' + 'reset twin (which folds a present `null` back to the current hash) ' @@ -785,6 +818,51 @@ export const SaveMetaItemResponseSchema = lazySchema(() => z.object({ message: z.string().optional(), })); +/** + * Metadata Conflict — the 409 body of the `/meta/:type/:name` write doors + * + * Describes the body `PUT /api/v1/meta/:type/:name` (and its sibling write + * doors: `POST …/publish`, `POST …/rollback`, `DELETE …`) answers when the + * ADR-0008 optimistic lock refuses the write: a version token that names no + * current head (`If-Match`), a "no row" pin a row has since contradicted + * (`If-None-Match: *`), or a race the repository lost. The body is the REST + * door's flat ADR-0112 dialect — `code` beside `error`, not inside it — the + * position `check:route-envelope` holds for that door. + * + * `currentVersion` is the current head AS DATA (#22114). The sentence in + * `error` has always named it (`… (current is )`), so a client offering + * "reload" or "overwrite anyway" had to parse prose or re-send unguarded; it + * now reads the token it would pin to here. On the save door it is the token + * the next item read at the same address serves as `version` + * ({@link GetMetaItemResponseSchema}), and `null` when no row of the target + * lifecycle is there (a draft save after a publish drained the draft) — the + * state `If-None-Match: *` pins. Both doors of the chain serve it keyed, never + * the stored content hash. + * + * Scope: this is the body of the conflicts the four `/meta` item doors answer + * through the protocol's one conflict builder, which is how each of them + * answers the repository's refusal. Not described here: a per-item + * `METADATA_CONFLICT` inside a batch outcome (`failed[]` of the package publish + * door), which is a row of a 2xx, not a refusal body. + */ +export const MetadataConflictErrorSchema = lazySchema(() => z.object({ + error: z.string().describe( + 'Human-readable refusal sentence, unchanged by `currentVersion`: it names ' + + 'the item and the version the door judged current. For display and logs; ' + + 'branch on `code` and read `currentVersion`, never parse this.', + ), + code: z.literal('METADATA_CONFLICT').describe( + 'ADR-0112 registered code of the optimistic-lock refusal.', + ), + currentVersion: z.string().nullable().describe( + 'The version token of the head the door judged current — the value the ' + + 'sentence names — or `null` when no row of the target lifecycle exists. ' + + 'Send it as `If-Match` to overwrite deliberately (or `If-None-Match: *` ' + + 'when it is `null`), or re-read the item to merge first. Opaque — echo ' + + 'it verbatim, never parse it.', + ), +})); + /** * Publish Metadata Item Request * @@ -3287,6 +3365,8 @@ export type GetMetaItemLayeredResponse = z.input; export type SaveMetaItemRequest = z.input; export type SaveMetaItemResponse = z.input; +/** The 409 `METADATA_CONFLICT` body of the `/meta/:type/:name` write doors (#22114). */ +export type MetadataConflictError = z.input; export type PublishMetaItemRequest = z.input; export type PublishMetaItemResponse = z.input; /** diff --git a/packages/types/src/data-error-classification.ts b/packages/types/src/data-error-classification.ts index af9155a5334..22d350975ce 100644 --- a/packages/types/src/data-error-classification.ts +++ b/packages/types/src/data-error-classification.ts @@ -1022,6 +1022,29 @@ function structuredCodeAnswer( }, }; } + // [#22114] The metadata twin of the arm above: the `/meta` item doors' + // optimistic-lock refusal → 409 with the current head AS DATA + // (`MetadataConflictErrorSchema` in `@objectstack/spec`). The producer is + // `metadata-protocol`'s one conflict builder, which states `currentVersion` + // (the keyed head its sentence names, or `null` for no row) on every + // refusal it composes; the arm keys on that statement as well as the code, + // so a `METADATA_CONFLICT` that carries none keeps the passthrough's body + // byte for byte. `null` is relayed — it is the answer "no row", not an + // absence — which is why this reads presence, not truthiness. + if (error?.code === 'METADATA_CONFLICT' + && error !== null && typeof error === 'object' + && Object.prototype.hasOwnProperty.call(error, 'currentVersion') + && (typeof error.currentVersion === 'string' || error.currentVersion === null)) { + return { + status: 409, + body: { + error: armSentence(error) ?? 'Metadata item was modified by another author', + code: 'METADATA_CONFLICT', + currentVersion: error.currentVersion, + ...(object ? { object } : {}), + }, + }; + } // [commit 10220a7bf] The engine's insert-conflict envelope → 409 `UNIQUE_VIOLATION`, // with the structured `field` restored. // From ba183bff1196d15ea6497d26efcaa6c3e198b91a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 21:57:12 +0000 Subject: [PATCH 02/10] test(meta): pin the read token, the expect-no-row pin and the conflict's currentVersion at the door (WIP) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- .changeset/22114-meta-read-version-token.md | 16 + .../src/protocol.served-content-hash.test.ts | 93 +++++ ...esponse-structured-arm-door-parity.test.ts | 73 ++++ .../src/meta-item-version-token-occ.test.ts | 318 ++++++++++++++++++ .../src/api/meta-item-response-shapes.test.ts | 44 ++- 5 files changed, 543 insertions(+), 1 deletion(-) create mode 100644 .changeset/22114-meta-read-version-token.md create mode 100644 packages/rest/src/meta-item-version-token-occ.test.ts diff --git a/.changeset/22114-meta-read-version-token.md b/.changeset/22114-meta-read-version-token.md new file mode 100644 index 00000000000..ff78de8f2cf --- /dev/null +++ b/.changeset/22114-meta-read-version-token.md @@ -0,0 +1,16 @@ +--- +"@objectstack/spec": minor +"@objectstack/metadata-protocol": minor +"@objectstack/rest": minor +"@objectstack/types": minor +--- + +feat(meta): the `/meta` item read serves the ADR-0008 version token, `If-None-Match: *` pins a save that expects no row, and the 409 `METADATA_CONFLICT` body carries the current version as data + +Clause-②: yes (widening) + +ADR-0008's optimistic lock on `PUT /api/v1/meta/:type/:name` takes `If-Match: `, but a client can only send a token it was served, and only a save receipt served one. So the first save after a load could not be pinned: two editors who each loaded an item and saved once overwrote each other. Three additions close that. + +- **The read serves the token.** `GET /meta/:type/:name` now carries `version` (declared on `GetMetaItemResponseSchema`): the keyed token of the stored row a save to this item would compare against, at the read's scope (the caller's organization partition and `?package=`) and lifecycle (`?state=draft` for the draft row, the plain read for the active row). It is the same producer and the same row as the save receipt's `version`, so a read after a save serves the receipt's token byte for byte. Send it back as `If-Match`. `null` means no stored row is there (an item served from code or a package artifact, or from a row in a scope the save does not write), so the next save is a create. The cached published-value branch (the default plain read) and `?preview=draft` publish no `version`, as they publish no `lock`; their `ETag` stays the cache validator and is not the token. +- **"Expect no row" can be said.** `If-None-Match: *` on `PUT /meta/:type/:name` (with `?mode=draft` for a draft) saves only where no row of that lifecycle exists, and is refused `409 METADATA_CONFLICT` once one does. This is the `parentVersion: null` pin `SaveMetaItemRequestSchema` already declared, now reachable over HTTP. The header takes `*` alone: any other value, or `If-None-Match` beside `If-Match`, is refused `400 VALIDATION_ERROR` and nothing is written. No first-party client sends `If-None-Match` on a `PUT`. A save with neither header is last-writer-wins, as before. +- **The conflict body names the current version as data.** The 409 of the `/meta` item write doors (save, publish, rollback, reset) now carries `currentVersion` beside the unchanged `error` sentence: the token the sentence names, or `null` when no row of the target lifecycle exists (declared as `MetadataConflictErrorSchema`). On the save door it equals the token the next read at the same address serves, so a client offering "reload" or "overwrite" re-pins without parsing prose. The SDK already exposes it as `err.details.currentVersion`. diff --git a/packages/metadata-protocol/src/protocol.served-content-hash.test.ts b/packages/metadata-protocol/src/protocol.served-content-hash.test.ts index aa2b6908a32..009dc8bdd52 100644 --- a/packages/metadata-protocol/src/protocol.served-content-hash.test.ts +++ b/packages/metadata-protocol/src/protocol.served-content-hash.test.ts @@ -693,3 +693,96 @@ describe('[#21978] the repository lock: a row with a checksum is judged exactly expect(caseGridRow(again)).toBeUndefined(); }); }); + +/** + * [#22114] `getMetaItem`'s registry reads, answered empty: this double holds + * no artifact, so every item it serves comes from a stored row. + */ +function withReadRegistry(h: ReturnType): ReturnType { + h.engine.registry = { + ...h.engine.registry, + getItem: () => undefined, + getArtifactItem: () => undefined, + listItems: () => [], + getObject: () => undefined, + isPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + }; + return h; +} + +const READ = { type: 'view', name: 'case_grid', organizationId: ORG } as const; + +describe('[#22114] the item read serves the version token the save door accepts', () => { + it('active and draft: the read\'s `version` is the receipt\'s token, keyed, never the stored hash — and it pins the next save', async () => { + const h = withReadRegistry(makeEngine()); + const p = new ObjectStackProtocolImplementation(h.engine); + const active: any = await p.saveMetaItem({ ...ref, item: viewBody('v1') } as any); + const draft: any = await p.saveMetaItem({ ...ref, item: viewBody('staged'), mode: 'draft' } as any); + + const readActive: any = await p.getMetaItem({ ...READ }); + const readDraft: any = await p.getMetaItem({ ...READ, state: 'draft' }); + expect(readActive.version).toBe(active.version); + expect(readDraft.version).toBe(draft.version); + expect(readActive.version).toMatch(KEYED); + expect(readActive.version).toBe(await keyedDigest(activeHash(h))); + expectNoStoredHash(JSON.stringify(readActive), storedHashes(h)); + + const next: any = await p.saveMetaItem({ ...ref, item: viewBody('v2'), parentVersion: readActive.version } as any); + expect(next.success).toBe(true); + expect(((await p.getMetaItem({ ...READ })) as any).version).toBe(next.version); + }); + + it('`null` where the save\'s address holds no row — an organization falling back to the env-wide row', async () => { + const h = withReadRegistry(makeEngine()); + const p = new ObjectStackProtocolImplementation(h.engine); + // Env-wide row only; the org-scoped read serves it (ADR-0005 fallback), + // while an org-scoped save writes the org partition, where nothing is. + await p.saveMetaItem({ type: 'view', name: 'case_grid', item: viewBody('env-wide'), actor: 'admin' } as any); + + const read: any = await p.getMetaItem({ ...READ }); + expect(read.item?.label).toBe('env-wide'); + expect(read.version).toBeNull(); + // `null` is the create pin: honoured, and then refused once the row exists. + const created: any = await p.saveMetaItem({ ...ref, item: viewBody('org'), parentVersion: null } as any); + const refused = await rejection(() => + p.saveMetaItem({ ...ref, item: viewBody('org again'), parentVersion: null } as any)); + expect(refused.code).toBe('METADATA_CONFLICT'); + expect(refused.status).toBe(409); + expect(refused.currentVersion).toBe(created.version); + }); + + it('a `previewDrafts` read publishes no `version` — it serves two lifecycles', async () => { + const h = withReadRegistry(makeEngine()); + const p = new ObjectStackProtocolImplementation(h.engine); + await p.saveMetaItem({ ...ref, item: viewBody('v1') } as any); + const withoutDraft: any = await p.getMetaItem({ ...READ, previewDrafts: true }); + expect(withoutDraft).not.toHaveProperty('version'); + await p.saveMetaItem({ ...ref, item: viewBody('staged'), mode: 'draft' } as any); + const withDraft: any = await p.getMetaItem({ ...READ, previewDrafts: true }); + expect(withDraft.item?.label).toBe('staged'); + expect(withDraft).not.toHaveProperty('version'); + }); + + it('the conflict refusal states `currentVersion`: the keyed head the next read serves, or `null` with no row', async () => { + const h = withReadRegistry(makeEngine()); + const p = new ObjectStackProtocolImplementation(h.engine); + const v1: any = await p.saveMetaItem({ ...ref, item: viewBody('v1') } as any); + const v2: any = await p.saveMetaItem({ ...ref, item: viewBody('v2'), parentVersion: v1.version } as any); + + const stale = await rejection(() => + p.saveMetaItem({ ...ref, item: viewBody('lost'), parentVersion: v1.version } as any)); + expect(stale.code).toBe('METADATA_CONFLICT'); + expect(stale.status).toBe(409); + expect(stale.currentVersion).toBe(v2.version); + expect(((await p.getMetaItem({ ...READ })) as any).version).toBe(stale.currentVersion); + expect(stale.message).toContain(`(current is ${v2.version})`); + + // A draft pin with no draft row: the head is `null`, stated as such. + const noDraft = await rejection(() => + p.saveMetaItem({ ...ref, item: viewBody('d'), mode: 'draft', parentVersion: v2.version } as any)); + expect(noDraft.code).toBe('METADATA_CONFLICT'); + expect(noDraft.status).toBe(409); + expect(noDraft).toHaveProperty('currentVersion', null); + }); +}); diff --git a/packages/rest/src/error-response-structured-arm-door-parity.test.ts b/packages/rest/src/error-response-structured-arm-door-parity.test.ts index e1e07e87dc7..472cbb440cc 100644 --- a/packages/rest/src/error-response-structured-arm-door-parity.test.ts +++ b/packages/rest/src/error-response-structured-arm-door-parity.test.ts @@ -123,6 +123,9 @@ interface Case { readonly expect: Wire; } +/** [#22114] A keyed version token, the shape the `/meta` doors serve. */ +const META_TOKEN = `hmac-sha256:${'b'.repeat(64)}`; + const DELETE_RESTRICTED_DEV = "Cannot delete account (a1): 3 dependent contact record(s) reference it via account_id. " + "Delete or reassign them first, or set deleteBehavior:'cascade' on contact.account_id."; @@ -318,6 +321,76 @@ const CASES: readonly Case[] = [ }, }, }, + { + // [#22114] The metadata twin of `CONCURRENT_UPDATE`: the `/meta` item + // doors' optimistic-lock refusal, as `metadata-protocol`'s one conflict + // builder (`metadataConflictRefusal`) composes it — the keyed head as + // `currentVersion` beside the sentence that names it. + covers: ['METADATA_CONFLICT'], + what: "metadata-protocol's metadataConflictRefusal (`code`/`status = 409`/`actualHead`/`currentVersion`)", + error: () => { + const err: any = new Error('view/case_grid has been modified since you loaded it. The version token ' + + `sent is not the current version (current is ${META_TOKEN}).`); + err.code = 'METADATA_CONFLICT'; + err.status = 409; + err.actualHead = META_TOKEN; + err.currentVersion = META_TOKEN; + return err; + }, + restored: ['currentVersion'], + expect: { + status: 409, + body: { + error: 'view/case_grid has been modified since you loaded it. The version token ' + + `sent is not the current version (current is ${META_TOKEN}).`, + code: 'METADATA_CONFLICT', + currentVersion: META_TOKEN, + }, + }, + }, + { + // [#22114] …and `null` — "no row of this lifecycle is there" — is an + // answer the arm relays, not an absence it drops. + covers: ['METADATA_CONFLICT'], + what: "metadataConflictRefusal with no current row (`currentVersion: null`)", + error: () => { + const err: any = new Error('view/case_grid has been modified since you loaded it. The version token ' + + 'sent is not the current version (current is null).'); + err.code = 'METADATA_CONFLICT'; + err.status = 409; + err.actualHead = null; + err.currentVersion = null; + return err; + }, + restored: ['currentVersion'], + expect: { + status: 409, + body: { + error: 'view/case_grid has been modified since you loaded it. The version token ' + + 'sent is not the current version (current is null).', + code: 'METADATA_CONFLICT', + currentVersion: null, + }, + }, + }, + { + // [#22114] The control: a `METADATA_CONFLICT` that states no + // `currentVersion` (a raw repository refusal shape) is not the arm's — + // its body is the passthrough's, byte for byte, on both doors. + covers: ['METADATA_CONFLICT'], + what: 'a METADATA_CONFLICT that states no `currentVersion` (control)', + error: () => { + const err: any = new Error('view/case_grid has been modified since you loaded it.'); + err.code = 'METADATA_CONFLICT'; + err.status = 409; + return err; + }, + restored: [], + expect: { + status: 409, + body: { error: 'view/case_grid has been modified since you loaded it.', code: 'METADATA_CONFLICT' }, + }, + }, { // The CONTROL, and the ruling this card generalises: this code has had // door parity since #3770, by a named exclusion on the passthrough. diff --git a/packages/rest/src/meta-item-version-token-occ.test.ts b/packages/rest/src/meta-item-version-token-occ.test.ts new file mode 100644 index 00000000000..bd0db99dcb1 --- /dev/null +++ b/packages/rest/src/meta-item-version-token-occ.test.ts @@ -0,0 +1,318 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22114] The `/meta` item read serves the ADR-0008 version token, a save can + * pin "no row here", and the 409 carries the current version as data. + * + * ADR-0008 says a client passes `If-Match: `, and a client can only send + * a token it was served. Before this, only a save receipt served one: the read + * a client edits from (`GET /meta/:type/:name?state=draft`, Studio's designers) + * served no `version` and no token-bearing `ETag`, so the first save after a + * load could not be pinned and two editors who each loaded and saved once were + * last-writer-wins. With no draft row, every `If-Match` was refused and + * omitting it was unguarded, so a create or the first draft after a publish + * could not be pinned either. And the 409 named the current version only + * inside its sentence. + * + * Driven at the HTTP door on the real stack — a better-sqlite3 `:memory:` + * engine, the real `sys_metadata*` objects, a real + * `ObjectStackProtocolImplementation` and the real routes — because the claim + * is about what each request DID to the store, which a protocol double could + * only echo. The stub is the auth boundary (`resolveExecCtx`). + * + * ⚠️ `@objectstack/metadata-protocol` resolves through `exports` to its + * **`dist/`** (`check-test-source-alias.mjs`, `KNOWN_UNALIASED_TEST_IMPORTS`): + * rebuild it before reading a result here after touching `protocol.ts`. + * + * Every refusal pin asserts the ADR-0112 `code` and the HTTP status, and reads + * the store to show the refused write did not land. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadata, SysMetadataHistoryObject, SysMetadataAuditObject } from '@objectstack/platform-objects/metadata'; +import { GetMetaItemResponseSchema, MetadataConflictErrorSchema } from '@objectstack/spec/api'; +import { RestServer } from './rest-server.js'; + +/** `registry.registerObject` requires a package id (see the sibling real-stack tests). */ +const TEST_PACKAGE_ID = 'objectstack-test'; +const META = '/api/v1/meta'; +/** The keyed token shape the doors serve with no crypto provider registered (the process key). */ +const KEYED = /^hmac-sha256:[0-9a-f]{64}$/; +/** The save door's own first sentence, unchanged by the structured field. */ +const SAVE_CONFLICT_SENTENCE = 'view/case_grid has been modified since you loaded it.'; + +const TASK = { + name: 'task', + label: 'Task', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, + name: { name: 'name', type: 'text' as const, label: 'Name' }, + }, +}; + +const view = (label: string) => ({ + name: 'case_grid', + label, + object: 'task', + viewKind: 'list', + columns: [{ field: 'name', label: 'Name' }], +}); + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const res: any = { statusCode: 200, body: undefined, headers: {} as Record }; + res.status = (code: number) => { res.statusCode = code; return res; }; + res.json = (body: any) => { res.body = body; return res; }; + res.send = () => res; + res.end = () => res; + res.header = (k: string, v: string) => { res.headers[k] = v; return res; }; + res.setHeader = () => {}; + res.write = () => true; + return res; +} + +/** + * Boot the real stack. `enableCache` is the deployment switch the plain read's + * arm turns on: the default cached arm publishes no OCC carriers, so the + * active-row pins boot with it off. + */ +async function boot(opts: { enableCache?: boolean } = {}) { + const engine = new ObjectQL(); + liveEngines.push(engine); + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }), true); + await engine.init(); + engine.registry.registerObject(TASK as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadata as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadataHistoryObject as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadataAuditObject as any, TEST_PACKAGE_ID); + await engine.syncSchemas(); + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const config = opts.enableCache === false + ? { api: { requireAuth: false }, metadata: { enableCache: false } } + : { api: { requireAuth: false } }; + const rest: any = new RestServer(createMockServer() as any, protocol as any, config as any); + // An author: `manage_metadata` saves and reads drafts. + rest.resolveExecCtx = async () => ({ userId: 'u_author', systemPermissions: ['manage_metadata'] }); + rest.registerRoutes(); + + const route = (method: string, path: string) => { + const found = rest.getRoutes().find((r: any) => r.method === method && r.path === path); + if (!found) throw new Error(`${method} ${path} is not registered`); + return found; + }; + const call = async (method: string, routePath: string, req: Record) => { + const res = makeRes(); + await route(method, routePath).handler({ method, headers: {}, query: {}, params: {}, ...req }, res); + return res; + }; + const item = { type: 'view', name: 'case_grid' }; + const path = `${META}/view/case_grid`; + + /** `GET /meta/view/case_grid`. */ + const read = (query: Record = {}, type = item.type, name = item.name) => + call('GET', `${META}/:type/:name`, { path: `${META}/${type}/${name}`, params: { type, name }, query }); + /** `PUT /meta/view/case_grid`. */ + const save = (body: unknown, query: Record = {}, headers: Record = {}) => + call('PUT', `${META}/:type/:name`, { path, params: item, query, headers, body }); + /** `POST /meta/view/case_grid/publish`. */ + const publish = () => + call('POST', `${META}/:type/:name/publish`, { path: `${path}/publish`, params: item, body: {} }); + /** `DELETE /meta/view/case_grid`. */ + const reset = (headers: Record = {}) => + call('DELETE', `${META}/:type/:name`, { path, params: item, headers }); + + /** The stored rows of the item, by lifecycle — the store, not the door's word for it. */ + const stored = async (state: 'active' | 'draft') => { + const rows = await engine.find('sys_metadata', { + where: { type: 'view', name: 'case_grid', state }, + context: { isSystem: true }, + } as any); + return (rows ?? []).map((r: any) => JSON.parse(String(r.metadata)).label as string); + }; + + return { read, save, publish, reset, stored }; +} + +const ok = (res: any) => { + expect(res.statusCode, JSON.stringify(res.body)).toBe(200); + return res.body; +}; + +/** A refusal: the ADR-0112 `code` and the status, both asserted. */ +const refusedConflict = (res: any) => { + expect(res.statusCode, JSON.stringify(res.body)).toBe(409); + expect(res.body.code).toBe('METADATA_CONFLICT'); + return res.body; +}; + +describe('[#22114] the item read serves the version token the save door pins with', () => { + it('draft: `?state=draft` serves the draft save receipt\'s `version`, byte for byte, as a declared member', async () => { + const h = await boot(); + const receipt = ok(await h.save(view('v1'), { mode: 'draft' })); + expect(receipt.version).toMatch(KEYED); + + const body = ok(await h.read({ state: 'draft' })); + expect(body.version).toBe(receipt.version); + + // Declared: the spec's response schema keeps it through a parse. + const parsed = GetMetaItemResponseSchema.parse(body); + expect(parsed.version).toBe(receipt.version); + }, 60_000); + + it('read, then save with the read\'s token: 200, and the next read serves the new receipt\'s token', async () => { + const h = await boot(); + ok(await h.save(view('v1'), { mode: 'draft' })); + const token = ok(await h.read({ state: 'draft' })).version; + + const receipt = ok(await h.save(view('v2'), { mode: 'draft' }, { 'if-match': token })); + expect(receipt.version).not.toBe(token); + expect(ok(await h.read({ state: 'draft' })).version).toBe(receipt.version); + expect(await h.stored('draft')).toEqual(['v2']); + }, 60_000); + + it('two readers save in turn: the second is refused 409, and its `currentVersion` is the next read\'s token', async () => { + const h = await boot(); + ok(await h.save(view('v1'), { mode: 'draft' })); + const readerA = ok(await h.read({ state: 'draft' })).version; + const readerB = ok(await h.read({ state: 'draft' })).version; + expect(readerB).toBe(readerA); + + const won = ok(await h.save(view('by A'), { mode: 'draft' }, { 'if-match': readerA })); + const lost = refusedConflict(await h.save(view('by B'), { mode: 'draft' }, { 'if-match': readerB })); + + // B's edit did not land over A's. + expect(await h.stored('draft')).toEqual(['by A']); + // The structured field: the token A's save left, which is what the next read serves. + expect(lost.currentVersion).toBe(won.version); + expect(ok(await h.read({ state: 'draft' })).version).toBe(lost.currentVersion); + // Today's sentence, unchanged beside it — it still names the same token. + expect(lost.error.startsWith(SAVE_CONFLICT_SENTENCE)).toBe(true); + expect(lost.error).toContain(`(current is ${won.version})`); + // Declared: the conflict body parses under its schema, field intact. + expect(MetadataConflictErrorSchema.parse(lost).currentVersion).toBe(won.version); + + // B re-pins from the refusal and is accepted. + ok(await h.save(view('by B, merged'), { mode: 'draft' }, { 'if-match': lost.currentVersion })); + expect(await h.stored('draft')).toEqual(['by B, merged']); + }, 60_000); + + it('`If-None-Match: *` writes a first draft, and once a draft exists it is refused 409 with that draft\'s token', async () => { + const h = await boot(); + const first = ok(await h.save(view('first'), { mode: 'draft' }, { 'if-none-match': '*' })); + expect(await h.stored('draft')).toEqual(['first']); + + const refused = refusedConflict(await h.save(view('second'), { mode: 'draft' }, { 'if-none-match': '*' })); + expect(refused.currentVersion).toBe(first.version); + expect(ok(await h.read({ state: 'draft' })).version).toBe(refused.currentVersion); + expect(await h.stored('draft')).toEqual(['first']); + }, 60_000); + + it('after a publish (no draft row): a held `If-Match` is refused 409 with `currentVersion: null`; `If-None-Match: *` writes the next draft', async () => { + const h = await boot(); + const draft = ok(await h.save(view('d1'), { mode: 'draft' })); + ok(await h.publish()); + expect(await h.stored('draft')).toEqual([]); + + const stale = refusedConflict(await h.save(view('d2 pinned'), { mode: 'draft' }, { 'if-match': draft.version })); + expect(stale.currentVersion).toBeNull(); + expect(MetadataConflictErrorSchema.parse(stale).currentVersion).toBeNull(); + expect(await h.stored('draft')).toEqual([]); + + ok(await h.save(view('d2'), { mode: 'draft' }, { 'if-none-match': '*' })); + expect(await h.stored('draft')).toEqual(['d2']); + }, 60_000); + + it('control: a save with neither header is last-writer-wins, as before', async () => { + const h = await boot(); + ok(await h.save(view('v1'), { mode: 'draft' })); + const held = ok(await h.read({ state: 'draft' })).version; + + ok(await h.save(view('by A'), { mode: 'draft' })); + ok(await h.save(view('by B'), { mode: 'draft' })); + expect(await h.stored('draft')).toEqual(['by B']); + expect(ok(await h.read({ state: 'draft' })).version).not.toBe(held); + }, 60_000); +}); + +describe('[#22114] the active row, on the uncached plain read', () => { + it('serves the active receipt\'s token; `If-None-Match: *` pins the create; an item with no stored row serves `null`', async () => { + const h = await boot({ enableCache: false }); + const created = ok(await h.save(view('live'), {}, { 'if-none-match': '*' })); + expect(ok(await h.read()).version).toBe(created.version); + + const again = refusedConflict(await h.save(view('second create'), {}, { 'if-none-match': '*' })); + expect(again.currentVersion).toBe(created.version); + expect(await h.stored('active')).toEqual(['live']); + + // `task` is registered in code: no stored row, so the next save is a create. + const codeOnly = ok(await h.read({}, 'object', 'task')); + expect(codeOnly.item).toBeDefined(); + expect(codeOnly.version).toBeNull(); + expect(GetMetaItemResponseSchema.parse(codeOnly).version).toBeNull(); + }, 60_000); + + it('the reset door\'s refusal carries `currentVersion` too — one conflict builder for the item doors', async () => { + const h = await boot({ enableCache: false }); + const v1 = ok(await h.save(view('v1'))); + const v2 = ok(await h.save(view('v2'), {}, { 'if-match': v1.version })); + + const refused = refusedConflict(await h.reset({ 'if-match': v1.version })); + expect(refused.currentVersion).toBe(v2.version); + expect(ok(await h.read()).version).toBe(v2.version); + expect(await h.stored('active')).toEqual(['v2']); + }, 60_000); + + it('the cached arm (the default) publishes no `version`: its ETag stays the cache validator', async () => { + const h = await boot(); + ok(await h.save(view('live'))); + const res = await h.read(); + const body = ok(res); + expect(body).not.toHaveProperty('version'); + expect(res.headers.ETag).toBeDefined(); + expect(String(res.headers.ETag)).not.toMatch(/hmac-sha256:/); + }, 60_000); +}); + +describe('[#22114] `If-None-Match` on the save door takes `*` alone', () => { + const REFUSED_PINS: Record> = { + 'beside If-Match': { 'if-match': 'hmac-sha256:anything', 'if-none-match': '*' }, + 'an entity-tag': { 'if-none-match': '"abc"' }, + 'a weak wildcard': { 'if-none-match': 'W/"*"' }, + 'a list': { 'if-none-match': '*, "abc"' }, + 'empty': { 'if-none-match': '' }, + }; + for (const [label, headers] of Object.entries(REFUSED_PINS)) { + it(`${label}: refused 400 VALIDATION_ERROR, nothing written`, async () => { + const h = await boot(); + const res = await h.save(view('never'), { mode: 'draft' }, headers); + expect(res.statusCode, JSON.stringify(res.body)).toBe(400); + expect(res.body.error.code).toBe('VALIDATION_ERROR'); + expect(await h.stored('draft')).toEqual([]); + }, 60_000); + } + + it('` * ` (whitespace around the wildcard) is the wildcard', async () => { + const h = await boot(); + ok(await h.save(view('first'), { mode: 'draft' }, { 'if-none-match': ' * ' })); + expect(await h.stored('draft')).toEqual(['first']); + }, 60_000); +}); diff --git a/packages/spec/src/api/meta-item-response-shapes.test.ts b/packages/spec/src/api/meta-item-response-shapes.test.ts index 109d0f74927..5e643384dc4 100644 --- a/packages/spec/src/api/meta-item-response-shapes.test.ts +++ b/packages/spec/src/api/meta-item-response-shapes.test.ts @@ -23,8 +23,9 @@ import { describe, it, expect } from 'vitest'; import { GetMetaItemResponseSchema, GetMetaItemLayeredResponseSchema, + MetadataConflictErrorSchema, } from './protocol.zod'; -import type { GetMetaItemResponse, GetMetaItemLayeredResponse } from './protocol.zod'; +import type { GetMetaItemResponse, GetMetaItemLayeredResponse, MetadataConflictError } from './protocol.zod'; import type { MetadataLock } from '../kernel/metadata-protection.zod'; /** Type-level identity / assignability helpers. */ @@ -58,6 +59,15 @@ export type LockIsTheAdr0010Union = Assert< Eq< GetMetaItemResponse['lock'], Met export type LayeredCarriesNoItem = Assert< Eq< 'item' extends keyof GetMetaItemLayeredResponse ? true : false, false > >; export type PlainCarriesNoLayers = Assert< Eq< 'effective' extends keyof GetMetaItemResponse ? true : false, false > >; +/** + * #22114 — the read half of the token chain is reachable and typed: a string + * to echo as `If-Match`, `null` for "no stored row here", absent where the + * branch publishes none. And the conflict's current version is data a client + * reads without a cast. + */ +export type ReadVersionIsTokenNullOrAbsent = Assert< Eq< GetMetaItemResponse['version'], string | null | undefined > >; +export type ConflictCurrentVersionIsTokenOrNull = Assert< Eq< MetadataConflictError['currentVersion'], string | null > >; + /** The document under `item` — irrelevant to these shapes, so kept trivial. */ const CUSTOMER = { name: 'customer', label: 'Customer' }; @@ -212,3 +222,35 @@ describe('GetMetaItemLayeredResponseSchema — the three-layer projection', () = expect(throughPlain.error?.issues.map((i) => i.path.join('.'))).toContain('item'); }); }); + +describe('[#22114] the read serves the version token; the conflict carries the current one as data', () => { + const TOKEN = 'hmac-sha256:' + 'a'.repeat(64); + + it('GetMetaItemResponseSchema keeps `version` through a parse — a token, `null`, or absent', () => { + expect(GetMetaItemResponseSchema.parse({ type: 'view', name: 'grid', item: {}, version: TOKEN }).version).toBe(TOKEN); + expect(GetMetaItemResponseSchema.parse({ type: 'view', name: 'grid', item: {}, version: null }).version).toBeNull(); + // The cached branch and a `?preview=draft` read publish none: absent is legal and stays absent. + const cached = GetMetaItemResponseSchema.parse({ type: 'view', name: 'grid', item: {} }); + expect('version' in cached).toBe(false); + }); + + it('rejects a `version` that is not a token', () => { + expect(() => GetMetaItemResponseSchema.parse({ type: 'view', name: 'grid', item: {}, version: 3 })).toThrow(); + }); + + it('MetadataConflictErrorSchema parses the door\'s 409 body with `currentVersion` intact — a token or `null`', () => { + const body = { + error: `view/grid has been modified since you loaded it. The version token sent is not the current version (current is ${TOKEN}).`, + code: 'METADATA_CONFLICT', + currentVersion: TOKEN, + }; + expect(MetadataConflictErrorSchema.parse(body).currentVersion).toBe(TOKEN); + expect(MetadataConflictErrorSchema.parse({ ...body, currentVersion: null }).currentVersion).toBeNull(); + }); + + it('REQUIRES `currentVersion` and the one code — absence is not "no row"', () => { + const body = { error: 'view/grid has been modified since you loaded it.', code: 'METADATA_CONFLICT' }; + expect(MetadataConflictErrorSchema.safeParse(body).success).toBe(false); + expect(MetadataConflictErrorSchema.safeParse({ ...body, currentVersion: null, code: 'CONCURRENT_UPDATE' }).success).toBe(false); + }); +}); From ec2c526382baaab3905281c8d3f3645b61978d90 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:39:24 +0000 Subject: [PATCH 03/10] fix(spec): name the parsed conflict body; type the new test's store read (WIP) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- packages/rest/src/meta-item-version-token-occ.test.ts | 5 +---- packages/spec/src/api/protocol.zod.ts | 2 ++ 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/rest/src/meta-item-version-token-occ.test.ts b/packages/rest/src/meta-item-version-token-occ.test.ts index bd0db99dcb1..d6d633a0c03 100644 --- a/packages/rest/src/meta-item-version-token-occ.test.ts +++ b/packages/rest/src/meta-item-version-token-occ.test.ts @@ -142,10 +142,7 @@ async function boot(opts: { enableCache?: boolean } = {}) { /** The stored rows of the item, by lifecycle — the store, not the door's word for it. */ const stored = async (state: 'active' | 'draft') => { - const rows = await engine.find('sys_metadata', { - where: { type: 'view', name: 'case_grid', state }, - context: { isSystem: true }, - } as any); + const rows = await engine.find('sys_metadata', { where: { type: 'view', name: 'case_grid', state } }); return (rows ?? []).map((r: any) => JSON.parse(String(r.metadata)).label as string); }; diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 6b518c6c6f4..39044310f67 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -3367,6 +3367,8 @@ export type SaveMetaItemRequest = z.input; export type SaveMetaItemResponse = z.input; /** The 409 `METADATA_CONFLICT` body of the `/meta/:type/:name` write doors (#22114). */ export type MetadataConflictError = z.input; +/** Post-parse shape of {@link MetadataConflictError} — defaults applied, transforms run (ADR-0122). */ +export type MetadataConflictErrorParsed = z.infer; export type PublishMetaItemRequest = z.input; export type PublishMetaItemResponse = z.input; /** From 256d5420d92cead9bb57ae04a8dc92581317dbf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:39:39 +0000 Subject: [PATCH 04/10] chore(spec): regenerate the authorable surface and schema manifest for the read token and the conflict body (WIP) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- packages/spec/authorable-surface/api.json | 4 ++++ packages/spec/json-schema.manifest/api.json | 1 + 2 files changed, 5 insertions(+) diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index d580fd33ccd..60a6ea5d915 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -798,6 +798,7 @@ "api/GetMetaItemResponse:resettable", "api/GetMetaItemResponse:sortability", "api/GetMetaItemResponse:type", + "api/GetMetaItemResponse:version", "api/GetMetaItemsRequest:organizationId", "api/GetMetaItemsRequest:packageId", "api/GetMetaItemsRequest:previewDrafts", @@ -1058,6 +1059,9 @@ "api/MetadataCacheResponse:lastModified", "api/MetadataCacheResponse:notModified", "api/MetadataCacheResponse:version", + "api/MetadataConflictError:code", + "api/MetadataConflictError:currentVersion", + "api/MetadataConflictError:error", "api/MetadataDeleteResponse:data", "api/MetadataDeleteResponse:error", "api/MetadataDeleteResponse:meta", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index d385b825bbd..b3aa076b28e 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -254,6 +254,7 @@ "api/MetadataBulkUnregisterRequest", "api/MetadataCacheRequest", "api/MetadataCacheResponse", + "api/MetadataConflictError", "api/MetadataDeleteResponse", "api/MetadataDependenciesResponse", "api/MetadataDependentsResponse", From 12a0d85252387b918dd1fa9c763d3199aceb2055 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:52:39 +0000 Subject: [PATCH 05/10] chore(spec): regenerate api-surface, export origins, declaration map, reference docs and the strictness ledger counts Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 22 +++++++++++++++---- content/docs/references/index.mdx | 10 ++++----- .../api.md | 2 +- packages/spec/api-surface/api.json | 3 +++ packages/spec/declaration-map/api.json | 2 ++ packages/spec/export-origins/api.json | 3 +++ 6 files changed, 32 insertions(+), 10 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 935f0ca8af0..79361ee500e 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1,7 +1,7 @@ --- title: Protocol schema — API Protocol reference navTitle: Protocol -description: "Protocol schemas of the ObjectStack API Protocol: AiAgentCapabilities and 133 more — each property with its type, default and a TypeScript example." +description: "Protocol schemas of the ObjectStack API Protocol: AiAgentCapabilities and 134 more — each property with its type, default and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -13,8 +13,8 @@ description: "Protocol schemas of the ObjectStack API Protocol: AiAgentCapabilit ## TypeScript Usage ```typescript -import { AiAgentCapabilitiesSchema, AiAgentChatRequestSchema, AiAgentSummarySchema, AiAgentsResponseSchema, AiChatRequestSchema, AiChatResponseSchema, AiCompleteRequestSchema, AiConversationSchema, AiMessageSchema, AiModelsResponseSchema, AiPendingActionSchema, AiPendingActionStatusSchema, AiStreamChunkSchema, ApproveAiPendingActionResponseSchema, AuditMetaItemRequestSchema, AuditMetaItemResponseSchema, AutomationActionsResponseSchema, AutomationTriggerRequestSchema, AutomationTriggerResponseSchema, BatchDataRequestSchema, BatchDataResponseSchema, CheckPermissionRequestSchema, CheckPermissionResponseSchema, CloneDataResponseSchema, CreateAiConversationRequestSchema, CreateDataRequestSchema, CreateDataResponseSchema, CreateManyDataRequestSchema, CreateManyDataResponseSchema, DeleteDataRequestSchema, DeleteDataResponseSchema, DeleteManyDataRequestSchema, DeleteManyDataResponseSchema, DeleteMetaItemRequestSchema, DeleteMetaItemResponseSchema, DiffMetaItemResponseSchema, DisablePackageRequestSchema, DisablePackageResponseSchema, EnablePackageRequestSchema, EnablePackageResponseSchema, FindDataRequestSchema, FindDataResponseSchema, FindReferencesToMetaResponseSchema, GetDataRequestSchema, GetDataResponseSchema, GetDiscoveryRequestSchema, GetDiscoveryResponseSchema, GetEffectivePermissionsRequestSchema, GetEffectivePermissionsResponseSchema, GetFieldLabelsRequestSchema, GetFieldLabelsResponseSchema, GetLocalesRequestSchema, GetLocalesResponseSchema, GetMetaDiagnosticsResponseSchema, GetMetaItemCachedRequestSchema, GetMetaItemCachedResponseSchema, GetMetaItemLayeredRequestSchema, GetMetaItemLayeredResponseSchema, GetMetaItemRequestSchema, GetMetaItemResponseSchema, GetMetaItemsRequestSchema, GetMetaItemsResponseSchema, GetMetaTypesRequestSchema, GetMetaTypesResponseSchema, GetNotificationPreferencesRequestSchema, GetNotificationPreferencesResponseSchema, GetObjectPermissionsRequestSchema, GetObjectPermissionsResponseSchema, GetPackageRequestSchema, GetPackageResponseSchema, GetPresenceRequestSchema, GetPresenceResponseSchema, GetPublishedMetaItemResponseSchema, GetTranslationsRequestSchema, GetTranslationsResponseSchema, GetUiViewRequestSchema, GetUiViewResponseSchema, HistoryMetaItemRequestSchema, HistoryMetaItemResponseSchema, HttpFindQueryParamsSchema, InstallPackageRequestSchema, InstallPackageResponseSchema, ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, ListAiPendingActionsRequestSchema, ListAiPendingActionsResponseSchema, ListDraftsResponseSchema, ListNotificationsRequestSchema, ListNotificationsResponseSchema, ListPackagesRequestSchema, ListPackagesResponseSchema, MarkAllNotificationsReadRequestSchema, MarkAllNotificationsReadResponseSchema, MarkNotificationsReadRequestSchema, MarkNotificationsReadResponseSchema, NotificationSchema, NotificationPreferencesSchema, PublishMetaItemRequestSchema, PublishMetaItemResponseSchema, PublishPackageDraftsResponseSchema, RealtimeConnectRequestSchema, RealtimeConnectResponseSchema, RealtimeDisconnectRequestSchema, RealtimeDisconnectResponseSchema, RealtimeSubscribeRequestSchema, RealtimeSubscribeResponseSchema, RealtimeUnsubscribeRequestSchema, RealtimeUnsubscribeResponseSchema, RegisterDeviceRequestSchema, RegisterDeviceResponseSchema, RejectAiPendingActionResponseSchema, RollbackMetaItemResponseSchema, RuntimeAuthoringIssueSchema, SaveMetaItemRequestSchema, SaveMetaItemResponseSchema, SearchAllHitSchema, SearchAllPageHitSchema, SearchAllResponseSchema, SetPresenceRequestSchema, SetPresenceResponseSchema, UninstallPackageRequestSchema, UninstallPackageResponseSchema, UnregisterDeviceRequestSchema, UnregisterDeviceResponseSchema, UpdateAiConversationRequestSchema, UpdateDataRequestSchema, UpdateDataResponseSchema, UpdateManyDataRequestSchema, UpdateManyDataResponseSchema, UpdateNotificationPreferencesRequestSchema, UpdateNotificationPreferencesResponseSchema, ValidateDataIssueSchema, ValidateDataRequestSchema, ValidateDataResponseSchema } from '@objectstack/spec/api'; -import type { AiAgentCapabilities, AiAgentChatRequest, AiAgentSummary, AiAgentsResponse, AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiPendingAction, AiPendingActionStatus, AiStreamChunk, ApproveAiPendingActionResponse, AuditMetaItemRequest, AuditMetaItemResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CloneDataResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DiffMetaItemResponse, DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, FindDataRequest, FindDataResponse, FindReferencesToMetaResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaDiagnosticsResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemLayeredRequest, GetMetaItemLayeredResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPackageRequest, GetPackageResponse, GetPresenceRequest, GetPresenceResponse, GetPublishedMetaItemResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, HistoryMetaItemRequest, HistoryMetaItemResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListDraftsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, Notification, NotificationPreferences, PublishMetaItemRequest, PublishMetaItemResponse, PublishPackageDraftsResponse, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, RollbackMetaItemResponse, RuntimeAuthoringIssue, SaveMetaItemRequest, SaveMetaItemResponse, SearchAllHit, SearchAllPageHit, SearchAllResponse, SetPresenceRequest, SetPresenceResponse, UninstallPackageRequest, UninstallPackageResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, ValidateDataIssue, ValidateDataRequest, ValidateDataResponse } from '@objectstack/spec/api'; +import { AiAgentCapabilitiesSchema, AiAgentChatRequestSchema, AiAgentSummarySchema, AiAgentsResponseSchema, AiChatRequestSchema, AiChatResponseSchema, AiCompleteRequestSchema, AiConversationSchema, AiMessageSchema, AiModelsResponseSchema, AiPendingActionSchema, AiPendingActionStatusSchema, AiStreamChunkSchema, ApproveAiPendingActionResponseSchema, AuditMetaItemRequestSchema, AuditMetaItemResponseSchema, AutomationActionsResponseSchema, AutomationTriggerRequestSchema, AutomationTriggerResponseSchema, BatchDataRequestSchema, BatchDataResponseSchema, CheckPermissionRequestSchema, CheckPermissionResponseSchema, CloneDataResponseSchema, CreateAiConversationRequestSchema, CreateDataRequestSchema, CreateDataResponseSchema, CreateManyDataRequestSchema, CreateManyDataResponseSchema, DeleteDataRequestSchema, DeleteDataResponseSchema, DeleteManyDataRequestSchema, DeleteManyDataResponseSchema, DeleteMetaItemRequestSchema, DeleteMetaItemResponseSchema, DiffMetaItemResponseSchema, DisablePackageRequestSchema, DisablePackageResponseSchema, EnablePackageRequestSchema, EnablePackageResponseSchema, FindDataRequestSchema, FindDataResponseSchema, FindReferencesToMetaResponseSchema, GetDataRequestSchema, GetDataResponseSchema, GetDiscoveryRequestSchema, GetDiscoveryResponseSchema, GetEffectivePermissionsRequestSchema, GetEffectivePermissionsResponseSchema, GetFieldLabelsRequestSchema, GetFieldLabelsResponseSchema, GetLocalesRequestSchema, GetLocalesResponseSchema, GetMetaDiagnosticsResponseSchema, GetMetaItemCachedRequestSchema, GetMetaItemCachedResponseSchema, GetMetaItemLayeredRequestSchema, GetMetaItemLayeredResponseSchema, GetMetaItemRequestSchema, GetMetaItemResponseSchema, GetMetaItemsRequestSchema, GetMetaItemsResponseSchema, GetMetaTypesRequestSchema, GetMetaTypesResponseSchema, GetNotificationPreferencesRequestSchema, GetNotificationPreferencesResponseSchema, GetObjectPermissionsRequestSchema, GetObjectPermissionsResponseSchema, GetPackageRequestSchema, GetPackageResponseSchema, GetPresenceRequestSchema, GetPresenceResponseSchema, GetPublishedMetaItemResponseSchema, GetTranslationsRequestSchema, GetTranslationsResponseSchema, GetUiViewRequestSchema, GetUiViewResponseSchema, HistoryMetaItemRequestSchema, HistoryMetaItemResponseSchema, HttpFindQueryParamsSchema, InstallPackageRequestSchema, InstallPackageResponseSchema, ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, ListAiPendingActionsRequestSchema, ListAiPendingActionsResponseSchema, ListDraftsResponseSchema, ListNotificationsRequestSchema, ListNotificationsResponseSchema, ListPackagesRequestSchema, ListPackagesResponseSchema, MarkAllNotificationsReadRequestSchema, MarkAllNotificationsReadResponseSchema, MarkNotificationsReadRequestSchema, MarkNotificationsReadResponseSchema, MetadataConflictErrorSchema, NotificationSchema, NotificationPreferencesSchema, PublishMetaItemRequestSchema, PublishMetaItemResponseSchema, PublishPackageDraftsResponseSchema, RealtimeConnectRequestSchema, RealtimeConnectResponseSchema, RealtimeDisconnectRequestSchema, RealtimeDisconnectResponseSchema, RealtimeSubscribeRequestSchema, RealtimeSubscribeResponseSchema, RealtimeUnsubscribeRequestSchema, RealtimeUnsubscribeResponseSchema, RegisterDeviceRequestSchema, RegisterDeviceResponseSchema, RejectAiPendingActionResponseSchema, RollbackMetaItemResponseSchema, RuntimeAuthoringIssueSchema, SaveMetaItemRequestSchema, SaveMetaItemResponseSchema, SearchAllHitSchema, SearchAllPageHitSchema, SearchAllResponseSchema, SetPresenceRequestSchema, SetPresenceResponseSchema, UninstallPackageRequestSchema, UninstallPackageResponseSchema, UnregisterDeviceRequestSchema, UnregisterDeviceResponseSchema, UpdateAiConversationRequestSchema, UpdateDataRequestSchema, UpdateDataResponseSchema, UpdateManyDataRequestSchema, UpdateManyDataResponseSchema, UpdateNotificationPreferencesRequestSchema, UpdateNotificationPreferencesResponseSchema, ValidateDataIssueSchema, ValidateDataRequestSchema, ValidateDataResponseSchema } from '@objectstack/spec/api'; +import type { AiAgentCapabilities, AiAgentChatRequest, AiAgentSummary, AiAgentsResponse, AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiPendingAction, AiPendingActionStatus, AiStreamChunk, ApproveAiPendingActionResponse, AuditMetaItemRequest, AuditMetaItemResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CloneDataResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DiffMetaItemResponse, DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, FindDataRequest, FindDataResponse, FindReferencesToMetaResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaDiagnosticsResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemLayeredRequest, GetMetaItemLayeredResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPackageRequest, GetPackageResponse, GetPresenceRequest, GetPresenceResponse, GetPublishedMetaItemResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, HistoryMetaItemRequest, HistoryMetaItemResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListDraftsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, MetadataConflictError, Notification, NotificationPreferences, PublishMetaItemRequest, PublishMetaItemResponse, PublishPackageDraftsResponse, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, RollbackMetaItemResponse, RuntimeAuthoringIssue, SaveMetaItemRequest, SaveMetaItemResponse, SearchAllHit, SearchAllPageHit, SearchAllResponse, SetPresenceRequest, SetPresenceResponse, UninstallPackageRequest, UninstallPackageResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, ValidateDataIssue, ValidateDataRequest, ValidateDataResponse } from '@objectstack/spec/api'; // Validate data const result = AiAgentCapabilitiesSchema.parse(data); @@ -1354,6 +1354,7 @@ Enable package response | **editable** | `boolean` | optional | Whether an overlay write is permitted — false iff `lock` is `no-overlay` or `full`. A derived verdict: do not recompute it from `lock` client-side. | | **deletable** | `boolean` | optional | Whether deleting the overlay is permitted — false iff `lock` is `no-delete` or `full`. | | **resettable** | `boolean` | optional | Whether the item can be reset to its packaged default — true iff it is artifact-backed, i.e. there is a baseline to reset TO. | +| **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the caller's organization partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (the environment-wide row an organization falls back to, a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. | ### Nested Shape: `GetMetaItemResponse.sortability` @@ -2233,6 +2234,19 @@ Installed package with runtime lifecycle state | **readCount** | `number` | ✅ | Number of notifications marked as read | +--- + +## MetadataConflictError + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **error** | `string` | ✅ | Human-readable refusal sentence, unchanged by `currentVersion`: it names the item and the version the door judged current. For display and logs; branch on `code` and read `currentVersion`, never parse this. | +| **code** | `'METADATA_CONFLICT'` | ✅ | ADR-0112 registered code of the optimistic-lock refusal. | +| **currentVersion** | `string \| null` | ✅ | The version token of the head the door judged current — the value the sentence names — or `null` when no row of the target lifecycle exists. Send it as `If-Match` to overwrite deliberately (or `If-None-Match: *` when it is `null`), or re-read the item to merge first. Opaque — echo it verbatim, never parse it. | + + --- ## Notification @@ -2583,7 +2597,7 @@ Installed package with runtime lifecycle state | **name** | `string` | ✅ | Item name — lowercase snake_case segments, optionally dot-qualified (`crm_lead`, `crm_lead.pipeline`). Slash-compound names are refused at the publish door. | | **item** | `any` | ✅ | Metadata item definition | | **organizationId** | `string` | optional | Organization (tenant) scope for the write. Load-bearing, not advisory: it selects the overlay partition (ADR-0005) the row lands in — an org-scoped save writes that tenant's own overlay, while an org-less save writes the environment-wide row every tenant reads — and it is the scope stamped on the write's audit row. An org-scoped write of a type whose registry entry declares `allowOrgOverride: false` is refused (403). Absent = environment-wide. | -| **parentVersion** | `string \| null` | optional | ADR-0008 optimistic-concurrency pin: the version token the caller believes is current (on the REST door, the `If-Match` request header). Present as a string, a concurrent edit is reported as a 409 conflict instead of silently overwritten. ⚠️ `null` is NOT the same as absent: a present `null` asserts "no current row of this lifecycle" — the first-write pin, refused 409 when a row already exists — while an ABSENT key is unpinned: the implementation adopts the current row's hash as the parent (last-write-wins). Nullable because that is the implementation's parameter type, and unlike the reset twin (which folds a present `null` back to the current hash) this verb passes `null` through to the repository's conflict check unchanged. | +| **parentVersion** | `string \| null` | optional | ADR-0008 optimistic-concurrency pin: the version token the caller believes is current (on the REST door, the `If-Match` request header; the item read's `version` and a save receipt's `version` serve it). Present as a string, a concurrent edit is reported as a 409 conflict instead of silently overwritten. ⚠️ `null` is NOT the same as absent: a present `null` asserts "no current row of this lifecycle" — the first-write pin, refused 409 when a row already exists (on the REST door, `If-None-Match: *`; that header takes `*` alone and never beside `If-Match`, and any other spelling is refused 400) — while an ABSENT key is unpinned: the implementation adopts the current row's hash as the parent (last-write-wins). Nullable because that is the implementation's parameter type, and unlike the reset twin (which folds a present `null` back to the current hash) this verb passes `null` through to the repository's conflict check unchanged. | | **actor** | `string` | optional | Identity recorded on the write's history event (`recorded_by`, a lookup into `sys_user`) and audit row. On the REST door this is the request's authenticated identity (one producer) — never a caller-supplied header. Absent, the event is recorded actor-less (null), deliberately not attributed to "system". | | **force** | `boolean` | optional | Destructive-change acknowledgement (`?force=true` on the REST door): skips the safety diff that refuses an `object` save whose body drops fields or narrows types the stored item still carries (409 with the findings otherwise). Only `object` saves reach that diff, so the flag is inert for every other type. Absent = the guard runs. | | **mode** | `Enum<'draft' \| 'publish'>` | optional | Per-item lifecycle (ADR-0005 drafts): `draft` stages the body as a pending draft overlay (`?mode=draft` on the REST door; the publish door promotes it later); `publish` or ABSENT writes straight to the live `active` row — the legacy default, kept so callers that predate the draft/publish split keep working. Any value other than `draft` is read as `publish`. | diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 70735ca465b..f4f1955984b 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1516 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1517 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 32 | 430 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 32 | 431 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 13 | 70 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 30 | 178 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 34 | 275 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 165 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **196** | **1516** | 14 protocol modules | +| **Total** | **196** | **1517** | 14 protocol modules | --- @@ -63,7 +63,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 430 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 431 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -91,7 +91,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. | [`package-api-assembled.zod.ts`](/docs/references/api/package-api-assembled) | `AssembledInstalledPackage`, `GetInstalledPackageResponse`, `InstalledPackageAtEitherStage`, `ListInstalledPackagesResponse` | | [`package-lifecycle.zod.ts`](/docs/references/api/package-lifecycle) | `DiscardPackageDraftsResponse`, `DuplicatePackageResponse`, `ListPackageCommitsResponse`, `PackageExportManifest`, `PackagePublishResult`, `ReassignOrphanedMetadataResponse`, `RevertPackageCommitResponse`, `RollbackToPackageCommitResponse` | | [`plugin-rest-api.zod.ts`](/docs/references/api/plugin-rest-api) | `ErrorHandlingConfig`, `OpenApiGenerationConfig`, `RequestValidationConfig`, `ResponseEnvelopeConfig`, `RestApiEndpoint`, `RestApiPluginConfig`, `RestApiRouteCategory`, `RestApiRouteRegistration`, `ValidationMode` | -| [`protocol.zod.ts`](/docs/references/api/protocol) | `AiAgentCapabilities`, `AiAgentChatRequest`, `AiAgentSummary`, `AiAgentsResponse`, `AiChatRequest`, `AiChatResponse`, `AiCompleteRequest`, `AiConversation`, `AiMessage`, `AiModelsResponse`, `AiPendingAction`, `AiPendingActionStatus`, `AiStreamChunk`, `ApproveAiPendingActionResponse`, `AuditMetaItemRequest`, `AuditMetaItemResponse`, `AutomationActionsResponse`, `AutomationTriggerRequest`, `AutomationTriggerResponse`, `BatchDataRequest`, `BatchDataResponse`, `CheckPermissionRequest`, `CheckPermissionResponse`, `CloneDataResponse`, `CreateAiConversationRequest`, `CreateDataRequest`, `CreateDataResponse`, `CreateManyDataRequest`, `CreateManyDataResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DiffMetaItemResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `FindReferencesToMetaResponse`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaDiagnosticsResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemLayeredRequest`, `GetMetaItemLayeredResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetPublishedMetaItemResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HistoryMetaItemRequest`, `HistoryMetaItemResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListDraftsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `Notification`, `NotificationPreferences`, `PublishMetaItemRequest`, `PublishMetaItemResponse`, `PublishPackageDraftsResponse`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `RollbackMetaItemResponse`, `RuntimeAuthoringIssue`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `SearchAllHit`, `SearchAllPageHit`, `SearchAllResponse`, `SetPresenceRequest`, `SetPresenceResponse`, `UninstallPackageRequest`, `UninstallPackageResponse`, `UnregisterDeviceRequest`, `UnregisterDeviceResponse`, `UpdateAiConversationRequest`, `UpdateDataRequest`, `UpdateDataResponse`, `UpdateManyDataRequest`, `UpdateManyDataResponse`, `UpdateNotificationPreferencesRequest`, `UpdateNotificationPreferencesResponse`, `ValidateDataIssue`, `ValidateDataRequest`, `ValidateDataResponse` | +| [`protocol.zod.ts`](/docs/references/api/protocol) | `AiAgentCapabilities`, `AiAgentChatRequest`, `AiAgentSummary`, `AiAgentsResponse`, `AiChatRequest`, `AiChatResponse`, `AiCompleteRequest`, `AiConversation`, `AiMessage`, `AiModelsResponse`, `AiPendingAction`, `AiPendingActionStatus`, `AiStreamChunk`, `ApproveAiPendingActionResponse`, `AuditMetaItemRequest`, `AuditMetaItemResponse`, `AutomationActionsResponse`, `AutomationTriggerRequest`, `AutomationTriggerResponse`, `BatchDataRequest`, `BatchDataResponse`, `CheckPermissionRequest`, `CheckPermissionResponse`, `CloneDataResponse`, `CreateAiConversationRequest`, `CreateDataRequest`, `CreateDataResponse`, `CreateManyDataRequest`, `CreateManyDataResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DiffMetaItemResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `FindReferencesToMetaResponse`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaDiagnosticsResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemLayeredRequest`, `GetMetaItemLayeredResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetPublishedMetaItemResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HistoryMetaItemRequest`, `HistoryMetaItemResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListDraftsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `MetadataConflictError`, `Notification`, `NotificationPreferences`, `PublishMetaItemRequest`, `PublishMetaItemResponse`, `PublishPackageDraftsResponse`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `RollbackMetaItemResponse`, `RuntimeAuthoringIssue`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `SearchAllHit`, `SearchAllPageHit`, `SearchAllResponse`, `SetPresenceRequest`, `SetPresenceResponse`, `UninstallPackageRequest`, `UninstallPackageResponse`, `UnregisterDeviceRequest`, `UnregisterDeviceResponse`, `UpdateAiConversationRequest`, `UpdateDataRequest`, `UpdateDataResponse`, `UpdateManyDataRequest`, `UpdateManyDataResponse`, `UpdateNotificationPreferencesRequest`, `UpdateNotificationPreferencesResponse`, `ValidateDataIssue`, `ValidateDataRequest`, `ValidateDataResponse` | | [`query-adapter.zod.ts`](/docs/references/api/query-adapter) | `ODataQueryAdapter`, `OperatorMapping`, `QueryAdapterConfig`, `QueryAdapterTarget`, `RestQueryAdapter` | | [`realtime.zod.ts`](/docs/references/api/realtime) | `RealtimeConfig`, `RealtimeEvent`, `RealtimeEventType`, `RealtimePresence`, `Subscription`, `SubscriptionEvent`, `TransportProtocol` | | [`realtime-shared.zod.ts`](/docs/references/api/realtime-shared) | `BasePresence`, `PresenceStatus`, `RealtimeRecordAction` | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md index f2c5eb1e792..e80aa9ab0da 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `api/` | 433 | +| `api/` | 434 | diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index c199cba31fd..5e1c117823f 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -622,6 +622,9 @@ "MetadataCacheResponse (type)", "MetadataCacheResponseParsed (type)", "MetadataCacheResponseSchema (const)", + "MetadataConflictError (type)", + "MetadataConflictErrorParsed (type)", + "MetadataConflictErrorSchema (const)", "MetadataDeleteResponse (type)", "MetadataDeleteResponseParsed (type)", "MetadataDeleteResponseSchema (const)", diff --git a/packages/spec/declaration-map/api.json b/packages/spec/declaration-map/api.json index 451b2263de1..2e4d0fd23a0 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -460,6 +460,8 @@ "MetadataCacheRequestSchema": "api/MetadataCacheRequest", "MetadataCacheResponse": "api/MetadataCacheResponse", "MetadataCacheResponseSchema": "api/MetadataCacheResponse", + "MetadataConflictError": "api/MetadataConflictError", + "MetadataConflictErrorSchema": "api/MetadataConflictError", "MetadataDeleteResponse": "api/MetadataDeleteResponse", "MetadataDeleteResponseSchema": "api/MetadataDeleteResponse", "MetadataDependenciesResponse": "api/MetadataDependenciesResponse", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index 76aaaa769d8..40a19b403ce 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -596,6 +596,9 @@ "MetadataCacheResponse": "src/api/http-cache.zod.ts#MetadataCacheResponse (type)", "MetadataCacheResponseParsed": "src/api/http-cache.zod.ts#MetadataCacheResponseParsed (type)", "MetadataCacheResponseSchema": "src/api/http-cache.zod.ts#MetadataCacheResponseSchema (const)", + "MetadataConflictError": "src/api/protocol.zod.ts#MetadataConflictError (type)", + "MetadataConflictErrorParsed": "src/api/protocol.zod.ts#MetadataConflictErrorParsed (type)", + "MetadataConflictErrorSchema": "src/api/protocol.zod.ts#MetadataConflictErrorSchema (const)", "MetadataDeleteResponse": "src/api/metadata.zod.ts#MetadataDeleteResponse (type)", "MetadataDeleteResponseParsed": "src/api/metadata.zod.ts#MetadataDeleteResponseParsed (type)", "MetadataDeleteResponseSchema": "src/api/metadata.zod.ts#MetadataDeleteResponseSchema (const)", From e5e7817b8e2ef0c757e173a26ccd0809a322453e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 23:10:05 +0000 Subject: [PATCH 06/10] docs(changeset): spell the If-Match token in words Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- .changeset/22114-meta-read-version-token.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/22114-meta-read-version-token.md b/.changeset/22114-meta-read-version-token.md index ff78de8f2cf..8fffb503436 100644 --- a/.changeset/22114-meta-read-version-token.md +++ b/.changeset/22114-meta-read-version-token.md @@ -9,7 +9,7 @@ feat(meta): the `/meta` item read serves the ADR-0008 version token, `If-None-Ma Clause-②: yes (widening) -ADR-0008's optimistic lock on `PUT /api/v1/meta/:type/:name` takes `If-Match: `, but a client can only send a token it was served, and only a save receipt served one. So the first save after a load could not be pinned: two editors who each loaded an item and saved once overwrote each other. Three additions close that. +ADR-0008's optimistic lock on `PUT /api/v1/meta/:type/:name` takes the version a client read as `If-Match`, but a client can only send a token it was served, and only a save receipt served one. So the first save after a load could not be pinned: two editors who each loaded an item and saved once overwrote each other. Three additions close that. - **The read serves the token.** `GET /meta/:type/:name` now carries `version` (declared on `GetMetaItemResponseSchema`): the keyed token of the stored row a save to this item would compare against, at the read's scope (the caller's organization partition and `?package=`) and lifecycle (`?state=draft` for the draft row, the plain read for the active row). It is the same producer and the same row as the save receipt's `version`, so a read after a save serves the receipt's token byte for byte. Send it back as `If-Match`. `null` means no stored row is there (an item served from code or a package artifact, or from a row in a scope the save does not write), so the next save is a create. The cached published-value branch (the default plain read) and `?preview=draft` publish no `version`, as they publish no `lock`; their `ETag` stays the cache validator and is not the token. - **"Expect no row" can be said.** `If-None-Match: *` on `PUT /meta/:type/:name` (with `?mode=draft` for a draft) saves only where no row of that lifecycle exists, and is refused `409 METADATA_CONFLICT` once one does. This is the `parentVersion: null` pin `SaveMetaItemRequestSchema` already declared, now reachable over HTTP. The header takes `*` alone: any other value, or `If-None-Match` beside `If-Match`, is refused `400 VALIDATION_ERROR` and nothing is written. No first-party client sends `If-None-Match` on a `PUT`. A save with neither header is last-writer-wins, as before. From 8f04870ef4c80b8a926912a3c14a1055276e98ed Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 23:15:48 +0000 Subject: [PATCH 07/10] test(rest): the sandbox arm census covers the METADATA_CONFLICT arm Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- .../error-response-sandbox-arm-message.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/rest/src/error-response-sandbox-arm-message.test.ts b/packages/rest/src/error-response-sandbox-arm-message.test.ts index 789cfca5b97..cb83d27840b 100644 --- a/packages/rest/src/error-response-sandbox-arm-message.test.ts +++ b/packages/rest/src/error-response-sandbox-arm-message.test.ts @@ -140,6 +140,15 @@ const ARMS: readonly Arm[] = [ code: 'CONCURRENT_UPDATE', keeps: { currentVersion: 7, object: 'account' }, }, + { + // [#22114] The metadata twin of `CONCURRENT_UPDATE`, keyed on the code + // AND the producer's stated `currentVersion`. + arm: 'METADATA_CONFLICT', + declares: { code: 'METADATA_CONFLICT', status: 409, currentVersion: `hmac-sha256:${'c'.repeat(64)}` }, + status: 409, + code: 'METADATA_CONFLICT', + keeps: { currentVersion: `hmac-sha256:${'c'.repeat(64)}`, object: 'account' }, + }, { arm: 'ERR_DATASOURCE_UNAVAILABLE', declares: { code: 'ERR_DATASOURCE_UNAVAILABLE', datasource: 'warehouse', kind: 'blocked' }, @@ -345,10 +354,11 @@ describe('#14704 · the single `/data` door never ships the QuickJS wrapper out * mechanically, so the next arm added to the shared classification is * either covered above or excused here BY NAME. * - * Measured re-derivation on this tree: thirteen declared-code literals - * sit above the unwrap door — ten reachable by a sandboxed producer - * (the {@link ARMS} rows) and three that are not, each for a reason the - * source states in the arm itself. + * Measured re-derivation on this tree: fourteen declared-code literals + * sit above the unwrap door — eleven reachable by a sandboxed producer + * (the {@link ARMS} rows; `METADATA_CONFLICT` joined in #22114) and + * three that are not, each for a reason the source states in the arm + * itself. */ describe('§4-derivation — the arm list is DERIVED from the tree, not copied', () => { /** Code literals a sandboxed producer provably cannot reach. */ From 3950502c6cc9cf6f20b9ce135ed608be725cb1e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 00:26:29 +0000 Subject: [PATCH 08/10] fix(spec): the receipts' version describes name the keyed token and METADATA_CONFLICT; the changeset grades the two 400 refusals as a narrowing Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- .changeset/22114-meta-read-version-token.md | 7 ++++- packages/spec/src/api/protocol.zod.ts | 32 +++++++++++++-------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/.changeset/22114-meta-read-version-token.md b/.changeset/22114-meta-read-version-token.md index 8fffb503436..db32da828e9 100644 --- a/.changeset/22114-meta-read-version-token.md +++ b/.changeset/22114-meta-read-version-token.md @@ -7,10 +7,15 @@ feat(meta): the `/meta` item read serves the ADR-0008 version token, `If-None-Match: *` pins a save that expects no row, and the 409 `METADATA_CONFLICT` body carries the current version as data -Clause-②: yes (widening) +Clause-②: yes (narrowing) + +**BREAKING** for two request shapes on `PUT /api/v1/meta/:type/:name` that were answered `200` before this release and are now refused `400 VALIDATION_ERROR`, with nothing written: an `If-None-Match` header whose value is anything but `*` (an entity-tag, a list, a weak `W/"*"`, an empty value), and an `If-None-Match` header sent beside `If-Match`. Before, the door did not read `If-None-Match` at all and wrote the body, unguarded or under the `If-Match` alone. The remedy: send `If-None-Match: *` alone to save only where no row exists, or `If-Match` with the version you read alone to save only over that version. No first-party client sends `If-None-Match` on a `PUT`: the SDK sends it only on its cached `GET`, and objectui's ETag hook has no caller. ADR-0008's optimistic lock on `PUT /api/v1/meta/:type/:name` takes the version a client read as `If-Match`, but a client can only send a token it was served, and only a save receipt served one. So the first save after a load could not be pinned: two editors who each loaded an item and saved once overwrote each other. Three additions close that. - **The read serves the token.** `GET /meta/:type/:name` now carries `version` (declared on `GetMetaItemResponseSchema`): the keyed token of the stored row a save to this item would compare against, at the read's scope (the caller's organization partition and `?package=`) and lifecycle (`?state=draft` for the draft row, the plain read for the active row). It is the same producer and the same row as the save receipt's `version`, so a read after a save serves the receipt's token byte for byte. Send it back as `If-Match`. `null` means no stored row is there (an item served from code or a package artifact, or from a row in a scope the save does not write), so the next save is a create. The cached published-value branch (the default plain read) and `?preview=draft` publish no `version`, as they publish no `lock`; their `ETag` stays the cache validator and is not the token. - **"Expect no row" can be said.** `If-None-Match: *` on `PUT /meta/:type/:name` (with `?mode=draft` for a draft) saves only where no row of that lifecycle exists, and is refused `409 METADATA_CONFLICT` once one does. This is the `parentVersion: null` pin `SaveMetaItemRequestSchema` already declared, now reachable over HTTP. The header takes `*` alone: any other value, or `If-None-Match` beside `If-Match`, is refused `400 VALIDATION_ERROR` and nothing is written. No first-party client sends `If-None-Match` on a `PUT`. A save with neither header is last-writer-wins, as before. - **The conflict body names the current version as data.** The 409 of the `/meta` item write doors (save, publish, rollback, reset) now carries `currentVersion` beside the unchanged `error` sentence: the token the sentence names, or `null` when no row of the target lifecycle exists (declared as `MetadataConflictErrorSchema`). On the save door it equals the token the next read at the same address serves, so a client offering "reload" or "overwrite" re-pins without parsing prose. The SDK already exposes it as `err.details.currentVersion`. +- **The receipts' `version` describes are corrected.** `SaveMetaItemResponseSchema.version`, `PublishMetaItemResponseSchema.version` and the package publish door's `published[].version` said "Content hash … currently emitted as `sha256:`" and "409 `metadata_conflict`". The token has been the keyed `hmac-sha256:` digest of the stored content hash since the doors stopped serving the raw hash, and the conflict code on the wire is `METADATA_CONFLICT`. Only the description text moves. + + diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 39044310f67..17970d0c430 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -767,12 +767,16 @@ export const SaveMetaItemRequestSchema = lazySchema(() => z.object({ export const SaveMetaItemResponseSchema = lazySchema(() => z.object({ success: z.boolean(), version: z.string().describe( - 'Content hash of the just-committed body, and the token the ADR-0008 ' + 'Version token of the row this write left, and the token the ADR-0008 ' + 'optimistic-concurrency chain runs on: send it back as the `If-Match` ' + 'request header on the next write to that item and a concurrent edit is ' - + 'reported as 409 `metadata_conflict` instead of silently overwritten. ' - + 'Opaque to callers — echo it verbatim, never parse it. Currently emitted ' - + 'as `sha256:<64 hex chars>`, but the format is not part of this contract.', + + 'refused 409 `METADATA_CONFLICT` instead of silently overwritten. The item ' + + 'read serves the same token for the same row (`GetMetaItemResponseSchema.version`). ' + + 'A keyed digest of the stored content hash, never the hash itself (under ' + + 'the registered crypto provider\'s key, or a process-scoped key while none ' + + 'is registered). Opaque to callers — echo it verbatim, never parse it. ' + + 'Currently emitted as `hmac-sha256:` plus 64 hex characters, but the ' + + 'format is not part of this contract.', ), seq: z.number().int().describe( 'Monotonic sequence number of the metadata event this write appended to ' @@ -978,12 +982,15 @@ export const PublishMetaItemResponseSchema = lazySchema(() => z.object({ + 'best-effort side effects below, each of which reports its own `success`.', ), version: z.string().describe( - 'Content hash of the just-promoted body, and the token the ADR-0008 ' + 'Version token of the row this promotion left, and the token the ADR-0008 ' + 'optimistic-concurrency chain runs on: send it back as the `If-Match` ' + 'request header on the next write to that item and a concurrent edit is ' - + 'reported as 409 `metadata_conflict` instead of silently overwritten. ' - + 'Opaque to callers — echo it verbatim, never parse it. Currently emitted ' - + 'as `sha256:<64 hex chars>`, but the format is not part of this contract.', + + 'refused 409 `METADATA_CONFLICT` instead of silently overwritten. A keyed ' + + 'digest of the stored content hash, never the hash itself — the same form ' + + '`SaveMetaItemResponseSchema.version` and the item read\'s `version` ' + + 'serve. Opaque to callers — echo it verbatim, never parse it. Currently ' + + 'emitted as `hmac-sha256:` plus 64 hex characters, but the format is not ' + + 'part of this contract.', ), seq: z.number().int().describe( 'Monotonic sequence number of the `op=\'publish\'` metadata event this ' @@ -1149,11 +1156,12 @@ export const PublishPackageDraftsResponseSchema = lazySchema(() => z.object({ type: z.string().describe('Metadata type of the promoted draft (canonical singular).'), name: z.string().describe('Item name of the promoted draft.'), version: z.string().describe( - 'Content hash of the just-promoted body — the same ADR-0008 ' + 'Version token of the promoted row — the same keyed ADR-0008 ' + 'optimistic-concurrency token the single-item doors return: echo it ' - + 'back as `If-Match` on the next write to this item. Opaque to ' - + 'callers; currently `sha256:<64 hex chars>`, but the format is not ' - + 'part of this contract.', + + 'back as `If-Match` on the next write to this item. A keyed digest of ' + + 'the stored content hash, never the hash itself. Opaque to callers; ' + + 'currently `hmac-sha256:` plus 64 hex characters, but the format is ' + + 'not part of this contract.', ), advisories: z.array(RuntimeAuthoringIssueSchema).optional().describe( 'Non-gating findings the runtime authoring gate raised against ' From 54ae811d45247ea7ac4e4eafc3091ffbd7e950da Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 00:49:54 +0000 Subject: [PATCH 09/10] chore(spec): regenerate the reference docs on the merged head Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 79361ee500e..e7767c33d25 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -2313,7 +2313,7 @@ Installed package with runtime lifecycle state | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Always true on a 2xx — the draft was promoted. It does NOT cover the best-effort side effects below, each of which reports its own `success`. | -| **version** | `string` | ✅ | Content hash of the just-promoted body, and the token the ADR-0008 optimistic-concurrency chain runs on: send it back as the `If-Match` request header on the next write to that item and a concurrent edit is reported as 409 `metadata_conflict` instead of silently overwritten. Opaque to callers — echo it verbatim, never parse it. Currently emitted as `sha256:<64 hex chars>`, but the format is not part of this contract. | +| **version** | `string` | ✅ | Version token of the row this promotion left, and the token the ADR-0008 optimistic-concurrency chain runs on: send it back as the `If-Match` request header on the next write to that item and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of silently overwritten. A keyed digest of the stored content hash, never the hash itself — the same form `SaveMetaItemResponseSchema.version` and the item read's `version` serve. Opaque to callers — echo it verbatim, never parse it. Currently emitted as `hmac-sha256:` plus 64 hex characters, but the format is not part of this contract. | | **seq** | `integer` | ✅ | Monotonic sequence number of the `op='publish'` metadata event this promotion appended to the item history (sys_metadata_history.event_seq). Orders writes; unlike `version` it is not an OCC token. | | **seedApplied** | `{ success: boolean; inserted: integer; updated: integer; error?: string; … }` | optional | Outcome of materializing a published `seed` body into data rows. Present ONLY when the published type is `seed` — publishing a seed is what makes its rows live, so the load rides along with the metadata promotion. Best-effort: a seed-load problem is surfaced here, never thrown, so a caller must check `seedApplied.success` instead of assuming the 200 covered the data. Absent on the batch path, which suppresses the per-item apply and loads every seed body in one later pass. | | **materializeApplied** | `{ success: boolean; inserted: integer; updated: integer; error?: string }` | optional | Outcome of the ADR-0086 P2 publish-time materializer — the step that projects the published body into its data-plane row (e.g. `permission` → `sys_permission_set`, under the owning package). Present ONLY when a materializer is registered for this metadata type, which is why it is optional: its absence means "no materializer ran", never "it failed". Best-effort, same contract as `seedApplied`. | @@ -2387,7 +2387,7 @@ Installed package with runtime lifecycle state | :--- | :--- | :--- | :--- | | **type** | `string` | ✅ | Metadata type of the promoted draft (canonical singular). | | **name** | `string` | ✅ | Item name of the promoted draft. | -| **version** | `string` | ✅ | Content hash of the just-promoted body — the same ADR-0008 optimistic-concurrency token the single-item doors return: echo it back as `If-Match` on the next write to this item. Opaque to callers; currently `sha256:<64 hex chars>`, but the format is not part of this contract. | +| **version** | `string` | ✅ | Version token of the promoted row — the same keyed ADR-0008 optimistic-concurrency token the single-item doors return: echo it back as `If-Match` on the next write to this item. A keyed digest of the stored content hash, never the hash itself. Opaque to callers; currently `hmac-sha256:` plus 64 hex characters, but the format is not part of this contract. | | **advisories** | `{ rule: string; path: string; where: string; message: string; … }[]` | optional | Non-gating findings the runtime authoring gate raised against THIS draft's promotion (the same element shape and the same omitted-when-empty discipline as `PublishMetaItemResponseSchema.advisories`, riding each element rather than a parallel top-level map). Present ONLY when at least one finding was raised — an empty array is never emitted, so an advisory-free batch's response bytes are unchanged. Advisory by construction: every entry is `warning`/`info`, because an `error` finding refuses the promotion and — the batch being all-or-nothing — aborts the whole batch as `failed[]` instead; `failed[]` elements never carry this key. | ### Nested Shape: `PublishPackageDraftsResponse.failed[number]` @@ -2614,7 +2614,7 @@ Installed package with runtime lifecycle state | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | | -| **version** | `string` | ✅ | Content hash of the just-committed body, and the token the ADR-0008 optimistic-concurrency chain runs on: send it back as the `If-Match` request header on the next write to that item and a concurrent edit is reported as 409 `metadata_conflict` instead of silently overwritten. Opaque to callers — echo it verbatim, never parse it. Currently emitted as `sha256:<64 hex chars>`, but the format is not part of this contract. | +| **version** | `string` | ✅ | Version token of the row this write left, and the token the ADR-0008 optimistic-concurrency chain runs on: send it back as the `If-Match` request header on the next write to that item and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of silently overwritten. The item read serves the same token for the same row (`GetMetaItemResponseSchema.version`). A keyed digest of the stored content hash, never the hash itself (under the registered crypto provider's key, or a process-scoped key while none is registered). Opaque to callers — echo it verbatim, never parse it. Currently emitted as `hmac-sha256:` plus 64 hex characters, but the format is not part of this contract. | | **seq** | `integer` | ✅ | Monotonic sequence number of the metadata event this write appended to the item history (sys_metadata_history.event_seq). Orders writes; unlike `version` it is not an OCC token. | | **state** | `Enum<'draft' \| 'active'>` | ✅ | Lifecycle the body was written into: "draft" when the request asked for draft mode (`?mode=draft`), otherwise "active" (published and live). A draft is staged only — it is not served to the runtime until published. | | **projectionApplied** | `{ success: boolean; error?: string }` | optional | Outcome of the awaited ADR-0094 mutation projector — the post-persist step that materializes this metadata into its derived data-plane read model (e.g. `permission` → `sys_permission_set`). Present ONLY when a projector is registered for this metadata type, which is why it is optional: its absence means "no projector ran", never "the projection failed". Best-effort by design — a projector failure is reported here and logged, never thrown, so a caller that needs the read model to be live must check `projectionApplied.success` rather than rely on the 200. | From e2a28fadf1c02a48c965f0303776872e1a323b79 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 01:45:02 +0000 Subject: [PATCH 10/10] fix(spec,objectql): register the METADATA_CONFLICT arm's stamp under @objectstack/types; the alternate-spelling read pin names the version head read Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- packages/objectql/src/protocol-meta.test.ts | 13 ++++++++++++- packages/spec/src/api/error-code-ledger.zod.ts | 7 +++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index aa82903a782..da8e2f9aa7b 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -786,7 +786,18 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { const result = await protocol.getMetaItem({ type: 'app', name: 'test_app' }); expect(result.item).toMatchObject(sampleApp); - expect(mockEngine.findOne).toHaveBeenCalledTimes(2); + // Two overlay reads, the canonical spelling and then the other — + // and [#22114] then ONE head read at the SAVE's address (canonical + // spelling, env-wide, package-unbound), which serves the read's + // `version`. The row served here sits under the other spelling, + // which no save writes, so that head is empty and `version` is null. + const reads = mockEngine.findOne.mock.calls.map(([table, opts]: any[]) => ({ table, where: opts?.where })); + expect(reads.map((r: any) => r.where?.type)).toEqual(['app', 'apps', 'app']); + expect(reads.every((r: any) => r.table === 'sys_metadata')).toBe(true); + expect(reads[2].where).toMatchObject({ + type: 'app', name: 'test_app', state: 'active', organization_id: null, package_id: null, + }); + expect((result as any).version).toBeNull(); }); it('should return undefined item when not in registry or DB', async () => { diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 902ff00ab15..959c47916c3 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -937,6 +937,13 @@ export const ERROR_CODE_LEDGER = { // runner adopts its verdict for each failed row. 'CONCURRENT_UPDATE', 'ERR_DATASOURCE_UNAVAILABLE', + // [#22114] The `/meta` item write doors' optimistic-lock refusal, carrying + // `currentVersion`: the arm `structuredCodeAnswer` stamps for a + // `METADATA_CONFLICT` whose producer (metadata-protocol's conflict builder) + // stated one. Wire path: the `/meta` save, publish, rollback and reset + // routes answer through `handleRouteError`, which resolves the thrown + // refusal through this table. The code itself is metadata-protocol's. + 'METADATA_CONFLICT', 'UNIQUE_VIOLATION', ], '@objectstack/hono': [