Skip to content

feat(core): stage-based pipeline — product-spec §8.1 (PIPE-1..PIPE-40) - #38

Merged
Wahbeh-Mohammad merged 3 commits into
6-phase-4from
9-phase-4c-stage-based-pipeline
Aug 26, 2026
Merged

feat(core): stage-based pipeline — product-spec §8.1 (PIPE-1..PIPE-40)#38
Wahbeh-Mohammad merged 3 commits into
6-phase-4from
9-phase-4c-stage-based-pipeline

Conversation

@Wahbeh-Mohammad

Copy link
Copy Markdown
Contributor

Closes #9.

What changed

Adds packages/core/src/pipeline/, which satisfies product-spec §8.1. The phase ships
plumbing only. It ships no pillar step bodies, no resilience preset, and nothing new on
the public API surface. The committed API report does not change.

  • stage.tsStage as a string-literal union, plus STAGE_ORDER and PILLAR_STAGES.
    A TS enum is not allowed, because erasableSyntaxOnly is on. To add a stage later,
    splice one entry into STAGE_ORDER. No existing stage identity changes (PIPE-1..4, PIPE-8).
  • step.tsStep, StepContext, Next, and StepDescriptor. A step is a function.
    The descriptor holds a type symbol. PIPE-6 keys reference identity on that symbol.
    PIPE-18/19 match anchors on it.
  • cursor.ts — one recursive dispatcher per call. ctx.next and each ctx.fork() are
    one-shot closures over that dispatcher. Both target the same position. All of them share
    one mutable in-flight request, so a substitution holds for the rest of the call
    (PIPE-9..PIPE-17).
  • runtime.tsRuntime implements Transport. An empty pipeline dispatches directly to
    the transport. A pipeline with steps installs the call context, promotes it, and evicts
    it in a finally on both the resolve path and the throw path. exchangeSource builds
    the exchange context from the request that was sent, not from the request the caller
    supplied (PIPE-9, PIPE-10, PIPE-25..27, CTX-17).
  • builder.ts — stage-bucketed surgical edits. Validation is fail-fast at the mutating
    call. build() flattens once (PIPE-7, PIPE-18..PIPE-25, PIPE-38).
  • errors.ts — five flat DexpaceError leaves. Each error puts its identifying symbols
    in its own message.

Tests are colocated and cite their PIPE IDs. They include fast-check properties for the
builder ordering laws and a driven probe test for stage order.

All CI steps pass locally: typecheck, lint, build, test --coverage (690 tests, pipeline
files at 100%), api, lint:publish, verify:dual-consumption, verify:consumer-types,
verify:seam-1, verify:runtime-floor, audit, and test:node.

Reviews made

Three review passes ran against docs/knowledge/ and against §8.1 conformance clauses.
No behavioral defect was found. Each pass added tags, docs, or tests.

  • Pass 1 — added @throws TSDoc to every throwing builder method. Added tests for the
    two untested PIPE-12 clauses: a step that short-circuits, and a step that substitutes the
    response. Added tests for PIPE-10/PIPE-11 concurrency, for the frozen step view, and for
    the copied constructor array.
  • Pass 2 — added @internal release tagsdded a test for
    PIPE-26 nesting, which proves that a pipelie's transport and
    that the caller options survive both hops. kiness across
    forks. Added a test for PIPE-23 rollback on the SEND path.
  • Pass 3 — recorded the two `eslint-disabesign ledger,
    because the plan had assumed none. Recordede design Testing
    section. Made the pillar guard read on one

Open items & deferred items

  • F9 (open, needs a decision). The cursornal to the transport, but does not check the signal beorted() raises a
    DOMException that the SDK taxonomy does nase 5a Task 1.
    The roadmap tracks it.
  • PIPE-17, remaining clauseStepContexignal go to
    Phase 5a Task 1. No step in this phase can read them.
  • PIPE-24, PIPE-35, PIPE-39 — the preset Phase 5+.
  • PIPE-2 (second half) and PIPE-40 conformance — both need a real redirect step. They
    go to Phase 5b/5c.
  • PIPE-36 and PIPE-37 — placement contracts for the phase that ships the first pillar
    step family.
  • The node-conformance matrix pins Node 20.3.0 and current LTS. This machine has Node v26
    only, so test:node ran on v26 here. CI covers both legs.

@Wahbeh-Mohammad Wahbeh-Mohammad self-assigned this Aug 26, 2026
…, XCUT-14)

Ships the per-call correlation state the pipeline is built on, per
product-spec/07-execution-context-model.md and
docs/superpowers/specs/2026-07-25-phase4a-execution-context-design.md.

New `packages/core/src/context/`, layered instrumentation → errors → context →
store, with no `index.ts` barrel (docs/knowledge/module-organization.md:18 bans
internal barrels; 4c imports the files directly):

- `instrumentation.ts` — the `InstrumentationBundle` shape (CTX-14) and its
  frozen no-op default (CTX-15, CTX-20). `activeSpan`/`tracerFactory` stay typed
  `unknown`: a real tracing adapter owns their shape, deferred to Phase 7.
- `errors.ts` — `DuplicateContextKeyError extends DexpaceError`, carrying the
  offending `key` as a readonly field (CTX-8).
- `context.ts` — `DispatchContext`/`RequestContext`/`ExchangeContext` as a
  frozen discriminated union over plain data, with `create*` factories and the
  two one-way promotions (CTX-1, CTX-2, CTX-3, CTX-5, CTX-6, CTX-7, CTX-16).
  No classes: nothing here owns a lifecycle. Call keys are `Symbol()`, never a
  trace-derived string. The factories and both promotions freeze the
  instrumentation bundle in place — `Object.freeze` is shallow, so freezing only
  the context would leave a caller-supplied bundle writable behind the
  `instrumentation` slot, and the flavors are interfaces, so a literal-built
  context can reach a promotion without passing a factory.
- `store.ts` — `ContextStore`, a bounded `Map` with a post-insert drain loop,
  plus the process-wide `contextStore` singleton (CTX-7..13, CTX-18, CTX-19).
  Also the subject of XCUT-14, which names "context registries" first among the
  caller-keyed process-lived maps that must be capped — and is the only
  appendix-B conformance row this code satisfies, since appendix B has no CTX
  section at all.

Nothing enters the public barrel: `context/` is SDK-internal correlation
plumbing, and `packages/core/etc/core.api.md` is byte-identical.

Tests: 45 across four colocated files, each header citing the IDs it exercises.
A 23-mutant sweep over the module kills 21; one survivor was an equivalent
mutant, and the other — collapsing `#drain`'s loop into a single
check-then-evict — is unkillable by construction, since both callers set one key
before draining so the map never exceeds cap + 1. The loop is kept because
CTX-12 and XCUT-14 mandate the shape for runtimes with real concurrency; both
`#drain` and its describe block say so, and it is registered as open item A6.

Verified against the CI-pinned toolchain rather than the local one: every `ci`
job step under bun 1.3.14 (`.bun-version`), and node-conformance on both matrix
legs — the 20.3.0 floor and lts/* (v24.20.0) — 31 pass each. The context module
itself was additionally exercised against the built artifact on both Node
versions, confirming the plan's claim that nothing here is runtime-divergent.

Also fixes bunfig.toml, where merging the Phase 3 and knowledge-CLI branches
left `root = "packages"` twice under `[test]`; Bun refuses a config with a
redefined key, so `bun test` failed to start at all.

Deliberate deferrals, all registered in docs/open-items.md rather than left
silent: CTX-17's positive half (install-on-first-promotion) belongs to 4c, which
owns the store handle; real W3C Trace Context generation to Phase 7;
`contextsEqual()` unscheduled; and CTX-8's message clause (a Symbol's
description names the flavor, not the instance) awaiting a decision as A5.
…ECOV-16)

Phase 4b. Ships `packages/core/src/recovery/` — six files, no folder barrel,
nothing on the published API surface (`packages/core/etc/core.api.md` is
byte-identical):

- `outcome.ts`      Outcome<T>, success/failure/fold                (RECOV-1)
- `request-chain.ts` sequential fold, empty = identity, throw
                     propagates for the orchestrator to convert     (RECOV-3, 14)
- `response-chain.ts` response phase on Success only, recovery phase
                     always, close-on-throw exactly once with the
                     original throwable primary, no auto-close on a
                     deliberately returned substitute        (RECOV-4..9, 12..14)
- `cancellation.ts`  wrapCancellation — never throws, which is what
                     keeps RECOV-2 absolute                         (RECOV-11)
- `status-mapping.ts` a thin response step over 3b's unchanged
                     toHttpError()                                  (RECOV-15, 16)
- `orchestrator.ts`  dispatchWithRecovery — one try/catch over the
                     request chain and the transport hop; the final
                     unwrap rethrows by identity                    (RECOV-2, 10)

Plus two package-root helpers: `assertNever` in `invariant.ts` (the codebase's
first discriminated-union `default`) and `suppress()` in `suppress.ts`.

F1, the cross-phase blocker, resolved to branch (b)
---------------------------------------------------
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 `engines.node >=20.3` floor (set by `AbortSignal.any()`),
and is absent from the `lib` it compiles against, so the direct form neither
type-checks nor runs there. Raising the floor would drop Node 18, 20 and 22 for
one error class. `suppress()` uses the native class where the runtime has one
and a shape-compatible stand-in where it does not, reading the global per call.
Callers assert the shape, never `instanceof SuppressedError` — that form would
silently assert nothing on the floor. Phases 5a, 6a, 6b and 6c reached for the
native class on the same premise; their docs now point at the helper.

F2 resolved as a Deviation Ledger row: the zero-vs-fifteen `invariant()` split
with 4c is project-wide, so Phase 10 settles the density rule once.

Three review passes
-------------------
Pass 1, against the knowledge corpus: a dead `statusMappingStep;` statement
reaching the published dist/ (`satisfies` erases to its operand, not to
nothing); two test files that could not survive parallel execution because they
deleted a global; no type-level test for the exported generic `Outcome<T>`; the
RECOV-15 conformance clause tested on the step in isolation rather than through
the chain; two step-down-rule violations.

Pass 2, against the normative text: **a RECOV-8 violation** — `apply()` could
raise `TypeError: undefined is not an object` when a step returned a
non-outcome, against "MUST NOT throw under any input". `toFailureClosingSuccess`
is now total: the discriminant read and the `close()` call share one `try`, so a
misbehaving step becomes a Failure with its own throwable still primary. Also an
unguarded `String()` in `assertNever`'s default message, which throws on a
null-prototype object.

Pass 3: re-ran every step of both CI jobs, swept the structure, and wrote what
survives into `docs/open-items.md` section F.

Also fixes a merge residue: the phase-3 merge left `bunfig.toml` with a
duplicated `[test] root` key, which TOML rejects, so `bun test` failed to load
bunfig at all on this branch.

Verification (all exit 0)
-------------------------
`bun install --frozen-lockfile`, typecheck, lint, build, `bun test --coverage`
(588 tests / 50 files, 98.68% funcs / 99.73% lines against the 80% floor), api,
lint:publish, verify:dual-consumption, verify:consumer-types, verify:seam-1,
verify:runtime-floor, audit, test:node (36 cases, +1 file covering the
SuppressedError guard and RECOV-12 over Node's own Web Streams), test:knowledge.
No `node:` import, no `enum`, no internal barrel, SPDX on line 1 of all 15 new
files, no import cycle under `packages/core/src`.

Refs: #8
Phase 4c. Ships `packages/core/src/pipeline/` — the fixed-stage step composition
runtime, its builder, the per-call cursor/fork mechanism, and the
execution-context-store wiring 4a deferred here. Plumbing only: no pillar step
bodies, no standard-resilience preset, nothing added to the public barrel.

- `stage.ts` — `Stage` as a string-literal union plus `STAGE_ORDER` and
  `PILLAR_STAGES`. No TS `enum` (`erasableSyntaxOnly`); inserting a stage later
  is one splice and touches no existing stage identity (PIPE-1..4, PIPE-8).
- `step.ts` — `Step`/`StepContext`/`Next`/`StepDescriptor`. A step is a function
  wrapped in a descriptor carrying a `type` symbol, which is what PIPE-6's
  reference identity and PIPE-18/19's anchor matching key off.
- `cursor.ts` — one recursive dispatcher per call. `ctx.next` and every
  `ctx.fork()` are one-shot closures over it, pinned to the same target position,
  sharing a single mutable in-flight request so a substitution sticks for the
  whole call (PIPE-9..PIPE-17).
- `runtime.ts` — `Runtime implements Transport`: empty-pipeline fast path,
  context install/promote/evict-in-`finally` on both paths, and `exchangeSource`
  so the exchange context describes the request that was actually sent
  (PIPE-9, PIPE-10, PIPE-25..27, CTX-17).
- `builder.ts` — stage-bucketed surgical edits with fail-fast validation at the
  mutating call, flattened once at `build()` (PIPE-7, PIPE-18..PIPE-25, PIPE-38).
- `errors.ts` — five flat `DexpaceError` leaves, each rendering its identifying
  symbols into its own message.

Tests are colocated and cite their PIPE IDs, including fast-check properties for
the builder's ordering laws (PIPE-22, PIPE-38) and the driven probe test for
PIPE-1/PIPE-2's stage ordering. Deliberately deferred, each named in the design
doc or the roadmap's Deferred Items Log: PIPE-17's "readable by any step" clause
and `StepContext.signal` (Phase 5a Task 1), PIPE-24/PIPE-35/PIPE-39 (Phase 5+),
PIPE-2's redirect/retry half and PIPE-40's 2-hop clause (Phase 5b/5c). Open
finding F9 — the cursor does not observe the caller's `AbortSignal` between
steps — stays undecided in the roadmap and must be settled before 5a Task 1.

Full CI sequence green locally: typecheck, lint, build, test --coverage (690
tests, pipeline files at 100%), api (report byte-identical), lint:publish,
verify:dual-consumption, verify:consumer-types, verify:seam-1,
verify:runtime-floor, audit, test:node.
@Wahbeh-Mohammad
Wahbeh-Mohammad force-pushed the 9-phase-4c-stage-based-pipeline branch from 726f05b to c78c327 Compare August 26, 2026 17:59
@Wahbeh-Mohammad
Wahbeh-Mohammad merged commit fe9e857 into 6-phase-4 Aug 26, 2026
3 checks passed
@Wahbeh-Mohammad
Wahbeh-Mohammad deleted the 9-phase-4c-stage-based-pipeline branch August 26, 2026 18:09
Wahbeh-Mohammad added a commit that referenced this pull request Aug 26, 2026
…eline (#39)

* feat(core): add the execution context model — Phase 4a (CTX-1..CTX-20, XCUT-14) (#36)

Ships the per-call correlation state the pipeline is built on, per
product-spec/07-execution-context-model.md and
docs/superpowers/specs/2026-07-25-phase4a-execution-context-design.md.

New `packages/core/src/context/`, layered instrumentation → errors → context →
store, with no `index.ts` barrel (docs/knowledge/module-organization.md:18 bans
internal barrels; 4c imports the files directly):

- `instrumentation.ts` — the `InstrumentationBundle` shape (CTX-14) and its
  frozen no-op default (CTX-15, CTX-20). `activeSpan`/`tracerFactory` stay typed
  `unknown`: a real tracing adapter owns their shape, deferred to Phase 7.
- `errors.ts` — `DuplicateContextKeyError extends DexpaceError`, carrying the
  offending `key` as a readonly field (CTX-8).
- `context.ts` — `DispatchContext`/`RequestContext`/`ExchangeContext` as a
  frozen discriminated union over plain data, with `create*` factories and the
  two one-way promotions (CTX-1, CTX-2, CTX-3, CTX-5, CTX-6, CTX-7, CTX-16).
  No classes: nothing here owns a lifecycle. Call keys are `Symbol()`, never a
  trace-derived string. The factories and both promotions freeze the
  instrumentation bundle in place — `Object.freeze` is shallow, so freezing only
  the context would leave a caller-supplied bundle writable behind the
  `instrumentation` slot, and the flavors are interfaces, so a literal-built
  context can reach a promotion without passing a factory.
- `store.ts` — `ContextStore`, a bounded `Map` with a post-insert drain loop,
  plus the process-wide `contextStore` singleton (CTX-7..13, CTX-18, CTX-19).
  Also the subject of XCUT-14, which names "context registries" first among the
  caller-keyed process-lived maps that must be capped — and is the only
  appendix-B conformance row this code satisfies, since appendix B has no CTX
  section at all.

Nothing enters the public barrel: `context/` is SDK-internal correlation
plumbing, and `packages/core/etc/core.api.md` is byte-identical.

Tests: 45 across four colocated files, each header citing the IDs it exercises.
A 23-mutant sweep over the module kills 21; one survivor was an equivalent
mutant, and the other — collapsing `#drain`'s loop into a single
check-then-evict — is unkillable by construction, since both callers set one key
before draining so the map never exceeds cap + 1. The loop is kept because
CTX-12 and XCUT-14 mandate the shape for runtimes with real concurrency; both
`#drain` and its describe block say so, and it is registered as open item A6.

Verified against the CI-pinned toolchain rather than the local one: every `ci`
job step under bun 1.3.14 (`.bun-version`), and node-conformance on both matrix
legs — the 20.3.0 floor and lts/* (v24.20.0) — 31 pass each. The context module
itself was additionally exercised against the built artifact on both Node
versions, confirming the plan's claim that nothing here is runtime-divergent.

Also fixes bunfig.toml, where merging the Phase 3 and knowledge-CLI branches
left `root = "packages"` twice under `[test]`; Bun refuses a config with a
redefined key, so `bun test` failed to start at all.

Deliberate deferrals, all registered in docs/open-items.md rather than left
silent: CTX-17's positive half (install-on-first-promotion) belongs to 4c, which
owns the store handle; real W3C Trace Context generation to Phase 7;
`contextsEqual()` unscheduled; and CTX-8's message clause (a Symbol's
description names the flavor, not the instance) awaiting a decision as A5.

* feat(core): recovery-chain primitives — product-spec §8.2 (RECOV-1..RECOV-16) (#37)

Phase 4b. Ships `packages/core/src/recovery/` — six files, no folder barrel,
nothing on the published API surface (`packages/core/etc/core.api.md` is
byte-identical):

- `outcome.ts`      Outcome<T>, success/failure/fold                (RECOV-1)
- `request-chain.ts` sequential fold, empty = identity, throw
                     propagates for the orchestrator to convert     (RECOV-3, 14)
- `response-chain.ts` response phase on Success only, recovery phase
                     always, close-on-throw exactly once with the
                     original throwable primary, no auto-close on a
                     deliberately returned substitute        (RECOV-4..9, 12..14)
- `cancellation.ts`  wrapCancellation — never throws, which is what
                     keeps RECOV-2 absolute                         (RECOV-11)
- `status-mapping.ts` a thin response step over 3b's unchanged
                     toHttpError()                                  (RECOV-15, 16)
- `orchestrator.ts`  dispatchWithRecovery — one try/catch over the
                     request chain and the transport hop; the final
                     unwrap rethrows by identity                    (RECOV-2, 10)

Plus two package-root helpers: `assertNever` in `invariant.ts` (the codebase's
first discriminated-union `default`) and `suppress()` in `suppress.ts`.

F1, the cross-phase blocker, resolved to branch (b)
---------------------------------------------------
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 `engines.node >=20.3` floor (set by `AbortSignal.any()`),
and is absent from the `lib` it compiles against, so the direct form neither
type-checks nor runs there. Raising the floor would drop Node 18, 20 and 22 for
one error class. `suppress()` uses the native class where the runtime has one
and a shape-compatible stand-in where it does not, reading the global per call.
Callers assert the shape, never `instanceof SuppressedError` — that form would
silently assert nothing on the floor. Phases 5a, 6a, 6b and 6c reached for the
native class on the same premise; their docs now point at the helper.

F2 resolved as a Deviation Ledger row: the zero-vs-fifteen `invariant()` split
with 4c is project-wide, so Phase 10 settles the density rule once.

Three review passes
-------------------
Pass 1, against the knowledge corpus: a dead `statusMappingStep;` statement
reaching the published dist/ (`satisfies` erases to its operand, not to
nothing); two test files that could not survive parallel execution because they
deleted a global; no type-level test for the exported generic `Outcome<T>`; the
RECOV-15 conformance clause tested on the step in isolation rather than through
the chain; two step-down-rule violations.

Pass 2, against the normative text: **a RECOV-8 violation** — `apply()` could
raise `TypeError: undefined is not an object` when a step returned a
non-outcome, against "MUST NOT throw under any input". `toFailureClosingSuccess`
is now total: the discriminant read and the `close()` call share one `try`, so a
misbehaving step becomes a Failure with its own throwable still primary. Also an
unguarded `String()` in `assertNever`'s default message, which throws on a
null-prototype object.

Pass 3: re-ran every step of both CI jobs, swept the structure, and wrote what
survives into `docs/open-items.md` section F.

Also fixes a merge residue: the phase-3 merge left `bunfig.toml` with a
duplicated `[test] root` key, which TOML rejects, so `bun test` failed to load
bunfig at all on this branch.

Verification (all exit 0)
-------------------------
`bun install --frozen-lockfile`, typecheck, lint, build, `bun test --coverage`
(588 tests / 50 files, 98.68% funcs / 99.73% lines against the 80% floor), api,
lint:publish, verify:dual-consumption, verify:consumer-types, verify:seam-1,
verify:runtime-floor, audit, test:node (36 cases, +1 file covering the
SuppressedError guard and RECOV-12 over Node's own Web Streams), test:knowledge.
No `node:` import, no `enum`, no internal barrel, SPDX on line 1 of all 15 new
files, no import cycle under `packages/core/src`.

Refs: #8

* feat(core): stage-based pipeline — product-spec §8.1 (PIPE-1..PIPE-40) (#38)

* feat(core): add the execution context model — Phase 4a (CTX-1..CTX-20, XCUT-14)

Ships the per-call correlation state the pipeline is built on, per
product-spec/07-execution-context-model.md and
docs/superpowers/specs/2026-07-25-phase4a-execution-context-design.md.

New `packages/core/src/context/`, layered instrumentation → errors → context →
store, with no `index.ts` barrel (docs/knowledge/module-organization.md:18 bans
internal barrels; 4c imports the files directly):

- `instrumentation.ts` — the `InstrumentationBundle` shape (CTX-14) and its
  frozen no-op default (CTX-15, CTX-20). `activeSpan`/`tracerFactory` stay typed
  `unknown`: a real tracing adapter owns their shape, deferred to Phase 7.
- `errors.ts` — `DuplicateContextKeyError extends DexpaceError`, carrying the
  offending `key` as a readonly field (CTX-8).
- `context.ts` — `DispatchContext`/`RequestContext`/`ExchangeContext` as a
  frozen discriminated union over plain data, with `create*` factories and the
  two one-way promotions (CTX-1, CTX-2, CTX-3, CTX-5, CTX-6, CTX-7, CTX-16).
  No classes: nothing here owns a lifecycle. Call keys are `Symbol()`, never a
  trace-derived string. The factories and both promotions freeze the
  instrumentation bundle in place — `Object.freeze` is shallow, so freezing only
  the context would leave a caller-supplied bundle writable behind the
  `instrumentation` slot, and the flavors are interfaces, so a literal-built
  context can reach a promotion without passing a factory.
- `store.ts` — `ContextStore`, a bounded `Map` with a post-insert drain loop,
  plus the process-wide `contextStore` singleton (CTX-7..13, CTX-18, CTX-19).
  Also the subject of XCUT-14, which names "context registries" first among the
  caller-keyed process-lived maps that must be capped — and is the only
  appendix-B conformance row this code satisfies, since appendix B has no CTX
  section at all.

Nothing enters the public barrel: `context/` is SDK-internal correlation
plumbing, and `packages/core/etc/core.api.md` is byte-identical.

Tests: 45 across four colocated files, each header citing the IDs it exercises.
A 23-mutant sweep over the module kills 21; one survivor was an equivalent
mutant, and the other — collapsing `#drain`'s loop into a single
check-then-evict — is unkillable by construction, since both callers set one key
before draining so the map never exceeds cap + 1. The loop is kept because
CTX-12 and XCUT-14 mandate the shape for runtimes with real concurrency; both
`#drain` and its describe block say so, and it is registered as open item A6.

Verified against the CI-pinned toolchain rather than the local one: every `ci`
job step under bun 1.3.14 (`.bun-version`), and node-conformance on both matrix
legs — the 20.3.0 floor and lts/* (v24.20.0) — 31 pass each. The context module
itself was additionally exercised against the built artifact on both Node
versions, confirming the plan's claim that nothing here is runtime-divergent.

Also fixes bunfig.toml, where merging the Phase 3 and knowledge-CLI branches
left `root = "packages"` twice under `[test]`; Bun refuses a config with a
redefined key, so `bun test` failed to start at all.

Deliberate deferrals, all registered in docs/open-items.md rather than left
silent: CTX-17's positive half (install-on-first-promotion) belongs to 4c, which
owns the store handle; real W3C Trace Context generation to Phase 7;
`contextsEqual()` unscheduled; and CTX-8's message clause (a Symbol's
description names the flavor, not the instance) awaiting a decision as A5.

* feat(core): recovery-chain primitives — product-spec §8.2 (RECOV-1..RECOV-16)

Phase 4b. Ships `packages/core/src/recovery/` — six files, no folder barrel,
nothing on the published API surface (`packages/core/etc/core.api.md` is
byte-identical):

- `outcome.ts`      Outcome<T>, success/failure/fold                (RECOV-1)
- `request-chain.ts` sequential fold, empty = identity, throw
                     propagates for the orchestrator to convert     (RECOV-3, 14)
- `response-chain.ts` response phase on Success only, recovery phase
                     always, close-on-throw exactly once with the
                     original throwable primary, no auto-close on a
                     deliberately returned substitute        (RECOV-4..9, 12..14)
- `cancellation.ts`  wrapCancellation — never throws, which is what
                     keeps RECOV-2 absolute                         (RECOV-11)
- `status-mapping.ts` a thin response step over 3b's unchanged
                     toHttpError()                                  (RECOV-15, 16)
- `orchestrator.ts`  dispatchWithRecovery — one try/catch over the
                     request chain and the transport hop; the final
                     unwrap rethrows by identity                    (RECOV-2, 10)

Plus two package-root helpers: `assertNever` in `invariant.ts` (the codebase's
first discriminated-union `default`) and `suppress()` in `suppress.ts`.

F1, the cross-phase blocker, resolved to branch (b)
---------------------------------------------------
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 `engines.node >=20.3` floor (set by `AbortSignal.any()`),
and is absent from the `lib` it compiles against, so the direct form neither
type-checks nor runs there. Raising the floor would drop Node 18, 20 and 22 for
one error class. `suppress()` uses the native class where the runtime has one
and a shape-compatible stand-in where it does not, reading the global per call.
Callers assert the shape, never `instanceof SuppressedError` — that form would
silently assert nothing on the floor. Phases 5a, 6a, 6b and 6c reached for the
native class on the same premise; their docs now point at the helper.

F2 resolved as a Deviation Ledger row: the zero-vs-fifteen `invariant()` split
with 4c is project-wide, so Phase 10 settles the density rule once.

Three review passes
-------------------
Pass 1, against the knowledge corpus: a dead `statusMappingStep;` statement
reaching the published dist/ (`satisfies` erases to its operand, not to
nothing); two test files that could not survive parallel execution because they
deleted a global; no type-level test for the exported generic `Outcome<T>`; the
RECOV-15 conformance clause tested on the step in isolation rather than through
the chain; two step-down-rule violations.

Pass 2, against the normative text: **a RECOV-8 violation** — `apply()` could
raise `TypeError: undefined is not an object` when a step returned a
non-outcome, against "MUST NOT throw under any input". `toFailureClosingSuccess`
is now total: the discriminant read and the `close()` call share one `try`, so a
misbehaving step becomes a Failure with its own throwable still primary. Also an
unguarded `String()` in `assertNever`'s default message, which throws on a
null-prototype object.

Pass 3: re-ran every step of both CI jobs, swept the structure, and wrote what
survives into `docs/open-items.md` section F.

Also fixes a merge residue: the phase-3 merge left `bunfig.toml` with a
duplicated `[test] root` key, which TOML rejects, so `bun test` failed to load
bunfig at all on this branch.

Verification (all exit 0)
-------------------------
`bun install --frozen-lockfile`, typecheck, lint, build, `bun test --coverage`
(588 tests / 50 files, 98.68% funcs / 99.73% lines against the 80% floor), api,
lint:publish, verify:dual-consumption, verify:consumer-types, verify:seam-1,
verify:runtime-floor, audit, test:node (36 cases, +1 file covering the
SuppressedError guard and RECOV-12 over Node's own Web Streams), test:knowledge.
No `node:` import, no `enum`, no internal barrel, SPDX on line 1 of all 15 new
files, no import cycle under `packages/core/src`.

Refs: #8

* feat(core): stage-based pipeline — product-spec §8.1 (PIPE-1..PIPE-40)

Phase 4c. Ships `packages/core/src/pipeline/` — the fixed-stage step composition
runtime, its builder, the per-call cursor/fork mechanism, and the
execution-context-store wiring 4a deferred here. Plumbing only: no pillar step
bodies, no standard-resilience preset, nothing added to the public barrel.

- `stage.ts` — `Stage` as a string-literal union plus `STAGE_ORDER` and
  `PILLAR_STAGES`. No TS `enum` (`erasableSyntaxOnly`); inserting a stage later
  is one splice and touches no existing stage identity (PIPE-1..4, PIPE-8).
- `step.ts` — `Step`/`StepContext`/`Next`/`StepDescriptor`. A step is a function
  wrapped in a descriptor carrying a `type` symbol, which is what PIPE-6's
  reference identity and PIPE-18/19's anchor matching key off.
- `cursor.ts` — one recursive dispatcher per call. `ctx.next` and every
  `ctx.fork()` are one-shot closures over it, pinned to the same target position,
  sharing a single mutable in-flight request so a substitution sticks for the
  whole call (PIPE-9..PIPE-17).
- `runtime.ts` — `Runtime implements Transport`: empty-pipeline fast path,
  context install/promote/evict-in-`finally` on both paths, and `exchangeSource`
  so the exchange context describes the request that was actually sent
  (PIPE-9, PIPE-10, PIPE-25..27, CTX-17).
- `builder.ts` — stage-bucketed surgical edits with fail-fast validation at the
  mutating call, flattened once at `build()` (PIPE-7, PIPE-18..PIPE-25, PIPE-38).
- `errors.ts` — five flat `DexpaceError` leaves, each rendering its identifying
  symbols into its own message.

Tests are colocated and cite their PIPE IDs, including fast-check properties for
the builder's ordering laws (PIPE-22, PIPE-38) and the driven probe test for
PIPE-1/PIPE-2's stage ordering. Deliberately deferred, each named in the design
doc or the roadmap's Deferred Items Log: PIPE-17's "readable by any step" clause
and `StepContext.signal` (Phase 5a Task 1), PIPE-24/PIPE-35/PIPE-39 (Phase 5+),
PIPE-2's redirect/retry half and PIPE-40's 2-hop clause (Phase 5b/5c). Open
finding F9 — the cursor does not observe the caller's `AbortSignal` between
steps — stays undecided in the roadmap and must be settled before 5a Task 1.

Full CI sequence green locally: typecheck, lint, build, test --coverage (690
tests, pipeline files at 100%), api (report byte-identical), lint:publish,
verify:dual-consumption, verify:consumer-types, verify:seam-1,
verify:runtime-floor, audit, test:node.

* docs: add changesets for phase 4a & phase 4c.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant