From 91f01c50adb1ab76a5c6d617408b741db1702a92 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 14:52:37 +0000 Subject: [PATCH 1/2] feat(rest): surface silently-dropped write fields on PATCH/POST /data (#3431) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the engine's `onFieldsDropped` strip-observability channel (#3413) through the DataProtocol and REST write path so an external API caller is no longer silently stripped when `readonly` (#2948) / `readonlyWhen` (#3042) drops caller-supplied fields. The write still succeeds — this only makes the strip observable (the same silent-success class #3407 fixed flow-side). - metadata-protocol: `updateData` collects the engine's `onFieldsDropped` events; `createData` surfaces the #3043 static-`readonly` INGRESS strip via a payload diff (that strip runs BEFORE the engine, which is INSERT-readonly- exempt, so the engine listener never sees it). Both attach an optional `droppedFields` list to the response when ≥1 field was dropped. - spec: `UpdateDataResponseSchema` / `CreateDataResponseSchema` gain an optional `droppedFields: DroppedFieldsEvent[]` — present only on a drop, so the shape stays backward-compatible for clients that only read `record`. - rest: PATCH `/data/:object/:id` and POST `/data/:object` echo drops as the `X-ObjectStack-Dropped-Fields` response header and keep the structured list on the body. Status/success semantics unchanged (200 update / 201 create). Tests: protocol passthrough (update forwards engine events; create surfaces the ingress strip; no-drop omits the key) and REST header/body (single + multi field, no-drop, create 201). Deferred (issue #3431 D2 open questions): bulk (`updateManyData` / `createManyData` / `batchData`) and GraphQL mutation wiring, typed `@objectstack/client` warnings, and adding the header to the Hono CORS `exposeHeaders` allow-list for cross-origin browser reads. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NNqeAmxCCq9jgLFwa9subq --- .changeset/rest-patch-data-dropped-fields.md | 45 +++++++ .../src/protocol.dropped-fields.test.ts | 124 ++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 62 +++++++-- packages/rest/src/rest-dropped-fields.test.ts | 110 ++++++++++++++++ packages/rest/src/rest-server.ts | 44 +++++++ packages/spec/src/api/protocol.zod.ts | 19 +++ 6 files changed, 396 insertions(+), 8 deletions(-) create mode 100644 .changeset/rest-patch-data-dropped-fields.md create mode 100644 packages/metadata-protocol/src/protocol.dropped-fields.test.ts create mode 100644 packages/rest/src/rest-dropped-fields.test.ts diff --git a/.changeset/rest-patch-data-dropped-fields.md b/.changeset/rest-patch-data-dropped-fields.md new file mode 100644 index 0000000000..c078ef6b0a --- /dev/null +++ b/.changeset/rest-patch-data-dropped-fields.md @@ -0,0 +1,45 @@ +--- +"@objectstack/spec": minor +"@objectstack/metadata-protocol": minor +"@objectstack/rest": minor +--- + +feat(rest): surface silently-dropped write fields on PATCH/POST /data (#3431) + +#3413 (closes #3407) built the engine-level strip-observability channel +(`WriteObservabilityOptions.onFieldsDropped`) and wired the flow side +(`update_record` / `create_record` emit a step warning + `droppedFields`). The +**REST write path was never wired**, so an external API caller writing N fields +still got a bare `200 + record` when `readonly` (#2948) / `readonlyWhen` (#3042) +stripping meant `< N` actually landed — the same silent-success class #3407 +fixed flow-side, just on HTTP. The only way to notice was a per-field diff of +the returned row (which need not echo every field). This wires the channel +through the protocol → REST, on both write verbs. + +**Passthrough (metadata-protocol).** `updateData` now registers an +`onFieldsDropped` collector on `engine.update` and returns the events on the +response as `droppedFields`. `createData` surfaces the #3043 static-`readonly` +INGRESS strip too — that strip runs at the protocol ingress +(`stripReadonlyForInsert`), *before* the engine, so it is recovered by diffing +the supplied payload against the stripped one (the engine's `onFieldsDropped` is +also wired for a future insert-side engine strip). A faulty listener never +breaks the write — the engine catches and logs. + +**Contract (spec).** `UpdateDataResponseSchema` / `CreateDataResponseSchema` +gain an **optional** `droppedFields: DroppedFieldsEvent[]` — present only when +≥1 field was dropped. Optional + omit-when-empty keeps the response shape +backward-compatible for clients that only read `record`. + +**REST surface.** PATCH `/data/:object/:id` and POST `/data/:object` echo the +drops as an `X-ObjectStack-Dropped-Fields` response header +(`field;reason=` tokens, comma-joined — e.g. +`approval_status;reason=readonly`) and keep the structured `droppedFields` on +the body. **Status/success semantics are unchanged** (200 update / 201 create) — +a strip is legitimate semantics, not a failure (same principle as #3413). The +FLS write gate is untouched (it already fails closed with 403). + +Out of scope (issue #3431 D2 open questions, deferred): bulk +(`updateManyData` / `createManyData` / `batchData`) and GraphQL mutation wiring, +typed `@objectstack/client` warnings, and adding the header to the Hono CORS +`exposeHeaders` allow-list for cross-origin browser reads (the body +`droppedFields` is the cross-origin-safe channel meanwhile). diff --git a/packages/metadata-protocol/src/protocol.dropped-fields.test.ts b/packages/metadata-protocol/src/protocol.dropped-fields.test.ts new file mode 100644 index 0000000000..07f52f36e3 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.dropped-fields.test.ts @@ -0,0 +1,124 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#3431] REST/API write paths must not SILENTLY drop caller-supplied fields. +// #3413 built the engine-level `onFieldsDropped` channel and wired the flow +// (`update_record`) side; this locks the DataProtocol passthrough that carries +// the strip back to the REST layer: +// - updateData forwards the engine's onFieldsDropped events (readonly / +// readonly_when) onto the response as `droppedFields`; +// - createData surfaces the #3043 static-`readonly` INGRESS strip, which runs +// BEFORE the engine (so it is recovered by diffing the payload, not via the +// engine listener) — symmetric with update; +// - no strip → NO `droppedFields` key, so the response shape stays +// backward-compatible for clients that only read `record`. + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +const SCHEMA = { + name: 'approval_case', + fields: { + title: { name: 'title', type: 'text' }, + approval_status: { name: 'approval_status', type: 'text', readonly: true, defaultValue: 'draft' }, + }, +}; + +describe('updateData — forwards engine write strips as droppedFields (#3431)', () => { + it('surfaces a readonly strip the engine reports via onFieldsDropped', async () => { + const engine = { + registry: { getObject: () => SCHEMA }, + // Stand in for the engine stripping `approval_status` and reporting it. + update: vi.fn(async (object: string, data: any, options?: any) => { + options?.onFieldsDropped?.({ object, fields: ['approval_status'], reason: 'readonly' }); + return { id: 'rec-1', title: data.title }; + }), + findOne: vi.fn(async () => null), + }; + const p = new ObjectStackProtocolImplementation(engine as any); + const res: any = await p.updateData({ + object: 'approval_case', + id: 'rec-1', + data: { title: 'B', approval_status: 'approved' }, + context: { userId: 'u1' }, + }); + expect(res.droppedFields).toEqual([ + { object: 'approval_case', fields: ['approval_status'], reason: 'readonly' }, + ]); + // The write still succeeded; the returned record is unchanged in shape. + expect(res.record).toEqual({ id: 'rec-1', title: 'B' }); + }); + + it('forwards multiple strip passes in order (readonly_when then readonly)', async () => { + const engine = { + registry: { getObject: () => SCHEMA }, + update: vi.fn(async (object: string, _data: any, options?: any) => { + options?.onFieldsDropped?.({ object, fields: ['locked'], reason: 'readonly_when' }); + options?.onFieldsDropped?.({ object, fields: ['approval_status'], reason: 'readonly' }); + return { id: 'rec-1' }; + }), + findOne: vi.fn(async () => null), + }; + const p = new ObjectStackProtocolImplementation(engine as any); + const res: any = await p.updateData({ object: 'approval_case', id: 'rec-1', data: {} }); + expect(res.droppedFields).toEqual([ + { object: 'approval_case', fields: ['locked'], reason: 'readonly_when' }, + { object: 'approval_case', fields: ['approval_status'], reason: 'readonly' }, + ]); + }); + + it('omits droppedFields entirely when the engine stripped nothing', async () => { + const engine = { + registry: { getObject: () => SCHEMA }, + update: vi.fn(async (_o: string, data: any) => ({ id: 'rec-1', ...data })), + findOne: vi.fn(async () => null), + }; + const p = new ObjectStackProtocolImplementation(engine as any); + const res: any = await p.updateData({ object: 'approval_case', id: 'rec-1', data: { title: 'B' } }); + expect(res).not.toHaveProperty('droppedFields'); + }); +}); + +describe('createData — surfaces the #3043 ingress readonly strip as droppedFields (#3431)', () => { + function makeProtocol() { + const engine = { + registry: { getObject: (n: string) => (n === 'approval_case' ? SCHEMA : undefined) }, + insert: vi.fn(async (_object: string, data: any) => ({ id: 'rec-1', ...data })), + }; + return { p: new ObjectStackProtocolImplementation(engine as any), engine }; + } + + it('reports the forged readonly field a non-system create dropped', async () => { + const { p } = makeProtocol(); + const res: any = await p.createData({ + object: 'approval_case', + data: { title: 'A', approval_status: 'approved' }, + context: { userId: 'u1' }, + }); + expect(res.droppedFields).toEqual([ + { object: 'approval_case', fields: ['approval_status'], reason: 'readonly' }, + ]); + // Stripped field is absent from the persisted payload (existing #3043 behaviour). + expect(res.record).not.toHaveProperty('approval_status'); + }); + + it('omits droppedFields when the create seeds no readonly field', async () => { + const { p } = makeProtocol(); + const res: any = await p.createData({ + object: 'approval_case', + data: { title: 'A' }, + context: { userId: 'u1' }, + }); + expect(res).not.toHaveProperty('droppedFields'); + }); + + it('a system-context create keeps the field — no strip, no droppedFields', async () => { + const { p } = makeProtocol(); + const res: any = await p.createData({ + object: 'approval_case', + data: { title: 'A', approval_status: 'approved' }, + context: { isSystem: true }, + }); + expect(res).not.toHaveProperty('droppedFields'); + expect(res.record.approval_status).toBe('approved'); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index fce493ebe6..02853b5c95 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -16,7 +16,7 @@ import type { } from '@objectstack/spec/api'; import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities } from '@objectstack/spec/api'; import { readServiceSelfInfo } from '@objectstack/spec/api'; -import { parseFilterAST, isFilterAST } from '@objectstack/spec/data'; +import { parseFilterAST, isFilterAST, type DroppedFieldsEvent } from '@objectstack/spec/data'; import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared'; import { type FormView, isAggregatedViewContainer } from '@objectstack/spec/ui'; import { METADATA_FORM_REGISTRY } from '@objectstack/spec/system'; @@ -588,6 +588,34 @@ function stripReadonlyForInsert(schema: any, data: any, context: any): any { return Array.isArray(data) ? data.map(stripRow) : stripRow(data); } +/** + * [#3431] Recover a `DroppedFieldsEvent` from a before/after write-payload diff. + * + * The UPDATE strips (static `readonly` / `readonlyWhen`) run INSIDE the engine, + * which reports them via the `onFieldsDropped` listener (wired in `updateData`). + * The CREATE `readonly` strip, however, runs at THIS protocol ingress + * (`stripReadonlyForInsert`, #3043) — BEFORE the engine — so the engine listener + * never sees it. Diffing the caller-supplied keys against the stripped payload + * recovers exactly which supplied fields the ingress strip removed, so the create + * path can surface them symmetrically with update. + * + * Returns `null` when nothing was dropped (same reference, non-object, array, or + * no key delta) so callers can `if (ev) dropped.push(ev)` without emitting empty + * events. Mirrors the engine's own before/after key-set diff (`reportDroppedFields` + * in objectql/engine.ts) so both channels agree on what "dropped" means. + */ +function diffDroppedFields( + object: string, + before: unknown, + after: unknown, + reason: DroppedFieldsEvent['reason'], +): DroppedFieldsEvent | null { + if (before === after || before == null || typeof before !== 'object' || Array.isArray(before)) return null; + const afterObj = (after ?? {}) as Record; + const fields = Object.keys(before as Record).filter((k) => !(k in afterObj)); + return fields.length > 0 ? { object, fields, reason } : null; +} + /** * Service Configuration for Discovery * Maps service names to their routes and plugin providers. @@ -2829,15 +2857,24 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { request.data, request.context, ); - const result = await this.engine.insert( - request.object, - data, - request.context !== undefined ? { context: request.context } as any : undefined, - ); + // [#3431] The #3043 ingress strip above is SILENT by contract; surface it + // so a REST/API caller learns which supplied fields were dropped, symmetric + // with `updateData`. The strip lives at THIS ingress (not the engine, which + // is INSERT-readonly-exempt, #3413), so recover it by diffing the supplied + // payload against the stripped one. The engine's `onFieldsDropped` is ALSO + // wired below so a FUTURE insert-side engine strip surfaces automatically + // through the same list instead of going silent. + const dropped: DroppedFieldsEvent[] = []; + const ingressDropped = diffDroppedFields(request.object, request.data, data, 'readonly'); + if (ingressDropped) dropped.push(ingressDropped); + const opts: any = { onFieldsDropped: (e: DroppedFieldsEvent) => { dropped.push(e); } }; + if (request.context !== undefined) opts.context = request.context; + const result = await this.engine.insert(request.object, data, opts); return { object: request.object, id: result.id, - record: result + record: result, + ...(dropped.length > 0 ? { droppedFields: dropped } : {}), }; } @@ -2928,11 +2965,20 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { await this.assertVersionMatch(request.object, request.id, request.expectedVersion, request.context); const opts: any = { where: { id: request.id } }; if (request.context !== undefined) opts.context = request.context; + // [#3407/#3431] Capture the engine's LEGAL write strips (static `readonly` + // (#2948) / TRUE `readonlyWhen` (#3042)) so a REST/API caller is not left + // to field-by-field diff the returned row to discover a value never + // landed. The write still succeeds — this only makes the strip observable, + // mirroring service-automation's `update_record` wiring (#3413). A faulty + // listener never breaks the write (the engine catches + logs). + const dropped: DroppedFieldsEvent[] = []; + opts.onFieldsDropped = (e: DroppedFieldsEvent) => { dropped.push(e); }; const result = await this.engine.update(request.object, request.data, opts); return { object: request.object, id: request.id, - record: result + record: result, + ...(dropped.length > 0 ? { droppedFields: dropped } : {}), }; } diff --git a/packages/rest/src/rest-dropped-fields.test.ts b/packages/rest/src/rest-dropped-fields.test.ts new file mode 100644 index 0000000000..8a8799b70d --- /dev/null +++ b/packages/rest/src/rest-dropped-fields.test.ts @@ -0,0 +1,110 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#3431] The REST write paths must surface the engine's LEGAL field strips so +// an external caller is not left to diff the returned row to notice a field +// never landed (the same silent-success class #3407 fixed flow-side). The +// DataProtocol result carries `droppedFields`; the REST layer echoes it as the +// `X-ObjectStack-Dropped-Fields` response header AND keeps the structured list +// on the body. The STATUS CODE is unchanged (200 update / 201 create) — a strip +// is legitimate semantics, not a failure. + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server'; + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function mockRes() { + const res: any = { statusCode: 200, body: undefined, headers: {} as Record }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.header = vi.fn((k: string, v: string) => { res.headers[k] = v; return res; }); + res.end = vi.fn(() => res); + return res; +} + +function buildServer(protocolOverrides: Record) { + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', endpoints: {} }), + getMetaTypes: vi.fn().mockResolvedValue([]), + // No object registered → enforceApiAccess default-allows and the handler + // proceeds straight to the (mocked) write method. + getMetaItems: vi.fn().mockResolvedValue([]), + ...protocolOverrides, + }; + const rest = new RestServer(mockServer() as any, protocol, { api: { requireAuth: false } } as any); + rest.registerRoutes(); + const find = (method: string, suffix: string) => + rest.getRoutes().find((r) => r.method === method && r.path.endsWith(suffix))!; + return { + protocol, + patch: find('PATCH', '/:object/:id'), + create: find('POST', '/:object'), + }; +} + +const DROPPED = [{ object: 'approval_case', fields: ['approval_status'], reason: 'readonly' as const }]; + +describe('PATCH /data/:object/:id — X-ObjectStack-Dropped-Fields (#3431)', () => { + it('sets the header and keeps droppedFields on the body when the write stripped a field', async () => { + const updateData = vi.fn().mockResolvedValue({ + object: 'approval_case', id: 'rec-1', record: { id: 'rec-1', title: 'B' }, droppedFields: DROPPED, + }); + const { patch } = buildServer({ updateData }); + const res = mockRes(); + await patch.handler( + { params: { object: 'approval_case', id: 'rec-1' }, body: { title: 'B', approval_status: 'approved' }, headers: {} } as any, + res, + ); + expect(res.headers['X-ObjectStack-Dropped-Fields']).toBe('approval_status;reason=readonly'); + expect(res.body.droppedFields).toEqual(DROPPED); + // Success semantics unchanged — no error status was set. + expect(res.statusCode).toBe(200); + }); + + it('joins multiple dropped fields/reasons into one header value', async () => { + const updateData = vi.fn().mockResolvedValue({ + object: 'approval_case', id: 'rec-1', record: {}, + droppedFields: [ + { object: 'approval_case', fields: ['locked_at', 'owner'], reason: 'readonly_when' }, + { object: 'approval_case', fields: ['approval_status'], reason: 'readonly' }, + ], + }); + const { patch } = buildServer({ updateData }); + const res = mockRes(); + await patch.handler({ params: { object: 'approval_case', id: 'rec-1' }, body: {}, headers: {} } as any, res); + expect(res.headers['X-ObjectStack-Dropped-Fields']).toBe( + 'locked_at;reason=readonly_when, owner;reason=readonly_when, approval_status;reason=readonly', + ); + }); + + it('does NOT set the header when nothing was dropped', async () => { + const updateData = vi.fn().mockResolvedValue({ object: 'approval_case', id: 'rec-1', record: { id: 'rec-1' } }); + const { patch } = buildServer({ updateData }); + const res = mockRes(); + await patch.handler({ params: { object: 'approval_case', id: 'rec-1' }, body: { title: 'B' }, headers: {} } as any, res); + expect(res.headers).not.toHaveProperty('X-ObjectStack-Dropped-Fields'); + expect(res.body).not.toHaveProperty('droppedFields'); + }); +}); + +describe('POST /data/:object — X-ObjectStack-Dropped-Fields on create (#3431)', () => { + it('sets the header (status still 201) when the create ingress stripped a readonly field', async () => { + const createData = vi.fn().mockResolvedValue({ + object: 'approval_case', id: 'rec-1', record: { id: 'rec-1', title: 'A' }, droppedFields: DROPPED, + }); + const { create } = buildServer({ createData }); + const res = mockRes(); + await create.handler( + { params: { object: 'approval_case' }, body: { title: 'A', approval_status: 'approved' }, headers: {} } as any, + res, + ); + expect(res.statusCode).toBe(201); + expect(res.headers['X-ObjectStack-Dropped-Fields']).toBe('approval_status;reason=readonly'); + expect(res.body.droppedFields).toEqual(DROPPED); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 7586279b07..b11ce914a9 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -10,6 +10,7 @@ import { RouteManager } from './route-manager.js'; import { RestServerConfig, RestApiConfig, CrudEndpointsConfig, MetadataEndpointsConfig, BatchEndpointsConfig, RouteGenerationConfig } from '@objectstack/spec/api'; import { DataProtocol, MetadataProtocol } from '@objectstack/spec/api'; import { PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; +import type { DroppedFieldsEvent } from '@objectstack/spec/data'; /** * The protocol slice the REST layer actually consumes (ADR-0076 D9 / #2462 @@ -413,6 +414,41 @@ function isExpectedDataStatus(status: number): boolean { return status === 403 || status === 404 || status === 409 || status === 502 || status === 503; } +/** + * [#3431] `X-ObjectStack-Dropped-Fields` — surface the engine's LEGAL write + * strips (static `readonly` #2948 / TRUE `readonlyWhen` #3042 / #3043 create + * ingress) on the REST write response so an API caller isn't left to diff the + * returned row to discover a field never landed (same silent-success class as + * flow-side #3407). The strip is legitimate — the write still succeeded — so the + * STATUS CODE is unchanged (200/201); this is a warning header, not a failure. + * + * Format: one `field;reason=` token per dropped field, comma-space + * joined — e.g. `approval_status;reason=readonly` or + * `owner;reason=readonly, locked_at;reason=readonly_when`. Field API names are + * identifiers, so they never contain the `;`/`,`/`=` delimiters. Returns '' when + * nothing was dropped. The same events also ride the response body's + * `droppedFields` (the structured/cross-origin-safe channel). + */ +function droppedFieldsHeaderValue(events: DroppedFieldsEvent[] | undefined): string { + if (!events?.length) return ''; + return events + .flatMap((e) => e.fields.map((f) => `${f};reason=${e.reason}`)) + .join(', '); +} + +/** + * Set the `X-ObjectStack-Dropped-Fields` header from a data-write protocol + * result, tolerating both the Hono-style `res.header(name, value)` used + * elsewhere in this file and the node/Express-style `res.setHeader`. No-ops when + * the result carried no drops (or the response object supports neither method). + */ +function applyDroppedFieldsHeader(res: any, result: unknown): void { + const header = droppedFieldsHeaderValue((result as { droppedFields?: DroppedFieldsEvent[] } | null)?.droppedFields); + if (!header) return; + if (typeof res?.header === 'function') res.header('X-ObjectStack-Dropped-Fields', header); + else if (typeof res?.setHeader === 'function') res.setHeader('X-ObjectStack-Dropped-Fields', header); +} + /** * Pure per-object API-exposure check: given an object's `enable` block, decide * whether `operation` is denied on the *external* REST surface (ADR-0049 / @@ -3436,6 +3472,10 @@ export class RestServer { ...(environmentId ? { environmentId } : {}), ...(context ? { context } : {}), } as any); + // [#3431] Advertise fields the #3043 create ingress strip + // dropped via the response header (body also carries + // `droppedFields`). Status stays 201. + applyDroppedFieldsHeader(res, result); res.status(201).json(result); } catch (error: any) { const mapped = mapDataError(error, req.params?.object); @@ -3523,6 +3563,10 @@ export class RestServer { ...(environmentId ? { environmentId } : {}), ...(context ? { context } : {}), } as any); + // [#3431] Advertise any LEGALLY-stripped write fields via + // the response header before serialising (the body also + // carries `droppedFields`). Status stays 200. + applyDroppedFieldsHeader(res, result); res.json(result); } catch (error: any) { const mapped = mapDataError(error, req.params?.object); diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index d814f43d8c..61ddf10b3b 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -6,6 +6,7 @@ import { DiscoverySchema } from './discovery.zod'; import { BatchUpdateRequestSchema, BatchUpdateResponseSchema, BatchOptionsSchema } from './batch.zod'; import { MetadataCacheRequestSchema, MetadataCacheResponseSchema } from './http-cache.zod'; import { QuerySchema } from '../data/query.zod'; +import { DroppedFieldsEventSchema } from '../data/data-engine.zod'; import { AnalyticsQueryRequestSchema, AnalyticsResultResponseSchema, @@ -392,6 +393,15 @@ export const CreateDataResponseSchema = lazySchema(() => z.object({ object: z.string().describe('The object name.'), id: z.string().describe('The ID of the newly created record.'), record: z.record(z.string(), z.unknown()).describe('The created record, including server-generated fields (created_at, owner).'), + droppedFields: z.array(DroppedFieldsEventSchema).optional().describe( + 'Write-observability (#3407/#3431): caller-supplied fields that were LEGALLY stripped ' + + 'before the record was written — a non-system create cannot seed a static `readonly` ' + + 'column (#3043 ingress strip), so those keys are dropped and the field re-derives its ' + + 'default. Present ONLY when ≥1 field was dropped; the create still succeeded without ' + + 'them (status/success semantics unchanged). REST additionally surfaces this as the ' + + '`X-ObjectStack-Dropped-Fields` response header. Optional — omit-when-empty keeps the ' + + 'shape backward-compatible for existing clients.' + ), })); /** @@ -426,6 +436,15 @@ export const UpdateDataResponseSchema = lazySchema(() => z.object({ object: z.string().describe('Object name'), id: z.string().describe('Updated record ID'), record: z.record(z.string(), z.unknown()).describe('Updated record'), + droppedFields: z.array(DroppedFieldsEventSchema).optional().describe( + 'Write-observability (#3407/#3431): caller-supplied fields the engine LEGALLY stripped ' + + 'from the write before persisting — static `readonly` (#2948) or a TRUE `readonlyWhen` ' + + 'predicate (#3042). Present ONLY when ≥1 field was dropped; the update still succeeded ' + + 'without them (status/success semantics unchanged — stripping is legitimate, not an ' + + 'error). REST additionally surfaces this as the `X-ObjectStack-Dropped-Fields` response ' + + 'header. Optional — omit-when-empty keeps the shape backward-compatible for existing ' + + 'clients that only read `record`.' + ), })); /** From a2f47ba9902a81970ff910aa9496ab63a015dd35 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 14:55:55 +0000 Subject: [PATCH 2/2] docs(spec): regenerate protocol.mdx for droppedFields response fields (#3431) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `content/docs/references/api/protocol.mdx` is generated from the spec Zod schemas (build-docs.ts) and is checked in; the new optional `droppedFields` on Create/Update DataResponse must be regenerated so `check:docs` (run inside the TypeScript Type Check job) stays green. Generated output only — no hand-edits. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NNqeAmxCCq9jgLFwa9subq --- content/docs/references/api/protocol.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 3a0224acb5..b64b4d98f0 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -219,6 +219,7 @@ const result = AiInsightsRequest.parse(data); | **object** | `string` | ✅ | The object name. | | **id** | `string` | ✅ | The ID of the newly created record. | | **record** | `Record` | ✅ | The created record, including server-generated fields (created_at, owner). | +| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when'> }[]` | optional | Write-observability (#3407/#3431): caller-supplied fields that were LEGALLY stripped before the record was written — a non-system create cannot seed a static `readonly` column (#3043 ingress strip), so those keys are dropped and the field re-derives its default. Present ONLY when ≥1 field was dropped; the create still succeeded without them (status/success semantics unchanged). REST additionally surfaces this as the `X-ObjectStack-Dropped-Fields` response header. Optional — omit-when-empty keeps the shape backward-compatible for existing clients. | --- @@ -1179,6 +1180,7 @@ const result = AiInsightsRequest.parse(data); | **object** | `string` | ✅ | Object name | | **id** | `string` | ✅ | Updated record ID | | **record** | `Record` | ✅ | Updated record | +| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when'> }[]` | optional | Write-observability (#3407/#3431): caller-supplied fields the engine LEGALLY stripped from the write before persisting — static `readonly` (#2948) or a TRUE `readonlyWhen` predicate (#3042). Present ONLY when ≥1 field was dropped; the update still succeeded without them (status/success semantics unchanged — stripping is legitimate, not an error). REST additionally surfaces this as the `X-ObjectStack-Dropped-Fields` response header. Optional — omit-when-empty keeps the shape backward-compatible for existing clients that only read `record`. | ---