Skip to content

Commit dea214f

Browse files
claude[bot]claude
andauthored
test(approvals): probe the second resubmit on a stranded-resubmit strand (#17601 step 1) (#17613)
* test(approvals): probe the second `resubmit` on a stranded-resubmit strand #17601 recorded a hypothesis derived from reading and explicitly not executed: on the third strand shape `continueRestoredRun` repairs — a stranded `resubmit` paused at the `approval_revise` node — the row is left `returned` with no newer row, so a second `resubmit` by the same submitter was read as passing every door guard and writing a second `action: 'resubmit'` row, against the "at most one such action row per request" discriminator assumption in `resolveRecordedContinuation`. Driven on the real engine and the real door, it reproduces. Once an operator re-arms the consumed pause with `restoreConsumedSuspension` — the state `continueRestoredRun` exists to serve — the second call is admitted, resumes, and the request carries TWO `action: 'resubmit'` rows. The pin records the measurement with a firing control on each guard the card reads as "passes" (submitter-only, the collision check and the supersede check each shown refusing on that very row in that very state), an un-re-armed control showing `assertRunResumable` refusing before any write, and a bound: the discriminator's own read is a presence check, so two rows still decide `resubmit` — what is falsified is the stated invariant and the audit trail's one-row-per-advancement shape, not the edge the repair verb reads on today's code. No production code is changed. The repair choice is ruling-grade and the card routed it to the decision box. Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude <noreply@anthropic.com> * chore: record the probe's engine-double pins in the pinned ledger `pnpm check:engine-double-contract` named this directly: the probe's in-memory ObjectQL stand-in routes `delete()` and `update()` through ObjectQL's own dispatch predicates, so the gate holds the pins — but the ledger has to record them or it never protects the file. Produced by `node scripts/check-engine-double-contract.mjs --write`; the diff is two rows naming only the new test file, pure insertions, and the shrink-only baseline is untouched. Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0918c44 commit dea214f

2 files changed

Lines changed: 375 additions & 0 deletions

File tree

Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,365 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #17601 PROBE — RECORDED DEFECT, not endorsed behaviour.
5+
*
6+
* ⛔ Nothing here asserts that a second `resubmit` on a stranded-resubmit strand
7+
* is CORRECT. This file records what the door does today, because the card it
8+
* came from was a hypothesis derived from reading and explicitly not executed,
9+
* and the next reader of `continueRestoredRun` should not have to re-derive the
10+
* same chain from the same lines. Whether the door should be closed, or the
11+
* discriminator's prose scoped instead, is a ruling-grade question the card
12+
* routed to the decision box. ⇒ When that ruling lands and the repair changes
13+
* this behaviour, THIS PIN is the thing to update, and its update is the
14+
* repair's evidence.
15+
*
16+
* ## The hypothesis, and what it tested
17+
*
18+
* `resolveRecordedContinuation` discriminates the two continuation issuers of a
19+
* `returned` row by the presence of an `action: 'resubmit'` audit row, and
20+
* argues the discriminator is *"exact and structural"* on three clauses:
21+
* `action: 'resubmit'` has exactly one writer in the file, it is inserted
22+
* before that resume, and a resubmit opens the next round as a NEW row — *"so
23+
* at most one such action row exists per request"*.
24+
*
25+
* ⭐ MEASURED: the third clause does not hold on the stranded-resubmit strand.
26+
* A `resubmit` whose resume strands writes its audit row and opens NO new round
27+
* (the row stays `returned`), so once an operator re-arms the pause with
28+
* `restoreConsumedSuspension` — the state `continueRestoredRun` exists to serve
29+
* — a second `resubmit` by the same submitter passes all five door guards and
30+
* writes a SECOND `action: 'resubmit'` row on the same request.
31+
*
32+
* ## The bound on it, measured too, so the finding is not read wider than it is
33+
*
34+
* The discriminator's own READ is a presence check (`limit: 1`), so two rows
35+
* decide exactly as one does: MEASUREMENT C drives the resolver on the doubled
36+
* row and it still answers `resubmit`. ⇒ What is falsified is the stated
37+
* invariant and the audit trail's one-row-per-advancement shape, ⛔ not (on
38+
* today's code) the edge the repair verb reads.
39+
*/
40+
41+
import { describe, it, expect } from 'vitest';
42+
import { AutomationEngine, InMemorySuspendedRunStore } from '@objectstack/service-automation';
43+
// [#4550] The engine double routes its write verbs through ObjectQL's OWN
44+
// dispatch predicates rather than a hand-mirrored copy.
45+
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/objectql';
46+
import { APPROVAL_REVISE_NODE_TYPE } from '@objectstack/spec/automation';
47+
import { strandedDecisionDetails } from '@objectstack/types';
48+
import { ApprovalService } from './approval-service.js';
49+
import { registerApprovalNode } from './approval-node.js';
50+
51+
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as any;
52+
const asUser = (userId: string) =>
53+
({ isSystem: false, userId, positions: [], permissions: [] }) as any;
54+
const noopLogger = { info() {}, warn() {}, error() {}, debug() {} };
55+
56+
/** In-memory ObjectQL stand-in for the approvals tables. */
57+
function makeFakeEngine() {
58+
const tables = new Map<string, any[]>();
59+
const rows = (o: string) => (tables.get(o) ?? (tables.set(o, []), tables.get(o)!));
60+
// The lever that makes a REAL strand reachable from a test: fail the very next
61+
// insert into one table, once. The approval node's executor opens the next
62+
// round by inserting a `sys_approval_request`, so failing that insert strands
63+
// the resume the same way a downstream node's throw does — `RESUME_FAILED`
64+
// with `repairable: true`, the suspension already consumed.
65+
let failNextInsertOn: string | undefined;
66+
const matches = (row: any, where: any) => Object.entries(where ?? {}).every(([k, v]) => {
67+
if (k.startsWith('$')) throw new Error(`fake engine: unsupported filter operator ${k}`);
68+
if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]);
69+
if (v && typeof v === 'object' && '$ne' in (v as any)) return row[k] !== (v as any).$ne;
70+
return row[k] === v;
71+
});
72+
return {
73+
tables,
74+
set failNextInsert(object: string | undefined) { failNextInsertOn = object; },
75+
get failNextInsert() { return failNextInsertOn; },
76+
async find(object: string, opts: any = {}) {
77+
const where = opts.where ?? opts.filter ?? {};
78+
const out = rows(object).filter(r => matches(r, where));
79+
// ⚠️ `orderBy` is honoured, and that is load-bearing rather than polish:
80+
// `assertLatestForRun` selects the newest request with
81+
// `orderBy [{field:'created_at', order:'desc'}], limit 1`. A double that
82+
// ignored it returned the OLDEST row, so the guard passed on every input
83+
// and a pin naming it would have measured nothing — the phantom-check
84+
// shape. SortNode's key is `order`, not `direction`.
85+
if (Array.isArray(opts.orderBy)) {
86+
for (const sort of [...opts.orderBy].reverse()) {
87+
const field = sort?.field;
88+
if (!field) continue;
89+
const dir = sort?.order === 'desc' ? -1 : 1;
90+
out.sort((a, b) => (a[field] < b[field] ? -1 : a[field] > b[field] ? 1 : 0) * dir);
91+
}
92+
}
93+
// The caller's bound is honoured by PRESENCE, never truthiness.
94+
const start = opts.offset ?? 0;
95+
const page = typeof opts.limit === 'number' ? out.slice(start, start + opts.limit) : out.slice(start);
96+
return page.map(r => ({ ...r }));
97+
},
98+
async insert(object: string, data: any) {
99+
if (failNextInsertOn === object) {
100+
failNextInsertOn = undefined;
101+
throw new Error(`injected one-shot insert failure on ${object}`);
102+
}
103+
rows(object).push({ ...data }); return { ...data };
104+
},
105+
async update(object: string, data: any, options?: any) {
106+
const dispatch = assertEngineUpdateDispatch(data, options);
107+
const table = rows(object);
108+
if (dispatch.kind === 'multi') {
109+
let n = 0;
110+
for (let i = 0; i < table.length; i++) {
111+
if (matches(table[i], options?.where)) { table[i] = { ...table[i], ...data }; n++; }
112+
}
113+
return { updated: n };
114+
}
115+
const i = table.findIndex(r => r.id === dispatch.id);
116+
if (i >= 0) table[i] = { ...table[i], ...data };
117+
return i >= 0 ? { ...table[i] } : null;
118+
},
119+
async delete(object: string, options?: any) {
120+
const dispatch = assertEngineDeleteDispatch(options);
121+
const table = rows(object);
122+
if (dispatch.kind === 'multi') {
123+
const survivors = table.filter(r => !matches(r, options?.where));
124+
const deleted = table.length - survivors.length;
125+
table.splice(0, table.length, ...survivors);
126+
return { deleted };
127+
}
128+
const i = table.findIndex(r => r.id === dispatch.id);
129+
if (i >= 0) table.splice(i, 1);
130+
return { id: dispatch.id };
131+
},
132+
};
133+
}
134+
135+
/** ADR-0044 revise window: send-back parks the run at the `approval_revise` node. */
136+
const REVISE_FLOW = {
137+
name: 'revise_flow', label: 'Revise Flow', type: 'autolaunched',
138+
nodes: [
139+
{ id: 'start', type: 'start', label: 'Start' },
140+
{ id: 'review', type: 'approval', label: 'Review', config: { approvers: [{ type: 'user', value: 'u1' }] } },
141+
{ id: 'wait_revision', type: APPROVAL_REVISE_NODE_TYPE, label: 'Awaiting Revision' },
142+
{ id: 'on_approved', type: 'mark', label: 'Approved' },
143+
{ id: 'on_rejected', type: 'mark', label: 'Rejected' },
144+
{ id: 'end', type: 'end', label: 'End' },
145+
],
146+
edges: [
147+
{ id: 'e1', source: 'start', target: 'review' },
148+
{ id: 'e2', source: 'review', target: 'on_approved', label: 'approve' },
149+
{ id: 'e3', source: 'review', target: 'on_rejected', label: 'reject' },
150+
{ id: 'e4', source: 'review', target: 'wait_revision', label: 'revise' },
151+
{ id: 'e5', source: 'wait_revision', target: 'review', label: 'resubmit', type: 'back' },
152+
{ id: 'e6', source: 'on_approved', target: 'end' },
153+
{ id: 'e7', source: 'on_rejected', target: 'end' },
154+
],
155+
};
156+
157+
/**
158+
* One live process per scenario — real engine, real approval node, real
159+
* approvals service. Each scenario owns its own tables, so no leg can select a
160+
* row another leg left behind.
161+
*/
162+
function scenario() {
163+
const marks: string[] = [];
164+
const data = makeFakeEngine();
165+
const service = new ApprovalService({ engine: data as any, logger: noopLogger });
166+
const automation = new AutomationEngine(noopLogger as any, new InMemorySuspendedRunStore());
167+
registerApprovalNode(automation, service, noopLogger as any);
168+
automation.registerNodeExecutor({
169+
type: 'mark',
170+
async execute(node: any) { marks.push(node.id); return { success: true }; },
171+
} as never);
172+
automation.registerFlow('revise_flow', REVISE_FLOW as never);
173+
service.attachAutomation(automation);
174+
175+
const countActions = async (requestId: string, action: string) =>
176+
(await data.find('sys_approval_action', { where: { request_id: requestId, action } })).length;
177+
const runRows = async (runId: string) =>
178+
await data.find('sys_approval_request', { where: { flow_run_id: runId } });
179+
const rowOf = async (id: string) =>
180+
(await data.find('sys_approval_request', { where: { id } }))[0];
181+
const parkedAt = async (runId: string) =>
182+
(await automation.listSuspendedRunsDurable()).find((r: any) => String(r.runId) === String(runId))?.nodeId;
183+
const tryResubmit = (requestId: string, userId: string) => service
184+
.resubmit(requestId, { actorId: userId } as any, asUser(userId))
185+
.then((value: any) => ({ ok: true as const, value }), (e: Error) => ({ ok: false as const, message: e.message }));
186+
187+
/**
188+
* ⛔ PRECONDITION, asserted step by step. A probe that never reached the
189+
* stranded state is a probe that measured NOTHING about the second call, so
190+
* every step of getting there is a hard assertion rather than a setup line.
191+
*/
192+
async function strandAResubmit(recordId: string) {
193+
await automation.execute('revise_flow', {
194+
object: 'crm_deal', record: { id: recordId, amount: 100 }, userId: 'submitter',
195+
} as never);
196+
const req: any = (await data.find('sys_approval_request', {
197+
where: { record_id: recordId, status: 'pending' },
198+
}))[0];
199+
expect(req?.flow_node_id, 'PRECONDITION: parked at the approval node').toBe('review');
200+
const runId = String(req.flow_run_id);
201+
202+
await service.sendBack(req.id, { actorId: 'u1', comment: 'redo' } as any, SYSTEM_CTX);
203+
expect(await parkedAt(runId), 'PRECONDITION: the send-back parked the run at the revise window')
204+
.toBe('wait_revision');
205+
expect((await rowOf(req.id)).status, 'PRECONDITION: and the row reads `returned`').toBe('returned');
206+
207+
// Strand the resubmit: the back-edge re-enters `review`, whose executor
208+
// opens round 2 by inserting a request — fail that insert, once.
209+
data.failNextInsert = 'sys_approval_request';
210+
const stranded = await service
211+
.resubmit(req.id, { actorId: 'submitter' } as any, asUser('submitter'))
212+
.then(() => null, (e: Error) => e);
213+
expect(stranded?.message, 'PRECONDITION: a REAL stranded resubmit').toMatch(/^RESUME_FAILED/);
214+
expect(strandedDecisionDetails(stranded)?.repairable,
215+
'PRECONDITION: the repairable strand this card is about').toBe(true);
216+
expect(data.failNextInsert, 'PRECONDITION: the injected failure fired and was consumed').toBeUndefined();
217+
218+
// ⭐ The card's own description of the third strand shape, measured rather
219+
// than assumed: `returned`, no newer row, one audit row, pause consumed.
220+
expect((await rowOf(req.id)).status, 'PRECONDITION: the row is left `returned`').toBe('returned');
221+
expect(await runRows(runId), 'PRECONDITION: with NO newer row on the run').toHaveLength(1);
222+
expect(await countActions(req.id, 'resubmit'),
223+
'PRECONDITION: exactly one `action: resubmit` row so far').toBe(1);
224+
expect(await automation.hasSuspendedRun(runId),
225+
'PRECONDITION: the stranded resume consumed the suspension').toBe(false);
226+
227+
return { req, runId };
228+
}
229+
230+
return { data, service, automation, marks, countActions, runRows, rowOf, parkedAt, tryResubmit, strandAResubmit };
231+
}
232+
233+
describe('#17601 — a second `resubmit` on a stranded-resubmit strand', () => {
234+
it('MEASUREMENT A — re-armed: every door guard passes and a SECOND `resubmit` audit row lands', async () => {
235+
const s = scenario();
236+
const { req, runId } = await s.strandAResubmit('d1');
237+
238+
// The re-arm the card's `assertRunResumable → passes via hasSuspendedRun`
239+
// row depends on. This is not an exotic state: it is exactly the state
240+
// `restoreConsumedSuspension` puts a stranded run into, and the one
241+
// `continueRestoredRun` was built to serve.
242+
const rearmed = await s.automation.restoreConsumedSuspension(runId, { requestedBy: 'ops' });
243+
expect(rearmed.restored, 'PRECONDITION: the pause really is back').toBe(true);
244+
expect(await s.automation.hasSuspendedRun(runId)).toBe(true);
245+
expect(await s.parkedAt(runId), 'PRECONDITION: re-armed at the revise window').toBe('wait_revision');
246+
247+
// ── ⛔ FIRING CONTROLS. Each guard the card reads as "passes" is shown able
248+
// to REFUSE on this very row, in this very state — otherwise "it passed" is
249+
// indistinguishable from "it was never consulted".
250+
const foreign = await s.tryResubmit(req.id, 'u1');
251+
expect(foreign.ok, 'CONTROL: the submitter-only guard still fires').toBe(false);
252+
expect(foreign.ok === false && foreign.message).toMatch(/^FORBIDDEN: only the submitter may resubmit/);
253+
254+
const requests = s.data.tables.get('sys_approval_request')!;
255+
const before = requests.length;
256+
// A colliding PENDING request on the same record, on another run so the
257+
// supersede guard cannot be the thing that speaks.
258+
requests.push({
259+
id: 'areq_collider', object_name: 'crm_deal', record_id: 'd1', status: 'pending',
260+
flow_run_id: 'run_other', created_at: '2020-01-01T00:00:00.000Z',
261+
});
262+
const collided = await s.tryResubmit(req.id, 'submitter');
263+
expect(collided.ok, 'CONTROL: the collision check still fires').toBe(false);
264+
expect(collided.ok === false && collided.message).toMatch(/^DUPLICATE_REQUEST/);
265+
requests.pop();
266+
267+
// A newer row on the SAME run, so `assertLatestForRun` is the speaker.
268+
requests.push({
269+
id: 'areq_newer', object_name: 'crm_deal', record_id: 'd1', status: 'pending',
270+
flow_run_id: runId, created_at: '2099-01-01T00:00:00.000Z',
271+
});
272+
const superseded = await s.tryResubmit(req.id, 'submitter');
273+
expect(superseded.ok, 'CONTROL: the supersede guard still fires').toBe(false);
274+
expect(superseded.ok === false && superseded.message)
275+
.toBe('INVALID_STATE: a newer approval request supersedes this one');
276+
requests.pop();
277+
expect(requests.length, 'CONTROL: both control rows removed — the state is the one under test')
278+
.toBe(before);
279+
expect(await s.countActions(req.id, 'resubmit'),
280+
'CONTROL: and not one refusal wrote an audit row').toBe(1);
281+
282+
// ── ⭐ THE MEASUREMENT. Same submitter, nothing else changed.
283+
const second = await s.tryResubmit(req.id, 'submitter');
284+
285+
expect(second.ok, '⭐ the second `resubmit` is ADMITTED — the hypothesis reproduces').toBe(true);
286+
expect(second.ok === true && second.value.resumed, 'and the run moved').toBe(true);
287+
expect(second.ok === true && second.value.runId).toBe(runId);
288+
289+
// ⭐ The falsified clause: `action: 'resubmit'` is written twice for one
290+
// request, where `resolveRecordedContinuation` records that at most one
291+
// such row exists per request.
292+
expect(await s.countActions(req.id, 'resubmit'),
293+
'⭐ TWO `action: resubmit` rows on one request').toBe(2);
294+
295+
// What did and did not follow from it. Round 2 opened exactly ONCE — the
296+
// flow is not doubly advanced — and the row the second call acted on is
297+
// still the `returned` round-1 row, untouched.
298+
expect(await s.runRows(runId), 'round 2 opened, once').toHaveLength(2);
299+
expect((await s.rowOf(req.id)).status, 'the round-1 row is still `returned`').toBe('returned');
300+
expect(await s.parkedAt(runId), 'and the run is parked back at the approval node').toBe('review');
301+
expect(s.marks, 'no downstream mark node ran — the back-edge re-parks at `review`').toEqual([]);
302+
});
303+
304+
it('MEASUREMENT B — CONTROL, un-re-armed: `assertRunResumable` refuses before anything is written', async () => {
305+
// The same second call with the ONE difference that matters: no re-arm, so
306+
// the suspension the stranded resume consumed is still gone. This is what
307+
// keeps MEASUREMENT A from being a statement about `resubmit` in general —
308+
// the re-armed pause is the specific thing that opens the door.
309+
const s = scenario();
310+
const { req, runId } = await s.strandAResubmit('d2');
311+
312+
const second = await s.tryResubmit(req.id, 'submitter');
313+
expect(second.ok, 'refused').toBe(false);
314+
expect(second.ok === false && second.message).toMatch(/^RESUME_TARGET_LOST: the flow run/);
315+
316+
// ⭐ And the refusal lands BEFORE the insert, which is why the audit row
317+
// count is the discriminating reading between the two measurements.
318+
expect(await s.countActions(req.id, 'resubmit'), 'still exactly one audit row').toBe(1);
319+
expect(await s.runRows(runId), 'and no round 2').toHaveLength(1);
320+
});
321+
322+
it('MEASUREMENT C — the BOUND: the doubled row still rebuilds as `resubmit`, not as something else', async () => {
323+
// ⛔ Keeps the finding from being read wider than it is. The discriminator's
324+
// read is a PRESENCE check (`limit: 1`), so two rows answer exactly as one
325+
// does. What the doubling falsifies is the stated invariant and the audit
326+
// trail's one-row-per-advancement shape — ⛔ not, on today's code, the edge
327+
// the repair verb reads.
328+
const s = scenario();
329+
const { req, runId } = await s.strandAResubmit('d3');
330+
await s.automation.restoreConsumedSuspension(runId, { requestedBy: 'ops' });
331+
const second = await s.tryResubmit(req.id, 'submitter');
332+
expect(second.ok, 'PRECONDITION: the doubling happened').toBe(true);
333+
expect(await s.countActions(req.id, 'resubmit')).toBe(2);
334+
335+
// Force the rebuild path: strip the journal the failing door wrote, which
336+
// is also what a run stranded before the journal shipped looks like.
337+
const config = JSON.parse((await s.rowOf(req.id)).node_config_json);
338+
expect(config.__strandedContinuation, 'PRECONDITION: the door journalled it').toBeTruthy();
339+
delete config.__strandedContinuation;
340+
await s.data.update('sys_approval_request', {
341+
id: req.id, node_config_json: JSON.stringify(config),
342+
}, { context: SYSTEM_CTX });
343+
344+
const rebuilt = await (s.service as any).resolveRecordedContinuation(await s.rowOf(req.id), req.id);
345+
expect(rebuilt.source).toBe('reconstructed');
346+
expect(rebuilt.signal.decision, 'two rows decide as one row does').toBe('resubmit');
347+
expect(rebuilt.signal.branchLabel).toBe('resubmit');
348+
349+
// REVERSE CONTROL: the same resolver on a `returned` row with NO resubmit
350+
// action row answers `revise` — so the line above is a reading, not a
351+
// constant this resolver returns for every input.
352+
const bare = scenario();
353+
await bare.automation.execute('revise_flow', {
354+
object: 'crm_deal', record: { id: 'd4', amount: 4 }, userId: 'submitter',
355+
} as never);
356+
const other: any = (await bare.data.find('sys_approval_request', {
357+
where: { record_id: 'd4', status: 'pending' },
358+
}))[0];
359+
await bare.service.sendBack(other.id, { actorId: 'u1', comment: 'redo' } as any, SYSTEM_CTX);
360+
expect(await bare.countActions(other.id, 'resubmit'), 'CONTROL: no resubmit row on this one').toBe(0);
361+
const rebuiltBare = await (bare.service as any)
362+
.resolveRecordedContinuation(await bare.rowOf(other.id), other.id);
363+
expect(rebuiltBare.signal.decision, 'CONTROL: no resubmit row ⇒ the send-back').toBe('revise');
364+
});
365+
});

0 commit comments

Comments
 (0)