Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/approval-decision-result-stranded-throw-is-readable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@objectstack/spec": patch
---

`ApprovalDecisionResult`'s contract docblock now records what the stranded-decision throw carries — execution item 2 of the #13807 ruling (maintainer 2026-09-04, decision batch #37).

The posture itself was already declared: a decision that finalises a flow-bound request and cannot resume its own run throws rather than answering `resumed: false`, because a recorded decision whose flow never advances is #4420's zombie half-state. What the docblock did not say is that the throw is now *readable*. The ruling's item 2 owes both halves — "throws with the decision and run identified; the fields are the published way to read it" — and only the first half was written down.

- **Prose only. No member moves.** `finalized`, `decision`, `runId` and `repairable` ride the ERROR body of the 500-class `RESUME_FAILED`, as `StrandedDecisionDetails` (`@objectstack/types`, attached by `strandedDecisionFailure`, read back by `strandedDecisionDetails`, merged into the response by the REST approvals door). Adding them to `ApprovalDecisionResult` would declare a success shape that never carries them, so the docblock names them where they actually live and says so explicitly.
- **The status code does not move either.** A durable decision over a run that will not advance is still a failure; what the ruling changed is that a caller holding the status code alone no longer has to read a bare 500 as "the decision did not happen" when the row is terminal.
- **Kept distinct from `resumeFailure`.** That member is the #16472 family's different event — a resume failure told *behind an answer that still succeeded* — and the new paragraph says so, so the two carriers are not read as one.
20 changes: 20 additions & 0 deletions packages/spec/src/contracts/approval-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,26 @@ export interface ApprovalDecisionResult {
* whose parent then stranded (#15556) still answers `true`, and the
* parent's strand is told on {@link resumeFailure} — carried on the
* success answer per the #16472 ruling, never thrown.
*
* What that throw carries is published, not prose only (#13807, maintainer
* ruling 2026-09-04, decision batch #37). The status code does not move — a
* durable decision over a run that will not advance is still a failure —
* but the 500-class `RESUME_FAILED` it raises names, on its ERROR body, the
* four facts a caller needs: `finalized` (the decision stands), `decision`,
* `runId`, and `repairable` — the engine's own `'stranded'` discriminator
* carried through, never inferred from the message text. That envelope is
* `StrandedDecisionDetails` (`@objectstack/types`), attached by
* `strandedDecisionFailure` and read back by `strandedDecisionDetails`; the
* REST approvals door merges it into the `RESUME_FAILED` response body.
* Those four facts are the published way to read the posture this member
* declares — a caller holding only the status code reads a bare 500 as "the
* decision did not happen", and the row IS terminal.
*
* ⛔ They are not members of this result and must never be added to it:
* they ride the ERROR, so declaring them here would declare a success shape
* that never carries them. ⛔ Nor are they {@link resumeFailure}, which
* reports the other event of the #16472 ruling — a resume failure behind an
* answer that still succeeded.
*/
resumed?: boolean;
/**
Expand Down
Loading