Skip to content

Commit 4ef8247

Browse files
Trumpclaude
andauthored
fix(approvals): classify every ExecutionStatus member for the dead-run sweep, so a refused run releases its pending approval (#17248)
* fix(approvals): classify every ExecutionStatus member for the dead-run sweep TERMINAL_RUN_STATUSES was a hand-copied four-member subset of a nine-member enum. #14945 appended `refused` ("Terminal, never resumed") and the subset did not grow with it, so releaseDeadRunRequests skipped refused runs and a pending approval on one read ALIVE forever. Latent today; live the day #15788 lane 2 drives a run to `refused`. Replaced with a total map over ExecutionStatus (`satisfies Record<...>`), from which the terminal set is derived. A tenth member now fails to compile until someone classifies it, and fails a test too. The same construction is applied to STRANDABLE_REQUEST_STATUSES, the file's other hand-copied subset, whose derived value is byte-identical to the literal it replaces. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * chore(changeset): patch @objectstack/plugin-approvals for the dead-run sweep fix Argued rather than defaulted: the leak is latent in this repo, but ApprovalService takes a host-supplied automation surface via attachAutomation, so a host whose getRun already answers `refused` sees the corrected behaviour on upgrade. Real behaviour change in a published package => a bump, not skip-changeset. Not minor: the barrel is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * test(approvals): route the new engine double through the real dispatch predicates Two gates fired on the fake engine the new pin declares, and both were right: - check:engine-double-contract — its delete()/update() hand-rolled their own id/multi handling instead of routing through assertEngineDeleteDispatch / assertEngineUpdateDispatch. A double looser than ObjectQL is how a dead REST route once shipped green. Taken from @objectstack/metadata-core, which this package's vitest config aliases to source. The pinned ledger learns the new coverage via --write (2 rows added, 0 lost). - check:where-matcher — the double's matches() read a filter combinator as a field name. It now refuses `$`-prefixed keys loudly rather than silently matching nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent cf6e0a1 commit 4ef8247

4 files changed

Lines changed: 428 additions & 20 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/plugin-approvals": patch
3+
---
4+
5+
fix(approvals): the dead-run sweep classifies every `ExecutionStatus` member, so a `refused` run releases its pending approval (#16433)
6+
7+
`ApprovalService.releaseDeadRunRequests` guarded on a hand-copied four-member subset of `ExecutionStatus``completed`, `failed`, `cancelled`, `timed_out` — written when that enum had eight members. #14945 then appended `refused`, documented on the enum as *"Terminal, never resumed"*, and the subset did not grow with it. A run in `refused` was therefore skipped by the sweep, so a still-pending approval on it read as ALIVE, was never released, and kept its record lock forever.
8+
9+
**Why this is shipped as a fix rather than left alone.** Nothing inside this repo drives a run to `refused` yet — that is #15788 (lane 2 of the #14945 ruling), still open. But `ApprovalService` takes a HOST-supplied automation surface through `attachAutomation`, so a host whose `getRun` already answers with the status the published spec declares sees the corrected behaviour the moment it upgrades, rather than on the day lane 2 lands. That is a real behaviour change in a published package, which is why it carries a bump instead of `skip-changeset`.
10+
11+
The repair is not "add `refused`" — that yields a five-member hand-copy with the identical trap re-armed for the tenth member — and it is not "derive the terminal set from the enum" either, since `running` and `paused` are plainly not terminal and a wholesale derivation would default every future member to terminal, i.e. to releasing approvals out from under LIVE runs. Instead the file now declares a **total map** over `ExecutionStatus`, classifying each member `terminal` or `live`, from which the terminal set is derived. A tenth member fails to compile until someone classifies it, and fails a test as well.
12+
13+
No API change: the classification is module-internal and the package barrel is untouched.

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

Lines changed: 137 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import {
88
canonicalApproverType,
99
normalizeDecisionOutputs,
1010
type ApprovalNodeConfig,
11+
// [#16433] The canonical run-status vocabulary, imported for its TYPE so the
12+
// liveness classification below is checked against it member for member.
13+
type ExecutionStatus,
1114
} from '@objectstack/spec/automation';
1215
import { ExpressionEngine, collectCelRootIdentifiers } from '@objectstack/formula';
1316
// [#10101] The SHARED platform-row organization resolver — the cloud#1395
@@ -284,33 +287,147 @@ export const SLA_ACTOR_ID = 'system:sla';
284287
export const ESCALATION_ENABLED_FLIP_CUTOFF_MS = Date.parse('2026-08-28T00:00:00Z');
285288
/** Reserved actor id for requests abandoned because their run died (#3456). */
286289
export const DEAD_RUN_ACTOR_ID = 'system:dead-run';
290+
/** How the dead-run sweep must treat one `ExecutionStatus` member. */
291+
type RunLiveness = 'terminal' | 'live';
292+
287293
/**
288-
* Run statuses that mean "this run will never resume", so a request still
289-
* pending on it is orphaned (#3456). A CLOSED set, deliberately: the dead-run
290-
* sweep treats every other answer — `paused` (a run waiting on its approval,
291-
* the normal case), `running`, an unknown status, or no answer at all — as
292-
* alive, so an unrecognised state can never cost someone a live approval.
294+
* EVERY `ExecutionStatus` member (`@objectstack/spec`), classified for the
295+
* dead-run sweep: `'terminal'` means the run has stopped and will never resume,
296+
* so a request still pending on it is orphaned (#3456); `'live'` means it may
297+
* still move, and the sweep must leave it strictly alone.
298+
*
299+
* ## Why a total MAP and not a list of the terminal ones (#16433)
300+
*
301+
* This was a hand-copied FOUR-member subset — `completed`, `failed`,
302+
* `cancelled`, `timed_out` — written when `ExecutionStatus` had eight members
303+
* and correct on the day it was written. #14945 then appended `refused`
304+
* ("Terminal, never resumed"), and this subset did not grow with it: the sweep
305+
* `continue`d past a refused run, so a pending approval on it read ALIVE and
306+
* was never released. Nothing went red, because a subset that is missing a
307+
* member is still a valid subset.
308+
*
309+
* ⚠️ Note what #14945's author DID do, directly above that member: *"Appended
310+
* last so every reader that indexes `.options` keeps its positions."* That is a
311+
* real precaution, taken with downstream readers genuinely in mind — but it
312+
* protects readers that index the enum BY POSITION. **This reader hand-copied
313+
* a subset, which lives by CONTENT, and position-safety does nothing for it.**
314+
* ⇒ Enum-growth compatibility reasoning points naturally at order and index;
315+
* the copies that rot are the ones keyed on membership, and they rot silently.
316+
*
317+
* So the repair is not "add `refused`" — that produces a five-member hand-copy
318+
* with the identical trap re-armed for the tenth member. It is not "derive the
319+
* terminal set from the enum" either: `running` and `paused` are obviously not
320+
* terminal, and a wholesale derivation would make every FUTURE member terminal
321+
* by default — the same silent default pointing the other way, and pointing at
322+
* the worse outcome (releasing approvals out from under live runs).
293323
*
294-
* `completed` belongs here with the failure states. The approval node only
295-
* writes a request row on the path where it also suspends the run, and every
296-
* in-band transition (decide / recall / send-back / resubmit) finalises the
297-
* request *before* it resumes the run — so a completed run with a still-pending
298-
* request means the run was resumed out of band and left the request behind.
324+
* What this map is instead: a TOTAL function over the enum, so growth forces a
325+
* decision. `satisfies Record<ExecutionStatus, RunLiveness>` is exhaustive in
326+
* both directions — a tenth member added to `ExecutionStatus` with no entry
327+
* here fails to compile, and an entry for a member the enum dropped fails too.
328+
* `run-status-liveness.test.ts` asserts the same totality at RUNTIME against
329+
* `ExecutionStatus.options` and drives every member through the real sweep, so
330+
* the classification and the behaviour cannot drift apart either.
331+
*
332+
* ⛔ Terminality is NOT declared machine-readably anywhere today — `refused`'s
333+
* terminality lives in a COMMENT beside the enum member, and a comment is not a
334+
* gate. The `TERMINAL_RUN_STATUSES` exported by `@objectstack/service-automation`
335+
* is a DIFFERENT vocabulary (which terminal states a run may be RECORDED in,
336+
* tied to `sys_automation_run.status`' options) that excludes `refused` on
337+
* purpose, and that package is only a devDependency here. Hence a local total
338+
* map rather than a shared import; see the card for the spec-level proposal.
339+
*
340+
* `completed` is classified terminal alongside the failure states. The approval
341+
* node only writes a request row on the path where it also suspends the run,
342+
* and every in-band transition (decide / recall / send-back / resubmit)
343+
* finalises the request *before* it resumes the run — so a completed run with a
344+
* still-pending request means the run was resumed out of band and left the
345+
* request behind.
346+
*
347+
* `pending` / `running` / `paused` / `retrying` are live. `paused` especially:
348+
* that is a run waiting on its own approval, the normal case.
299349
*/
300-
const TERMINAL_RUN_STATUSES: ReadonlySet<string> = new Set([
301-
'completed', 'failed', 'cancelled', 'timed_out',
302-
]);
350+
export const RUN_STATUS_LIVENESS = {
351+
pending: 'live', // queued, has not started
352+
running: 'live', // executing right now
353+
paused: 'live', // parked at a wait/checkpoint — usually THIS approval
354+
retrying: 'live', // failed and will run again
355+
completed: 'terminal', // finished; a pending request means an out-of-band resume
356+
failed: 'terminal', // terminated with an error
357+
cancelled: 'terminal', // cancelled by hand
358+
timed_out: 'terminal', // exceeded its budget
359+
// [#14945 / #16433] The flow reached an `end` node declaring
360+
// `outcome: 'refused'` — a successful evaluation that said no. The enum
361+
// documents it "Terminal, never resumed"; this is that sentence made
362+
// machine-readable for the one reader that has to act on it.
363+
refused: 'terminal',
364+
} as const satisfies Record<ExecutionStatus, RunLiveness>;
365+
366+
/**
367+
* Run statuses that mean "this run will never resume" — DERIVED from
368+
* {@link RUN_STATUS_LIVENESS}, never re-typed (#16433).
369+
*
370+
* Still a closed set at the point of use, and deliberately so: the sweep treats
371+
* every other answer — a live status, an unknown status, or no answer at all —
372+
* as alive, so an unrecognised state can never cost someone a live approval.
373+
*/
374+
const TERMINAL_RUN_STATUSES: ReadonlySet<string> = new Set(
375+
Object.entries(RUN_STATUS_LIVENESS)
376+
.filter(([, liveness]) => liveness === 'terminal')
377+
.map(([status]) => status),
378+
);
379+
380+
/** Whether one {@link ApprovalStatus} can leave a zombie behind (#4469). */
381+
type RequestStrandability = 'strandable' | 'not-strandable';
303382

304383
/**
305-
* Request statuses that can leave a ZOMBIE behind (#4469) — the terminal states
306-
* a decision reaches only by ALSO resuming the owning run.
384+
* EVERY {@link APPROVAL_STATUSES} member, classified for the stranded-request
385+
* scan: `'strandable'` are the terminal states a decision reaches only by ALSO
386+
* resuming the owning run (#4469), so a row sitting in one with its run still
387+
* parked is a zombie.
388+
*
389+
* ## The same construction as {@link RUN_STATUS_LIVENESS}, for the same reason
307390
*
308-
* `recalled` is deliberately absent: a recall ABANDONS the request on purpose,
309-
* and {@link ApprovalService.recall} explicitly tolerates a run it cannot
310-
* resume (the withdrawal and the lock release are the point). Reporting those
311-
* would bury the real findings under expected ones.
391+
* [#16433] This was the second hand-copied subset of a growing enum in this
392+
* file, and the card that fixed the first one asked for a reading of it rather
393+
* than an assumption. The reading: `APPROVAL_STATUSES` gained `cancelled`
394+
* (#13568) AFTER this subset was written, and `cancelled` is correctly outside
395+
* it — {@link ApprovalService.cancelForDeletedRecord} does NOT resume
396+
* the run, says so in its own `warn` naming the parked run ids, and reporting
397+
* those here would bury the real findings under expected ones, exactly the
398+
* `recalled` argument. ⇒ the VALUE was right; only the MECHANISM was the
399+
* hand-copy, and nothing recorded that `cancelled` had been considered at all,
400+
* so the next reader could not tell "weighed and excluded" from "written before
401+
* it existed".
402+
*
403+
* The derived list below is byte-identical to the four-year-old literal
404+
* (`['approved', 'rejected', 'returned']`, in that order, which the `$in` at
405+
* the call site depends on). What changed is that a SEVENTH approval status now
406+
* fails to compile until someone classifies it.
407+
*
408+
* `recalled` is deliberately not strandable: a recall ABANDONS the request on
409+
* purpose, and {@link ApprovalService.recall} explicitly tolerates a run it
410+
* cannot resume (the withdrawal and the lock release are the point).
411+
* `pending` is not terminal at all.
412+
*/
413+
export const REQUEST_STATUS_STRANDABILITY = {
414+
pending: 'not-strandable', // not terminal — still awaiting a decision
415+
approved: 'strandable', // a decision that should have resumed the run
416+
rejected: 'strandable', // idem
417+
recalled: 'not-strandable', // deliberate abandonment; tolerates an unresumable run
418+
returned: 'strandable', // send-back resumes down the `revise` edge
419+
cancelled: 'not-strandable', // [#13568] platform void; never resumes, and says so
420+
} as const satisfies Record<ApprovalStatus, RequestStrandability>;
421+
422+
/**
423+
* The strandable statuses, DERIVED from {@link REQUEST_STATUS_STRANDABILITY}
424+
* and never re-typed (#16433). Declaration order follows `APPROVAL_STATUSES`.
312425
*/
313-
const STRANDABLE_REQUEST_STATUSES = ['approved', 'rejected', 'returned'] as const;
426+
const STRANDABLE_REQUEST_STATUSES: readonly ApprovalStatus[] = Object.entries(
427+
REQUEST_STATUS_STRANDABILITY,
428+
)
429+
.filter(([, strandability]) => strandability === 'strandable')
430+
.map(([status]) => status as ApprovalStatus);
314431

315432
/**
316433
* Where {@link ApprovalService.journalStrandedContinuation} keeps the signal a

0 commit comments

Comments
 (0)