diff --git a/.changeset/approval-decision-result-stranded-throw-is-readable.md b/.changeset/approval-decision-result-stranded-throw-is-readable.md new file mode 100644 index 0000000000..3a960f9947 --- /dev/null +++ b/.changeset/approval-decision-result-stranded-throw-is-readable.md @@ -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. diff --git a/packages/spec/src/contracts/approval-service.ts b/packages/spec/src/contracts/approval-service.ts index ea350ea768..f64b7fb0d5 100644 --- a/packages/spec/src/contracts/approval-service.ts +++ b/packages/spec/src/contracts/approval-service.ts @@ -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; /**