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

Add the execution-context model for product-spec §7 (`CTX-1`–`CTX-20`, `XCUT-14`). No public API change.

Everything this adds lives under `packages/core/src/context/` 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/context/*.js`, and a consumer stepping through the package in a debugger will see them.

What landed: `ExecutionContext` as a three-member discriminated union — `DispatchContext` (before any
request), `RequestContext` (an outbound request assembled), `ExchangeContext` (a response arrived, terminal) —
with `promoteToRequest`/`promoteToExchange` as the pure promotion chain and `createDispatchContext`/
`createRequestContext`/`createExchangeContext` as the off-chain factories `CTX-5`/`CTX-6` require.
`InstrumentationBundle` plus the `noopInstrumentationBundle` disabled-tracing default. `ContextStore`, a
bounded keyed registry with `install`/`installIfAbsent`/`find`/`close`, and `DuplicateContextKeyError`.

Three design calls worth recording:

- **Call keys are `Symbol()`, not a counter or a UUID.** `CTX-4`'s uniqueness requirement cannot lean on any
field of the instrumentation bundle, because `noopInstrumentationBundle`'s fields are all constants shared
by every context that takes the default. A fresh `Symbol()` per call is distinct across the process and
across all three context flavors by construction, and `ContextInit.key` is the pin that makes two contexts
deliberately share one store slot (`CTX-5`).
- **The store's cap drains in a loop, and holds strong references.** `XCUT-14` names context registries first
among the caller-keyed process-lived maps that MUST carry a hard cap and drain back under it after each
insert — an unbounded one is a memory-exhaustion vector, not merely a leak. The loop (rather than a single
check-then-evict) is what makes an insert burst converge. `Map`, never `WeakMap`/`WeakRef`: a registered
context keeps its whole `Request`+`Response` graph reachable on purpose, so the cap is the backstop rather
than the collector (`CTX-19`).
- **Promotions never touch a store.** `context.ts` does not import `store.ts`, which is what satisfies
`CTX-17`'s negative half structurally — constructing a head context must not auto-register it. Wiring the
store into the promotions would invert the layering and make every promotion a global side effect. The
positive half — the first store entry, installed by the first promotion — is Phase 4c's `Runtime.send()`.

Two known deviations, both already in the deferral register (`docs/open-items.md`):

- `contextStore` is a module-level mutable singleton, which
`docs/knowledge/variables-and-declarations.md:22` bans. Accepted because threading a store handle through
builder → runtime → every step would be a wide API change for no observable gain; logged in the design's
Deviation Ledger for Phase 10. Tests build their own `new ContextStore()` rather than asserting through the
singleton, which is shared by every file in a `bun test` run.
- `activeSpan` and `tracerFactory` stay typed `unknown`, and `activeSpan` is `undefined` rather than a no-op
span object. `CTX-14`/`CTX-15` ship as the bundle's frozen shape and the disabled default only; real W3C
Trace Context generation waits for the Phase 7 tracing adapter that gets to define `Span`.
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`.
52 changes: 52 additions & 0 deletions .changeset/2026-08-26-stage-based-pipeline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
'@dexpace/core': patch
---

Add the stage-based pipeline for product-spec §8.1 (`PIPE-1`–`PIPE-40`). No public API change.

Everything this adds lives under `packages/core/src/pipeline/` 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/pipeline/*.js`, and a consumer stepping through the package in a debugger will see them.

What landed: `Stage` and `STAGE_ORDER`, the fixed total order from `PRE_REDIRECT` out to the reserved terminal
`SEND`, with `PILLAR_STAGES` marking the slots that admit at most one step. `Step`, `StepContext`, `Next` and
`StepDescriptor` as the step contract. `PipelineBuilder`, the surgical-edit API — `append`/`prepend`/
`appendAll`/`prependAll`/`insertAfter`/`insertBefore`/`replace`/`remove`/`reload` — flattening into an
immutable `Runtime` at `build()`. `Cursor`, one instance per call, driving the flattened array. Five typed
errors: `PillarCollisionError`, `AnchorNotFoundError`, `CrossStageEditError`, `CursorAlreadyAdvancedError`,
`ReservedStageError`.

Design calls worth recording:

- **`Runtime` implements `Transport` itself (`PIPE-26`), and its `close()` is a deliberate no-op
(`PIPE-27`).** Phase 2's `Transport` SPI has a single `send`, so there is no second async entry point to
delegate through. The pipeline never owns the transport it wraps, so closing the pipeline must not close it.
- **Continuations are one-shot, and a fork is a closure, not a second cursor.** `next` and every `fork()`
handle are one-shot closures over one private recursive dispatcher indexed by array position
(`PIPE-15`/`PIPE-16`); reusing an already-invoked handle rejects with `CursorAlreadyAdvancedError`. There is
deliberately no settable start position — a step that must re-drive the chain calls `ctx.fork()` again. The
dispatcher shares one mutable in-flight request, so a `PIPE-14` substitution sticks for every later step
*and* the terminal dispatch.
- **`Stage` is a string-literal union, not an enum.** `erasableSyntaxOnly` bars enums, and `Stage` carries no
behavior beyond ordering, which `STAGE_ORDER` alone provides. Adding a stage later is one splice into that
array — no existing `Stage` value changes, so there is no numeric-gap renumbering to design around.
- **`prependAll` reverses its batch and `appendAll` does not.** The asymmetry falls out of prepending each
element individually, and is the documented one `PIPE-38` allows rather than an oversight. `reload` is the
transactional bulk path (`PIPE-23`): fully validated before any existing content is touched, so a rejected
batch leaves the builder untouched instead of half-applied.
- **`replace` is the sanctioned way past a pillar collision.** `PIPE-5` exempts it from the pillar check;
re-seating the *same* `type` symbol anywhere is an idempotent no-op rather than a second step (`PIPE-6`),
which is also what keeps the bulk paths from seating two steps where `append` would seat one.
- **`send()` closes `CTX-17`'s positive half.** The first promotion installs into Phase 4a's `contextStore`,
the exchange promotion replaces it under the same key, and the `finally` evicts whichever context was
installed last. `exchangeSource()` is exported (still `@internal`) so its two branches can be asserted as
the pure function they are: when a step substituted the outbound request, the exchange is promoted from an
off-chain rebuild around the request that was *actually sent*, pinned to the same call key and carrying the
same instrumentation bundle by reference. Promoting straight off the original would pair the response with a
request that never left the process.

One deferral, recorded in `docs/open-items.md`: `StepContext` carries neither the per-call `options` nor the
`AbortSignal`. `Cursor` holds both and threads them into the terminal dispatch (`PIPE-17`), but the
"readable by any step" clause has no reader until Phase 5a's retry engine, which adds both fields as one
additive amendment.
6 changes: 3 additions & 3 deletions bunfig.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
# Scope discovery to the workspace packages. Without this, `bun test` also collects
# test/node-conformance/*.test.mjs -- the Node-only layer that exists precisely because it must NOT
# run on Bun (checkpoint 5.9). Running it under both runners would inflate the unit count and quietly
# erase the distinction the suite was added to draw. It also keeps `scripts/*.test.mjs` repo
# tooling, run via `bun run test:knowledge` (`node --test`) out of both the run and the 80% floor,
# which is a statement about `packages/core`.
# erase the distinction the suite was added to draw. It likewise keeps `scripts/*.test.mjs` -- repo
# tooling, run via `bun run test:knowledge` (`node --test`) -- out of both the run and the coverage
# floor, which is a statement about `packages/core`.
root = "packages"
coverage = true
coverageThreshold = 0.8
Expand Down
Loading
Loading