Skip to content

Commit a6c3f38

Browse files
baozhoutaoclaude
andauthored
feat(approvals): expose the pending node's lockRecord policy on the request row (#3814, objectui#2902) (#3815)
An approval node declares `lockRecord` (default `true`) and the record-lock `beforeUpdate` hook enforces exactly that — `lockRecord: false` and the record stays writable while the node waits. Correct since Phase B, and invisible to every client. `rowFromRequest` parses `node_config_json` but projects a whitelist out of it (`__flowLabel`, `__nodeLabel`, `__round`, `escalation.timeoutHours`, `decisionOutputs`); `lockRecord` was never in that list, and no other field on `ApprovalRequestRow` carried the lock. So the strongest thing a console could learn from `GET /approvals/requests` was "a pending request exists", from which it can only assume the record is locked. That is wrong on every opted-out node, and a flow chaining nodes with different policies makes it visibly wrong: one UI state for "you may edit this" and "your save dies with RECORD_LOCKED". `ApprovalRequestRow` now carries `lock_record: boolean`, read from the same snapshot the hook reads with the same `!== false` default, on every service read. The flag a client renders and the rule the server applies cannot drift. Additive and backward compatible. The showcase's `showcase_budget_approval` declares `lockRecord: false` on its single-approver Manager Review and keeps `true` on the multi-approver Executive Review, so one flow exercises both. Closes #3814 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 2f47489 commit a6c3f38

5 files changed

Lines changed: 108 additions & 1 deletion

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-approvals": minor
4+
---
5+
6+
feat(approvals): expose the pending node's `lockRecord` policy on the request row (#3814, objectui#2902)
7+
8+
An approval node declares `lockRecord` (default `true`), and the record-lock
9+
`beforeUpdate` hook enforces exactly that: `lockRecord: false` and the record
10+
stays writable for the whole time the node waits. The behavior was correct and
11+
has been since Phase B — but it was **invisible to every client**.
12+
13+
`rowFromRequest` parses `node_config_json` and projects a whitelist out of it
14+
(`__flowLabel`, `__nodeLabel`, `__round`, `escalation.timeoutHours`,
15+
`decisionOutputs`). `lockRecord` was never in that list, and no other field on
16+
`ApprovalRequestRow` carried the lock either. So the strongest thing a console
17+
could learn from `GET /approvals/requests` was *"a pending request exists"*
18+
from which it can only assume the record is locked.
19+
20+
That assumption is wrong on every opted-out node, and a flow that chains nodes
21+
with different policies makes it visibly wrong: the same UI state renders for
22+
"you may edit this" and "the server will reject your save with `RECORD_LOCKED`".
23+
The console has no third option — guessing the other way would offer an edit
24+
that dies on save.
25+
26+
`ApprovalRequestRow` now carries **`lock_record: boolean`**, read from the same
27+
snapshot the hook reads, with the same `!== false` default. Present on every
28+
service read (`openNodeRequest` / `getRequest` / `listRequests`), so the flag a
29+
client renders and the rule the server applies cannot drift.
30+
31+
Additive and backward compatible — nothing to migrate. A client that wants
32+
node-accurate lock state reads `request.lock_record`; treat `undefined` (an
33+
older backend) as locked, which is the pre-existing behavior.
34+
35+
The showcase's `showcase_budget_approval` now declares `lockRecord: false` on
36+
its single-approver Manager Review and keeps `true` on the multi-approver
37+
Executive Review, so both policies are exercised in one flow.

examples/app-showcase/src/automation/flows/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,15 @@ export const BudgetApprovalFlow = defineFlow({
186186
config: {
187187
approvers: [{ type: 'position', value: 'manager' }],
188188
behavior: 'first_response',
189-
lockRecord: true,
189+
// Deliberately UNLOCKED, and the counterpart to `exec_review` below —
190+
// together they dogfood both record-lock policies in one flow
191+
// (objectui#2902). A single-approver step like this is the case the
192+
// flag exists for: the manager is expected to correct the budget
193+
// narrative in place rather than send the whole thing back. It also
194+
// matches this node's own revise loop, which assumes the record is
195+
// reworkable. The console must show "in approval · editable" here and
196+
// keep inline editing live; on `exec_review` it must show the lock.
197+
lockRecord: false,
190198
// ADR-0044: at most two send-backs; the third auto-rejects.
191199
maxRevisions: 2,
192200
},
@@ -212,6 +220,8 @@ export const BudgetApprovalFlow = defineFlow({
212220
config: {
213221
approvers: [{ type: 'position', value: 'exec' }],
214222
behavior: 'unanimous',
223+
// Locked, unlike `manager_review` — a multi-approver sign-off must
224+
// decide on a stable record, so edits are refused until it completes.
215225
lockRecord: true,
216226
},
217227
},

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

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

186+
// ── record-lock policy on the read row (objectui#2902) ──────────
187+
//
188+
// The lock is enforced in `lifecycle-hooks.ts` off `node_config_json`, but
189+
// the row projection used to drop the flag entirely — so a client could see
190+
// "a pending request exists" and nothing more, and had to assume every
191+
// pending node locked the record. Chaining nodes with different policies
192+
// made that visibly wrong. These pin the flag onto every read path.
193+
194+
it('lock_record: true when the node locks (the schema default)', async () => {
195+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
196+
expect(req.lock_record).toBe(true);
197+
const [listed] = await svc.listRequests({ object: 'opportunity', recordId: 'opp1' }, SYS);
198+
expect(listed.lock_record).toBe(true);
199+
expect((await svc.getRequest(req.id, SYS))!.lock_record).toBe(true);
200+
});
201+
202+
it('lock_record: false when the node opts out — the same read the hook honors', async () => {
203+
const req = await svc.openNodeRequest(openInput(['u9'], {}, { lockRecord: false }), CTX);
204+
expect(req.lock_record).toBe(false);
205+
const [listed] = await svc.listRequests({ object: 'opportunity', recordId: 'opp1' }, SYS);
206+
expect(listed.lock_record).toBe(false);
207+
expect((await svc.getRequest(req.id, SYS))!.lock_record).toBe(false);
208+
});
209+
210+
it('lock_record: an unset lockRecord reads as locked, matching the hook default', async () => {
211+
// The hook allows the write only on an explicit `=== false`; the flag must
212+
// default the same way or the UI would offer an edit the server rejects.
213+
const req = await svc.openNodeRequest(
214+
{ ...openInput(['u9']), config: { approvers: [{ type: 'user' as const, value: 'u9' }], behavior: 'first_response' as const } } as any,
215+
CTX,
216+
);
217+
expect(req.lock_record).toBe(true);
218+
});
219+
186220
it('openNodeRequest: deduplicates a pending request per (object, record)', async () => {
187221
await svc.openNodeRequest(openInput(['u9']), CTX);
188222
await expect(svc.openNodeRequest(openInput(['u9'], { runId: 'run_2' }), CTX))

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,14 @@ function rowFromRequest(row: any): ApprovalRequestRow {
324324
sla_due_at: slaDueAt(row.created_at, cfg),
325325
// ADR-0044 revision round (rides the config snapshot; absent ⇒ round 1).
326326
round: typeof cfg?.__round === 'number' ? cfg.__round : undefined,
327+
// objectui#2902: the node's record-lock policy. The lock is enforced
328+
// server-side in `lifecycle-hooks.ts` off THIS SAME snapshot with the
329+
// same `!== false` default, so the flag a client renders and the rule the
330+
// server applies can never drift. Without it a console can only see
331+
// "a pending request exists" and has to assume the record is locked —
332+
// which mislabels every `lockRecord: false` node as locked and hides an
333+
// edit the server would have accepted.
334+
lock_record: cfg?.lockRecord !== false,
327335
// #3447 P2: the node's author-declared decision outputs, surfaced so a
328336
// decision UI can render input fields for them and POST `outputs` on
329337
// approve/reject. Per-request (each node declares its own), which is why

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,24 @@ export interface ApprovalRequestRow {
139139
* `node_config_json` snapshot (`__round`), so no schema migration.
140140
*/
141141
round?: number;
142+
/**
143+
* Whether THIS node's pending request locks the target record from edits
144+
* (objectui#2902). Mirrors the `lockRecord` policy the record-lock
145+
* `beforeUpdate` hook enforces, read from the same `node_config_json`
146+
* snapshot the hook reads — so a client never has to guess, and never
147+
* disagrees with the server.
148+
*
149+
* `lockRecord` defaults to `true` (see `ApprovalNodeConfigSchema`), so this
150+
* is `false` only when the node explicitly opted out. Always present on a
151+
* service read; a client that gets `undefined` is talking to a pre-#3814
152+
* backend and should fail closed (assume locked) rather than offer an edit
153+
* the server will reject with `RECORD_LOCKED`.
154+
*
155+
* Node-scoped, not request-scoped in spirit: a flow chaining several
156+
* approval nodes with different policies produces one request per node, and
157+
* each carries its own value.
158+
*/
159+
lock_record?: boolean;
142160
/**
143161
* Server-computed decision aggregation progress (#3266, single-request reads
144162
* of PENDING requests only). Present when the node's behavior aggregates

0 commit comments

Comments
 (0)