Skip to content

Commit e08892d

Browse files
claude[bot]claude
andauthored
feat(spec,metadata-protocol): cloneData reports droppedFields like every other create face — CloneDataResponseSchema gains the optional member (#16823)
* wip(spec,metadata-protocol,client): cloneData reports droppedFields — schema member, listener, pins, changeset Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x * chore(spec): regenerate authorable-surface and reference docs for CloneDataResponse.droppedFields Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x * docs(api): the clone route's 201 body names its optional droppedFields member Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f89812e commit e08892d

10 files changed

Lines changed: 197 additions & 40 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata-protocol': minor
4+
'@objectstack/client': minor
5+
---
6+
7+
`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.
8+
9+
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.
10+
11+
- **`@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.
12+
- **`@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.
13+
- **`@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.
14+
15+
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.

content/docs/api/data-api.mdx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,13 @@ Gated by the object's `enable.clone` capability (default `true`); an object with
355355
top of the copied values (a bare field map is also accepted). The natural place
356356
to set a new name or clear a unique field.
357357

358-
**Response** `201`: `{ object, id, sourceId, record }`
358+
**Response** `201`: `{ object, id, sourceId, record, droppedFields? }` — the
359+
bare `CloneDataResponseSchema` body, no envelope. `droppedFields` appears only
360+
when the engine stripped a static `readonly` column from the copy — one the
361+
source row carried (a clone is the one create that holds a read-only value the
362+
caller never typed) or one supplied through `overrides` — and names the dropped
363+
keys; the clone still succeeded and the field re-derived its default. It rides
364+
the body only: this route sets no `X-ObjectStack-Dropped-Fields` header.
359365

360366
---
361367

content/docs/references/api/protocol.mdx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,17 @@ Canonical cross-paradigm action/node descriptor (ADR-0018)
484484
| **id** | `string` || The ID of the newly created clone. |
485485
| **sourceId** | `string` || The ID of the record the clone was copied from. |
486486
| **record** | `Record<string, any>` || 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. |
487+
| **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. |
488+
489+
### Nested Shape: `CloneDataResponse.droppedFields[number]`
490+
491+
A write-path strip event: caller-supplied fields legally dropped from the payload
492+
493+
| Property | Type | Required | Description |
494+
| :--- | :--- | :--- | :--- |
495+
| **object** | `string` || Object the write targeted (resolved object name) |
496+
| **fields** | `string[]` || Caller-supplied field names the engine removed from the write payload |
497+
| **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 |
487498

488499

489500
---

packages/client/src/index.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -430,14 +430,24 @@ export interface CreateDataResult<T = any> {
430430
* Spec: CloneDataResponseSchema (#11924)
431431
*
432432
* `CreateDataResult`'s structural sibling plus `sourceId` — `id` names the NEW
433-
* record, `sourceId` the record it was copied from. No `droppedFields`: unlike
434-
* `createData`, the clone producer emits no write-observability event.
433+
* record, `sourceId` the record it was copied from. Since #15703 it carries
434+
* `droppedFields` too: the clone producer reports the engine's readonly-strip
435+
* verdict exactly as `createData` does.
435436
*/
436437
export interface CloneDataResult<T = any> {
437438
object: string;
438439
id: string;
439440
sourceId: string;
440441
record: T;
442+
/**
443+
* [#15703] Fields the server LEGALLY stripped before the clone was written —
444+
* a non-system clone cannot seed a static `readonly` column, whether the value
445+
* was COPIED from the source row or supplied through `overrides`, so those
446+
* keys are dropped and the field re-derives its default. Present only when
447+
* ≥1 field was dropped; the clone still succeeded. Body only: unlike `create`,
448+
* the clone route sets no `X-ObjectStack-Dropped-Fields` header.
449+
*/
450+
droppedFields?: DroppedFieldsEvent[];
441451
}
442452

443453
/** Spec: UpdateDataResponseSchema */

packages/metadata-protocol/src/protocol.readonly-insert.test.ts

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@
2121
// `batchData`'s `create` rows AND both arms of `upsert` that create;
2222
// - every create face whose RESPONSE carries `droppedFields` surfaces the
2323
// ENGINE's `onFieldsDropped` there, which is the channel the ingress used
24-
// to FAKE with a before/after payload diff. `cloneData` is the one face
25-
// that does not: `CloneDataResponseSchema` declares no such member (pinned
26-
// in the firing-control block at the bottom).
24+
// to FAKE with a before/after payload diff. Since #15703 that is every one
25+
// of the six: `cloneData` was the face that did not — its contract
26+
// (`CloneDataResponseSchema`, declared AS PRODUCED) had no such member —
27+
// until the member and the listener landed together (maintainer ruling
28+
// 2026-09-08, option 1); the firing-control block at the bottom enumerates it.
2729
// The enforcement itself is pinned where it now runs, against a real engine:
2830
// `packages/objectql/src/engine-insert-static-readonly-strip.test.ts`. This
2931
// 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
131133
expect(inserts[0].options.context).toEqual({ isSystem: true });
132134
});
133135

134-
it('cloneData forwards the copied row AND the caller overrides whole', async () => {
136+
it('cloneData forwards the copied row AND the caller overrides whole — and reports the engine’s verdict on both', async () => {
135137
const { p, inserts } = makeProtocol();
136-
await p.cloneData({
138+
const res: any = await p.cloneData({
137139
object: 'approval_case',
138140
id: 'src-1',
139141
overrides: { source: 'forged' },
@@ -143,6 +145,15 @@ describe('#14147 — the create ingress DELEGATES the readonly strip to engine.i
143145
// through them is still the engine's to strip (#3043's carried-over case).
144146
expect(inserts[0].data.source).toBe('forged');
145147
expect(inserts[0].data.approval_status, 'the copied readonly column travels too').toBe('approved');
148+
// [#15703] ...and the 201 body says what the engine dropped — the column
149+
// the caller never typed (copied from the source) and the one it forged
150+
// through `overrides`, in the engine's one event. Until #15703 the clone
151+
// stripped and warned but reported nothing on the wire.
152+
expect(res.droppedFields).toEqual([
153+
{ object: 'approval_case', fields: ['approval_status', 'source'], reason: 'readonly' },
154+
]);
155+
expect(res.record).not.toHaveProperty('approval_status');
156+
expect(res.record).not.toHaveProperty('source');
146157
});
147158

148159
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
223234

224235
describe('#14147 — engine listener wiring (the firing control for every assertion above)', () => {
225236
// The faces enumerated here are the ones whose RESPONSE carries
226-
// `droppedFields`: `CreateDataResponse`, `CreateManyDataResponse`, and the
227-
// per-row results of `insertManyData` / `batchData`. `cloneData` is
228-
// deliberately NOT among them — its contract has no such member; the case
229-
// after this one pins that exclusion so "every" stays true of what is listed.
237+
// `droppedFields`: `CreateDataResponse`, `CloneDataResponse` (since #15703),
238+
// `CreateManyDataResponse`, and the per-row results of `insertManyData` /
239+
// `batchData`. That is every create face; the case after this one pins the
240+
// clone by name so the enumeration cannot silently lose the face that was
241+
// the exclusion until its contract gained the member.
230242
it('every create face whose response carries droppedFields passes an onFieldsDropped listener to the engine', async () => {
231243
const { p, inserts } = makeProtocol();
232244
await p.createData({ object: 'approval_case', data: { title: 'A' } });
245+
await p.cloneData({ object: 'approval_case', id: 'src-1' } as any);
233246
await p.createManyData({ object: 'approval_case', records: [{ title: 'A' }] });
234247
await p.batchData({
235248
object: 'approval_case',
@@ -244,26 +257,28 @@ describe('#14147 — engine listener wiring (the firing control for every assert
244257
request: { operation: 'upsert', records: [{ id: 'new-1', data: { title: 'A' } }] },
245258
} as any);
246259
await p.insertManyData({ object: 'approval_case', records: [{ title: 'A' }] });
247-
expect(inserts, 'createData · createManyData · batchData create · batchData upsert-create ×2 (no id / unknown id) · insertManyData')
248-
.toHaveLength(6);
260+
expect(inserts, 'createData · cloneData · createManyData · batchData create · batchData upsert-create ×2 (no id / unknown id) · insertManyData')
261+
.toHaveLength(7);
249262
for (const call of inserts) {
250263
expect(typeof call.options?.onFieldsDropped, 'a face with no listener reports a silent drop').toBe('function');
251264
}
252265
});
253266

254-
it('cloneData is the one create face that passes NO listener — its response contract declares no droppedFields', async () => {
255-
// `CloneDataResponseSchema` (#11924, declared AS PRODUCED) is exactly
256-
// `{ object, id, sourceId, record }`; `search-clone-schema-conformance.test.ts`
257-
// holds the producer to that key set and asserts `droppedFields` in
258-
// particular is absent. So a listener here would have nowhere contracted
259-
// to report to. The engine still strips a copied-over or overridden
260-
// readonly column and still logs the `warn` line — the clone simply does
261-
// not carry the event on the wire. Reporting it means a new response key,
262-
// which is a spec change with its own card, not a delegation detail.
267+
it('cloneData passes the listener too — the sixth face, now that its response contract declares droppedFields (#15703)', async () => {
268+
// Until #15703 this case pinned the ABSENCE of the listener, with its
269+
// reason: `CloneDataResponseSchema` (#11924, declared AS PRODUCED) was
270+
// exactly `{ object, id, sourceId, record }`, so a listener had nowhere
271+
// contracted to report to, and the engine's strip of a copied-over or
272+
// overridden readonly column reached only the `warn` log. The maintainer
273+
// ruling of 2026-09-08 (option 1) added the optional member and this
274+
// listener in one change — the schema stays declared as produced — and
275+
// `search-clone-schema-conformance.test.ts` now measures the produced
276+
// member on the wire. This case pins the presence by name, so the
277+
// enumeration above cannot drop the clone without a red here.
263278
const { p, inserts } = makeProtocol();
264279
await p.cloneData({ object: 'approval_case', id: 'src-1' } as any);
265280
expect(inserts).toHaveLength(1);
266-
expect(inserts[0].options?.onFieldsDropped).toBeUndefined();
281+
expect(typeof inserts[0].options?.onFieldsDropped, 'a clone with no listener reports a silent drop').toBe('function');
267282
});
268283

269284
it('a create that drops NOTHING reports no droppedFields at all', async () => {

packages/metadata-protocol/src/protocol.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10891,7 +10891,18 @@ export class ObjectStackProtocolImplementation implements
1089110891
// goes over whole and the insert re-derives the field's `defaultValue`,
1089210892
// symmetric with createData. `overrides` are applied ABOVE this line, so
1089310893
// a readonly key smuggled through them is still judged by the strip.
10894-
const result = await this.engine.insert(request.object, data, ctxOpt as any);
10894+
//
10895+
// [#15703] And the verdict is REPORTED, the same listener `createData`
10896+
// wires: a clone is the one create shape that carries a read-only column
10897+
// without the caller typing it (the source's `approval_status` travels in
10898+
// the copy), so the 201 body says which keys the engine dropped instead of
10899+
// leaving the caller to diff `record` against the source. Maintainer
10900+
// ruling 2026-09-08 (option 1); `CloneDataResponseSchema` declares the
10901+
// member in the same change, because that schema is declared AS PRODUCED.
10902+
const dropped: DroppedFieldsEvent[] = [];
10903+
const opts: any = { onFieldsDropped: (e: DroppedFieldsEvent) => { dropped.push(e); } };
10904+
if (ctx !== undefined) opts.context = ctx;
10905+
const result = await this.engine.insert(request.object, data, opts);
1089510906
// [#7823] Same ingress strip as `createData` — a clone's 201 body is
1089610907
// the same generic-data-path surface. (The SOURCE row was read through
1089710908
// the engine's find path, which already omits internal fields, so the
@@ -10903,6 +10914,7 @@ export class ObjectStackProtocolImplementation implements
1090310914
id: result.id,
1090410915
sourceId: request.id,
1090510916
record: result,
10917+
...(dropped.length > 0 ? { droppedFields: dropped } : {}),
1090610918
};
1090710919
}
1090810920

0 commit comments

Comments
 (0)