From 0257092344564d16e642e94cc19c9381c8edddd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 22:35:15 +0000 Subject: [PATCH] docs(spec): ApprovalDecisionResult records what the stranded-decision throw carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract already declared the posture: 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 it never said is that the throw is now readable. The maintainer ruling #13807 (2026-09-04, decision batch #37) 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. The `resumed` docblock now names the second: the 500-class `RESUME_FAILED` carries `finalized`, `decision`, `runId` and `repairable` on its ERROR body as `StrandedDecisionDetails` (`@objectstack/types`, attached by `strandedDecisionFailure`, read back by `strandedDecisionDetails`, merged into the response by the REST approvals door), and the status code does not move because a durable decision over a run that will not advance is still a failure. Prose only. No member is added: the four facts ride the ERROR, so putting them on this success shape would declare a shape that never carries them, and the docblock says so explicitly. It also keeps them distinct from `resumeFailure`, which reports the #16472 family's different event — a resume failure told behind an answer that still succeeded. The changeset is owed on the second half of the two-part test, not the first: `src/contracts/approval-service.ts` is outside `packages/spec`'s `files[]`, but the changed docblock text is emitted into `dist/contracts/index.d.ts` and `index.d.mts`, which `files[]` publishes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x --- ...ision-result-stranded-throw-is-readable.md | 11 ++++++++++ .../spec/src/contracts/approval-service.ts | 20 +++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 .changeset/approval-decision-result-stranded-throw-is-readable.md 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; /**