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
24 changes: 24 additions & 0 deletions .changeset/2026-08-26-recovery-chain-primitives.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@dexpace/core': patch
---

Add the recovery-chain primitives for product-spec §8.2 (`RECOV-1`–`RECOV-16`). No public API change.

Everything this adds lives under `packages/core/src/recovery/` plus two package-root helpers, and none of it is
re-exported from `src/index.ts` — `packages/core/etc/core.api.md` is byte-identical before and after. `patch`
rather than an empty changeset because files under `packages/` did change: the published tarball carries the
new `dist/recovery/*.js` and `dist/suppress.js`, and a consumer stepping through the package in a debugger will
see them.

What landed: `Outcome<T>` with `success`/`failure`/`fold`; `RequestRecoveryChain` and `ResponseRecoveryChain`
(defensive copies on both, concurrency-safe by construction); `dispatchWithRecovery`, whose single `try`/`catch`
wraps both the request chain and the transport hop so no throwable from either can bypass the recovery hooks;
`wrapCancellation`; and `statusMappingStep`, a thin response step over Phase 3b's unchanged `toHttpError()`.
`assertNever` joins `invariant.ts` as the codebase's first discriminated-union `default` case.

One consumer-visible-in-principle detail worth recording: `RECOV-12` pairs a step's throwable with a close
failure, which is what `SuppressedError` is for — and `SuppressedError` reached Node only in 24.0.0, against
this package's `>=20.3` floor. Rather than raise the floor and drop Node 18, 20 and 22 for one error class,
`suppress()` uses the native class where the runtime has one and returns a shape-compatible stand-in (`name`,
`error`, `suppressed`) where it does not. Code that catches one of these should read its fields, not test
`instanceof SuppressedError`.
123 changes: 111 additions & 12 deletions docs/open-items.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@
Running register of everything known to be unmet, unverified, misreported, or deliberately deferred across the
implemented portion of this project. Reviewed state: **scaffold milestone** (committed, `0ebdc79`),
**Phase 1 — Core HTTP Domain Model** (branch `2-phase-1-core-http-domain-model`, uncommitted at time of
review), and **Phase 4a — Execution Context** (branch `7-phase-4a-execution-context`, three review passes).
review), **Phase 3a/3b**, **Phase 4a — Execution Context** (branch `7-phase-4a-execution-context`, three
review passes), and **Phase 4b — Recovery-Chain Primitives** (branch
`8-phase-4b-recovery-chain-primitives`). 4a and 4b are both merged into `9-phase-4c-stage-based-pipeline`.
Last reviewed **2026-08-26**.

Sections A–E below were written against Phase 1 and are re-verified at each review; section F is Phase 4b's.

A requirement absent from this file is either satisfied or belongs to a phase that has not started. The point
of the file is that nothing is unmet *silently* — every gap below is either scheduled against a named phase or
awaiting a decision.
Expand Down Expand Up @@ -144,7 +148,13 @@ observable and owes a real test.

## B. Gates and tooling

### B1 — NFR-10 / NFR-17: CI never runs on the declared minimum runtime — **ACT** (trigger has now fired)
### B1 — NFR-10 / NFR-17: CI never runs on the declared minimum runtime — **RESOLVED** (2026-08-26)

Closed by the `node-conformance` job in `.github/workflows/ci.yml`, which runs `test:node` against the built
artifact as a matrix over `['20.3.0', 'lts/*']` — the declared floor and current LTS. `test/node-conformance/`
holds 36 cases. Re-verified 2026-08-26. Original finding kept below for provenance.

#### Original finding — **ACT** (trigger has now fired)

The scaffold checklist deferred this explicitly: *"recommend adding an `actions/setup-node@v4` step pinned to
`18.17` running `scripts/verify-dual-consumption.mjs` once real Node-API usage lands (Phase 1 onward), rather
Expand All @@ -161,13 +171,14 @@ run" is still missing.
Phase 1 established the convention ("every new source file opens with `// SPDX-License-Identifier: MIT` on line
1") and every file under `packages/core/src/http/` complies. Three files predating it do not:

- `scripts/verify-runtime-floor.mjs`
- `scripts/verify-seam-1.mjs`
- `eslint.config.js`
- ~~`scripts/verify-runtime-floor.mjs`~~ — fixed
- ~~`scripts/verify-seam-1.mjs`~~ — fixed
- `eslint.config.js` — **still missing** (re-verified 2026-08-26; line 1 is
`import {createRequire} from 'node:module';`)

`scripts/verify-dual-consumption.mjs` gained one during Phase 1, which is what makes the omission of its two
siblings look accidental rather than scoped. NFR-13 is a review convention, not a mechanical gate, so this is a
one-line-per-file cleanup.
one-line cleanup on the one file left. Phase 9's `NFR-13` sweep owns it if it is not done sooner.

### B3 — NFR-12: reproducible builds asserted, never proven — **WATCH**

Expand All @@ -177,7 +188,8 @@ it. Becomes real at first publish (~Phase 10): build twice, diff artifact digest
### B4 — NFR-14: `expect-type` breaks the single-source-of-versions convention — **WATCH**

Every other devDependency is centralized at the workspace root; Phase 1 added `expect-type` to
`packages/core/package.json`'s own `devDependencies`. Harmless with one package — it is exactly the restatement
`packages/core/package.json`'s own `devDependencies` (re-verified 2026-08-26 — still there, and Phase 4b added
three more call sites, so the convention is now load-bearing in four files rather than two). Harmless with one package — it is exactly the restatement
NFR-14 warns about once a second package exists (Phase 8). Either hoist it to the root now or fold it into the
NFR-14 decision at Phase 8.

Expand Down Expand Up @@ -225,16 +237,16 @@ No action now. Each is already owned by a named phase; this table exists so none

| Item | Requirement | Owner phase | Note |
|---|---|---|---|
| Body lifecycle: write/replayability, single-use, close, charset | HTTP-36 – HTTP-43 | 3b | `Request`/`Response` `body` is typed `unknown` as an explicit placeholder |
| Lazy `TypedResponse<T>` with parse-once memoization | HTTP-44, HTTP-45 | 3b | |
| `MultipartBody` — the one builder-based model HTTP-3 lists that Phase 1 did not build | HTTP-51 | 3b | Depends on body-lifecycle contracts |
| 1 MiB error-body buffering cap | HTTP-52 | 3b | |
| ~~Body lifecycle: write/replayability, single-use, close, charset~~ | HTTP-36 – HTTP-43 | 3b | **Done** — `packages/core/src/body/`, merged 2026-08-26 |
| ~~Lazy `TypedResponse<T>` with parse-once memoization~~ | HTTP-44, HTTP-45 | 3b | **Done** — `body/typed-response.ts` |
| ~~`MultipartBody`~~ | HTTP-51 | 3b | **Done** — `body/multipart-body.ts`. Its non-appearance clause stays partial; see the roadmap's Phase-3-owned residuals |
| ~~1 MiB error-body buffering cap~~ | HTTP-52 | 3b | **Done** — `body/http-status-error.ts`; `RECOV-16` reuses it unchanged |
| `Request.equals` compares body by reference, not by value | HTTP-46 (body clause) | 3b | Blocked on a real `Body` model supplying value equality |
| `RequestConditions.applyTo` cannot emit an obs-text ETag | HTTP-18 vs HTTP-48/50 | 10 | Spec text in scope does not resolve the tension; strict outbound path kept rather than guessed. Documented in `applyTo`'s TSDoc |
| Seam contracts (byte-stream, transport, codec, projection) | SEAM-2 – SEAM-30 | 2–8 | |
| Adapter packages, peer-dependency dedup | NFR-2 | 8 | |
| Shrink-survival regression guard | NFR-9 | 9 | |
| Concurrency-model agnosticism check | NFR-11 | 4c | Retargeted from "Phase 4" by the 4a design: everything in 4a is synchronous, and 4c's stage pipeline is where async-facing surface appears |
| Concurrency-model agnosticism check | NFR-11 | 4c | Retargeted from "Phase 4" by the 4a design: everything in 4a is synchronous, 4b's surface is `Promise`-only, and 4c's stage pipeline is where async-facing surface appears. 4c's plan claims closure; re-verify when 4c executes |
| `CTX-17`'s positive half — the first store entry installed by the first promotion | CTX-17 | 4c | 4a satisfies only the negative half (constructing a head context must not auto-register it), which holds structurally because `context.ts` never imports `store.ts`. Wiring the store into the promotions would invert the layering and make every promotion a global side effect |
| Real W3C Trace Context generation behind `InstrumentationBundle` | CTX-14, CTX-15 | 7 | 4a ships the bundle's frozen shape and the no-op default only. `activeSpan`/`tracerFactory` stay typed `unknown`, and `activeSpan` is `undefined` rather than a no-op span object, until a tracing adapter defines `Span` |
| `contextsEqual()`, value equality over `ExecutionContext` | CTX-5 (equality framing) | none | Built only if 4b or 4c needs one. `CTX-5`'s operative half — pinning an explicit shared key — ships via `ContextInit.key` |
Expand All @@ -260,6 +272,93 @@ departure.

---

## F. Phase 4b — Recovery-Chain Primitives

Three review passes ran over this phase; everything they found is either fixed in the branch or listed here.
Nothing below blocks the phase — the `RECOV-1`–`RECOV-16` mapping is satisfied and every CI step is green.

### F1 — `ResponseRecoveryChain.apply()` still trusts its *seed* outcome — **WATCH**

`RECOV-8` is absolute: "the response recovery chain's apply operation MUST NOT throw under any input." Pass 2
found and closed the reachable half — a *step* returning a non-outcome used to raise
`TypeError: undefined is not an object` out of `apply()`, because `toFailureClosingSuccess` read `.kind` outside
its `try`. That function is now total, and three regression tests pin it.

What is not guarded is the seed: `apply(garbage)` with at least one response step installed throws on the
`current.kind !== 'success'` read at the loop head. Left alone deliberately — `current` is only ever the
caller's argument at that point, and the sole caller is `dispatchWithRecovery`, which constructs it with
`success()` or `wrapCancellation()`. Guarding it needs either a cast plus an optional chain (which
`no-unnecessary-condition` rejects on a typed value) or the postcondition assertions F3 defers.

**Trigger:** `recovery/` gaining a public export, or any JavaScript caller reaching `apply()` directly. Either
makes the seed a third-party value and this a real defect.

### F2 — A step returning a non-outcome poisons the fold silently when nothing downstream reads it — **WATCH**

The mirror of F1 on the value side. A response step returning `undefined` yields `success(undefined)`; if no
later step touches it, `apply()` resolves with a malformed Success and `dispatchWithRecovery` hands `undefined`
back as the response. Nothing throws, so `RECOV-8` holds — the failure surfaces layers away, in the caller.

This is the concrete cost named in the roadmap's finding F2 (assertion density), and it is why that finding is
recorded as a Deviation Ledger row rather than as "no assertions needed." **Trigger:** the same as F1, or
Phase 5's retry step being the first real third-party-shaped consumer.

### F3 — Zero `invariant()` assertions across `recovery/` — **SCHEDULED** (Phase 10)

`docs/knowledge/assertions.md:6-7` sets a 2-per-function module average; this phase ships none across roughly a
dozen functions. Project-wide inconsistency rather than 4b's — Phases 1/2/3b/4a ship zero, 4c's plan ships
fifteen — so adding them to 4b alone would deepen the split. Recorded in the phase design's Deviation Ledger.

One constraint Phase 10 must carry into the decision: **at the fold sites, `invariant()` is the wrong tool.**
An `invariant()` inside `apply()` throws, and `RECOV-8` forbids `apply()` from throwing. The correct shape
there is to convert a broken postcondition into a Failure, which is what the F1 fix already does.

### F4 — The chains are classes where `data-modeling.md:10` asks for free functions — **SCHEDULED** (Phase 10)

`RequestRecoveryChain` / `ResponseRecoveryChain` own no lifecycle and hold no mutable state, so the corpus
would have them be plain data plus free functions. Kept as classes because `RECOV-14`'s text is written about
the chain and step *instances*, and because the defensive copy wants a construction boundary. Ledgered in the
phase design.

### F5 — `#private` fields carry no per-use justification — **SCHEDULED** (Phase 10)

`data-modeling.md:20-23` makes `private` the default and requires a comment justifying each `#private` as a
genuine runtime-privacy requirement. No such claim is made for either chain class — unlike 3b's `Response`,
whose `#closed` must survive `Object.freeze(this)`. `#private` is the package-wide style (Phases 1, 3b, 4a), so
this is one project-wide reconciliation, not a 4b edit.

### F6 — `RECOV-11` is a no-op in this port — **SCHEDULED** (Phase 10, ledgered)

`wrapCancellation(error)` is `failure(error)`. The reference re-asserts a clearable `Thread.interrupt()` flag;
`AbortSignal.aborted` is durable once fired and the SDK never holds the caller's `AbortController`, so there is
nothing to re-assert. The helper exists as the one named site where the disposition lives. If Phase 5's retry
step lands without giving it behavior, inline it there and carry the disposition wholly in the ledger.

### F7 — `suppress()`'s branch selection is only ever half-covered on any single runtime — **WATCH**

`suppress()` returns the native `SuppressedError` where the runtime has one and `FallbackSuppressedError` where
it does not. No test forces the other branch by deleting the global — that cannot survive parallel execution
(`docs/knowledge/testing.md:50`). Coverage comes from the `test:node` matrix instead: `lts/*` exercises the
native branch, the pinned `20.3.0` exercises the fallback. **Trigger:** if the matrix ever collapses to one
runtime, or the floor rises past Node 24 (where the fallback becomes dead code to be deleted, not guarded).

### F8 — 4b did not depend on 4a — **RESOLVED** (2026-08-26)

The issue lists Phases 0–4a as 4b's dependency and 4b's design says "4a (execution context, done)". Neither
was true on the `8-phase-4b-recovery-chain-primitives` branch, where `packages/core/src/context/` did not
exist: 4b turned out not to depend on it — its only imports outside `recovery/` are `http/`,
`body/http-status-error.js`, `seams/transport.js`, `invariant.js` and `suppress.js`. The design's
parenthetical is corrected. The sequencing half is now closed too: 4a and 4b are both merged into
`9-phase-4c-stage-based-pipeline`, so `context/` is present ahead of 4c, which does depend on it.

### F9 — The Phase 4 checklist's ✅ marks for 4a and 4c are still plan-level — **WATCH**

`plans/2026-07-26-phase4-execution-context-and-pipelines-checklist.md` now says so explicitly in its Status
line, but the §7.x and §8.1 tables read identically to the §8.2 ones that are now real. Re-scan both when their
phases execute, per this file's own maintenance rule.

---

## Maintaining this file

Add an entry the moment a gap is found, not when it is fixed — the failure mode this file prevents is a
Expand Down
Loading
Loading