From fa3d1e5f32abce5daf7e57f8fcb5d9b293b3cefd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 06:34:13 +0000 Subject: [PATCH 1/3] =?UTF-8?q?wip(spec,metadata-protocol,client):=20clone?= =?UTF-8?q?Data=20reports=20droppedFields=20=E2=80=94=20schema=20member,?= =?UTF-8?q?=20listener,=20pins,=20changeset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x --- .changeset/clone-data-dropped-fields.md | 15 ++++ packages/client/src/index.ts | 14 ++- .../src/protocol.readonly-insert.test.ts | 57 +++++++----- packages/metadata-protocol/src/protocol.ts | 14 ++- .../search-clone-schema-conformance.test.ts | 86 ++++++++++++++++--- packages/objectql/src/protocol-data.test.ts | 8 +- packages/spec/src/api/protocol.zod.ts | 23 ++++- 7 files changed, 178 insertions(+), 39 deletions(-) create mode 100644 .changeset/clone-data-dropped-fields.md diff --git a/.changeset/clone-data-dropped-fields.md b/.changeset/clone-data-dropped-fields.md new file mode 100644 index 0000000000..0ca58322b2 --- /dev/null +++ b/.changeset/clone-data-dropped-fields.md @@ -0,0 +1,15 @@ +--- +'@objectstack/spec': minor +'@objectstack/metadata-protocol': minor +'@objectstack/client': minor +--- + +`cloneData` reports `droppedFields` like every other create face: `CloneDataResponseSchema` (`@objectstack/spec/api`) gains an optional `droppedFields` member of the same shape as `CreateDataResponseSchema`'s, and the `POST /data/:object/:id/clone` 201 body carries it whenever the engine stripped a static `readonly` column from the clone. + +A clone IS a create, and it is the one create shape that can carry a read-only column without the caller typing it: the source row is copied whole (`approval_status: 'approved'` included), `overrides` are applied on top, and the copy is inserted. Since the create-side strip moved into `engine.insert` that column has been stripped and logged at `warn` — but the 201 body said nothing, so a caller that cloned an approved record and read `record.approval_status: 'draft'` back had no field in the response telling it why, while `createData`, `createManyData`, `insertManyData` and every `batchData` row that created already answered on the wire. Maintainer ruling 2026-09-08 (option 1 on #15703): report it, the same way. + +- **`@objectstack/spec`** — `CloneDataResponseSchema.droppedFields`: `DroppedFieldsEvent[]`, optional, omit-when-empty — present ONLY when ≥1 field was dropped, and the clone still succeeded without them (status unchanged). The schema is declared AS PRODUCED, so the member and the producer land in one change. Additive: a client that reads only `object` / `id` / `sourceId` / `record` sees no difference. +- **`@objectstack/metadata-protocol`** — `cloneData` passes the engine the same `onFieldsDropped` listener `createData` wires and spreads the collected events onto its return as `droppedFields`. The strip itself is unchanged and still the engine's (`isSystem`-gated, `defaultValue` re-derived); what is new is that a copied-in or overridden readonly key is now named in the body instead of only in the server log. +- **`@objectstack/client`** — `CloneDataResult` (the declared mirror of `CloneDataResponseSchema`, the return type of `client.data.clone`) gains the same optional `droppedFields?: DroppedFieldsEvent[]`, so a TypeScript caller reads the member without a cast; its docblock no longer states that the clone producer emits no write-observability event. + +Body only, deliberately: the clone route relays the producer verbatim and sets no `X-ObjectStack-Dropped-Fields` header (the single-record `POST /data/:object` and `PATCH /data/:object/:id` mounts do); the schema's `.describe()` says so rather than promising a header the route does not send. diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 7fa9e129ca..82613482f8 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -430,14 +430,24 @@ export interface CreateDataResult { * Spec: CloneDataResponseSchema (#11924) * * `CreateDataResult`'s structural sibling plus `sourceId` — `id` names the NEW - * record, `sourceId` the record it was copied from. No `droppedFields`: unlike - * `createData`, the clone producer emits no write-observability event. + * record, `sourceId` the record it was copied from. Since #15703 it carries + * `droppedFields` too: the clone producer reports the engine's readonly-strip + * verdict exactly as `createData` does. */ export interface CloneDataResult { object: string; id: string; sourceId: string; record: T; + /** + * [#15703] Fields the server LEGALLY stripped before the clone was written — + * a non-system clone cannot seed a static `readonly` column, whether the value + * was COPIED from the source row or supplied through `overrides`, so those + * keys are dropped and the field re-derives its default. Present only when + * ≥1 field was dropped; the clone still succeeded. Body only: unlike `create`, + * the clone route sets no `X-ObjectStack-Dropped-Fields` header. + */ + droppedFields?: DroppedFieldsEvent[]; } /** Spec: UpdateDataResponseSchema */ diff --git a/packages/metadata-protocol/src/protocol.readonly-insert.test.ts b/packages/metadata-protocol/src/protocol.readonly-insert.test.ts index ef08bee07b..f1d162c998 100644 --- a/packages/metadata-protocol/src/protocol.readonly-insert.test.ts +++ b/packages/metadata-protocol/src/protocol.readonly-insert.test.ts @@ -21,9 +21,11 @@ // `batchData`'s `create` rows AND both arms of `upsert` that create; // - every create face whose RESPONSE carries `droppedFields` surfaces the // ENGINE's `onFieldsDropped` there, which is the channel the ingress used -// to FAKE with a before/after payload diff. `cloneData` is the one face -// that does not: `CloneDataResponseSchema` declares no such member (pinned -// in the firing-control block at the bottom). +// to FAKE with a before/after payload diff. Since #15703 that is every one +// of the six: `cloneData` was the face that did not — its contract +// (`CloneDataResponseSchema`, declared AS PRODUCED) had no such member — +// until the member and the listener landed together (maintainer ruling +// 2026-09-08, option 1); the firing-control block at the bottom enumerates it. // The enforcement itself is pinned where it now runs, against a real engine: // `packages/objectql/src/engine-insert-static-readonly-strip.test.ts`. This // package does not depend on `@objectstack/objectql`, so a strip assertion here @@ -131,9 +133,9 @@ describe('#14147 — the create ingress DELEGATES the readonly strip to engine.i expect(inserts[0].options.context).toEqual({ isSystem: true }); }); - it('cloneData forwards the copied row AND the caller overrides whole', async () => { + it('cloneData forwards the copied row AND the caller overrides whole — and reports the engine’s verdict on both', async () => { const { p, inserts } = makeProtocol(); - await p.cloneData({ + const res: any = await p.cloneData({ object: 'approval_case', id: 'src-1', overrides: { source: 'forged' }, @@ -143,6 +145,15 @@ describe('#14147 — the create ingress DELEGATES the readonly strip to engine.i // through them is still the engine's to strip (#3043's carried-over case). expect(inserts[0].data.source).toBe('forged'); expect(inserts[0].data.approval_status, 'the copied readonly column travels too').toBe('approved'); + // [#15703] ...and the 201 body says what the engine dropped — the column + // the caller never typed (copied from the source) and the one it forged + // through `overrides`, in the engine's one event. Until #15703 the clone + // stripped and warned but reported nothing on the wire. + expect(res.droppedFields).toEqual([ + { object: 'approval_case', fields: ['approval_status', 'source'], reason: 'readonly' }, + ]); + expect(res.record).not.toHaveProperty('approval_status'); + expect(res.record).not.toHaveProperty('source'); }); it('createManyData forwards every row whole and AGGREGATES the engine’s event', async () => { @@ -223,13 +234,15 @@ describe('#14147 — the create ingress DELEGATES the readonly strip to engine.i describe('#14147 — engine listener wiring (the firing control for every assertion above)', () => { // The faces enumerated here are the ones whose RESPONSE carries - // `droppedFields`: `CreateDataResponse`, `CreateManyDataResponse`, and the - // per-row results of `insertManyData` / `batchData`. `cloneData` is - // deliberately NOT among them — its contract has no such member; the case - // after this one pins that exclusion so "every" stays true of what is listed. + // `droppedFields`: `CreateDataResponse`, `CloneDataResponse` (since #15703), + // `CreateManyDataResponse`, and the per-row results of `insertManyData` / + // `batchData`. That is every create face; the case after this one pins the + // clone by name so the enumeration cannot silently lose the face that was + // the exclusion until its contract gained the member. it('every create face whose response carries droppedFields passes an onFieldsDropped listener to the engine', async () => { const { p, inserts } = makeProtocol(); await p.createData({ object: 'approval_case', data: { title: 'A' } }); + await p.cloneData({ object: 'approval_case', id: 'src-1' } as any); await p.createManyData({ object: 'approval_case', records: [{ title: 'A' }] }); await p.batchData({ object: 'approval_case', @@ -244,26 +257,28 @@ describe('#14147 — engine listener wiring (the firing control for every assert request: { operation: 'upsert', records: [{ id: 'new-1', data: { title: 'A' } }] }, } as any); await p.insertManyData({ object: 'approval_case', records: [{ title: 'A' }] }); - expect(inserts, 'createData · createManyData · batchData create · batchData upsert-create ×2 (no id / unknown id) · insertManyData') - .toHaveLength(6); + expect(inserts, 'createData · cloneData · createManyData · batchData create · batchData upsert-create ×2 (no id / unknown id) · insertManyData') + .toHaveLength(7); for (const call of inserts) { expect(typeof call.options?.onFieldsDropped, 'a face with no listener reports a silent drop').toBe('function'); } }); - it('cloneData is the one create face that passes NO listener — its response contract declares no droppedFields', async () => { - // `CloneDataResponseSchema` (#11924, declared AS PRODUCED) is exactly - // `{ object, id, sourceId, record }`; `search-clone-schema-conformance.test.ts` - // holds the producer to that key set and asserts `droppedFields` in - // particular is absent. So a listener here would have nowhere contracted - // to report to. The engine still strips a copied-over or overridden - // readonly column and still logs the `warn` line — the clone simply does - // not carry the event on the wire. Reporting it means a new response key, - // which is a spec change with its own card, not a delegation detail. + it('cloneData passes the listener too — the sixth face, now that its response contract declares droppedFields (#15703)', async () => { + // Until #15703 this case pinned the ABSENCE of the listener, with its + // reason: `CloneDataResponseSchema` (#11924, declared AS PRODUCED) was + // exactly `{ object, id, sourceId, record }`, so a listener had nowhere + // contracted to report to, and the engine's strip of a copied-over or + // overridden readonly column reached only the `warn` log. The maintainer + // ruling of 2026-09-08 (option 1) added the optional member and this + // listener in one change — the schema stays declared as produced — and + // `search-clone-schema-conformance.test.ts` now measures the produced + // member on the wire. This case pins the presence by name, so the + // enumeration above cannot drop the clone without a red here. const { p, inserts } = makeProtocol(); await p.cloneData({ object: 'approval_case', id: 'src-1' } as any); expect(inserts).toHaveLength(1); - expect(inserts[0].options?.onFieldsDropped).toBeUndefined(); + expect(typeof inserts[0].options?.onFieldsDropped, 'a clone with no listener reports a silent drop').toBe('function'); }); it('a create that drops NOTHING reports no droppedFields at all', async () => { diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 456a24e53f..67bcd138cc 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -10891,7 +10891,18 @@ export class ObjectStackProtocolImplementation implements // goes over whole and the insert re-derives the field's `defaultValue`, // symmetric with createData. `overrides` are applied ABOVE this line, so // a readonly key smuggled through them is still judged by the strip. - const result = await this.engine.insert(request.object, data, ctxOpt as any); + // + // [#15703] And the verdict is REPORTED, the same listener `createData` + // wires: a clone is the one create shape that carries a read-only column + // without the caller typing it (the source's `approval_status` travels in + // the copy), so the 201 body says which keys the engine dropped instead of + // leaving the caller to diff `record` against the source. Maintainer + // ruling 2026-09-08 (option 1); `CloneDataResponseSchema` declares the + // member in the same change, because that schema is declared AS PRODUCED. + const dropped: DroppedFieldsEvent[] = []; + const opts: any = { onFieldsDropped: (e: DroppedFieldsEvent) => { dropped.push(e); } }; + if (ctx !== undefined) opts.context = ctx; + const result = await this.engine.insert(request.object, data, opts); // [#7823] Same ingress strip as `createData` — a clone's 201 body is // the same generic-data-path surface. (The SOURCE row was read through // the engine's find path, which already omits internal fields, so the @@ -10903,6 +10914,7 @@ export class ObjectStackProtocolImplementation implements id: result.id, sourceId: request.id, record: result, + ...(dropped.length > 0 ? { droppedFields: dropped } : {}), }; } diff --git a/packages/metadata-protocol/src/search-clone-schema-conformance.test.ts b/packages/metadata-protocol/src/search-clone-schema-conformance.test.ts index 6059a927a0..2e5c722b7a 100644 --- a/packages/metadata-protocol/src/search-clone-schema-conformance.test.ts +++ b/packages/metadata-protocol/src/search-clone-schema-conformance.test.ts @@ -29,6 +29,7 @@ import { describe, it, expect, vi } from 'vitest'; import { CloneDataResponseSchema, SearchAllHitSchema, SearchAllPageHitSchema, SearchAllResponseSchema } from '@objectstack/spec/api'; +import type { DroppedFieldsEvent } from '@objectstack/spec/data'; import { ObjectStackProtocolImplementation } from './protocol.js'; import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; @@ -172,22 +173,52 @@ describe('[#11924] searchAll conforms to SearchAllResponseSchema', () => { // cloneData — the real clone over a fixture engine // ───────────────────────────────────────────────────────────────────────────── -function makeCloneProtocol() { +/** + * A protocol over one clonable object whose field set includes a static + * `readonly` column — the one kind of key a clone carries WITHOUT the caller + * typing it (it is copied from the source row before `overrides` apply). + * + * [#15703] The engine double behaves like the real one AFTER #14147: it strips + * the `readonly` keys the payload carried and reports them, once, through + * `options.onFieldsDropped` — the channel whose wire shape this file measures. + * The strip RULE is not re-tested here (it is pinned against a real engine in + * `packages/objectql/src/engine-insert-static-readonly-strip.test.ts`); a + * double that never dropped anything would leave the `droppedFields` half of + * the conformance below vacuously green, so the source row is the switch: + * pass one carrying `approval_status` to exercise the produced branch, the + * default row to exercise omit-when-empty. + */ +function makeCloneProtocol(opts: { source?: Record } = {}) { const customer = { name: 'customer', fields: { name: text('name'), notes: text('notes'), + approval_status: { name: 'approval_status', type: 'text', readonly: true, defaultValue: 'draft' }, }, }; - const source = { id: 'cus_1', name: 'Acme Industrial', notes: 'source row' }; - const inserts: Array<{ object: string; data: Record }> = []; + const source = opts.source ?? { id: 'cus_1', name: 'Acme Industrial', notes: 'source row' }; + const inserts: Array<{ object: string; data: Record; options: unknown }> = []; const engine = { registry: { getObject: (n: string) => (n === 'customer' ? customer : undefined) }, findOne: vi.fn(async (object: string, query?: EngineFindOneQueryInput) => { assertEngineFindOnePredicate(object, query); return ({ ...source }); }), - insert: vi.fn(async (object: string, data: Record) => { - inserts.push({ object, data }); - return { id: 'cus_2', ...data, created_at: '2026-08-25T00:00:00Z' }; + insert: vi.fn(async ( + object: string, + data: Record, + options?: { onFieldsDropped?: (e: DroppedFieldsEvent) => void }, + ) => { + inserts.push({ object, data, options }); + const row: Record = { ...data }; + const dropped: string[] = []; + for (const [name, def] of Object.entries(customer.fields)) { + if (!(def as { readonly?: boolean }).readonly || !(name in row)) continue; + delete row[name]; + dropped.push(name); + } + if (dropped.length > 0 && typeof options?.onFieldsDropped === 'function') { + options.onFieldsDropped({ object, fields: dropped, reason: 'readonly' }); + } + return { id: 'cus_2', ...row, created_at: '2026-08-25T00:00:00Z' }; }), }; return { p: new ObjectStackProtocolImplementation(engine as never), inserts }; @@ -215,15 +246,50 @@ describe('[#11924] cloneData conforms to CloneDataResponseSchema', () => { expect(body.record).toMatchObject({ id: 'cus_2', name: 'Acme Copy', notes: 'source row' }); }); - it('emits no top-level key the spec does not declare — in particular no `droppedFields`', async () => { + it('emits no top-level key the spec does not declare — `droppedFields` included, now that it is declared AND produced (#15703)', async () => { + // The source row carries a static `readonly` column, so the copy does + // too — the clone-specific way a create ends up holding a key the + // caller never typed. The engine strips it and reports it; the body + // names it. Until #15703 this case pinned the key's ABSENCE by name, + // because the producer emitted nothing and the schema (declared AS + // PRODUCED, #11924) must not promise what conformance cannot measure; + // the member and the listener landed together, so the pin flips to the + // produced branch — measured on the real producer, not a hand-built body. + const { p } = makeCloneProtocol({ + source: { id: 'cus_1', name: 'Acme Industrial', notes: 'source row', approval_status: 'approved' }, + }); + const returned = await p.cloneData({ object: 'customer', id: 'cus_1' }); + const body = overTheWire(returned); + + const declared = declaredKeys(CloneDataResponseSchema); + const undeclared = Object.keys(body).filter((k) => !declared.has(k)); + expect(undeclared, 'keys emitted by cloneData that CloneDataResponseSchema never declares').toEqual([]); + + expect(body.droppedFields).toEqual([ + { object: 'customer', fields: ['approval_status'], reason: 'readonly' }, + ]); + expect(body.record).not.toHaveProperty('approval_status'); + + // Values, both as returned and over the wire: the declared element + // shape (`DroppedFieldsEventSchema`) holds for what the producer emits. + const raw = CloneDataResponseSchema.safeParse(returned); + expect(raw.error?.issues ?? []).toEqual([]); + expect(raw.success).toBe(true); + const wire = CloneDataResponseSchema.safeParse(body); + expect(wire.error?.issues ?? []).toEqual([]); + expect(wire.success).toBe(true); + }); + + it('omits `droppedFields` when nothing was dropped — the member is optional and omit-when-empty, as on createData', async () => { + // The default source row carries no readonly column, so the engine + // reports nothing and the key must not appear at all: an empty array + // would be a second spelling of "nothing dropped" (#7643 — absence is + // the answer). Still a subset of the declared key set. const { p } = makeCloneProtocol(); const body = overTheWire(await p.cloneData({ object: 'customer', id: 'cus_1' })); const declared = declaredKeys(CloneDataResponseSchema); const undeclared = Object.keys(body).filter((k) => !declared.has(k)); expect(undeclared, 'keys emitted by cloneData that CloneDataResponseSchema never declares').toEqual([]); - // Pinned by name because the declaration deliberately omits it: unlike - // `createData`, this producer reports no write-observability event, and - // the schema must not promise one it cannot measure (#11924). expect(body).not.toHaveProperty('droppedFields'); }); }); diff --git a/packages/objectql/src/protocol-data.test.ts b/packages/objectql/src/protocol-data.test.ts index bf7a26cd9f..e6962503cc 100644 --- a/packages/objectql/src/protocol-data.test.ts +++ b/packages/objectql/src/protocol-data.test.ts @@ -651,7 +651,13 @@ describe('ObjectStackProtocolImplementation - Data Operations', () => { const ctx = { userId: 'u1' }; await protocol.cloneData({ object: 'account', id: 'src-1', context: ctx }); expect(findOne).toHaveBeenCalledWith('account', expect.objectContaining({ context: ctx })); - expect(insert).toHaveBeenCalledWith('account', expect.anything(), { context: ctx }); + // [#15703] The insert options carry the context AND the + // `onFieldsDropped` listener `createData` wires — the clone reports + // the engine's readonly-strip verdict on its 201 body since #15703. + expect(insert).toHaveBeenCalledWith('account', expect.anything(), { + context: ctx, + onFieldsDropped: expect.any(Function), + }); }); it('rejects with 403 CLONE_DISABLED when enable.clone === false', async () => { diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 168e08d34d..1b3d1e969a 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -1999,10 +1999,14 @@ export const CreateDataResponseSchema = lazySchema(() => z.object({ * copy runs the insert path: engine-owned columns re-derived, the static * `readonly` strip applied inside `engine.insert` for a non-system caller — * the 2026-09-03 ruling, #14147; the #3043 ingress copy is deleted — and - * internal fields omitted from the response, #7823) — but unlike `createData` - * the producer emits no `droppedFields` - * member, so none is declared: a key the producer never writes would be a - * promise conformance cannot measure. + * internal fields omitted from the response, #7823) — and since #15703 it + * reports the engine's `onFieldsDropped` verdict as `droppedFields` exactly as + * `createData` does (maintainer ruling 2026-09-08, option 1): the producer + * passes the listener and the member is declared in the same change, so the + * key stays one conformance can measure. A clone is the one create shape that + * can carry a read-only column WITHOUT the caller typing it — the source row's + * `approval_status: 'approved'` is copied before `overrides` are applied — so + * this is the face where a silent strip was least discoverable. */ export const CloneDataResponseSchema = lazySchema(() => z.object({ object: z.string().describe('The object name.'), @@ -2014,6 +2018,17 @@ export const CloneDataResponseSchema = lazySchema(() => z.object({ + 're-derived by the insert path rather than copied from the source; caller-supplied ' + '`overrides` win over copied values.' ), + droppedFields: z.array(DroppedFieldsEventSchema).optional().describe( + 'Write-observability: fields that were LEGALLY stripped before the clone was written — ' + + 'a non-system clone cannot seed a static `readonly` column, whether the value was ' + + 'COPIED from the source row or supplied through `overrides` (the strip runs inside ' + + '`engine.insert`, after the `beforeInsert` hooks, `isSystem`-gated, exactly as on ' + + '`createData`), so those keys are dropped and the field re-derives its default. ' + + 'Present ONLY when ≥1 field was dropped; the clone still succeeded without them ' + + '(status/success semantics unchanged). Carried in the 201 body only — this route ' + + 'relays the producer verbatim and sets no `X-ObjectStack-Dropped-Fields` header. ' + + 'Optional — omit-when-empty keeps the shape backward-compatible for existing clients.' + ), })); /** From b617ce88608acd37be4bf792b975bf6956b888a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 06:44:11 +0000 Subject: [PATCH 2/3] chore(spec): regenerate authorable-surface and reference docs for CloneDataResponse.droppedFields Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x --- content/docs/references/api/protocol.mdx | 11 +++++++++++ packages/spec/authorable-surface/api.json | 1 + 2 files changed, 12 insertions(+) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index ff5005203a..38f24ad2ad 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -484,6 +484,17 @@ Canonical cross-paradigm action/node descriptor (ADR-0018) | **id** | `string` | ✅ | The ID of the newly created clone. | | **sourceId** | `string` | ✅ | The ID of the record the clone was copied from. | | **record** | `Record` | ✅ | The created clone, including server-generated fields. Engine-owned values (injected system/audit columns, autonumbers, computed formula/summary fields) are re-derived by the insert path rather than copied from the source; caller-supplied `overrides` win over copied values. | +| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability: fields that were LEGALLY stripped before the clone was written — a non-system clone cannot seed a static `readonly` column, whether the value was COPIED from the source row or supplied through `overrides` (the strip runs inside `engine.insert`, after the `beforeInsert` hooks, `isSystem`-gated, exactly as on `createData`), so those keys are dropped and the field re-derives its default. Present ONLY when ≥1 field was dropped; the clone still succeeded without them (status/success semantics unchanged). Carried in the 201 body only — this route relays the producer verbatim and sets no `X-ObjectStack-Dropped-Fields` header. Optional — omit-when-empty keeps the shape backward-compatible for existing clients. | + +### Nested Shape: `CloneDataResponse.droppedFields[number]` + +A write-path strip event: caller-supplied fields legally dropped from the payload + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **object** | `string` | ✅ | Object the write targeted (resolved object name) | +| **fields** | `string[]` | ✅ | Caller-supplied field names the engine removed from the write payload | +| **reason** | `Enum<'readonly' \| 'readonly_when' \| 'primary_key'>` | ✅ | Why the fields were dropped: static readonly, a TRUE readonlyWhen predicate, or the primary-key strip of a payload id the engine ruled is not an identifier | --- diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 36ecc5babf..2973d16f3b 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -310,6 +310,7 @@ "api/CheckPermissionRequest:recordId", "api/CheckPermissionResponse:allowed", "api/CheckPermissionResponse:reason", + "api/CloneDataResponse:droppedFields", "api/CloneDataResponse:id", "api/CloneDataResponse:object", "api/CloneDataResponse:record", From d117857520a6d6037dd8e1b4627b145b4a0eb390 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 08:41:53 +0000 Subject: [PATCH 3/3] docs(api): the clone route's 201 body names its optional droppedFields member Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x --- content/docs/api/data-api.mdx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/content/docs/api/data-api.mdx b/content/docs/api/data-api.mdx index d11a70e700..f773238fc3 100644 --- a/content/docs/api/data-api.mdx +++ b/content/docs/api/data-api.mdx @@ -355,7 +355,13 @@ Gated by the object's `enable.clone` capability (default `true`); an object with top of the copied values (a bare field map is also accepted). The natural place to set a new name or clear a unique field. -**Response** `201`: `{ object, id, sourceId, record }` +**Response** `201`: `{ object, id, sourceId, record, droppedFields? }` — the +bare `CloneDataResponseSchema` body, no envelope. `droppedFields` appears only +when the engine stripped a static `readonly` column from the copy — one the +source row carried (a clone is the one create that holds a read-only value the +caller never typed) or one supplied through `overrides` — and names the dropped +keys; the clone still succeeded and the field re-derived its default. It rides +the body only: this route sets no `X-ObjectStack-Dropped-Fields` header. ---