From 966a4acb1bfb2d3fc316fb4bf301bc514f2bd48e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 09:29:05 +0000 Subject: [PATCH 1/4] wip(spec): widen DeleteMetaItemResponseSchema with seq + projectionApplied --- packages/spec/src/api/protocol.test.ts | 140 +++++++++++++++++++++++++ packages/spec/src/api/protocol.zod.ts | 79 +++++++++++++- 2 files changed, 217 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index f920a059d0..cc6981b336 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -864,6 +864,146 @@ describe('PublishMetaItemResponseSchema (#7294 — declares the full publish res }); }); +import { DeleteMetaItemResponseSchema } from './protocol.zod'; +import type { DeleteMetaItemResponse } from './protocol.zod'; + +/** Type-level identity helpers for the reachability pins below. */ +type EqD< A, B > = (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false; +type AssertD< T extends true > = T; + +/** + * #13155 — the compile-time half, at module scope and EXPORTED. + * + * This is the cost the card names, stated as a type fact: `projectionApplied` + * is the channel a caller reads INSTEAD of trusting the 200, and `seq` is the + * ordering token the history/audit trail is read by. Undeclared, neither was + * reachable from a `DeleteMetaItemResponse` without an `as any` — the + * consumer-side tolerance Prime Directive #12 rejects. + * + * These live at module scope rather than inside an `it()` for the reason the + * sibling read-side file records: an unread alias in a function body is TS6196 + * under `noUnusedLocals`, and a pin no program compiles is a phantom check. + * `packages/spec` compiles its tests via `tsconfig.test.json`, so reverting the + * declaration turns these red at `pnpm typecheck`. + */ +export type DeleteSeqIsReachable = AssertD< EqD< DeleteMetaItemResponse['seq'], number | undefined > >; +export type DeleteProjectionIsReachable = AssertD< + EqD< DeleteMetaItemResponse['projectionApplied'], { success: boolean; error?: string | undefined } | undefined > +>; + +/** + * #13155 — the third verb on the metadata door declares what its branch sends. + * + * `save` (#5745, by the #5563 maintainer ruling) and `publish` (#7294) each + * declare `seq` and `projectionApplied`. `delete/reset` runs the same ADR-0094 + * projector and the same history append and puts the same two keys on the + * wire, and declared neither — so this is a decision made for one verb and + * never carried to its sibling, not a fresh question. The first case below is + * the one that was red: it asserts nothing is stripped. + * + * ⚠️ Optionality here DIFFERS from both siblings, and it is measured rather + * than mirrored: `seq` is REQUIRED on save and publish because each has a + * single success return that always sets it, while `deleteMetaItem` has FOUR + * success returns and only one of them appends a history event. The other + * three — the two "no row to delete" no-ops and the control-plane legacy + * raw-engine delete (#5264, which writes no history row and emits no watch + * event) — answer without a `seq`. Declaring it required would make three of + * the producer's own returns fail their own contract, which is the #5563 + * defect in mirror image. + */ +describe('DeleteMetaItemResponseSchema (#13155 — carries #5745 to the third verb)', () => { + /** + * A verbatim capture of a real `deleteMetaItem` return — the repository + * path's delete-ful branch, the only one of the four that carries `seq`. + */ + const realResponse = { + success: true, + reset: true, + seq: 3, + message: 'Customization overlay deleted — view/cases reset to artifact default. [seq=3]', + }; + + it('round-trips a real response without stripping any field', () => { + const parsed = DeleteMetaItemResponseSchema.parse(realResponse); + expect(Object.keys(parsed).sort()).toEqual(Object.keys(realResponse).sort()); + expect(parsed).toEqual(realResponse); + }); + + it('keeps seq as an integer and rejects a fractional one', () => { + expect(DeleteMetaItemResponseSchema.parse(realResponse).seq).toBe(3); + expect(DeleteMetaItemResponseSchema.safeParse({ ...realResponse, seq: 3.5 }).success).toBe(false); + }); + + it('leaves seq optional — the three branches that append no history event omit it', () => { + // The repository path's miss: a success/no-op, no history event. + const noOverlay = { + success: true, + reset: false, + message: 'No customization overlay found for view/cases — already at artifact default.', + }; + // The legacy raw-engine delete: a row really went away, still no `seq`, + // and its receipt message carries no `[seq=…]` suffix for that reason. + const legacyDeleted = { + success: true, + reset: true, + message: 'Customization overlay deleted — view/cases reset to artifact default.', + }; + for (const body of [noOverlay, legacyDeleted]) { + const parsed = DeleteMetaItemResponseSchema.parse(body); + expect(DeleteMetaItemResponseSchema.safeParse(body).success).toBe(true); + expect(parsed.seq).toBeUndefined(); + // Absence of `seq` must not be readable as "nothing happened" — `reset` + // is the key that answers that, and it survives on both branches. + expect(parsed.reset).toBe(body.reset); + } + }); + + it('requires success — every one of the four returns emits it', () => { + const body: Record = { ...realResponse }; + delete body.success; + expect(DeleteMetaItemResponseSchema.safeParse(body).success).toBe(false); + }); + + it('carries projectionApplied — the same ADR-0094 receipt both siblings declare', () => { + const parsed = DeleteMetaItemResponseSchema.parse({ + ...realResponse, + projectionApplied: { success: false, error: 'boom-from-projector' }, + }); + expect(parsed.projectionApplied).toEqual({ success: false, error: 'boom-from-projector' }); + // Best-effort by contract: the projector threw, the delete still + // succeeded, and the failure is reported here rather than as a non-200 — + // which is why a caller reads this instead of trusting the 200. + expect(parsed.success).toBe(true); + }); + + it('projectionApplied.success is required once the key is present', () => { + expect( + DeleteMetaItemResponseSchema.safeParse({ ...realResponse, projectionApplied: { error: 'x' } }).success, + ).toBe(false); + }); + + it('leaves projectionApplied optional — absent means no projector ran', () => { + expect(realResponse).not.toHaveProperty('projectionApplied'); + expect(DeleteMetaItemResponseSchema.safeParse(realResponse).success).toBe(true); + expect(DeleteMetaItemResponseSchema.parse(realResponse).projectionApplied).toBeUndefined(); + }); + + it('declares neither `version` nor `advisories` — this branch provably sends neither', () => { + // The mirror is the siblings' SHAPE, not their member list. A delete mints + // no new content hash (the row is gone, so there is no ADR-0008 OCC token + // to echo as `If-Match`), and the #4463 authoring gate runs on the two + // WRITE doors by D1 — a delete submits no body for it to judge. Declaring + // either would be a contract for bytes no producer emits. + const parsed = DeleteMetaItemResponseSchema.parse({ + ...realResponse, + version: 'sha256:0000000000000000000000000000000000000000000000000000000000000000', + advisories: [], + }) as Record; + expect(parsed).not.toHaveProperty('version'); + expect(parsed).not.toHaveProperty('advisories'); + }); +}); + import { RuntimeAuthoringIssueSchema } from './protocol.zod'; /** diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 05b5afa6bd..163951fdc1 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -1191,12 +1191,87 @@ export const DeleteMetaItemRequestSchema = lazySchema(() => z.object({ /** * Delete Metadata Item Response - * `reset === true` means a row was deleted (item is now at artifact default). - * `reset === false` means no overlay row existed (already at artifact default). + * + * Describes the FULL body `DELETE /api/v1/meta/:type/:name` returns (#13155 — + * the #5745 ruling carried to the third verb on this door). `reset === true` + * means a row was deleted (item is now at artifact default); `reset === false` + * means no overlay row existed (already at artifact default). + * + * **Why the widening.** The metadata door has three write verbs, all running + * the same ADR-0094 mutation projector and the same history append, and all + * three put `seq` (and `projectionApplied`, when a projector is registered) on + * the wire. #5745 ("补齐 spec 字段", the #5563 maintainer ruling) filled the gap + * on {@link SaveMetaItemResponseSchema} and #7294 carried it to + * {@link PublishMetaItemResponseSchema}; this declaration alone stopped at + * `{ success, reset, message }`, so a `.parse()` of a real reset response + * silently STRIPPED both keys and `DeleteMetaItemResponse` could not name them + * at the type level. That is a decision made for one verb and never carried to + * its sibling, not a fresh question — the two keys below are the siblings' + * own, unchanged in type and meaning. + * + * ⚠️ DECLARATION change only — zero runtime behaviour is altered, and nothing + * here says the wire was wrong. The wire is right and the two siblings already + * agree with it; the third declaration was short. + * + * **Presence was measured against `origin/main`, not assumed.** The sole + * producer is `ObjectStackProtocolImplementation.deleteMetaItem`, whose return + * the REST route hands to `res.json()` verbatim (`rest-server.ts`, the reset + * door), so the protocol return IS the wire body. That method has exactly FOUR + * success returns, and they are why both new keys are optional here while + * `seq` is REQUIRED on both siblings: + * + * - **repository path, row deleted** — the only return that carries either + * key: `seq` always, `projectionApplied` when a projector is registered. + * - **repository path, no row** ("nothing to delete" / "already at artifact + * default") — a success/no-op that appends no history event, so there is no + * sequence number to report. + * - **legacy raw-engine path, row deleted** — reachable in control-plane + * bootstrap for a code-only type (#5264, deliberately alive). It writes no + * history row and emits no watch event, so it carries no `seq` either; its + * receipt message omits the `[seq=…]` suffix for the same reason. + * - **legacy raw-engine path, no row** — same no-op as above. + * + * So `seq` absent means "this branch appended no history event", NEVER "the + * delete did not happen", and `projectionApplied` absent means "no projector + * ran", never "the projection failed" — the same reading its siblings declare. + * + * Two keys the siblings carry are deliberately NOT declared, because this + * branch provably never sends them: `version` (a delete mints no new content + * hash — the row is gone, so there is no ADR-0008 OCC token to echo) and + * `advisories` (the #4463 runtime authoring gate runs on the two WRITE doors + * by D1; a delete submits no body for it to judge). */ export const DeleteMetaItemResponseSchema = lazySchema(() => z.object({ success: z.boolean(), reset: z.boolean().optional(), + seq: z.number().int().optional().describe( + 'Monotonic sequence number of the metadata event this delete appended to ' + + 'the item history (sys_metadata_history.event_seq) — the ordering token ' + + 'the history/audit trail is read by, and the same key both write-verb ' + + 'siblings declare. Unlike `version` it is not an OCC token. Optional ' + + 'HERE, unlike on those siblings, because only the repository path\'s ' + + 'delete-ful branch appends an event: a no-op reset (no overlay row ' + + 'existed) and the control-plane legacy raw-engine path both answer ' + + 'without one. Absence means "this branch appended no history event", ' + + 'never "nothing was deleted" — read `reset` for that.', + ), + projectionApplied: z.object({ + success: z.boolean().describe('False when the projector threw; the metadata delete itself still succeeded.'), + error: z.string().optional().describe('Projector failure message, present only when `success` is false.'), + }).optional().describe( + 'Outcome of the awaited ADR-0094 mutation projector — the post-persist step ' + + 'that materializes this metadata into its derived data-plane read model. ' + + 'The same receipt {@link SaveMetaItemResponseSchema} and ' + + '{@link PublishMetaItemResponseSchema} carry, because the projector runs ' + + 'on all three verbs of this door: on a delete it re-reads the layered ' + + 'state and either retires the derived record or resets it to the artifact ' + + 'baseline. Present ONLY when a projector is registered for this metadata ' + + 'type AND a row was actually deleted, 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.', + ), message: z.string().optional(), })); From 235376aafe7f7d02734732abc7d537cf6a0a27e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 09:38:14 +0000 Subject: [PATCH 2/4] test(spec,objectql): pin the widened delete response on both sides --- .../delete-meta-response-conformance.test.ts | 268 ++++++++++++++++++ packages/spec/authorable-surface/api.json | 2 + packages/spec/src/api/protocol.test.ts | 5 +- 3 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 packages/objectql/src/delete-meta-response-conformance.test.ts diff --git a/packages/objectql/src/delete-meta-response-conformance.test.ts b/packages/objectql/src/delete-meta-response-conformance.test.ts new file mode 100644 index 0000000000..bdcf53b5a0 --- /dev/null +++ b/packages/objectql/src/delete-meta-response-conformance.test.ts @@ -0,0 +1,268 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #13155 — conformance gate: the body `deleteMetaItem` really returns must + * parse through `DeleteMetaItemResponseSchema` with NOTHING stripped. + * + * This is the producer side of the declaration. The spec-side suite + * (`packages/spec/src/api/protocol.test.ts`) pins what the schema says; this + * one pins that the schema still matches what the code emits, driving the REAL + * protocol against a REAL ObjectQL engine. The two together are what makes + * "declared = returned" checkable — a future field added to the response, or an + * existing one dropped, turns this red instead of silently vanishing at parse. + * + * Why the REST layer needs no separate case: the route hands this exact object + * to `res.json()` verbatim (`rest-server.ts`, `DELETE /meta/:type/:name`), so + * the protocol return IS the wire body. + * + * The exact shape of the two sibling gates on the same door + * (`save-meta-response-conformance.test.ts` #5745, + * `publish-meta-response-conformance.test.ts` #7294) — deliberately, because + * the third verb is the same class of surface and its declaration was the + * short one. Before this card the first assertion below was red in the same + * quiet way theirs were: `safeParse` SUCCEEDED and `seq` / + * `projectionApplied` were dropped from the parsed result, so the "stripped + * keys" set was non-empty. That is the direction it must never drift back to. + * + * ## The delete door's own surface: FOUR success returns, not one + * + * Both siblings have a single success return that always sets `seq`, so they + * declare it REQUIRED. `deleteMetaItem` has four, and only one of them appends + * a history event — which is the whole reason `seq` is `.optional()` here and + * why each branch needs its own case: + * + * 1. repository path, row deleted → `seq`, plus `projectionApplied` when a + * projector is registered. The only branch that carries either key. + * 2. repository path, no row ("nothing to delete") → a success/no-op. + * 3. legacy raw-engine path, row deleted (#5264, deliberately alive) → no + * history row, no watch event, so no `seq` even though a row really went + * away. The branch that would make a REQUIRED `seq` a false contract. + * 4. legacy raw-engine path, no row → the same no-op as (2). + * + * Cases 2 and 3 are the ones a mirror-the-siblings declaration gets wrong if + * it is written from the siblings' shape instead of from this producer. + */ +import { describe, it, expect } from 'vitest'; +import type { ServiceObject } from '@objectstack/spec/data'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { DeleteMetaItemResponseSchema } from '@objectstack/spec/api'; +import { ObjectQL } from './engine.js'; + +const sysMetadataObject: ServiceObject = { + name: 'sys_metadata', + label: 'System Metadata', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const }, + type: { name: 'type', label: 'Type', type: 'text' as const, required: true }, + name: { name: 'name', label: 'Name', type: 'text' as const, required: true }, + organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const }, + // [#8682] Part of the real row's uniqueness key `(type, name, + // organization_id, package_id)` and written by `SysMetadataRepository` + // — the declared-field door judges the payload against this map, so + // omitting it here would be a fixture defect, not a simplification. + package_id: { name: 'package_id', label: 'Package', type: 'text' as const }, + metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const }, + checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, + state: { name: 'state', label: 'State', type: 'text' as const }, + version: { name: 'version', label: 'Version', type: 'number' as const }, + created_at: { name: 'created_at', label: 'Created', type: 'datetime' as const }, + updated_at: { name: 'updated_at', label: 'Updated', type: 'datetime' as const }, + }, +}; + +function makeMemoryDriver() { + const stores = new Map>>(); + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + let nextId = 0; + // `$and` / `$or` are conjoined WITH their sibling keys, the way a real + // driver ANDs them — see #7620 for what the short-circuiting shape cost. + const matchesWhere = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k.startsWith('$')) continue; + const rowVal = row[k]; + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; + const a = rowVal === undefined ? null : rowVal; + const b = expected === undefined ? null : expected; + if (a !== b) return false; + } + return true; + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + return Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + nextId += 1; + const id = (data.id as string) ?? `r_${nextId}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const cur = s.get(id); + if (!cur) throw new Error(`not found: ${object}/${id}`); + const updated = { ...cur, ...data, id }; + s.set(id, updated); + return updated; + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + if (id && storeFor(object).has(id)) return this.update(object, id, data); + return this.create(object, data); + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, stores }; +} + +async function makeProtocol() { + const engine = new ObjectQL(); + const { driver, stores } = makeMemoryDriver(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(sysMetadataObject, 'test-package'); + return { p: new ObjectStackProtocolImplementation(engine), stores }; +} + +// [#7741] the inline arm requires the object binding pair +const viewBody = (label: string) => ({ + name: 'cases', type: 'grid', label, columns: ['id'], object: 'case', viewKind: 'list', +}); + +const ORG = 'org_x'; + +/** Keys the producer emitted that the schema refused to carry through. */ +function strippedKeys(raw: Record): string[] { + const parsed = DeleteMetaItemResponseSchema.parse(raw) as Record; + return Object.keys(raw).filter((k) => !(k in parsed)); +} + +describe('deleteMetaItem response conforms to DeleteMetaItemResponseSchema (#13155)', () => { + it('repository path, row deleted: parses green, strips nothing, and carries seq', async () => { + const { p } = await makeProtocol(); + await (p as any).saveMetaItem({ + type: 'view', name: 'cases', organizationId: ORG, item: viewBody('A'), + }); + + const raw: any = await (p as any).deleteMetaItem({ + type: 'view', name: 'cases', organizationId: ORG, + }); + + // The assertion that was red before the declaration: `seq` rode the + // wire and the schema dropped it on the floor. + expect(Object.keys(raw)).toContain('seq'); + expect(strippedKeys(raw)).toEqual([]); + + const parsed = DeleteMetaItemResponseSchema.parse(raw); + expect(parsed.success).toBe(true); + expect(parsed.reset).toBe(true); + // The ordering token the history/audit trail is read by — an integer, + // and the same value the receipt message's `[seq=…]` suffix quotes. + expect(typeof parsed.seq).toBe('number'); + expect(Number.isInteger(parsed.seq)).toBe(true); + expect(parsed.message).toContain(`[seq=${parsed.seq}]`); + }); + + it('with an ADR-0094 projector registered: projectionApplied is carried through', async () => { + const { p } = await makeProtocol(); + await (p as any).saveMetaItem({ + type: 'view', name: 'cases', organizationId: ORG, item: viewBody('P'), + }); + // Registered AFTER the save so the save's own projection is not what + // this case reads — the delete's is. + (p as any).registerMutationProjector('view', async () => { throw new Error('boom-from-projector'); }); + + const raw: any = await (p as any).deleteMetaItem({ + type: 'view', name: 'cases', organizationId: ORG, + }); + + expect(Object.keys(raw)).toContain('projectionApplied'); + expect(strippedKeys(raw)).toEqual([]); + const parsed = DeleteMetaItemResponseSchema.parse(raw); + // Best-effort by contract: the projector threw, the delete still + // succeeded, and the failure is reported HERE rather than as a non-200. + // This is the channel the card names — a caller that needs the derived + // read model to be live reads it instead of trusting the 200. + expect(parsed.success).toBe(true); + expect(parsed.projectionApplied).toEqual({ success: false, error: 'boom-from-projector' }); + }); + + it('no projector registered → projectionApplied is absent, which is why it is optional', async () => { + const { p } = await makeProtocol(); + await (p as any).saveMetaItem({ + type: 'view', name: 'cases', organizationId: ORG, item: viewBody('N'), + }); + + const raw: any = await (p as any).deleteMetaItem({ + type: 'view', name: 'cases', organizationId: ORG, + }); + + expect(raw.projectionApplied).toBeUndefined(); + expect(strippedKeys(raw)).toEqual([]); + expect(DeleteMetaItemResponseSchema.safeParse(raw).success).toBe(true); + }); + + it('repository path, no overlay row: a no-op success that carries no seq', async () => { + const { p } = await makeProtocol(); + + const raw: any = await (p as any).deleteMetaItem({ + type: 'view', name: 'never_written', organizationId: ORG, + }); + + // This is the branch that makes `seq` optional rather than required. + // Declaring it required would make the producer's own no-op fail its + // own contract — the #5563 defect in mirror image. + expect(raw).not.toHaveProperty('seq'); + expect(strippedKeys(raw)).toEqual([]); + const parsed = DeleteMetaItemResponseSchema.parse(raw); + expect(parsed.success).toBe(true); + // `reset`, not the absence of `seq`, is what says nothing was removed. + expect(parsed.reset).toBe(false); + expect(parsed.seq).toBeUndefined(); + }); + + it('seq really is the history event sequence: it advances across the item\'s writes', async () => { + const { p } = await makeProtocol(); + const saved: any = await (p as any).saveMetaItem({ + type: 'view', name: 'cases', organizationId: ORG, item: viewBody('S'), + }); + const raw: any = await (p as any).deleteMetaItem({ + type: 'view', name: 'cases', organizationId: ORG, + }); + + // The delete's tombstone event comes after the save's write event on + // the same item, which is the ordering property a history/audit + // consumer reads the key FOR. A `seq` that did not move would parse + // just as green, so the contract needs this asserted, not assumed. + const parsed = DeleteMetaItemResponseSchema.parse(raw); + expect(parsed.seq).toBeGreaterThan(saved.seq); + }); +}); diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 892b89620e..02dc09d002 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -485,7 +485,9 @@ "api/DeleteMetaItemRequest:state", "api/DeleteMetaItemRequest:type", "api/DeleteMetaItemResponse:message", + "api/DeleteMetaItemResponse:projectionApplied", "api/DeleteMetaItemResponse:reset", + "api/DeleteMetaItemResponse:seq", "api/DeleteMetaItemResponse:success", "api/DeleteResponse:error", "api/DeleteResponse:id", diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index cc6981b336..a8353c5dd2 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -2075,7 +2075,10 @@ describe('MetadataProtocol declares auditMetaItem (#11678)', () => { }); import { DeleteMetaItemRequestSchema } from './protocol.zod'; -import type { DeleteMetaItemRequest, DeleteMetaItemResponse } from './protocol.zod'; +// `DeleteMetaItemResponse` is imported once, above, beside the response-side +// suite that pins its two #13155 keys — the member pin below reads it from +// there rather than re-importing the same binding. +import type { DeleteMetaItemRequest } from './protocol.zod'; describe('DeleteMetaItemRequestSchema declares the contract members the reset door sends (#11679)', () => { // The sharper sibling of the audit door: the MEMBER was declared all along From 4348aa5ef39f3b7ed8222879dfac6b57e04e1664 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 10:25:25 +0000 Subject: [PATCH 3/4] fix(spec): DeleteMetaItemResponseSchema declares seq and projectionApplied (#13155) --- .changeset/issue-13155-delete-response-parity.md | 11 +++++++++++ content/docs/references/api/protocol.mdx | 9 +++++++++ .../2026-07-unknown-key-strictness-ledger.counts.md | 2 +- .../src/delete-meta-response-conformance.test.ts | 9 ++++++++- 4 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 .changeset/issue-13155-delete-response-parity.md diff --git a/.changeset/issue-13155-delete-response-parity.md b/.changeset/issue-13155-delete-response-parity.md new file mode 100644 index 0000000000..c94d4bcc3d --- /dev/null +++ b/.changeset/issue-13155-delete-response-parity.md @@ -0,0 +1,11 @@ +--- +"@objectstack/spec": minor +--- + +Widen `DeleteMetaItemResponseSchema` to parity with the two sibling verbs on the same metadata door (#13155). `DELETE /api/v1/meta/:type/:name` now declares `seq` and `projectionApplied` — the two keys its own branch has always sent, and the two keys `SaveMetaItemResponseSchema` (#5745, by the #5563 maintainer ruling) and `PublishMetaItemResponseSchema` (#7294) already declare. All three verbs run the same ADR-0094 mutation projector and the same history append; two declared the result and the third did not, so this carries an existing ruling to its sibling rather than taking a new decision. + +This is an accept-set widening with zero wire change — every payload that parsed before still parses, and the two keys the schema used to silently strip now survive a parse. `projectionApplied` is the channel a caller reads *instead of* trusting the 200 (the projector is best-effort: a failure is reported on the receipt, never thrown), and `seq` is the ordering token the history/audit trail is read by; undeclared, neither was reachable from a `DeleteMetaItemResponse` without an `as any`, and a generated client carried neither. + +Both keys are `.optional()`, measured against the producer rather than copied from the siblings: `deleteMetaItem` has **four** success returns and only one appends a history event, so `seq` is required on the siblings but optional here — the two "nothing to delete" no-ops and the control-plane legacy raw-engine delete (which writes no history row and emits no watch event) all answer without it. `seq` absent therefore means "this branch appended no history event", never "nothing was deleted"; `reset` remains the key that answers that. `version` and `advisories` are deliberately *not* declared: a delete mints no new content hash, and the runtime authoring gate runs on the two write doors only. + +Nothing in `@objectstack/client` changes — its `meta.deleteItem` binds this exported type rather than transcribing a member list, so it inherits the widening. Pinned on both sides: the spec suite pins what the schema says, and a new producer-side conformance gate in `@objectstack/objectql` drives the real protocol against a real engine and fails if the response and the declaration drift apart again. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 25a9cedac1..67eee114ae 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -688,8 +688,17 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | | | **reset** | `boolean` | optional | | +| **seq** | `integer` | optional | Monotonic sequence number of the metadata event this delete appended to the item history (sys_metadata_history.event_seq) — the ordering token the history/audit trail is read by, and the same key both write-verb siblings declare. Unlike `version` it is not an OCC token. Optional HERE, unlike on those siblings, because only the repository path's delete-ful branch appends an event: a no-op reset (no overlay row existed) and the control-plane legacy raw-engine path both answer without one. Absence means "this branch appended no history event", never "nothing was deleted" — read `reset` for that. | +| **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. The same receipt `{@link SaveMetaItemResponseSchema}` and `{@link PublishMetaItemResponseSchema}` carry, because the projector runs on all three verbs of this door: on a delete it re-reads the layered state and either retires the derived record or resets it to the artifact baseline. Present ONLY when a projector is registered for this metadata type AND a row was actually deleted, 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. | | **message** | `string` | optional | | +### Nested Shape: `DeleteMetaItemResponse.projectionApplied` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **success** | `boolean` | ✅ | False when the projector threw; the metadata delete itself still succeeded. | +| **error** | `string` | optional | Projector failure message, present only when `success` is false. | + --- diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index dfd18ba561..5932174968 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -257,7 +257,7 @@ directory rather than per file. | Dir | Sites | |---|---| | `ai/` | 77 | -| `api/` | 444 | +| `api/` | 445 | | `cloud/` | 83 | | `identity/` | 32 | | `integration/` | 10 | diff --git a/packages/objectql/src/delete-meta-response-conformance.test.ts b/packages/objectql/src/delete-meta-response-conformance.test.ts index bdcf53b5a0..652630d1b0 100644 --- a/packages/objectql/src/delete-meta-response-conformance.test.ts +++ b/packages/objectql/src/delete-meta-response-conformance.test.ts @@ -105,7 +105,14 @@ function makeMemoryDriver() { async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, async find(object: string, ast: any) { - return Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); + const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); + // Hold the caller's bound, AFTER the filter and by PRESENCE — a + // limit-blind double answers more rows than the caller asked for + // and every assertion about a bounded read passes for the wrong + // reason (`check:objectql-double-limit`). The two sibling + // conformance files predate that gate and sit in its shrink-only + // baseline; a new double conforms. + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; }, async findOne(object: string, ast: any) { for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r; From 408da7bd262713b71000c2dbb69de59f4930f3df Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 13:20:12 +0000 Subject: [PATCH 4/4] chore(spec): regenerate strictness ledger counts from the merged tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `api/` bucket moved on both sides of the merge — main widened the analytics and automation route response schemas (+4), this branch widened DeleteMetaItemResponseSchema (+1). The counts file carries a `merge=os-regen` driver, so the merge left it un-text-merged; this is the regenerated value, produced by `pnpm --filter @objectstack/spec gen:strictness-ledger`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LpRNHxWZgSUgVnFT9mQQo4 --- docs/audits/2026-07-unknown-key-strictness-ledger.counts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 5932174968..3c59612446 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -257,7 +257,7 @@ directory rather than per file. | Dir | Sites | |---|---| | `ai/` | 77 | -| `api/` | 445 | +| `api/` | 449 | | `cloud/` | 83 | | `identity/` | 32 | | `integration/` | 10 |