Skip to content

Commit 4c01609

Browse files
baozhoutaoclaude
andcommitted
fix(approvals,rest): surface the node's lock policy and the batch's dropped fields (#3794)
An approval flow reported record writability wrong in both directions: what the user could change said "locked", and what they couldn't said "updated successfully". Both halves were missing signal, not wrong behaviour — the server did the right thing and told nobody. `rowFromRequest` now emits `locks_record`, read from the same `node_config_json` snapshot the `beforeUpdate` lock hook reads, with the same default-true. A client could previously only see "a request is pending" and had to guess whether that locked the record; the Console guessed "locked" every time, which hides the whole point of a `lockRecord: false` node. `POST /batch` (cross-object transactional batch) never wired `onFieldsDropped`, so the one write path the Console's master-detail record form takes was also the one path with no write-observability — a `readonlyWhen` strip there was completely silent. It now collects per-op events and returns them as a top-level `droppedFields` list tagged with each operation's index; omitted when empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 8dd814d commit 4c01609

8 files changed

Lines changed: 178 additions & 4 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/plugin-approvals": minor
3+
"@objectstack/spec": minor
4+
"@objectstack/rest": minor
5+
---
6+
7+
fix(approvals,rest): tell the truth about what a write did — `locks_record` on the request, `droppedFields` on the cross-object batch (#3794)
8+
9+
Two halves of the same complaint: in an approval flow the platform reported
10+
record writability wrong in *both* directions — what you could change said
11+
"locked", and what you couldn't said "updated successfully".
12+
13+
**`locks_record` on the approval request.** The lock hook reads one thing to
14+
decide whether a pending approval blocks writes: the node config snapshot's
15+
`lockRecord` (`=== false` ⇒ the update goes through). Nothing exposed that, so a
16+
client had only "a pending request exists" and had to guess — and the Console
17+
guessed "locked", every time. A `lockRecord: false` node exists precisely so the
18+
approver can amend the record while deciding on it; painting "Locked for
19+
approval" over that hides the whole feature, and approvers never try. Request
20+
rows (`getRequest` / `listRequests`, and therefore `GET /api/v1/approvals/requests`)
21+
now carry `locks_record`, read from the same snapshot the hook reads, with the
22+
same default-true. Absent on rows from an older server ⇒ assume locked.
23+
24+
**`droppedFields` on `POST /batch`.** The engine strips writes to `readonly`
25+
(#2948) and `readonlyWhen`-locked (#3042) fields and completes the write without
26+
them. Every write path already reported which fields it dropped (#3431/#3455) —
27+
except the cross-object transactional batch, which never wired
28+
`onFieldsDropped` at all. That path is the Console record form's save for a
29+
master-detail record, so it is exactly where a *user* edits a `readonlyWhen`
30+
field: they changed it, the form said "updated successfully", the value never
31+
moved, and nothing anywhere said so. The response now carries a top-level
32+
`droppedFields` list, each event tagged with the `index` of the operation that
33+
produced it (`results` entries are bare record echoes with no envelope to hang a
34+
per-row list on). Omitted entirely when nothing was dropped, so the shape stays
35+
backward-compatible; the batch still commits either way — a strip is legal
36+
semantics, not an error.
37+
38+
The Console half of both fixes ships in objectui: the detail band now
39+
distinguishes "in approval (editable)" from "locked for approval", and the
40+
write-warning toast fires on batch saves.

packages/plugins/plugin-approvals/src/approval-service.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,38 @@ describe('ApprovalService (node era)', () => {
183183
expect(JSON.parse(raw.node_config_json)).toMatchObject({ behavior: 'first_response', lockRecord: true });
184184
});
185185

186+
// #3794 — the row tells a client whether THIS node locks the record, so
187+
// "pending" and "locked" stop being the same signal. Read from the same
188+
// snapshot key the `beforeUpdate` lock hook reads, with the same default.
189+
it('rows surface locks_record from the node config (default true)', async () => {
190+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
191+
expect(req.locks_record).toBe(true);
192+
const [listed] = await svc.listRequests({ status: 'pending' }, SYS);
193+
expect(listed.locks_record).toBe(true);
194+
});
195+
196+
it('rows surface locks_record=false for a lockRecord:false node', async () => {
197+
const req = await svc.openNodeRequest(
198+
openInput(['u9'], {}, { lockRecord: false }),
199+
CTX,
200+
);
201+
expect(req.locks_record).toBe(false);
202+
const [listed] = await svc.listRequests({ status: 'pending' }, SYS);
203+
expect(listed.locks_record).toBe(false);
204+
const fetched = await svc.getRequest(req.id, SYS);
205+
expect(fetched?.locks_record).toBe(false);
206+
});
207+
208+
it('locks_record defaults to true when the node config omits lockRecord', async () => {
209+
// A node that says nothing about locking locks — matching the hook, which
210+
// only returns early on an explicit `lockRecord === false`.
211+
const req = await svc.openNodeRequest(
212+
openInput(['u9'], {}, { lockRecord: undefined }),
213+
CTX,
214+
);
215+
expect(req.locks_record).toBe(true);
216+
});
217+
186218
it('openNodeRequest: deduplicates a pending request per (object, record)', async () => {
187219
await svc.openNodeRequest(openInput(['u9']), CTX);
188220
await expect(svc.openNodeRequest(openInput(['u9'], { runId: 'run_2' }), CTX))

packages/plugins/plugin-approvals/src/approval-service.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,11 @@ function rowFromRequest(row: any): ApprovalRequestRow {
280280
payload: parseJson(row.payload_json, undefined),
281281
flow_run_id: row.flow_run_id ?? undefined,
282282
flow_node_id: row.flow_node_id ?? undefined,
283+
// #3794: the node's record-lock policy, read from the SAME config snapshot
284+
// the `beforeUpdate` lock hook reads (`lockRecord === false` ⇒ allow), so a
285+
// client can tell "pending" from "locked" instead of assuming every pending
286+
// request locks. Default-true mirrors the hook's default.
287+
locks_record: cfg?.lockRecord !== false,
283288
completed_at: row.completed_at ?? undefined,
284289
created_at: row.created_at ?? undefined,
285290
updated_at: row.updated_at ?? undefined,

packages/rest/src/rest-batch-endpoint.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,45 @@ describe('POST {basePath}/batch — cross-object transactional batch', () => {
219219
expect(ql.transaction).not.toHaveBeenCalled();
220220
});
221221

222+
// ── write observability (#3794) ───────────────────────────────────────────
223+
//
224+
// The console's record form saves a master-detail record through THIS route,
225+
// so a `readonlyWhen`-locked field edited in that form was stripped by the
226+
// engine and the response said nothing — the user saw "updated successfully"
227+
// over a value that never changed. Every other write path already reports its
228+
// strips (#3431/#3455); this one now does too.
229+
230+
it('surfaces engine-stripped fields as droppedFields tagged with the op index', async () => {
231+
const ql = makeQl({
232+
update: vi.fn(async (_object: string, data: any, opts: any) => {
233+
opts?.onFieldsDropped?.({ object: 'invoice', fields: ['tax_rate'], reason: 'readonly_when' });
234+
const { tax_rate: _stripped, ...kept } = data;
235+
return kept;
236+
}),
237+
});
238+
const { route } = buildServer({ ql });
239+
const res = await post(route, {
240+
operations: [
241+
{ object: 'account', action: 'create', data: { name: 'Acme' } },
242+
{ object: 'invoice', action: 'update', id: 'inv_1', data: { status: 'paid', tax_rate: 9 } },
243+
],
244+
});
245+
expect(res.statusCode).toBe(200);
246+
// The batch still committed — a strip is legal semantics, not an error.
247+
expect(res.body.results).toHaveLength(2);
248+
expect(res.body.droppedFields).toEqual([
249+
{ object: 'invoice', fields: ['tax_rate'], reason: 'readonly_when', index: 1 },
250+
]);
251+
});
252+
253+
it('omits droppedFields entirely when nothing was stripped', async () => {
254+
const ql = makeQl();
255+
const { route } = buildServer({ ql });
256+
const res = await post(route, { operations: [{ object: 'account', data: { name: 'Acme' } }] });
257+
expect(res.statusCode).toBe(200);
258+
expect(res.body).not.toHaveProperty('droppedFields');
259+
});
260+
222261
// ── request validation ────────────────────────────────────────────────────
223262

224263
it('rejects a non-atomic request (this endpoint is always atomic)', async () => {

packages/rest/src/rest-server.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6594,23 +6594,35 @@ export class RestServer {
65946594
return result;
65956595
};
65966596

6597+
// [#3794] Write-observability on THIS surface too. The engine
6598+
// strips `readonly` / `readonlyWhen` writes silently, and every
6599+
// other write path already reports what it dropped (#3431/#3455)
6600+
// — but this one did not, and it is precisely the path the
6601+
// console's record form takes for a master-detail save. Result:
6602+
// a user edited a `readonlyWhen`-locked field, got "updated
6603+
// successfully", and the value never changed with nothing said
6604+
// (#3794 problem 2). Each event is tagged with its operation
6605+
// index, since `results` entries are bare record echoes with no
6606+
// envelope to hang a per-row list on.
6607+
const dropped: Array<DroppedFieldsEvent & { index: number }> = [];
65976608
const results = await ql.transaction(async (trxCtx: any) => {
65986609
const out: any[] = [];
6599-
for (const op of ops) {
6610+
for (const [index, op] of ops.entries()) {
66006611
const data = resolveRefs(op.data, out);
6612+
const onFieldsDropped = (e: DroppedFieldsEvent) => { dropped.push({ ...e, index }); };
66016613
if (op.action === 'create') {
6602-
out.push(await ql.insert(op.object, data, { context: trxCtx }));
6614+
out.push(await ql.insert(op.object, data, { context: trxCtx, onFieldsDropped }));
66036615
} else if (op.action === 'update') {
66046616
const id = op.id ?? data?.id;
6605-
out.push(await ql.update(op.object, { ...data, id }, { context: trxCtx }));
6617+
out.push(await ql.update(op.object, { ...data, id }, { context: trxCtx, onFieldsDropped }));
66066618
} else { // 'delete'
66076619
out.push(await ql.delete(op.object, { where: { id: op.id }, context: trxCtx }));
66086620
}
66096621
}
66106622
return out;
66116623
}, context);
66126624

6613-
res.json({ results });
6625+
res.json({ results, ...(dropped.length > 0 ? { droppedFields: dropped } : {}) });
66146626
} catch (error: any) {
66156627
// Log only genuine server faults; client 4xx (validation,
66166628
// unresolved ref, atomic rollback of a bad op) are expected.

packages/spec/json-schema.manifest.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@
152152
"api/CreateRequest",
153153
"api/CreateViewRequest",
154154
"api/CreateViewResponse",
155+
"api/CrossObjectBatchDroppedFields",
155156
"api/CrossObjectBatchOperation",
156157
"api/CrossObjectBatchRequest",
157158
"api/CrossObjectBatchResponse",

packages/spec/src/api/batch.zod.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,13 +298,37 @@ export const CrossObjectBatchRequestSchema = lazySchema(() => z.object({
298298

299299
export type CrossObjectBatchRequest = z.input<typeof CrossObjectBatchRequestSchema>;
300300

301+
/**
302+
* One strip event on a cross-object batch, tagged with the operation it
303+
* belongs to (#3794). The per-object bulk paths hang `droppedFields` on their
304+
* per-row result object; this batch's `results` entries are the raw record
305+
* echoes (`z.unknown()`), with no envelope to hang anything on — so the events
306+
* ride a parallel top-level list and carry `index` to point back at the
307+
* operation that produced them.
308+
*/
309+
export const CrossObjectBatchDroppedFieldsSchema = lazySchema(() => DroppedFieldsEventSchema.extend({
310+
index: z.number().int().min(0).describe('Index of the operation in the request `operations` array'),
311+
}).describe('A cross-object batch strip event: dropped fields plus the operation index'));
312+
313+
export type CrossObjectBatchDroppedFields = z.infer<typeof CrossObjectBatchDroppedFieldsSchema>;
314+
301315
/**
302316
* Response for the cross-object transactional batch — one result per operation,
303317
* index-aligned with the request `operations` (create/update echo the record,
304318
* delete echoes the driver's delete result).
305319
*/
306320
export const CrossObjectBatchResponseSchema = lazySchema(() => z.object({
307321
results: z.array(z.unknown()).describe('Per-operation result, index-aligned with the request operations'),
322+
droppedFields: z.array(CrossObjectBatchDroppedFieldsSchema).optional().describe(
323+
'Write-observability (#3407/#3431/#3455/#3794): caller-supplied fields the engine LEGALLY ' +
324+
'stripped from an operation before it was written — static `readonly` (#2948) or a TRUE ' +
325+
'`readonlyWhen` predicate (#3042). This endpoint is the console record form\'s save path ' +
326+
'(master-detail writes parent + children in one transaction), so without it the ONE surface ' +
327+
'where a user edits a `readonlyWhen` field reported plain success while the value never ' +
328+
'landed. Each event carries the `index` of its operation. Present ONLY when ≥1 field was ' +
329+
'dropped; the batch still committed without them (results/success semantics unchanged). ' +
330+
'Optional — omit-when-empty keeps the shape backward-compatible.'
331+
),
308332
}));
309333

310334
export type CrossObjectBatchResponse = z.infer<typeof CrossObjectBatchResponseSchema>;

packages/spec/src/contracts/approval-service.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,27 @@ export interface ApprovalRequestRow {
7070
type?: 'text' | 'user' | 'department' | 'position' | 'team';
7171
multiple?: boolean;
7272
}>;
73+
/**
74+
* Whether THIS request's node locks the target record for writes (#3794).
75+
* Mirrors the one policy the `beforeUpdate` lock hook actually enforces:
76+
* the node config snapshot's `lockRecord`, defaulting to `true` when the
77+
* node says nothing (`lockRecord === false` ⇒ the hook returns early and
78+
* the record stays editable while the request is pending).
79+
*
80+
* Surfaced because "a pending request exists" and "the record is locked"
81+
* are NOT the same statement, and a client that conflates them tells the
82+
* user the opposite of the truth in one direction or the other: a console
83+
* that renders "Locked for approval" on every pending request hides a
84+
* `lockRecord: false` node's whole point (the approver is meant to edit
85+
* while deciding), and one that renders nothing lets a user fill a form
86+
* that the hook will reject with `RECORD_LOCKED`. Read it instead of
87+
* re-deriving from the record's `approval_status` mirror — that field says
88+
* "in approval", never "locked", and some flows never materialize it.
89+
*
90+
* Optional only for version skew (a row from an older server has no
91+
* opinion); the service always emits it. Absent ⇒ assume locked.
92+
*/
93+
locks_record?: boolean;
7394
completed_at?: string;
7495
created_at?: string;
7596
updated_at?: string;

0 commit comments

Comments
 (0)