Skip to content

Commit c7aca0d

Browse files
os-trumpclaude
andauthored
feat(spec): declare ResumeFailureReport once and carry it on ApprovalRecallResult / ApprovalDecisionResult (batch #76) (#16640)
* wip(spec): ResumeFailureReport carrier on the approval results (#16559) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf * chore(spec): regenerate api-surface and export-origins for ResumeFailureReport (#16559) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent abae16a commit c7aca0d

5 files changed

Lines changed: 330 additions & 11 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
`ResumeFailureReport` — the machine-readable half of a resume failure, declared once in `contracts/approval-service.ts` and carried as the optional, additive `resumeFailure` member of `ApprovalRecallResult` and `ApprovalDecisionResult` (the contract half of the #16472 family ruling, decision batch #76).
6+
7+
The ruling: when a resume failure is told to the caller, it is told in a shape the caller can act on — a registered error code, the `runId` of the run that is actually stranded, and `repairable` — and the door's status code does not change because of it. A decision whose own run advanced still answers success, with the failure behind it carried on the success answer. This change declares that shape; the doors adopt it separately (#15556 for the decision door's subflow bubble-up, #15970 for `recall`).
8+
9+
- **Declared once, by reuse.** `ResumeFailureReport extends ResumeFailureDetails` (`api/automation-api.zod.ts`), the structure the automation resume door already publishes inside its `400 FLOW_FAILED` details. `runId`, `status` and `repairable` are inherited, never re-spelled, so the carriers cannot drift; a caller that parses the member with `ResumeFailureDetailsSchema` reads the same three facts it reads off that door. The report adds exactly the one member a success envelope cannot leave to its envelope: `code`.
10+
- **No new error code is minted.** `code` is typed as `ErrorCode`, the ADR-0112 ledger vocabulary, so an unregistered spelling fails `tsc` rather than reaching the wire. A consumer that needs a distinct code to branch is its own card.
11+
- **The absence rule is explicit and pinned.** The member is optional because it is additive, and an absent member means no report was made — a producer that predates this field, a door that never resumes — never that no run is stranded. A consumer may branch on presence to read a failure; it must not branch on absence to conclude health.
12+
- **`resumeError` is no longer "when `resumed` is false".** Both carriers' `resumeError` docblocks now say its presence is decided by whether a failure was told, never by `resumed`, and name `resumeFailure` as the machine-readable half of the same telling; both `resumed` docblocks say `true` speaks for this door's own resume, not for every run behind it. `ApprovalSendBackResult` and `ApprovalResubmitResult` are unchanged — the ruling names no carrier on those doors.
13+
- `StrandedDecisionDetails` (`@objectstack/types`, the error-envelope carrier of the `decide` door's own strand, #13807) is unchanged.

packages/spec/api-surface/contracts.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@
249249
"ReportSchedule (interface)",
250250
"ResolveDependenciesInput (interface)",
251251
"ResolveShareLinkResult (interface)",
252+
"ResumeFailureReport (interface)",
252253
"ResumeSignal (interface)",
253254
"RlsMembershipContext (interface)",
254255
"RollbackInput (interface)",

packages/spec/export-origins/contracts.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@
249249
"ReportSchedule": "src/contracts/report-service.ts#ReportSchedule (interface)",
250250
"ResolveDependenciesInput": "src/contracts/package-service.ts#ResolveDependenciesInput (interface)",
251251
"ResolveShareLinkResult": "src/contracts/share-link-service.ts#ResolveShareLinkResult (interface)",
252+
"ResumeFailureReport": "src/contracts/approval-service.ts#ResumeFailureReport (interface)",
252253
"ResumeSignal": "src/contracts/automation-service.ts#ResumeSignal (interface)",
253254
"RlsMembershipContext": "src/contracts/rls-membership-resolver.ts#RlsMembershipContext (interface)",
254255
"RollbackInput": "src/contracts/package-service.ts#RollbackInput (interface)",

packages/spec/src/contracts/approval-service.ts

Lines changed: 127 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
// (ADR-0095 D2) and `tabPermissions` — see item 3 of the module doc in
2626
// `./sharing-service.js` for the boundary and the measured consequence.
2727
import type { ExecutionContext } from '../kernel/execution-context.zod.js';
28+
import type { ErrorCode } from '../api/error-code-ledger.zod.js';
29+
import type { ResumeFailureDetails } from '../api/automation-api.zod.js';
2830

2931
/**
3032
* Lifecycle states of an approval request, in the order the
@@ -552,6 +554,69 @@ export interface ApprovalRecallInput {
552554
comment?: string;
553555
}
554556

557+
/**
558+
* The machine-readable half of a resume failure, as a SUCCESS envelope tells
559+
* it (the #16472 family ruling — maintainer 2026-09-07, decision batch #76).
560+
*
561+
* The rule: when a resume failure is told to the caller, it is told in a
562+
* shape the caller can act on — a registered error code, the `runId` of the
563+
* run that is actually stranded, and `repairable` — and the door's status
564+
* code does not change because of it. A decision whose own run advanced still
565+
* answers success, with the failure behind it carried as an optional,
566+
* additive member of that success answer: {@link ApprovalRecallResult} and
567+
* {@link ApprovalDecisionResult} carry it as `resumeFailure`.
568+
*
569+
* Declared ONCE, by reuse. The members every carrier shares — `runId`,
570+
* `status`, `repairable` — ARE {@link ResumeFailureDetails}
571+
* (`api/automation-api.zod.ts`), the structure the automation resume door
572+
* already publishes inside its `400 FLOW_FAILED` `error.details` (#15221, the
573+
* ruling's third carrier). They are inherited here, never re-spelled, so the
574+
* two cannot drift, and a caller that parses this member with
575+
* `ResumeFailureDetailsSchema` reads the same three facts it reads off that
576+
* door. What this adds is the one member a success envelope cannot leave to
577+
* its envelope: on the resume door the registered code is the answer's own
578+
* `code`; on a success answer nothing else names the failure class, so it
579+
* rides here as {@link code}.
580+
*
581+
* ⛔ No new error code is minted under the ruling. `code` is typed as
582+
* `ErrorCode` — the ADR-0112 vocabulary `ApiErrorSchema.code` parses
583+
* against — so an unregistered spelling fails `tsc` instead of reaching the
584+
* wire; a consumer that needs a distinct code to branch is its own card, and
585+
* a new code is a ledger event (#16404).
586+
*
587+
* ⛔ Not `StrandedDecisionDetails` (`@objectstack/types`): that is the
588+
* ERROR-envelope carrier of the `decide` door's OWN strand (#13807 — the
589+
* thrown `RESUME_FAILED` whose `finalized` / `decision` say the decision
590+
* stands), and it stays exactly as it is. This structure is what a success
591+
* answer carries, and `finalized` / `decision` are already top-level members
592+
* of the results that carry it. The two share the `runId` / `repairable`
593+
* vocabulary through the spec declaration, on purpose.
594+
*
595+
* Two halves of ONE telling. The prose `resumeError` beside this member is
596+
* the human-readable half; this is the machine-readable half. A producer
597+
* that tells one tells both, about the same event — a strand, a lost run, a
598+
* composition with no engine to advance the run, or a tolerated concurrent
599+
* duplicate (`RESUME_IN_PROGRESS`, `repairable: false`) — so a caller
600+
* branches on {@link code} and {@link repairable}, never on the text.
601+
*
602+
* The absence rule, and it is load-bearing: the member is optional because
603+
* it is ADDITIVE, and an absent member means no report was made — a producer
604+
* that predates this field, a door that never resumes — never that no run is
605+
* stranded. A consumer may branch on presence to READ a failure; it must not
606+
* branch on absence to CONCLUDE health. Reading an absent discriminator as a
607+
* negative is #15555's false-negative class, the misreading #15358 measured.
608+
*/
609+
export interface ResumeFailureReport extends ResumeFailureDetails {
610+
/**
611+
* The registered error code the failure is told under (ADR-0112 ledger):
612+
* `RESUME_FAILED` for a run that could not be advanced,
613+
* `RESUME_TARGET_LOST` when the run behind the request no longer exists,
614+
* `RESUME_IN_PROGRESS` for the tolerated duplicate. Required here, on
615+
* purpose: a success answer has no envelope `code` to fall back on.
616+
*/
617+
code: ErrorCode;
618+
}
619+
555620
/** Result of a recall. */
556621
export interface ApprovalRecallResult {
557622
request: ApprovalRequestRow;
@@ -562,14 +627,38 @@ export interface ApprovalRecallResult {
562627
* `output.decision = 'recall'`) so it doesn't stay suspended forever. The
563628
* engine has no run-cancel primitive yet; the reject edge is the closest
564629
* "did not pass" semantics.
630+
*
631+
* `true` says THIS door's own resume completed. It does not say every run
632+
* behind it advanced: a resume that completed and then stranded a run
633+
* further up (a subflow's parent, #15556) still answers `true`, with the
634+
* strand told on {@link resumeFailure}.
565635
*/
566636
resumed?: boolean;
567637
/**
568-
* Why the run was not resumed, when `resumed` is false but the recall itself
569-
* succeeded. A recall abandons the request, so a lost run does not fail the
570-
* call — but it must not read as a clean resume either (#4420).
638+
* Why a run was not resumed, in prose — the human-readable half. A recall
639+
* abandons the request, so a lost run does not fail the call — but it must
640+
* not read as a clean resume either (#4420).
641+
*
642+
* ⛔ Not gated on `resumed` being false. Since the #16472 ruling a resume
643+
* failure can ride a `resumed: true` answer (the run this door resumed
644+
* advanced; one behind it did not), so presence is decided by whether a
645+
* failure was TOLD, never by `resumed`. The machine-readable half of the
646+
* same telling is {@link resumeFailure}; a caller branches on that, never
647+
* on this text.
571648
*/
572649
resumeError?: string;
650+
/**
651+
* The machine-readable half of a resume failure this recall tolerated
652+
* (#15970; the #16472 ruling): the registered code, the `runId` of the run
653+
* that is actually stranded — which need not be {@link runId}, the run this
654+
* request gated — and whether the engine says it is repairable.
655+
*
656+
* Optional and ADDITIVE. ⚠️ An absent member means no report was made,
657+
* never that no run is stranded: a producer that predates this field
658+
* answers exactly what it always did, so absence is not a reading of
659+
* health. Presence is the signal; absence is not its negation.
660+
*/
661+
resumeFailure?: ResumeFailureReport;
573662
}
574663

575664
/** Input for sending a pending request back for revision (ADR-0044). */
@@ -634,19 +723,46 @@ export interface ApprovalDecisionResult {
634723
/**
635724
* True when the owning flow run was resumed as a result of this decision.
636725
*
637-
* A decision that finalises a flow-bound request and CANNOT resume its run
638-
* throws rather than returning `resumed: false` — a recorded decision whose
639-
* flow never advances is the zombie half-state of #4420. `false` here means
640-
* either there was nothing to resume (no run, not finalised, no automation
641-
* attached) or a benign duplicate, in which case see {@link resumeError}.
726+
* A decision that finalises a flow-bound request and CANNOT resume its OWN
727+
* run throws rather than returning `resumed: false` — a recorded decision
728+
* whose flow never advances is the zombie half-state of #4420. `false` here
729+
* means either there was nothing to resume (no run, not finalised, no
730+
* automation attached) or a benign duplicate, in which case see
731+
* {@link resumeError} and {@link resumeFailure}.
732+
*
733+
* `true` says this door's own resume completed. It does not say every run
734+
* behind it advanced: a decision inside a subflow whose child resumed and
735+
* whose parent then stranded (#15556) still answers `true`, and the
736+
* parent's strand is told on {@link resumeFailure} — carried on the
737+
* success answer per the #16472 ruling, never thrown.
642738
*/
643739
resumed?: boolean;
644740
/**
645-
* Why the run was not resumed, on the one path that tolerates it: a
646-
* concurrent duplicate resume (`RESUME_IN_PROGRESS`) — the other caller is
647-
* already advancing the run, so this decision is complete and correct.
741+
* Why a run was not resumed, in prose — the human-readable half.
742+
*
743+
* Before the #16472 ruling this was set on exactly one path, the tolerated
744+
* concurrent duplicate (`RESUME_IN_PROGRESS` — the other caller is already
745+
* advancing the run, so this decision is complete and correct), and only
746+
* beside `resumed: false`. ⛔ Not gated on `resumed` being false any more:
747+
* a failure behind a `resumed: true` answer is told here too, so presence
748+
* is decided by whether a failure was TOLD, never by `resumed`. The
749+
* machine-readable half of the same telling is {@link resumeFailure}; a
750+
* caller branches on that, never on this text.
648751
*/
649752
resumeError?: string;
753+
/**
754+
* The machine-readable half of a resume failure told on this success
755+
* answer (#15556; the #16472 ruling): the registered code, the `runId` of
756+
* the run that is actually stranded — the PARENT's when the strand is a
757+
* subflow's bubble-up, never the healthy child's that {@link runId} names —
758+
* and whether the engine says it is repairable.
759+
*
760+
* Optional and ADDITIVE. ⚠️ An absent member means no report was made,
761+
* never that no run is stranded: a producer that predates this field
762+
* answers exactly what it always did, so absence is not a reading of
763+
* health. Presence is the signal; absence is not its negation.
764+
*/
765+
resumeFailure?: ResumeFailureReport;
650766
}
651767

652768
/**

0 commit comments

Comments
 (0)