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