Skip to content

feat: the v1 MVP — eleven packages, the full pipeline, and the gate suite - #99

Merged
Wahbeh-Mohammad merged 133 commits into
mainfrom
mvp
Sep 7, 2026
Merged

feat: the v1 MVP — eleven packages, the full pipeline, and the gate suite#99
Wahbeh-Mohammad merged 133 commits into
mainfrom
mvp

Conversation

@Wahbeh-Mohammad

Copy link
Copy Markdown
Contributor

What changed

main carries one package, @dexpace/core, with two subsystems in it: http/ and seams/. This branch
carries the v1 MVP — 133 commits, 660 files — and takes the workspace to eleven packages, nine of them
publishable.

Core grows from two subsystems to twenty. pipeline/, retry/, redirect/, auth/, recovery/,
serde/, sse/, pagination/, io/, body/, config/, context/, observability/, cancellation
and testing/ join http/ and seams/. Zero runtime dependencies throughout, which verify:seam-1
enforces per package.

Ten packages are new. transport-fetch and transport-undici behind one transport seam, with
transport-shared holding the plumbing they need identically and the private transport-conformance
holding the TRANSPORT-N suite they both run; codec-json, body-file, logging-pino, logging-debug,
rx; and the private shrink-test, which proves each published bundle survives minify and tree-shake.
@dexpace/core is a peer of every one of them, never a dependency — the dual-package hazard is what that
rule is about.

Testing is now three suites. Colocated unit tests under packages/*/src/; the cross-cutting Bun
conformance tree at tests/conformance/xcut/; and tests/node-conformance/, fourteen files run under
node --test against the built dist/, because Bun's Web Streams, AbortSignal and Uint8Array are an
independent implementation of Node's. The two tests/ suites must never run together, and five files hold
that partition apart — verify:test-partition is what checks they still agree.

CI goes from a handful of steps to 22 across two jobs. New blocking gates: verify:seam-1,
verify:sse-37, verify:test-partition, verify:import-cycles, verify:runtime-floor,
verify:knowledge-structure, verify:reproducible-build, verify:dual-consumption,
verify:consumer-types, lint:publish, audit, and test:scripts — the gates' own tests. Nine committed
api-extractor reports, one per publishable package.

Docs. docs/sdk-documentation/ is the as-built tree, eleven files. docs/knowledge/ splits into
harvested/ and notes/, with a gate keeping them apart. The three registers were dissolved on
2026-09-04: docs/open-items.md is gone, and what survives it lives in docs/deviations.md,
docs/first-release.md, and two dated archives whose item IDs stay reserved because source comments still
cite them. docs/audit-67-decisions.md records the audit #67 remediation run — waves 1 through 6b, which
is the last 60-odd commits here.

Also: 48 changesets, examples/, CONTRIBUTING.md, SECURITY.md, CODE_OF_CONDUCT.md, and the
ci-preflight and housekeeping skills under .claude/.

Wahbeh-Mohammad and others added 30 commits August 26, 2026 20:25
* feat(tooling): add a query CLI and lookup skill for docs/knowledge/

The corpus is 39 topic files and ~1470 entries. Answering "what do we
already know about RETRY-13" meant grepping blind or reading a whole
20 KB file; there was no requirement-ID index and no query surface.

scripts/knowledge.mjs parses the corpus into entry records and filters
them by requirement ID, topic, section, provenance role, styleguide
chapter, and text. Different filters AND together; values within one
filter OR. A requirement-ID query runs ~120-580 tokens against a topic
file of ~1800-5200.

Two behaviours are load-bearing rather than incidental:

- The ID prefix allowlist is derived from appendix C at runtime, never
  hardcoded. A bare \b[A-Z]{2,12}-\d+\b also claims UTF-8, SHA-256,
  ISO-8601 and RFC-3986; parsing failure throws rather than falling
  back to that regex. Matching tokenizes then compares whole tokens, so
  --req HTTP-7 cannot match HTTP-70.

- Appendix B is the conformance checklist: its entries roll several IDs
  into one sentence and state none of them. 256 of the 641 cited IDs
  resolve only to a roll-up, which exits 0 and reads as answered. Those
  results are tagged [appendix-B roll-up], a --req answered entirely by
  them warns, and --coverage reports substantive (385) separately from
  roll-up-only (256) and uncited (4).

The skill carries the workflow the CLI alone cannot: run --section
conflicts once per phase (6 entries corpus-wide, where design-vs-
styleguide contradictions are recorded resolved or open); pass a task's
whole ID set in one call; check a hit is not a roll-up before trusting
it; reach the 16 topics that carry no requirement ID via --topic or
--chapter. It also records that styleguide <sub> paths are absolute to
a sibling repo and need their machine prefix stripped before being used
as a citation.

Tests run under node --test, not bun test: bunfig.toml scopes discovery
to packages so the 80% coverage floor stays a statement about
packages/core rather than about repo tooling.

No CI step. --coverage is a report run by hand, and --list-reqs prints
to stdout rather than generating another doc.

* docs(knowledge): cite governing requirement IDs on harvested entries

194 of appendix C's 645 IDs had no entry naming them, concentrated in
six subsystems: RETRY 41, AUTH 36, RECOV 29, PIPE 27, REDIR 25, CTX 20.
That was a citation gap, not a knowledge gap -- retry-and-resilience.md
already held 68 entries and authentication.md 49; the entries simply did
not name the IDs they govern, so `--req RETRY-12` found nothing on the
subsystems most in need of lookup.

Each entry's <sub> line records the spec file and line range it was
harvested from, and those lines carry the bolded requirement IDs, so
candidates were derived from the source rather than guessed, then matched
one by one against appendix C's per-ID text.

RETRY, AUTH, PIPE, REDIR and CTX now reach full coverage. Four IDs were
left alone as genuine knowledge gaps rather than annotated to make the
number look better: RECOV-32 (idempotency-key step), RECOV-33 (client-
identity step), RECOV-34 (retry-config validation) and SEAM-28 (the
projection's operation identifier) have no entry stating their content.

RECOV-17..34 are defined only in appendix C -- no numbered chapter body
carries them -- so they restate the recovery stack's contract that
chapter 09 expresses as RETRY-*. Those are annotated as pairs, e.g.
"(RETRY-27 / RECOV-20)".

Structure is unchanged and stays byte-compatible with what
/knowledge-harvest emits: 1470 bullets, 1470 <sub> lines, 234 sections.
Every one of the 234 changed lines is a "- " bullet; no provenance line,
source path, line range or sha was touched.

Note that this is a hand-authored pass, not generated output -- nothing
regenerates it, and a future re-harvest of an annotated topic file would
overwrite these citations.

CLAUDE.md's knowledge-query section is included here because its
substantive-vs-roll-up figures (385/256/4) only read true once this pass
has landed.
…body lifecycle (#34)

* feat(core): add I/O contracts — ByteQueue, BufferedSource/Sink, view. TeeSink. (#31)

* feat(core): add I/O contracts — ByteQueue, BufferedSource/Sink, views, TeeSink.

* test(core): close per-file coverage gaps in invariant, io/errors, rejection helper.

* fix(core): resolve I/O contract review findings — copy semantics, lifecycle, and encoding symmetry (IO-1..IO-42).

* feat(core): request/response body lifecycle. (#32)

* feat(core): add request/response body lifecycle: Body model, materialize(), TypedResponse, HttpStatusError, logging tees (BODY-1..37, HTTP-36..52).

* fix(core): resolve body-lifecycle review findings (BODY-3..37, HTTP-26/39/42/43/44/51).

* test(core): cover withBodyWriter teardown paths (RECOV-12, RETRY-2).

* fix(core): phase 3 review — sink ownership, close guarantees, HTTP-2

Nine defects found reviewing the shipped io/ and body/ layers against the
phase 3a/3b plans. Seven are wire-correctness or public-API.

Sink ownership, three sites and one root cause. A WritableStream adapter
declaring write and close but no abort silently swallows the delegate's
abort — the default abort algorithm is a no-op — so withRequestLogging left
the caller's sink open, locked, and never told the message was broken. It
now forwards both teardown paths, and closes on behalf of a delegate that
resolves without doing so. StreamBody.writeTo passes preventCancel: true:
pipeTo's default cancels the caller's source when the sink fails, taking
ownership BODY-8 leaves with the caller, and disagreeing with the
declared-length path, which only releases its reader.

Close guarantees. Response.bytes/text and toHttpError acquire the body
reader inside the try. getReader() itself throws when an external consumer
holds the lock — which BODY-15 forbids assuming away — so the one failure
BODY-16's guarantee most needs to cover was the one that skipped the close
and held the connection.

Declared length vs bytes written. MultipartBody.writeTo verifies its total
against contentLength, refusing an overrunning chunk before it is written.
The shared framing routine keeps the framing honest but takes each part's
own length on trust, and MultipartPart.body is the public Body interface,
so a caller implementation could report one length and write another
(HTTP-51). Every Body variant is frozen at construction, so contentLength
cannot be reassigned after the fact (HTTP-1, XCUT-15).

Public API. Response regained the private constructor and createResponse
friend hook a rewrite had dropped, which had published a field-wise
constructor (HTTP-2). [Symbol.asyncDispose] is removed from Response and
LoggedResponseBody: it postdates engines.node ">=18.17", where the computed
key binds the method to the string "undefined", and its type reached this
package only through a dev-only global — so the published .d.ts did not
compile for a consumer on the lib this package itself declares. The API
report is back to zero (undocumented) members, from 62.

New blocking gate verify:consumer-types compiles a throwaway consumer
against the built .d.ts on the declared lib with types: [], which is the
gate whose absence let the asyncDispose defect clear every other one.
Verified to fail on the reintroduced defect and pass once reverted.

Also: assertCount single-sourced in io/limits.ts and applied to TeeSink,
the fourth size-taking surface, which had none (IO-3); BODY-25's zero-chunk
rule applied on the exceeds-cap tail path, not only the drain; an
over-reporting primitive source raises SourceContractViolationError instead
of surfacing as an exhausted stream (IO-17); http/charset.ts's decodeText
renamed decodeBodyText so it stops colliding with io/text-codec's
deliberately different one.

* docs: expand phase 3 open findings, correct checkpoint status

The phase 3b plan lists the 2026-07-25 checkpoint as a signed-off
prerequisite. It has no commit and every box is unchecked — but §5.1 landed
in bunfig.toml and half of §5.3 landed in errors.ts, which is exactly what
made the claim look true to a spot check. Records the measured status of all
twelve §5 items rather than the flat "it did not run".

Grows the phase 3b execution findings from two rows to seven. E1 and E2 gain
verified version numbers and measured blast radius; E3-E7 are new: §5.3
applied to 2 of 10 error leaves and stopped, §5.7 no isolated linker
configured, §5.9 no test:node script exists although the 3b plan's own gate
sequence calls it, §5.10 none of the eleven model files carries the #private
justification, §5.8 stale NFR-14 reason.

Resolves phase 4b's F1 to branch (b). Two of its premises were false: the
floor was never raised, and SuppressedError arrived in Node 24.0.0 with the
full Explicit Resource Management proposal rather than in the 18.18.0/20.4.0
symbols backport — so branch (a) means dropping Node 18, 20 and 22 outright.
esnext.disposable supplies Symbol.asyncDispose's type but not
SuppressedError's runtime, so E1's floor bump does not fix F1 and must not be
read as doing so, including by 5a, 6b and 6c.

Adds the phase 3b checklist, which was missing entirely, and records this
phase's own residuals separately from the checkpoint's — among them the
multipart boundary non-appearance limitation, which is documented rather than
partially checked because a StreamBody part's bytes do not exist until the
write.

* chore: date-prefix changeset filenames

`@changesets/write` names every changeset with a random `human-id`
(`dry-candles-unite.md`), and there is no config knob for it — the ID comes
from a hardcoded `humanId()` call, and `.changeset/config.json`'s schema has
no filename field. The names were already being hand-corrected after the fact.

Add `scripts/changeset.mjs`, wired as `bun run changeset`: it forwards every
argument to the CLI, then renames whatever changeset the run produced to
`YYYY-MM-DD-<slug>.md`, matching `docs/superpowers/{specs,plans}`. The slug is
prompted for and defaults to the changeset's own first sentence; a non-TTY
caller takes that default rather than hanging on a prompt nobody can answer.
Subcommands that create nothing (`version`, `status`, `publish`, `tag`, `pre`,
`init`) pass straight through.

Renaming after the fact is safe because nothing reads the filename back: the
CLI globs `.changeset/*.md` and takes every decision from the frontmatter. The
seven existing changesets are backfilled with the date of the commit that
added each one.

No CI gate — a changeset written by hand or by another tool is not checked.

* ci(test): add the Node-runtime conformance suite, close checkpoint §5.9

`bun test` runs the whole unit suite on Bun's runtime and proves nothing
about the runtime this SDK ships to. Audited before writing anything: 319 of
the 516 unit tests, across 21 of 43 files, exercise a runtime-divergent
surface — Web Streams, AbortSignal, async iteration, or ByteQueue's
Uint8Array handling — against exactly two assertions of Node coverage in
scripts/verify-node-floor.mjs, neither of which touched io/. The ci job
pinned no Node at all, so its three node-executed gates ran on an undeclared
runner default, and node-floor-conformance pinned 18.17.0 alone, leaving
current LTS unexercised against sdk-design-nodejs/09:52-54's "in addition to
current LTS".

Implements §5.9's own prescription rather than a substitute. bun test stays
the unit runner untouched — docs/knowledge/testing.md mandates bun:test
symbol imports, setSystemTime and --concurrent, so migrating to node:test
would be a styleguide deviation plus a whole-suite rewrite — and is now
scoped to packages/ via bunfig's [test] root so the two layers cannot blur.
Without that scoping bun test collected the new .mjs files too, which would
have run the Node-only layer on Bun and erased the distinction it exists to
draw.

Adds test/node-conformance/: 30 `node --test` cases over the BUILT artifact,
never src/. Public surface arrives through the @dexpace/core specifier, the
path a real consumer takes; io/ is @internal with no public subpath in
exports, so it is reached by direct dist/ file path. Seeded with composeSignal,
Phase 3a's byte-stream surface (chunk-straddling CRLF, slice views not
advancing the parent, reader-lock release on close, tee mirror-and-forward,
writeAll), and Phase 3b's body surface (reader-lock discipline on
bytes/text/close, pipeTo preventCancel ownership, multipart framing through
Web Crypto, toHttpError buffering).

scripts/verify-node-floor.mjs is retired and its two AbortSignal.any
assertions folded in as the suite's first cases, per §5.9:375's "rather than
keeping two parallel Node entry points". The CI job is renamed
node-conformance and is now a fail-fast:false matrix over
['18.17.0', 'lts/*']; lts/* resolves at run time so the LTS half cannot go
stale as LTS moves.

The 3b plan's Task 13 Step 3 called `bun run test:node` when no such script
existed, so that gate sequence could not be executed as written; it is
corrected, along with the two blocking gates it had never listed.

NOTE: the CI job name changed. Branch protection requiring
`node-floor-conformance` must be updated to `node-conformance`.

* chore: add an empty changeset for the Node conformance suite

`changeset --empty`, deliberately, rather than no changeset at all. Commit
e3d0b18 touched zero files under packages/ — everything in it is repository
infrastructure that ships to nobody — so there is nothing for @dexpace/core
to bump, and a patch would put a changelog line in front of consumers that
means nothing to them.

The empty changeset is what distinguishes "this change needs no release"
from "somebody forgot a changeset". `changeset status` is unchanged by it:
still one minor for @dexpace/core, from the five existing non-empty
changesets.

Created through scripts/changeset.mjs so the filename follows the repo's
YYYY-MM-DD-<slug> convention; --empty produces no summary to derive a slug
from, so the wrapper fell back to a generic name and it was renamed using
the wrapper's own toSlug logic once the summary was written.

* fix(core): raise the Node floor to 20.3, close two floor defects

The PR's node-conformance job failed on the pinned floor and passed on
`lts/*`. Two unrelated defects, both invisible to `bun test` by construction.

`MultipartBody` generates its boundary from `crypto.getRandomValues` — a bare
global — while `engines.node` declared `">=18.17"`. Node exposes
`globalThis.crypto` unflagged only from 19.0.0, and never to an ES module on
any 18.x release: verified on 18.17.0 and 18.20.8, where `typeof
globalThis.crypto` is `undefined` in `.mjs` and an object in CJS. So every
`multipartBody(...)` call threw `ReferenceError: crypto is not defined` on the
version the package promised, and a CommonJS probe would have reported that
floor as satisfied. Bun supplies the global, which is why 516 unit tests never
saw it and E5's suite caught it the first time it ran the built artifact on the
pinned floor.

The floor moves to `>=20.3`, chosen over the two options that keep Node 18. A
`node:crypto` fallback puts a Node-only specifier in a package documented as
running on browsers, Deno, Bun and Workers, and cannot be reached synchronously
from the constructor that needs it. A non-crypto RNG silently downgrades the
unguessable-boundary mitigation HTTP-51 leans on against multipart injection,
on exactly the runtime CI pins. Node 18 went EOL in April 2025, so no supported
runtime is dropped.

20.3 and not 20.0: `AbortSignal.any()` — `composeSignal`'s own floor-defining
call, backported to 18.17.0 — reached the 20.x line only in 20.3.0, confirmed
by running the suite against a pinned 20.0.0. `lib`/`target` move to ES2023 so
`verify:runtime-floor` stays consistent; its `es2023` row is amended to `>=20.3`
with the built-ins, not the syntax, named as the reason the floor sits above the
language level's own minimum. The CI matrix pin moves 18.17.0 -> 20.3.0, and
`seams.test.mjs` gains a case asserting `globalThis.crypto.getRandomValues` is a
function *in ESM* — verified to fail on 18.17.0 and pass on 20.3.0 — so this
cannot regress silently.

Second defect: `seams.test.mjs` awaited an `AbortSignal.timeout()` abort with
nothing else scheduled. That timer is unref'd on every Node version by design,
so the loop drained before it fired and 18.17.0's runner cancelled the rest of
the file (`Promise resolution is still pending but the event loop has already
resolved`). Newer runners hold the loop open through handles of their own, which
is the whole reason it passed on LTS. It now holds a ref'd deadline that both
keeps the loop alive and fails the case if the abort never arrives.

`sdk-design-nodejs/02`'s runtime line claimed Node >=18.17 supplies
`globalThis.crypto.subtle`; corrected. Recorded as roadmap finding E8, which
also renumbers E1: Symbol.dispose/asyncDispose reached the 20.x line in 20.4.0,
so §5.4's bump now reads `>=20.3` -> `>=20.4`. The symbol is still declared
nowhere.

Gates: typecheck, lint, build, bun test (516), api, lint:publish,
verify:dual-consumption, verify:consumer-types, verify:seam-1,
verify:runtime-floor, audit, and test:node on both 20.3.0 and current Node.
…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.
…ce (#44)

* Phase 5a — retry engine, its two adapters, and the shared FakeTransport (#40)

* feat(core): phase 5a — the retry engine, its two adapters, and the shared FakeTransport.

Ships the retry pillar per product-spec/09-retry-and-resilience.md
(RETRY-1..RETRY-45) and appendix C's RECOV-17..RECOV-34, following
docs/superpowers/specs/2026-07-26-phase5a-retry-design.md. Per-requirement
disposition in docs/superpowers/plans/2026-07-26-phase5a-retry-checklist.md.

Executed out of numeric order, and 7a's first three tasks come with it.
5a's plan Prerequisite consumes 7a's config/{clock,http-date,retryable}.ts —
Task 8 needs the Clock seam, Task 4 the shared RFC 1123 parser, Task 2 the
retryable-status set — and its Global Constraints ban shipping private copies.
Those three files are therefore built here verbatim from 7a's plan Tasks 1-3
(CFG-15..17, CFG-29..31, CFG-35). 7a's Tasks 4-10 are untouched, and none of
the three enters the public barrel; 7a Task 10 still owns that decision.

New packages/core/src/retry/, eight files, no folder barrel:

- classify.ts — the two orthogonal axes (RETRY-1..8, 37). Retryability is an
  allow-list over an iterative, identity-tracking cause walk, which is what
  makes RETRY-25's fatal-error exclusion vacuous rather than coded: an
  unlisted throwable was never opted in. RETRY-23 vs RETRY-24 keys off the
  abort reason's name — AbortSignal.timeout() produces TimeoutError, a caller
  abort AbortError — which draws the line more precisely than the class
  hierarchy the reference describes.
- backoff.ts, pacing.ts — the pure math and the server-hint parser. Totality is
  pacing.ts's defining property (RETRY-16): it never throws, and every failure
  path returns null, never 0, because 0 means "retry immediately" and is the
  opposite of what a server sending a malformed header is asking for.
- settings.ts — RETRY-12's defaults, RECOV-34's construction validation, and
  totalTimeoutMs opt-in per RETRY-28's instruction to a unifying port.
- engine.ts — one attempt loop, reached by both adapters, so RETRY-13/14 and
  RECOV-30's "must not drift" is structural rather than a discipline.
- attempt-stamp.ts, retry-step.ts, retry-dispatch.ts — per-attempt stamping and
  the two ~30-line adapters. retryStep() closes PIPE-36 structurally: it is a
  factory returning a descriptor with stage: 'RETRY' baked in, so there is no
  class to subclass and no way to relocate it out of its pillar.

Plus recovery/idempotency-key.ts (RECOV-32) and testing/fake-transport.ts,
closing the roadmap's twice-punted FakeTransport deferral. countingResponse()
counts release by both routes it can happen — cancel() for an abandoned
response, pull()-to-EOF for one toHttpError() drained — because a helper
counting cancel() alone reads zero on exactly the RETRY-35 path it exists to
prove. Response instances are frozen, so the body stream is the only sanctioned
observation point.

StepContext gains signal and options (Task 1, additive). Cursor already carried
both and threaded them into terminal dispatch, but no step could read either:
RETRY-26's cancellable wait and RETRY-32 were unimplementable without the
signal, and PIPE-17's "readable by any step" MUST was unsatisfied outright
without the options — which is also the wire RETRY-41's per-call maxRetries
override (HTTP-35) had been missing since Phase 1 designed the knob.

Two dispositions worth reading before changing this code. RETRY-36's remap
applies only to responses the engine discards: a response surviving the gates
is returned live and unread, because toHttpError() drains the body and drops
the headers irreversibly, and 4c's pillar signature must return a Response.
And RETRY-41's "clamp a negative retry count to the default" is implemented as
a rejection — it collides head-on with HTTP-35, also a MUST, which rejects
precisely so the value cannot be silently reinterpreted; the port takes
HTTP-35's line on both surfaces. Both are in the design's deviation ledger.

Also tightens RequestOptionsBuilder.maxRetries to require a non-negative
integer (changeset included). It rejected only value < 0, so Infinity and NaN
reached a consumer as a retry ceiling that never terminates: unlike a negative
value, which still fails a downstream >= 1 guard, a non-finite one makes
"attempt >= ceiling" permanently false and the loop unbounded. Guarded at three
layers — the setter, the step's per-call derivation, and a precondition in
runWithRetry, the one choke point both adapters share.

Not included, each recorded rather than left silent: RETRY-29 (MAY, unscheduled
— it widens the classifier's input to server-controlled values and wants its own
trust decision), RECOV-33 (Phase 7a Task 9), the two structured log events and
RETRY-40's log-the-failure clause (Phase 7b Task 9 — 5a runs before 7b, and 7b
needs this commit's FakeTransport, so the cycle only breaks in this direction),
and public-barrel promotion of the step-authoring surface (Phase 5c, once the
preset exists). open-items.md carries the review findings deliberately left
open, including the same teardown-masking shape in Phase 3b's toHttpError.

Nothing reaches the public barrel: packages/core/etc/core.api.md and
packages/core/src/index.ts are byte-identical. 867 unit tests, plus a
node-conformance case for the three runtime-divergent surfaces this phase
touches — the TimeoutError naming the classifier keys off, the suppressed-trail
shape across the native/fallback split, and the real timer/abort race inside
defaultClock.sleep. Full gate sequence green.

* fix(core): escape the TSDoc '>' that fails the API surface check.

* feat(core): phase 5b — the redirect pillar step and its marker guard. (#41)

Ships the redirect pillar per product-spec/10-redirect-handling.md
(REDIR-1..REDIR-27), following
docs/superpowers/specs/2026-07-26-phase5b-redirect-design.md, and closes the
roadmap's PIPE-40 deferral. Per-requirement disposition in
docs/superpowers/plans/2026-07-26-phase5b-redirect-checklist.md.

New packages/core/src/redirect/, seven files, no folder barrel:

- codes.ts — the recognized {301,302,303,307,308} set and per-code method
  eligibility (REDIR-1..5). 303 is the only status branched on, so the four
  method-preserving codes cannot drift apart.
- cross-origin.ts — the RFC 6454 origin tuple compared against the SEED
  (REDIR-8) and the credential-suppression marker (REDIR-11).
- settings.ts — REDIR-17's defaults, REDIR-26's defensive copy, REDIR-27's
  configurable header. maxHops: 0 needs no special branch; it is the one value
  the ordinary cap gate always fails.
- decide.ts — the pure per-hop decision. No I/O, no clock, no side effects
  beyond the Request it returns.
- redirect-step.ts — the REDIRECT pillar. Every dispatch, including the first,
  takes a fresh ctx.fork(); ctx.next()'s single-invocation guard would trip on
  hop two (PIPE-15). stage: 'REDIRECT' is baked into the descriptor, which is
  how PIPE-36 is satisfied structurally.
- strip-marker-step.ts — a POST_AUTH guard plus withRedirect().
- errors.ts — NonReplayableBodyError, SchemeDowngradeError.

The cross-origin signal is a real header, not an in-process marker. A
WeakSet<Request> is unforgeable and never touches the wire, but stage order is
REDIRECT -> RETRY -> AUTH and 5a's attempt-stamping builds a fresh per-attempt
Request copy when enabled — an identity-keyed signal would silently stop
matching exactly when a retry sits between redirect and auth, which is when
cross-origin credential suppression matters most. Stamping preserves headers,
so a header survives that copy.

REDIR-11 names its own porter caveat: in the reference only the auth step
strips the marker, so a pipeline with none forwards it to the transport. 5b
ships before 5c, so that is not a future concern here — it is a live leak this
phase would otherwise ship. stripCrossOriginMarkerStep() occupies 4c's inert
POST_AUTH slot, so nothing in 4c or 5c had to change, and it stays installed as
a redundant backstop once 5c's auth step becomes the marker's real consumer.

Two origin-shaped checks, two deliberately different reference points, easy to
conflate. Cross-origin classification compares against the SEED for the whole
chain (REDIR-8), so a foreign host cannot hand the credential back by
redirecting to the seed's own origin. The downgrade guard compares the CURRENT
hop against its target (REDIR-15), so an HTTPS->HTTP->HTTPS chain flags only
the hop that actually downgraded.

Location resolution ends with an explicit http:/https: gate. WHATWG URL parses
javascript:, data:, file:, and mailto: without complaint and the downgrade
guard waves all of them through (none is http:), so without the gate the step
would dispatch a server-supplied javascript: target. The catch around
new URL(raw, base) is a genuinely narrow path, not the general garbage guard it
looks like: with a base supplied, a non-URL string resolves as a relative
reference rather than throwing.

One normative conflict, resolved and recorded rather than silently picked.
PIPE-40 and REDIR-22 disagree, both at MUST, about the non-replayable-body
path: PIPE-40 lists it among the responses "returned unclosed", REDIR-22(b)
lists the same trigger among those "closed before the error propagates".
REDIR-6 settles the control flow — that path "MUST fail with a clear error" —
so it throws, and a response never returned cannot be returned unclosed;
§10 also governs the redirect step's own lifecycle over the cross-cutting
default, and closing is the safer reading, since the alternative leaks a body
with no caller holding a reference. 5b closes and throws. One of the two spec
sentences needs an erratum either way; deferred to Phase 10 and recorded in the
design's Deviation Ledger and at open-items G1.

REDIR-20's "fully override" is read as scoped to code/method eligibility only,
not as license to bypass userinfo stripping, credential hygiene, the downgrade
guard, replayability, or loop/cap detection — those are unconditional MUSTs
elsewhere in the same chapter, and a predicate opting to follow a 307 with a
single-use body still cannot make that body re-sendable. A judgment call on
ambiguous wording; narrow to reverse, and flagged for Phase 9.

One file lands outside redirect/. Review pass 1 found both close-before-throw
paths replacing the very error they were meant to propagate, because
Response.close() rethrows whatever cancelling the body raised. The fix needed
releaseQuietly/withReleaseFailure, module-private inside 5a's retry/engine.ts;
rather than a second copy of a helper whose identity guard is load-bearing they
move to recovery/release.ts and both call sites import them. Behavior-neutral
for 5a — the diff is one import added and the two functions removed verbatim,
and 5a's suite passes untouched. The third close, releasing a superseded hop
before the next drive, stays bare: there is no primary error to preserve and
PIPE-40 makes the release itself part of the contract.

REDIR-28's structured events, and REDIR-15's separate "surface it observably"
obligation on a permitted downgrade, are NOT implemented. 5b executes before
7b, so an observability/logger.js import would not resolve, and 7b needs this
step for its own retrofit test — the dependency cannot run the other way.
7b's Task 9 owns them, named in redirectStep()'s TSDoc. Two of the four events
stay blocked even after that, behind a reason discriminant decide()'s
'return-current' variant does not carry; open-items G3.

Nothing reaches the public barrel: core.api.md and src/index.ts are unchanged,
and redirect/ gets no index.ts. 5c's promotion task is the first point any
pillar-authoring surface goes public.

Phase 5b's open and deferred items are registered as open-items.md section G,
with its cross-phase deferrals in section D. That pass also found 4c and 5a
were never registered at all; their absence there means "not reviewed", not
"nothing found", and the file's header now says so.

Gates: typecheck, lint, build, bun test --coverage (991), api, lint:publish,
verify:dual-consumption, verify:consumer-types, verify:seam-1,
verify:runtime-floor, audit, and test:node on both matrix legs — 20.3.0 and
lts/* (v24.20.0) — all run on the pinned bun 1.3.14 rather than the local
toolchain. The floor leg matters for this change specifically: Node 20.3.0 has
no native SuppressedError, so recovery/release.ts takes the fallback branch
there and the native one on 24.

* feat(core): phase 5c — the auth pillar step and the public authoring surface. (#42)

Ships the authentication layer per product-spec/11-authentication.md
(AUTH-1..AUTH-38), following
docs/superpowers/specs/2026-07-26-phase5c-auth-design.md, and closes four
roadmap deferrals: PIPE-35's seedFrom, AUTH-29's marker-consumption side (5b
produced the marker), PIPE-24/PIPE-39's standard-resilience preset, and
public-barrel promotion of the pillar-authoring surface. Per-requirement
disposition in docs/superpowers/plans/2026-07-26-phase5c-auth-checklist.md.

New packages/core/src/auth/, fifteen files, no folder barrel:

- scheme.ts, requirement.ts, descriptor.ts, resolve.ts — the descriptor/
  resolver model (AUTH-1..7). Pure data shapes and pure functions, no classes,
  so AUTH-7's "stateless, concurrency-safe, deterministic" falls out of the
  structure rather than being asserted about it. Tier selection is
  perCall ?? operation ?? client: the first tier PRESENT, never the first that
  succeeds, so a present-but-unsatisfiable override fails rather than silently
  demoting to a weaker one.
- credential.ts — BearerToken, ApiKeyCredential, NameKeyCredential,
  TokenProvider (AUTH-8..11). Two shapes for two equality requirements.
- challenge.ts — the RFC 7235 parser (AUTH-12/13), total by construction.
- md5.ts — RFC 1321, hand-rolled. Web Crypto excludes MD5 on security grounds
  and RFC 7616 still requires it for interop, so the alternatives were an npm
  dependency (SEAM-1) or node:crypto (portability).
- basic.ts, digest.ts, static-key.ts, composing-handler.ts — the stamping
  handlers (AUTH-14..26).
- bearer-cache.ts — the single-flight three-zone token cache (AUTH-34..37).
- auth-step.ts — the AUTH pillar (AUTH-27..33, 36, 38).
- preset.ts — standardResilience() (PIPE-24/39).

AUTH-27 mandates exactly one auth step, yet AUTH-30 names "the challenge hook"
and AUTH-34..37 name "the bearer auth step" as if three things. Reconciled as
one step, one pluggable challengeHook, and a scheme-dependent default body.
AUTH-30's contract — consult the hook, close the original on a non-null
replacement, re-drive once through a fresh chain copy, no nested re-challenge —
governs every scheme uniformly; AUTH-23..26 and AUTH-34..37 describe what the
DEFAULT does per resolved scheme. It is the only reading that satisfies all
four and leaves both named mechanisms a home.

Basic and Digest never stamp preemptively. Both are phrased entirely in terms
of answering a parsed challenge, and Digest structurally cannot stamp before
seeing the server's realm/nonce. OAUTH2 and API_KEY do; NO_AUTH never does.
Flagged as an interpretation rather than a certainty — §11 states it neither
way — and routed to Phase 9's sweep against any reference fixtures it turns up.

The cross-origin marker suppresses the WHOLE hop, not just the outbound pass.
5b marks a cross-origin re-issue; this step is its intended consumer. It reads
the marker, clears it unconditionally before either branch so it cannot reach
the wire, skips both the HTTPS guard and stamping — and declines to answer a
401 on that hop. Answering would stamp exactly the credential the outbound pass
withheld, onto a server-chosen foreign host, over a URL whose guard was
deliberately skipped. The joint 5b+5c conformance test asserts a credential
present on hop one, absent on the cross-origin hop, and re-stamped on return to
the seed origin, which is also PIPE-2's per-redirect-hop clause.

RequestOptions gains auth?: AuthDescriptor, giving AUTH-4's most-specific tier
a genuinely per-call source through StepContext.options (PIPE-17). The
operation tier still has none; no per-operation layer exists in this roadmap.

PipelineBuilder.seedFrom(runtime, 'flatten' | 'nest') has no default mode, per
PIPE-35's MUST that the choice be explicit rather than accidental. Runtime
gains a transport getter, without which flatten is not implementable at all.

THE PUBLIC BARREL CHANGES, for the first time since Phase 1. 5c is the first
point a caller can assemble a working pipeline, which is why every prior phase
withheld this. Promoted: Stage, STAGE_ORDER, PILLAR_STAGES, Step, StepContext,
Next, StepDescriptor, PipelineBuilder, Runtime, retryStep, redirectStep,
authStep, standardResilience — plus every type those signatures name, because a
promoted function whose parameter type is @internal is an API a caller cannot
call. Everything else under auth/ stays internal: a caller builds an
AuthStepSettings from the exported factories, never handler internals.

Review pass 1 found the promotion was not in the shipped artifact. The literal
string "@internal" inside an explanatory comment above the context export made
TypeScript's stripInternal — which substring-matches a declaration's whole
leading comment range — delete the export outright, so ExecutionContext and its
three members never reached dist/index.d.ts. api:ci was green because
api-extractor had recorded the ae-forgotten-export warning as text INSIDE the
committed report and was comparing report to report. Three changes, because the
bug class matters more than the instance: the comment is reworded,
ae-forgotten-export is now logLevel "error" so a forgotten export fails the
gate, and verify-consumer-types.mjs compiles a consumer naming the whole
promoted surface — the check this phase's plan claimed as its pass condition
and which did not exist.

Runtime's constructor is now private behind a createRuntime friend hook.
Promoting the class published a field-wise constructor that bypassed every
invariant build() enforces: new Runtime([authStep(a), authStep(b)], t) compiled
and ran both, a direct AUTH-27 violation with no PillarCollisionError. Same
reasoning CLAUDE.md already gives for every model in http/.

BearerToken is a class, not an interface. AUTH-8 requires every credential type
to redact its secret in any string or diagnostic form, and a frozen object
literal prints and JSON-serializes its token in full. Making it nominal also
closed an AUTH-9 bypass nobody had filed: TokenProvider returns BearerToken, so
a provider could hand back an object literal and skip blank-token validation.

Three defects review pass 2 found in this phase's own code, each now with a
regression test. A rejecting Response.close() REPLACED the primary error on
both AUTH-32 paths — a typed, catchable, @throws-documented
PlaintextCredentialError silently becoming a teardown failure — in a file
written one phase after 5b solved exactly that with releaseQuietly/
withReleaseFailure; both sites now use them, and RECOV-12's "never masks the
primary" holds. A one-shot request body skipped the challenge hook entirely, so
AUTH-36's eviction never fired and a revoked never-expiring token was re-sent
forever; the fast path is deleted and only the replay dispatch is gated on
replayability, which is what AUTH-31 actually says. And an unvalidated
bearerMarginMs of NaN made every expiry comparison false, serving an expired
token indefinitely; both margin doors now validate, per retrySettings' and
redirectSettings' precedent.

Single-flight no longer hands one caller's AbortSignal to shared work. A
coalesced fetch is by definition not owned by one call, so A's abort was
cancelling B's token fetch while B's own signal was inert. The shared fetch now
takes no caller signal and each caller races the shared promise against its
own; TokenProvider is back to the design doc's zero-argument shape, and a
provider bounds itself with its own AbortSignal.timeout.

A background refresh no longer kills the host process. An earlier shape
re-threw an InvariantViolation out of the fire-and-forget catch, reasoning that
programmer errors must crash loudly. But the throwable there comes from
caller-supplied provider code, and re-raising it into a detached promise
terminates the consumer's process asynchronously and unattributably — while the
request that triggered it was served the still-valid cached token. AUTH-37 says
a failed background refresh MUST NOT fail the in-flight request, full stop. The
crash-loudly rule governs our own invariants where we detect them.

A non-ASCII Digest realm no longer throws out of the step. Headers.setInbound
accepts obs-text and Headers.set rejects it, so echoing a server's UTF-8 realm
into Authorization threw HeaderValidationError — meaning AUTH-21's UTF-8 branch
hashed correctly but could never reach the wire. parseDigestChallenge now
declines such a challenge, so canHandle is false and the 401 surfaces unchanged
per AUTH-33; a non-header-safe configured username fails fast at construction
instead. RFC 7616 username* (RFC 5987) encoding is deferred and recorded.

Review pass 3 read whole files rather than the diff and found a test whose NAME
asserted behavior pass 2 had deleted, proving by mutation that it passed under
both shapes. The same mutation sweep found four documented behaviors with no
test that could fail: the abort-listener cleanup a comment promises, the
Proxy-Authorization half of AUTH-28's replay guard — where a proxy credential
could go out over plaintext with the suite green — the per-credential margin
override, and AUTH-34's own 30-second default, now bracketed at 29999/30001 ms
because a one-sided assertion admitted any margin above 20s. isProxy is gone
from the handler interfaces: no implementation read it, and AUTH-25's choice
lives in the step, where the header name is actually picked.

Deferred, each recorded rather than left silent: standardResilience() gains
loggingStep in Phase 7b Task 9 — 5c executes first, so an
observability/logging-step.js import would not resolve, and the plan's own
2026-07-29 correction says to skip its retrofit blocks; AUTH-37's
log-and-continue half, which has nowhere to go until a Logger exists;
re-verification of the preemptive-stamping reading at Phase 9; RFC 7616
username*; and a per-operation AuthTiers source, which is unscoped.
DigestChallengeUnsupportedError was cut instead — its only justification was a
caller driving digestHandler() directly, which is internal, and removing an
exported error class later would be a breaking change.

open-items.md gains G10..G13: the context family and Step promoted beyond the
plan's list as an accepted risk with Phase 7a as the trigger, the cut error
leaf, AUTH-37's deferred logging, and two pre-existing cleanups this phase
deliberately did not take.

1247 unit tests across 94 files, plus a node-conformance suite for the four
runtime-divergent surfaces this phase touches — crypto.subtle.digest against
RFC 7616 vectors, crypto.getRandomValues for AUTH-20's client nonce, btoa's
UTF-8-vs-Latin-1 encoding for Basic, and the AbortSignal listener add/remove
and Promise.race settling order the coalescing race rests on. Every one fails
silently rather than loudly if Bun and Node disagree: a wrong digest is still
well-formed hex. Full gate sequence green, including api:ci against the
regenerated report and test:node on both matrix legs.

* docs: add changeset for retry pillar and engine.
* Phase 6a: Serde seam and @dexpace/codec-json (#45)

* feat: phase 6a — the serde seam and @dexpace/codec-json

Reshape the serialization seam around an explicit runtime type witness and ship
the workspace's second package.

Every decode now takes a caller-supplied `Schema<T>` value — the structural
`{parse(input: unknown): T}` shape Zod, Valibot, ArkType and effect/schema all
satisfy without an adapter. That closes SEAM-21: TypeScript erases types
completely, so the schema value *is* the reification, and because the compiler
infers `T` from it, the runtime witness and the static type are one artifact
rather than two kept in sync by convention. `Serde` consequently drops its type
parameter — a bundle is per wire format, not per DTO, which is what SERDE-1
actually says — so one `jsonSerde()` serves every DTO in an application.

Phase 2 kept `Serde<T>` out of the public barrel precisely so this reshape would
not break a published API. It is promoted here, forced rather than chosen:
`@dexpace/codec-json` is a separate package and can reach core only through its
public entry point.

@dexpace/core:
- `Schema`, `Serializer`, `Deserializer`, `Serde` — all four SEAM-20 allocation
  profiles, including the fresh-string one an earlier draft dropped
- `Tristate<T>` with PATCH three-state semantics; `present()` takes
  `NonNullable<T>`, so SERDE-14's illegal fourth state is unrepresentable at the
  type level rather than rejected at construction
- `SerializationError` / `DeserializationError` as two flat leaves under
  `DexpaceError` plus an `isSerdeError` guard — the tree stays two levels
- `serdeBody()`: the serde's declared media type is the default `Content-Type`,
  with no format-agnostic fallback anywhere on the path (SERDE-2)
- `decodeResponse()` / `decodeSuccessResponse()`, closing the response on every
  path via Phase 4b's `releaseQuietly`/`withReleaseFailure` so a teardown failure
  rides along as suppressed instead of displacing the decode failure

@dexpace/codec-json (new, zero external dependencies — NFR-2):
- `jsonSerde()`, the Tristate replacer installed by default with a named opt-out,
  and the `tristate()` / `tristateObject()` decode combinators

Workspace, the three Phase-0 deferrals a second package makes live:
- Bun `workspaces.catalog` as the single source of tool versions (NFR-14)
- `@dexpace/core` peer + `peerDependenciesMeta`, with a cross-package test that
  proves the consequence rather than the declaration: `TRISTATE_BRAND` is a
  registry-global `Symbol.for`, so the codec keeps recognizing a caller's
  Tristate values even across two non-identical copies of core
- `verify:seam-1`, `verify:consumer-types` and `verify:dual-consumption`
  generalized from core-only to every package

Three adversarial-pass bugs worth naming, all reproduced against the built
artifacts before fixing:
- a stream failure was re-typed as a payload failure — the guard tested
  `instanceof IoError` while core's I/O tree is flat, so four of five classes
  were re-stamped as `DeserializationError`, inverting SERDE-12
- a JSON key named `""` collided with the replacer's top-level detection and
  emitted `null` where the key should have been omitted, silently turning a PATCH
  "leave unchanged" into "clear"
- `key in source` walked the prototype chain, so all eleven `Object.prototype`
  member names decoded as Present when the wire omitted them

Deviations, deferrals and the two open questions this phase could not settle
(`IoError`'s reachability, `toHttpError`'s teardown masking) are recorded in
docs/open-items.md §H1–H17.

* fix: ci checks.

* fix: ci checks.

* feat(core): phase 6b — Server-Sent Events (SSE-1..41). (#47)

Implement the full Server-Sent Events subsystem in `@dexpace/core` per
`docs/product-spec/13-server-sent-events-and-streaming.md` (SSE-1 through SSE-41).

The implementation is strictly pull-based, single-pass, and zero-dependency:
events are parsed 1:1 on demand as the consumer polls the stream, with no
unbounded buffering and no auto-reconnection logic.

@dexpace/core additions:
- `SseEvent`, `makeSseEvent`: Immutable event representation with defensively
  copied and frozen `data` lines (`SSE-20`). Structural equality (`sseEventsEqual`),
  string representation (`sseEventToString`), and content predicate
  (`isSseEventEmpty`, where comments count as content per `SSE-22`).
- `SseLineReader` (@internal): Hand-rolled line framing supporting `\n`, `\r`, and
  `\r\n` line terminators across chunk boundaries (`SSE-2`), start-only UTF-8 BOM
  stripping via non-consuming `peek()` lookahead (`SSE-12`), and an optional
  configurable line length cap (`maxLineBytes` / `SseLineTooLongError`, `SSE-19`).
- `SseParser` (@internal): Single-pass state machine implementing WHATWG SSE
  grammar with the three reference spec deviations: comments captured and
  dispatched (`SSE-6`), permissive dispatch when any of the 5 fields are set
  (`SSE-13`), and EOF dispatch of pending fields (`SSE-14`). Ignores NUL in IDs
  (`SSE-9`), unknown fields (`SSE-7`), and non-digit / overflow retries (`SSE-11`).
- `SseStream` / `sseStreamFrom`: Resource-owning single-pass AsyncGenerator
  facade (`SSE-18`, `SSE-23`–`SSE-32`). Guarantees exactly-once release of the
  underlying Response body and BufferedSource across all termination routes (clean
  EOF, explicit `close()`, early `break`, consumer error, mid-stream read error,
  or abort signal). Teardown promise memoization ensures concurrent `close()`
  awaits in-flight releases and propagates failures (`SSE-30`). In-flight reader
  teardown on close is mapped to `IoError` (`SSE-31`). Abort listeners are
  cleaned up on normal completion.
- `typedSseStream`: Lazy per-element stream adapter passing raw event name and
  newline-joined data (`SSE-33`, `SSE-35`). Dispatches `MapperOutcome<T>` union
  (`mapperValue`, `MAPPER_SKIP`, `MAPPER_DONE`, `SSE-34`). Releases stream
  resource before propagating any mapper error, attaching close failure as
  suppressed (`SSE-36`).

Tooling, gates, and conformance:
- `scripts/verify-sse-37.mjs` & `test:scripts`: Recursive AST/regex gate enforcing
  zero serde dependencies in core SSE (`SSE-37`) and no reconnect / `Last-Event-ID`
  paths (`SSE-38`).
- `test/node-conformance/sse.test.mjs`: 8 new conformance test cases running over
  real Node Web Streams and TextDecoder under `node --test` across Node 20.3.0 and
  LTS (`test:node`).
- `docs/superpowers/plans/2026-07-28-phase6b-sse-checklist.md`: Full requirement
  traceability checklist mapping all 41 SSE requirements to code and tests.
- `docs/open-items.md`: Recorded Section I entries for `SSE-41` reactive adapter
  deferral (Phase 8b `@dexpace/rx`), line reader separation rationale (`IO-14` vs
  `SSE-2`), `Symbol.asyncDispose` runtime floor guard, and JavaScript hash equality.

Gates verified: typecheck, lint, build, bun test (1,514 passing, 100% coverage on
src/sse/*), api, lint:publish (publint + attw), verify:dual-consumption,
verify:consumer-types, verify:seam-1, verify:sse-37, test:scripts (40 passing),
verify:runtime-floor, test:node (87 passing), and audit.

* feat(core): phase 6c — pagination engine and built-in strategies (#17). (#46)

Implement the Phase 6c pagination subsystem, delivering transport- and serde-agnostic
lazy pagination walks with dual consumption views, built-in strategies, RFC 8288 Link
parsing, verbatim query parameter splicing, and deterministic response lifecycle management.

Closes PAGE-1 through PAGE-36:

@dexpace/core:
- `Page<T>`: wraps a live transport response and frozen materialized items (`PAGE-1`,
  `PAGE-2`, `PAGE-30`); implements `AsyncDisposable` (`[Symbol.asyncDispose]`) for
  `await using` explicit resource management; metadata and items survive `close()` (`PAGE-2`).
- `PageInfo<T>` & `PaginationStrategy<T>`: stateless async parser interface returning
  `Promise<PageInfo<T>>` with `{items, nextRequest}`; `undefined` signals end of stream
  (`PAGE-4`, `PAGE-5`, `PAGE-29`).
- `Paginator<T>`: lazy generator driver (`PAGE-6`, `PAGE-7`) exposing:
  - `items()`: reusable sequence view yielding server-order items, closing each page's
    underlying response before yielding any items to eliminate stranded connections (`PAGE-8`,
    `PAGE-11`).
  - `pages()`: single-use sequence view yielding whole `Page` objects with raw response
    access (`PAGE-14`).
  - `maxPages` cap enforcement evaluated before wire dispatch (`PAGE-9`, `PAGE-10`).
  - Strict cancellation safety with pre-dispatch abort checks and drop-and-close handling
    for in-flight responses arriving after cancellation (`PAGE-25`, `PAGE-26`, `PAGE-33`).
  - Iterative generator loop guaranteeing stack safety across thousands of pages (`PAGE-31`).
- Built-in pagination strategies (`PAGE-16`–`PAGE-20`):
  - `cursorStrategy()`: single body read with configurable cursor query parameter.
  - `pageNumberStrategy()`: 1-based (or configured) start-page fallback with next-page advance.
  - `linkHeaderStrategy()`: RFC 8288 Link header parser supporting multi-header concatenation,
    unquoted/quoted/case-insensitive `rel="next"`, and RFC 3986 reference resolution.
- `query-splice` (internal):
  - Hand-rolled query substring tokenizer splicing targeted parameters without `URLSearchParams`
    canonicalization, preserving untargeted query bytes and non-query components byte-for-byte
    (`PAGE-21`, `PAGE-22`, `PAGE-23`, `PAGE-24`).
- `paginateWithFetchers()`:
  - Higher-level functional front-end threading a single shared mutable `PagingOptions` bag
    across `first()` and `next()` invocations (`PAGE-34`, `PAGE-35`).
- `PaginationError`:
  - Flat leaf under `DexpaceError` for precondition and engine misuse; underlying network, I/O,
    and parse causes propagate unwrapped (`PAGE-28`).

Node conformance:
- `test/node-conformance/pagination.test.mjs`: validates `Page` explicit resource management,
  `Paginator` items and pages walks, `AbortSignal` thread-through, and response stream cancellation
  against real Node Web Streams.

Traceability & ledger:
- `docs/superpowers/plans/2026-07-28-phase6c-pagination-checklist.md` tracking `PAGE-1`..`PAGE-36`.
- `docs/open-items.md` Section I updated with ledger entries I1–I8 (close-before-yield precedence,
  async parse boundary, `AsyncDisposable` lib requirement, WHATWG query encode-set boundary,
  transport-direct execution, single-use view asymmetry, iterative loop drive, error unwrapping).

Full gate sequence green (1527 unit tests with 100% pagination line/branch coverage, 82 Node
conformance tests, publint, attw, dual-consumption, consumer-types, seam-1, runtime-floor).

* docs: update changesets for phase6.
…acade (#50)

* feat(core): phase 7a — configuration model and platform primitives (#43)

Ships the layered configuration model, the injectable time seam, the proxy
model, and the shared date/identifier/equality/retryability primitives several
earlier phases were about to duplicate, per
product-spec/16-configuration.md (CFG-1..CFG-38), appendix C's RECOV-33 and
NFR-15, and
docs/superpowers/specs/2026-07-28-phase7a-configuration-design.md.

New `packages/core/src/config/`, no `index.ts` barrel
(docs/knowledge/module-organization.md:18 bans internal barrels; consumers
import the files directly):

- `configuration.ts` — `Configuration` (an interface over a hidden
  `LayeredConfiguration`), `ConfigurationBuilder`, the CFG-14 key constants,
  the CFG-13 global slot, and `defaultConfiguration()` (CFG-1..CFG-14, CFG-37,
  CFG-38). Three tiers, not four: Node has no ambient key/value store distinct
  from `process.env`, so the *production* property seam is empty while the seam
  itself stays substitutable — which is what makes CFG-3/CFG-4's
  normalized-vs-raw split, and CFG-24/CFG-26's property tier, testable at all.
- `duration.ts` — CFG-7's grammar (ISO-8601, `<number><unit>`, bare
  milliseconds), split out of `configuration.ts` on one-concept-per-file.
  Rejects the ambiguous month designator rather than guessing.
- `clock.ts` — `Clock`/`defaultClock` (CFG-15..CFG-17). One wait primitive, not
  the reference's blocking-sleep/scheduled-delay pair: Node has no carrier
  threads to distinguish them against. `sleep` rejects with the signal's own
  abort reason, which is this runtime's expression of CFG-17's "re-assert the
  cancellation status before propagating".
- `proxy.ts` — `ProxyOptions`, `createProxyOptions`, `formatProxyOptions`,
  `shouldBypassProxy`, `resolveProxyOptions` (CFG-22..CFG-28). Types and
  resolution only; no concrete `Transport` consumes them until Phase 8, the
  same `Serde<T>`-before-`codec-json` precedent Phase 2 set. CFG-22's masking is
  a free function, not a `toString()` interface member — every object satisfies
  such a member through `Object.prototype`, so declaring it would state a
  contract the type cannot enforce.
- `http-date.ts` — `formatHttpDate`/`parseHttpDate` (CFG-29..CFG-31), the
  module 5a's `pacing.ts` imports instead of keeping a private parser. Never
  `Date.parse`; every field range-checked, and `Date.UTC` avoided because it
  maps a four-digit year below 100 onto the
- `retryable.ts` — `RETRYABLE_STATUSES`/`isRetryableStatus` (CFG-35), the one
  definition 5a's `classify.ts` re-exports.
  mutation: CFG-35 calls this set a hard contract, and a contract a caller can
  edit is not one.
- `identifiers.ts` — `randomUuid` over `globalThis.crypto` (CFG-32), no
  `node:crypto`.
- `equality.ts` — `deepEqual`/`deepHash` (CFG-33, CFG-34). `@internal`; no
  requirement gives a caller access.
- `build-info.ts` — `getBuildInfo` (CFG-36), feature-detected across Node, Bun,
  Deno, and browsers, resolved on first accehe
  module keeps `sideEffects: false`.
- `client-identity-step.ts` — `clientIdentitposes
  onto the first existing value and preserves every other one, which needs the
  value list rebuilt explicitly since `Headet
  `set` and one-more `add`.

NFR-15 closes via build-time codegen: `packamjs`
writes `src/generated/version.ts` from `package.json` as the `prebuild` step of
both the package and root `build`. No runtimneeds
`node:fs`/`import.meta.url` tricks that woulalf of
core's runtime floor reporting the `unknown` placeholder NFR-15 forbids. The
generated file is committed so an unbuilt `brsion.

Barrel: every symbol above except `deepEqual`/`deepHash` and
`clientIdentityStep`, whose `StepDescriptor` return type is part of the still-
internal pipeline authoring surface (open it

Three review passes — shape, adversarial, red
rules. Four defects they caught that would o

- `HTTP_PROXY=http://proxy:80` resolved to `ecial
  scheme's default port to the empty string,
  `http://p:80` from `http://p`. CFG-25 bans *guessing* an absent port, not
  discarding one the operator wrote.
- `defaultConfiguration().getInt('constructor', 7)` threw `TypeError` on Node
  against the built artifact — `process.env[
  `Object.prototype.constructor`, so `getString` returned a function while its
  signature said `string`, breaking CFG-5/6/he
  production wiring.
- `NO_PROXY="*a*a*a*a*a*a*a*a*a*b"` against he
  event loop for 38 seconds. `shouldBypassProxy` is synchronous on the
  per-request path. The regex glob is now a ms),
  which also fixed an escaped `\*` matching nothing.
- `resolveProxyOptions` threw a bare `URIErr
  credentials — `decodeURIComponent` sat outside the `try`, against CFG-24's
  explicit "MUST NOT throw".

Also fixed: `sleep` silently collapsed any damp to
~1ms, turning a config typo into a hot retry loop; and an unsanitized ambient
`navigator.userAgent` would have made the deject
every outbound request with a `HeaderValidationError`.

Tests: 63 new across eleven colocated files plus twelve Node-runtime cases in
`test/node-conformance/config-primitives.tesmer,
and WebCrypto behavior that is Bun's independent implementation, not Node's).
Each header cites the IDs it exercises. The ts
that were coverage rather than verification — one whose assertion was the
implementation body, and one where wiring `msed
every assertion in both suites, leaving CFG-16's whole point unverified. Each
rewrite is proven by applying the named mutaand
reverting.

Deliberate deferrals, registered in docs/open-items.md (G1..G19) rather than
left silent: CFG-24's warning half and a faity
both wait on 7b's `Logger` (G10, G14); CFG-35's throwable axis is 5a's (G5);
`clientIdentityStep`'s barrel promotion and hever
phase publishes the pipeline surface (G1, G11); CFG-28's global-config
convenience resolver is unbuilt and awaitingo
tooling rather than this phase: no import-cycle gate exists in CI at all (G12),
and no `fast-check` property in the package rds
that `hasForbiddenNameByte` permits a space inside a header name, surfaced here
because `clientIdentityStep` is the first calied
header name into it — Phase 1 code, reported not touched.

* feat(core): phase 7b — instrumentation and observability facade, telemetry SPI, and logging adapters (#49)

Ships the structured logging facade, AsyncLocalStorage-backed diagnostic context
(MDC), redaction policies, OpenTelemetry-compatible tracing and metrics SPIs, the
LOGGING pillar step, and the Pino and debug adapter packages, per
docs/product-spec/15-instrumentation-and-observability.md (OBS-1..OBS-40) and
docs/superpowers/specs/2026-07-28-phase7b-observability-design.md.

New `packages/core/src/observability/` (no internal barrels; direct imports):

- `logger.ts` — `Logger`, `LogEvent`, `createLogger`, `NOOP_LOGGER`, `NOOP_EVENT`,
  and global logger slot `getGlobalLogger`/`setGlobalLogger` (OBS-1..OBS-9,
  OBS-40). Zero allocation on disabled levels, single-evaluation at event
  creation, 4-tier precedence folding (event fields > global context > diagnostic
  context), safe total field rendering with 8 KiB surrogate-safe truncation, and
  an at-most-once single emission guarantee.
- `diagnostic-context.ts` — `withDiagnosticFields`, `pushDiagnosticFields`,
  `getDiagnosticContext`, and `DEFAULT_DIAGNOSTIC_ALLOW_LIST` (OBS-10, OBS-24).
  AsyncLocalStorage store propagation across async boundaries, filtering by
  default to `{'trace.id', 'span.id'}` while skipping null values.
- `redaction.ts` — `redactUrl` and `redactHeaderValue` (OBS-11..OBS-18). URL
  userinfo always redacted to `***:***@`, query parameters redacted unless
  allow-listed (default `{'api-version'}`), fragment key=value pairs scrubbed,
  malformed URLs mapped to `[malformed url]` without throwing, and default-deny
  header redaction supporting `'mark'` (`REDACTED`) and `'omit'` policies.
- `tracing.ts` — `Tracer`, `Span`, `SpanContext`, `Scope`, `activateSpan`,
  `activateSpanForCorrelation`, `NOOP_TRACER`, `NOOP_SPAN`, and W3C/Datadog
  trace/span ID generators via WebCrypto (OBS-21..OBS-27, OBS-30).
  `activateSpanForCorrelation` automatically pushes `trace.id`/`span.id` from
  recording spans to diagnostic context and restores prior state on scope close.
- `metrics.ts` — `Meter`, `Counter`, `Histogram`, `NOOP_METER`, `NOOP_COUNTER`,
  and `NOOP_HISTOGRAM` (OBS-31..OBS-33). Zero external dependencies, non-negative
  counter contract, and non-throwing histogram value handling.
- `logging-step.ts` — `loggingStep`, `LOGGING_STEP_TYPE`, `LoggingGranularity`,
  and `LoggingStepSettings` (OBS-34..OBS-39). Installed into `standardResilience`
  pipeline with ambient resolution from `CFG_KEY_LOG_LEVEL`. Captures bounded
  body previews (default 8 KiB) with replayable request body probe and streaming
  skip for unknown-length chunked streams. Enforces asymmetric failure
  containment (OBS-20): logger and body-drain exceptions are safely caught and
  surfaced as `http.instrumentation.logFailure` at verbose, while tracer/meter
  exceptions propagate to the caller.

New adapter packages:
- `packages/logging-pino/` — `@dexpace/logging-pino` bridging Pino loggers to the
  SDK's `Logger` facade (`createPinoLogger`).
- `packages/logging-debug/` — `@dexpace/logging-debug` bridging the `debug` npm
  library (`Debugger` or `DebugFactory`) to the `Logger` facade
  (`createDebugLogger`), caching debuggers per level to eliminate allocations on
  disabled paths.

Subsystem retrofits & fixes:
- `retry/engine.ts`: Wrapped retry logging emissions (`http.retry.*`) in
  try-catch blocks so logger throwables never abort retries or drop backoff pacing.
- `redirect/redirect-step.ts`: Wrapped redirect logging emissions
  (`http.redirect.*`) in try-catch blocks and guaranteed `response.close()` /
  `releaseQuietly` runs on all failure paths.
- `auth/preset.ts`: Wired `loggingStep` into `standardResilience()` and restored
  full TSDoc documentation.

Verification & quality gates:
- Unit & property tests: 1977 tests passing across 140 files (`bun test`).
- Node conformance: 106 tests passing on native Node runner (`test:node`),
  including `test/node-conformance/observability.test.mjs` testing
  AsyncLocalStorage, WebCrypto RNG, and span correlation on native Node.js.
- API extractor: reports updated with 0 warnings across all 4 packages.
- Packaging: clean `publint` and `attw` dual-consumption verification.
- Changeset: `.changeset/2026-08-28-instrumentation-and-observability.md` added.

Deliberate deferrals recorded in docs/open-items.md Section L (L1..L4):
- `OBS-19` (transport dropped-header verbosity policy): deferred to Phase 8a
  alongside the concrete `fetch` transport.
- `OBS-28` (richer HTTP-tracer vocabulary) and `OBS-29` (tracer lifecycle
  ordering): deferred to Phase 8a / Phase 9.
- `PIPE-2` step placement note: `startSpan('http.client.request')` and metric
  recordings are scoped per attempt/hop rather than per logical operation.
…async-runtime bridge (#53)

* Phase 8a — the fetch and undici transport adapters, and the file-backed body (#52)

* feat(transport): phase 8a — the fetch and undici adapters, and the file-backed body.

Ships the two transport adapters, the file-backed request body, and the one
conformance suite both adapters are proven against — the first code in this
SDK that puts bytes on the wire, per
product-spec/17-transport-adapter-conformance-contract.md (TRANSPORT-1..30),
appendix C's SEAM-12/14/15/16/30, NFR-2/15 and BODY-11/12/13, and
docs/superpowers/plans/2026-07-28-phase8a-transport-checklist.md.

Four published packages and one private:

- `@dexpace/transport-fetch` — a `Transport` over the runtime's global
  `fetch`, zero dependencies beyond its `@dexpace/core` peer. There is no
  `proxy` option at all: an absent option, not a silently ignored one, because
  Node's bare `fetch` exposes no proxy hook that does not route through
  `undici` internals (TRANSPORT-30, scoped out). `close()` is a sanctioned
  no-op over a runtime global it does not own, so `send()` keeps working after
  it — this adapter's documented SEAM-15 mode.
- `@dexpace/transport-undici` — the full-featured one, taking exactly one
  external dependency. Ownership-aware `close()` over the dispatchers it
  constructed and never a bring-your-own one (SEAM-14), `NO_PROXY` bypass
  routed over a separate direct `Agent`, direct file-body dispatch honoring
  `start`/`count` (TRANSPORT-28), and a native-internal cancel told apart from
  a timeout by undici's own codes (TRANSPORT-8). An `Agent`, not a `Pool`: a
  `Pool` binds to one origin at construction, and a general-purpose transport
  must reach whatever origin each `Request` names. `undici` is loaded through
  `createRequire` by path, because Bun resolves the bare specifier to its own
  shim, whose `Agent` constructs but has no `request`.
- `@dexpace/body-file` — the concrete `fileBody()` factory, fail-fast `node:fs`
  construction validation, a fresh handle per write, short-write detection.
  Cannot live in core, which imports no `node:` module. Neither transport
  depends on it: they recognize it structurally through `body.kind === 'file'`
  and core's type-only `FileBodyDescriptor`, never a cross-package
  `instanceof`.
- `@dexpace/transport-shared` — the header drop/degrade pass, drop-log dedup
  policy, abort-to-SDK-error mapping, request-body pump, and delivery-detached
  signal fork. `@internal` exports only; published because a transport's
  `dependencies` must resolve for consumers. Exists so neither transport has to
  depend on its sibling.
- `@dexpace/transport-conformance` — unpublished. The single `TRANSPORT-N`
  suite plus its `node:http` fixture server, run once per transport through
  each package's own `*.conformance.test.ts`, so no requirement is proven for
  one adapter and assumed for the other.

Core gains `TransportFailureError` (TRANSPORT-20's canonical retryable
no-response failure) and the type-only `FileBodyDescriptor`, plus a `'file'`
member on `Body['kind']`. `IoError` is promoted from `@internal` to `@public`
as its base class. The subtyping is the requirement, not modelling
convenience: `classify.ts`'s cause-walk already returns true for every
`IoError`, so a no-response failure is retryable with no edit to the retry
layer. It costs a third hierarchy level against the styleguide's two-level
cap, recorded as Deviation Ledger row 17 rather than left silent.

SEAM-16 drove `signal-fork.ts`. Both native clients tie the response body's
lifetime to whatever signal they were handed, so passing the caller's straight
through would let a later `abort()` truncate a body the caller is still
reading. Each transport dispatches over a fork it detaches at delivery:
cancellation stays live for the whole in-flight window and goes inert
afterwards.

Five defects found reviewing the staged phase against the plan. One would have
taken a consumer's process down.

`transport-undici` never kept a handler on a streaming request body's
producer. When a server answers before the body finishes — an early 413, a
redirect — `send()` has already resolved, and a producer that then fails
reaches Node's default `unhandledRejection` policy and terminates the process.
`transport-fetch` was immune only incidentally, through the `Promise.race` it
uses to surface producer failures. That race is now `producerFailure` in
`transport-shared`, used by both, and the guarantee is a conformance row
driven by a new `/early-response` fixture — verified to fail on the
reintroduced defect and pass once reverted. Its absence is why the suite
missed this: TRANSPORT-19 had no undici row at all, against the suite's own
rule that no requirement is proven for one adapter and assumed for the other.

Every non-abort dispatch failure was classified `TransportFailureError`, and
`classify.ts` is an allow-list that returns true for every `IoError` — so
undici's argument-validation codes, which are permanent and perfectly
reproducible, were reported as always-retryable and would have spent a
caller's whole retry budget re-proving the same rejection. `UND_ERR_INVALID_ARG`
and `UND_ERR_NOT_SUPPORTED` now leave the `IoError` tree as a `TypeError`,
matching `selectDispatchers`, which already reports caller misconfiguration
that way. Reachable through a bring-your-own `ProxyAgent`, whose per-request
`Proxy-Authorization` the owned-proxy drop set does not cover.

The adaptation-throw path released the response body but not the request
producer, leaving it parked on backpressure — both adapters, and the one
non-delivering exit the TRANSPORT-19 audit trail claimed was covered.

`verify:seam-1` had quietly weakened. Generalizing it to an NFR-2 allow-list
replaced `deepEqual(dependencies, {})` with a key scan, so a package that
omits `dependencies` entirely passed; an omitted field is not a hard-committed
empty one. Every package outside the allow-list is held to the original
assertion again, with the banner comment rewritten to describe the allow-list
model it now implements.

BODY-11 and TRANSPORT-28 were each tested in isolation and never together: no
test sent a real `fileBody()` through a real transport, which matters most for
undici, whose file path bypasses `writeTo` entirely for its own
`createReadStream`. Covered now in `test/node-conformance/`, the only layer
where a Node-only package and a transport can meet, whole and ranged, for both
adapters.

Gates: `verify:seam-1` becomes a per-package allow-list, because NFR-2 grants
each optional capability core plus at most one external library —
`transport-undici` takes `undici`, the rest take none. `verify:dual-consumption`
exercises all five new packages under plain `node`; `verify:consumer-types`
references every symbol the three consumer-facing ones promote and asserts only
that `transport-shared`'s artifact exists, since no consumer is meant to import
its `@internal` surface. `lint:publish` and `api` extend to all four published
packages.

Tests: 72 colocated cases across the four packages, 26 conformance rows run
once per transport (capability-gated where §17 scopes a clause to one
reference implementation), and eleven Node-runtime cases per adapter under
`node --test`, because Bun's `fetch`, `AbortSignal`, and Web Streams are an
independent implementation of the surfaces a transport is made of. Each header
cites the IDs it exercises.

Deliberate gaps, recorded rather than silent. TRANSPORT-18's re-subscribable
producer is unbuildable here — neither client drives writes through one, so
there is no native internal resend to make idempotent, and 5a's replayability
gate covers the SDK's own retries. TRANSPORT-28's literal zero-copy path has
no `sendfile`-shaped API in Node's HTTP client stack. TRANSPORT-27's
Content-Length half is N/A: `Response.body` is a raw `ReadableStream`, with no
declared-length field for a -1 sentinel to live in. TRANSPORT-14's degrade path
is tested at its source, not end to end, because both native parsers reject a
control byte in a header value at the wire first. TRANSPORT-30's custom
`challengeHandler` cannot be dispatched on undici at all — `ProxyAgent` takes
its credential solely from its constructor, which runs before any challenge is
seen — so it warns at construction and again on the first real 407, and proxy
auth falls back to Basic. That last one is a deviation from the phase plan,
which had specified a retry-with-stamped-credential flow; Deviation Ledger
row 13.

* chore: resolve failing ci checks, add a new skill to run CI checks locally.

* chore: resolve failing ci checks, fixes on the ci skill.

* chore: resolve failing ci checks, fixes on the ci skill.

* feat(rx): phase 8b — the RxJS async-runtime bridge (#51)

Ships `@dexpace/rx`, exposing Phase 6b's `SseStream`/`typedSseStream` and
Phase 6c's `Paginator` as RxJS `Observable`s, per
docs/product-spec/18-asynchronous-runtime-adapter-contract.md
(ASYNC-1..ASYNC-22), docs/product-spec/13-server-sent-events-and-streaming.md
(SSE-41), and
docs/superpowers/specs/2026-07-28-phase8b-async-runtime-design.md.

New `packages/rx/src/`, the workspace's fifth package:

- `sse.ts` — `sseEvents$`, `typedSse$` (SSE-41, SSE-33..SSE-36, ASYNC-21).
  Single-subscription, and deliberately so: `SseStream` wraps an already-open,
  single-use response body (BODY-14) and is itself single-pass (SSE-26), so a
  second `subscribe()` reaches the facade's own guard and surfaces
  `SseStreamError` through the error channel. The restriction is inherited and
  documented, not reimplemented — there is no honest way to make
  re-subscription mean anything without a second HTTP call this package does
  not make.
- `pagination.ts` — `pageItems$`, `pages$` (PAGE-8). Cold and repeatable, the
  opposite asymmetry: `.items()`/`.pages()` build a fresh generator per call,
  and the wrapper defers that call to `[Symbol.asyncIterator]()` so each
  subscription drives an independent fetch sequence rather than reusing the
  first walk's exhausted generator.
- `from-async-iterable.ts` — `fromAsyncIterable`, `@internal` (ASYNC-6,
  ASYNC-13, ASYNC-21). See below; this file is a deviation from the plan, not
  a component it called for.
- `index.ts` — the four wrappers, nothing else.

**The bridge is hand-written, against this phase's own instruction.** The
design (§1) and plan (Global Constraints) both said: do not write an
`AsyncIterable`→`Observable` pull loop, use RxJS's `from()`, and *prove* it
satisfies each clause instead of assuming it. The proof failed on one.
`rxjs@7.8.2`'s async-iterable path (`internal/observable/innerFrom.js`) is a
bare `for await` that tests `subscriber.closed` only *after* a pull resolves,
so unsubscribing while a pull is suspended reaches the source only if and when
the server sends again. For pagination that is invisible; for SSE it is the
common case — an idle event stream is permanently suspended, so
`unsubscribe()` would leave the response body unreleased and the connection
open indefinitely. That is ASYNC-6's bidirectional-cancellation clause
unsatisfied, and SSE-30's release obligation with it. `fromAsyncIterable` is
the same loop plus a teardown that releases the caller-supplied source and
drives `iterator.return()` — release first, because closing the source is what
settles the suspended pull an async generator's queued `return()` would
otherwise sit behind. Scope is exactly the failing clause: no scheduler, no
error re-wrapping, no retry, no buffering. The conformance suite's last case
asserts the *defect* in RxJS's own `from()` alongside this module's fix, so it
fails the day RxJS closes the gap and the module should be deleted.

No RxJS scheduler appears in this package's production code, which is what
keeps ASYNC-8..ASYNC-11 free: every pull runs inside the continuation chain
that called `subscribe()`, exactly what `AsyncLocalStorage` tracks. A caller
who adds `observeOn`/`subscribeOn` downstream reintroduces the boundary 7b's
snapshot helper exists for, and the TSDoc says so rather than leaving them to
find out from a missing trace ID.

`rxjs` is a **required** peer (`optional: false`), unlike `pino`/`debug` in
the logging adapters. Those peer on a library they never import — they are
structural over `PinoLike`/`DebugLike` and work with anything of that shape.
This package imports `Observable` unconditionally at module load, so an
optional marking would suppress the one warning that catches a missing install
and hand the consumer `ERR_MODULE_NOT_FOUND` instead. Peer rather than
dependency for the usual reason: a duplicate copy breaks
`Observable`/`Subscription` identity the way two classloaders break
`instanceof`, the same hazard core's own peer-dedup guard exists to prevent.

Tests: 25 across three colocated files, plus 8 Node-runtime cases in
`test/node-conformance/rx-bridge.test.mjs`. The Node layer is not
precautionary here — whether the release lands depends on Node's
`ReadableStream.cancel()` settling a suspended read and on Node's
async-generator `return()` queueing behind an in-flight `next()`, both
independent implementations of Bun's. The conformance suite covers all four
cancellation shapes (unsubscribe from inside `next()`, while a pull is
suspended, before the first emission, and with a rejected release); ASYNC-21's
poll-once-per-demand case uses a source that outlives demand — ten available,
two taken, two pulled — since a generator yielding exactly what the subscriber
consumes cannot tell one-pull-per-emission from a bridge that prefetches.

Gate wiring: `typecheck`, `build`, `api`, and `lint:publish` extended to the
new package; `verify-dual-consumption.mjs` drives a real `SseStream` through
`sseEvents$` under plain `node`; `verify-consumer-types.mjs` compiles all four
signatures against the built `.d.ts`. `verify:seam-1` and
`verify:runtime-floor` pick the package up on their own.

Open items registered in docs/open-items.md §M rather than left silent: M1
records the hand-written bridge and names its removal trigger; M2 flags that
the checklist's eight 🚫 `ASYNC-*` rows collapse onto `TRANSPORT-*`
requirements **no shipped package implements yet**, so an appendix-B sweep run
between 8b and 8a does not count them as covered; M3 confirms at
implementation time what the design predicted — ASYNC-18's scheduled-delay
primitive is a full-port collapse, not an 8b scope boundary, since the
as-built package contains no timer, scheduler, or backoff at all.
…k guard. (#54)

Audits what Phases 0-8 built against product spec §19 (XCUT-1..24) and §20
(NFR-1..17). This is the first systematic tabulation of the XCUT family:
before this phase, zero XCUT-N citations existed in any source file.

Ships:

- @dexpace/shrink-test (NFR-9) — private, unpublished. esbuild
  bundle+minify+tree-shake of a synthetic consumer, a 24 KiB budget against
  a measured 16,671 bytes, then a child-process round trip. NFR-8's keep
  configuration ships nothing, deliberately: this port has no reflective
  discovery surface to keep-configure, so the guard targets the
  dual-package hazard instead (deliberate-deviations.md:32). Verified
  non-vacuous — a separately bundled IoError has a different class identity
  and instanceof is false across the boundary.

- tests/conformance/xcut/ — six suites, 36 tests driving the real
  standardResilience() pipeline over live sockets, plus 17 retrofit
  citations across 12 existing test files. All 24 XCUT and 17 NFR IDs are
  dispositioned with evidence in a new phase checklist; no silent gaps.

- NFR-13 SPDX sweep across every tracked source file: 3 offenders fixed,
  now 0.

Four findings filed as docs/open-items.md Section N, not patched here —
Phase 9 audits rather than edits another phase's code:

  N1  cancellation surfaces CancellationError from the transport but a bare
      AbortError under a SuppressedError from a retry backoff wait, so
      `e instanceof CancellationError` handles one path and silently misses
      the other (owner 5a)
  N2  HttpStatusError's public constructor accepts a 200, fabricating the
      "successful exception" XCUT-8 names and contradicting its own TSDoc
      (owner 3b)
  N3  the plan's `grep unresolved 2026-07-25 docs/knowledge/` step cannot
      return empty as written (owner Phase 10)
  N4  rxjs restated in three places against NFR-14's single source of truth
      (owner Phase 10)

Toolchain:

- build:deps gains codec-json and transport-fetch. Phase 9 made them the
  third and fourth packages imported by name with exports pointing at
  dist/, from shrink-test/src/ and from tests/. A warm tree hid this; a
  clean preflight failed typecheck and lint exactly as PR #52 did.
- bunfig pins [test] root = "packages", so a bare `bun test` never sees
  tests/. The root `test` script now passes both trees
  (bun test ./packages ./tests) and CI runs `bun run test --coverage`.
  typecheck gains tests/tsconfig.json, which was linted but never
  typechecked.
- CLAUDE.md documents the two-tree split, the bare-`bun test` trap, and the
  grown build:deps list.

Ten plan amendments are recorded in the checklist — the plan predates the
packages and several of its code blocks assume APIs that shipped
differently. The two that would otherwise have produced silently-passing
tests: the dispatch counter belongs on the transport rather than
Runtime.send, and Runtime.close() is a documented no-op (PIPE-27).

Gates: ci-preflight --clean green, all 14 steps, on .bun-version's Bun
1.3.14; test:node also green on the 20.3.0 engines.node floor; 2171 tests
passing at 99.72% line coverage against the 80% floor.
* fix: Phase 10 — deviation reconciliation, and the asyncDispose floor bug it found

Audits all 17 entries of the Phase 10 reconciled ledger
(docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md)
against the as-built code rather than against the phase specs that produced
it. Fourteen hold and are permanently uncorrectable; three did not survive
contact with the source. One of those three was a live defect.

Fixes the defect:

- Page, FetchTransport, and UndiciTransport each declared
  [Symbol.asyncDispose] as a plain computed class member. The symbol arrived
  in Node 20.4; every package declares engines.node ">=20.3". On the declared
  floor the computed key evaluates to `undefined`, so the method bound to the
  string key "undefined" — junk on the prototype, no working disposal, and a
  .d.ts promising AsyncDisposable regardless. Verified against the built
  artifact on real Node 20.3.0:

      before →  Page.prototype keys: [ 'constructor', 'undefined' ]
      after  →  Page.prototype keys: [ 'constructor', 'close' ]

  All three now install it via Object.defineProperty behind
  `typeof Symbol.asyncDispose === m. Phase 3b had
  already found this hazard and pinned Response's freedom from it
  (http/response.test.ts); 6c and classes.

- test/node-conformance/pagination
  `typeof page[Symbol.asyncDispose] === 'function'`. On the floor that reads
  page['undefined'], which *was* ttion passed over
  a Page that could not be disposed — the 20.3.0 matrix leg reported
  conformance it did not have. It nd asserts the
  junk key's absence on both legs: 20.3.0 → 3 pass / 1 skip, current → 4 pass.

Breaking, in the type system only (changeset included; pre-1.0 minor):
Page no longer declares `implementhTransport() /
undiciTransport() return `Transport` rather than `Transport & AsyncDisposable`.
`await using` therefore stops types. Deliberate:
the declaration was only ever true on 20.4+, and on the floor it type-checked
a call that silently did nothing —ant leaking every
pooled connection. close() is the supported teardown path and is unchanged.

Closes NFR-12 on evidence:

- Two clean builds of an identical tree (every dist/ and *.tsbuildinfo swept
  between them) emit 644 byte-idendexpace/core
  twice yields an identical tarball digest.
- scripts/verify-reproducible-builCI step, and is
  registered in ci-preflight's hardcoded STEPS list too — otherwise the
  preflight silently under-covers ci.yml. Negative-tested by injecting a
  Date.now() into gen-version.mjs: the gate fails naming
  dist/generated/version.js.
- The ledger's "cannot execute without a real build artifact" premise expired
  once Phases 1-9 shipped code. This needed *code*, not a *publish*, and sat
  open two phases longer than it had to (open-items.md B3, now closed).

Corrects four ledger entries that misdescribed the code, each with an explicit
"corrected 2026-08-29" note rather than a silent overwrite:

  item 4   "never bare structural interfaces" — core.api.md exports 61
           interfaces against 58 classes, and Configuration is builder-built,
           frozen, and exported structurally. Narrowed to the http/ wire models
  item 7   "three tiers, not four" — the code implements CFG-1's full
           override → env → property → default chain, with CFG-3 key
           normalization; withPropertySource()/getRawProperty() are public API.
           Only the default production binding of the property layer is empty
  item 11  claimed Body/Response add the member (they never have — two tests
           assert the opposite), claimed all sites were guarded (only SseStream
           was), and omitted both transports while asserting consistency
           "across all four sites"
  item 14  claimed `npm publish --provenance` "is scripted (Phase 0 Task 3)".
           It is not, anywhere. NFR-12 split out and closed; NFR-16 stays open

Adds docs/deviations.md: the 14 uncorrectable deviations, each with the
file:line proving the claim and why the reference mechanism cannot be restored.

Not done here, deliberately: the NFR-16 release workflow. Authoring it with
--provenance and id-token: write is actionable now — only running it against a
real registry is blocked, so the ledger's "blocked until first release" framing
was half wrong and is corrected. It is left out because a release workflow's
trigger, environment, tag convention, and publish permissions are a project
decision, not a defect repair. Recorded as actionable-now in open-items.md,
alongside a WATCH item: raising engines.node to >=20.4 would let all four
disposal sites drop the guard and restore `await using` honestly.

* fix: Phase 10 review — three defects behind the guarded asyncDispose install

A three-pass review (shape, adversarial, reader) over 27fb81f. The shape
held: the guarded installs are NFR-10's repair, not a workaround, and the
Node floor stays >=20.3. Reading the running code rather than the diff
turned up three more live defects, all on the same seam.

- scripts/verify-dual-consumption.mjs asserted
  `typeof transport[Symbol.asyncDispose] === 'function'` unguarded, inside a
  blocking CI gate. On the >=20.3 floor that reads transport['undefined'] —
  which, before 27fb81f, resolved to the junk prototype entry the bug itself
  created. The gate's greenness depended on the defect it existed to catch;
  fixing the defect broke the gate, and CI could not see it because the `ci`
  job runs setup-bun's default node. Reproduced on real Node 20.3.0:

      AssertionError: 'undefined' !== 'function'   (exit 1)

  Now guarded, and asserts the junk key's absence on both legs.

- UndiciTransport.close() walked `owned` with a bare `for … await`, so the
  first rejecting destroy() abandoned the rest. The set is walked reversed,
  so with a proxy configured the leaked dispatcher is the ProxyAgent holding
  the pooled connections — a teardown path that leaked exactly what it was
  called to release. The raw undici error also escaped a public method
  untyped. Every dispatcher is now destroyed before anything throws, and
  failures surface as TransportFailureError with {cause}, AggregateError
  when several fail. close() memoizes the rejection, so TRANSPORT-16's
  "stable state" holds: the same error object comes back every time.

- UndiciTransport.send() called prepareBody() before toUndiciHeaders(), and
  header mapping reads request.body.mediaType — a getter on a caller-supplied
  Body that can throw. pumpBody starts its producer eagerly, so the throw
  escaped with a live producer nothing could abandon and a rejection headed
  for Node's default unhandledRejection policy: the SEAM-30 hazard
  body-pump.ts documents. Header mapping is hoisted above body preparation.
  FetchTransport was already correct by object-literal evaluation order, which
  is implicit and easy to regress, so it gets the same guard test.

- The undici disposal test asserted only that the member existed; an
  asyncDispose wired to a bare resolved promise passed it. It now swaps
  undici's Agent binding for a capturing subclass and asserts the owned
  dispatcher is destroyed by dispose alone.

Closes the sideEffects question 27fb81f opened. Four packages now carry a
module-scope Object.defineProperty install while declaring "sideEffects":
false. packages/shrink-test/ asserts the symbol survives a real esbuild
bundle + minify + treeShaking pass, negative-tested by stripping the install
from the built page.js. sideEffects was deliberately not narrowed to four
paths — a narrow list goes stale on the next file move and fails open.
Widens the NFR-12 gate to the leg 27fb81f verified by hand. npm pack is
deterministic here (npm normalizes tar entries rather than stamping wall-clock
time), so all 9 publishable packages are packed and digest-compared on both
clean builds. Injecting Date.now() into gen-version.mjs now fails both legs,
naming dist/generated/version.js and npm-pack:dexpace-core-0.0.0.tgz. The
gate's registration was also one place short: ci-preflight's SKILL.md
playbook table had no row for it, and five step counts across that skill were
stale.

Makes the doc tree describe what Phase 10 became:

  roadmap :65, design :15   asserted "review only" / "ships no package".
                            It shipped a breaking type change across three
                            packages, two changesets, a blocking CI step, and
                            four defect fixes — the same assert-without-
                            checking this phase spent 86 lines correcting
  §10 ↔ docs/deviations.md  two ledgers on one numbering scheme with no
                            cross-reference, and a third file with a nearly
                            identical name. All three now disambiguated
  knowledge corpus          deliberate-deviations.md pins sha f9ecb6e7d87b,
                            traced to 05d649a — three revisions back. Flagged
                            at the head, and inline on the two entries that
                            are substantively false, so the correction reaches
                            `bun run knowledge` output and not just a reader
  roadmap :169              maxRetries was already fixed in cba4721; the row
                            claiming Phase 10 owned it is marked resolved
  F2, F7, CONSTANT_CASE     project-wide convention sweeps, recorded Not
                            scheduled with triggers. Phase 10 is the last
                            roadmap row, so there was no honest owner to name

Records the floor decision where it will be found. Raising engines.node to
>=20.4 to restore `implements AsyncDisposable` was recorded as a pending
unblock; it is now decided against, in open-items.md and the two other places
the recommendation had spread to. NFR-10 requires a higher-floor capability to
be isolated into its own unit rather than lifting the general-purpose core's
floor, and requires the emitted target and visible-API level to agree — the
clause the unguarded member violated. >=20.3 is derived, not chosen: the
lowest Node that runs what these packages emit, set by globalThis.crypto
(absent from ESM on every Node 18) and AbortSignal.any() (20.3.0). Phase 4b
already decided this shape once, shipping a guarded suppress() rather than
moving the floor for SuppressedError.

Verified: preflight 15/15 --clean and 16/16 --node-floor on the pinned Bun
1.3.14, including the real Node 20.3.0 leg; 2175 tests.
#60)

The repository had two top-level test trees whose names differed by one
character. `test/node-conformance/` held 14 `node --test` files run by
`bun run test:node` against the built dist/; `tests/conformance/xcut/` held
the Bun-run cross-package suites added by Phase 9. Both trees are necessary
and so is the split between them. Only the names were the problem, and no
file recorded that they differed at all.

Moves the Node tree to `tests/node-conformance/`, so one top-level tree holds
everything crossing a process, a network, or a runtime boundary, one
subdirectory per runner. Styleguide 11-testing scopes that rule to process and
network boundaries; reading a runtime boundary the same way is this repo's
extension, and it is now stated as one rather than cited as if the styleguide
said it.

What the move costs, and what pays for it:

Before, the file system held the separation — the Node tree sat outside
anything `bun test` could reach, so nothing could drift. Now one path written
into five files holds it, and every way that breaks is silent. Bun does not
error on a `node:test` import: it collects those files, runs them, and reports
them PASSING while proving nothing about Node. Bun accepts an unrecognized
`[test]` key with no warning, so `testPathIgnorePatterns` reads as configured
and does nothing. `node --test` over a glob matching nothing exits 0.

Measured on `bun run test`, pinned Bun 1.3.14:

    with pathIgnorePatterns   →  164 files, exit 0
    without it                →  178 files, exit 1

The 14 extra files are the Node suite collected by a runner that cannot prove
anything about Node. That run goes red only by accident — 13 of the 14 pass
silently and the 14th trips an unrelated timer assertion pointing nowhere near
the cause. The exit code is not a control.

So `scripts/verify-test-partition.mjs` reads the five files that must agree
(bunfig.toml, package.json, eslint.config.js, ci-preflight/run-ci.mjs, and the
tree's README) and blocks CI on disagreement. It runs neither suite. Seven
checks: the issue's five, plus two guarding rules CLAUDE.md states and nothing
enforced — the root script must name both trees whole, and `[test] root` must
stay "packages".

Adversarial review found six ways the first draft passed while the partition
was broken; each is now a check with a test:

  - Bun applies pathIgnorePatterns while WALKING, so a pattern naming a
    directory prunes the subtree without matching any file path. Adding
    `tests/conformance/fixtures` silently dropped a real test file and every
    full-path check stayed green. Directory prefixes now count.
  - The comment added above eslint.config.js's `files:` entry quoted the glob
    it documents, so deleting the entry left the check satisfied by the
    sentence explaining the guarantee.
  - A case named `retry.mjs` or `orphan.test.ts` was ignored by Bun, unmatched
    by test:node, and exempted by the check's own extension filter.
  - `**` compiled to a bare `.*`, matching paths Bun does not ignore — a gate
    green-lighting a config Bun reads differently.
  - A glob narrowed to one of 14 cases satisfied "matches at least one file".
  - `statSync` per entry threw a bare ENOENT stack trace on a broken symlink.

Closes open-items H13 — `bun run test:scripts` now runs in CI. Its trigger had
already fired: `knowledge.test.mjs` had been failing on main since 36c3f96,
whose Phase 10 correction to docs/knowledge/deliberate-deviations.md cites
CFG-1 and so gave a previously ID-less topic its first requirement ID, moving
the `--list-topics` count from 16 to 15. A gate whose own logic degrades still
exits 0, so nothing else in the run would have noticed. The same count is
quoted in CLAUDE.md and knowledge-lookup/SKILL.md; all three now move together
and the assertion's failure message names the other two. Three neighbouring
counts in that paragraph had rotted the same way (592 KB not 518, 255 not 256,
386 not 385, and a fourth bucket of 4 cited nowhere that went unmentioned) and
are now pinned by a second canary against `--coverage`.

Records what the audit found and this change does not fix: the 80% coverage
floor measures only the Bun run, and `test:node` contributes nothing to it
(open-items H20, RECORDED with a trigger).

Deliberately unchanged: docs/superpowers/plans/ and specs/, the validation
prompts, and .changeset/*.md keep their `test/node-conformance/` paths — they
are dated records and were correct when written. The node-conformance CI job
calls `bun run test:node`, not a path, so its steps are untouched.
tests/tsconfig.json gains a comment only; it sets no allowJs, so tsc still
never opens the .mjs subtree, and that subtree remains the one testing the
shipped artifact with the fewest static checks over it. CI running it on two
Node versions is the compensating control. No changeset — nothing here is
visible to a consumer.

Verified: node .claude/skills/ci-preflight/run-ci.mjs --clean --node-floor,
all 18 steps. `bun test ./tests` collects 7 files, not 21, on 1.3.14 and 1.4.0.
`bun run test:node` runs 137 tests across all 14 files on Node 26 and on the
20.3.0 floor. test:scripts is 81 cases. Commenting out the gate's
`process.exit(1)` fails a test; before the review it passed all 64.
#56) (#61)

A `<sub>` sha digests the whole source file, not the entry. Every entry
harvested from one document therefore carries the same value, and an edit to
an entry's text changes no sha. The next harvest cannot see the edit: it
regenerates the original text, or it writes a duplicate. Hand-written
knowledge under the harvested tree was scheduled for silent deletion.

The corpus is now two trees under docs/knowledge/:

- harvested/ — what the documents say. Roles spec, design, styleguide.
  Generated by knowledge-harvest. Never hand-edited. 38 topic files, 1457
  entries, INDEX.md, SOURCES.md.
- notes/ — what the implementation found. Role review, a manual sha: marker.
  Hand-written. 3 files, 5 entries. A note overrides the harvested entry it
  names.

docs/knowledge/README.md states that contract once. INDEX.md and SOURCES.md
point at it rather than restating it, because a harvest overwrites both.

Query surface (scripts/knowledge.mjs):

- Reads both trees. Every entry carries an `origin`, and `--origin` selects
  one.
- `--prefix HTTP` selects a whole requirement family. It is the audit-scale
  filter that `--req` cannot express. An unknown prefix exits 2, validated
  against appendix C.
- Every entry carries a stable key, <topic>/<8 hex>, digested from the entry
  text. A note cites that key. The CLI resolves the citation, prints
  `[overridden by notes/...]` on the harvested entry, and resolves one on
  demand with `--key`. The key changes when the rule's text changes, which is
  when the note needs a fresh check.
- An empty filter value is refused. `--topic ''` and the one-character typo
  `--topic 'a,b,'` matched every entry and printed the whole corpus.
- A zero-result query tests each filter alone before it blames one. It used
  to report "PAGE-11 is canonical but no entry cites it yet" for
  `--prefix HTTP --req PAGE-11`, which is false: three entries cite PAGE-11,
  and none of them cites an HTTP ID.
- The parser strips a BOM and accepts CRLF. A CRLF topic file parsed to zero
  entries, with no error.

Two checks:

- `bun run verify:knowledge-structure` — blocking, and the first step in
  ci.yml because it is pure Node over Markdown and needs no build. It rejects
  a review or invented role under harvested/, a Superseded entry there, an
  entry with no provenance line, a `<sub>` that cites outside the three
  source roots SOURCES.md names, a note that is not review-role, and a .md
  stranded at the root of docs/knowledge/ — which is where a harvest without
  `--corpus docs/knowledge/harvested` writes. It refuses to report OK below
  1000 harvested entries, so a failing parse cannot pass vacuously.
- `bun run knowledge:drift` — a hand-run report, deliberately not in CI. It
  compares each SOURCES.md sha against the file on disk, and it reports every
  note citation that no entry carries any more. Three sources drift today,
  all in docs/sdk-design-nodejs/. The 16 styleguide sources are NOT
  VERIFIABLE off the harvest machine, which is expected and never a failure.

Migration:

- Four resolved Conflicts entries are split. Each statement stays in
  harvested/. Each resolution is a note that names the statement by key.
- The PAGE-11 erratum moves to notes/pagination.md, under Superseded. The
  inline erratum marker it duplicated is removed from the harvested Reference
  entry, which now prints the override tag instead.
- deliberate-deviations.md leaves the corpus, with its SOURCES.md row. A
  register accumulates rows, so any harvest of one is a stale fraction of it.
  The copy held 13 entries against a 17-item register, at a three-revision-old
  sha, with two entries substantively false. notes/deliberate-deviations.md
  is the pointer. docs/open-items.md is the second register under the same
  rule.

Documentation:

- .claude/skills/knowledge-lookup/SKILL.md rewritten. Phase start is two
  queries, `--origin note` and `--section conflicts`; they are different sets,
  and a conflict with no override tag is still open. Adds the audit query as
  a stated exception to the rule against broad topic queries, the API-surface
  audit group, the audit loop, and the prefix-to-chapter table that completes
  the roll-up path. Frozen counts removed.
- CLAUDE.md records the two trees, both checks, and the citation convention.
- 107 corpus citations in packages/, test/, tests/ and .changeset/ are
  repointed to harvested/. Two cited the deleted file and now cite the ledger.
  docs/superpowers/ keeps its pre-split paths: those files are dated records
  of what a phase planned, and they are not retro-edited.

docs/open-items.md section O records three gaps: the global knowledge-harvest
skill still defaults --corpus to the tree no query reads (O1), a note's key
citation is checked by a report and not by a gate (O2), and the
docs/superpowers/ paths are deliberate (O3). H13 is raised in priority: a
blocking gate now depends on a parser that no CI job tests.

No changeset. No published package surface changes.

Verified: test:scripts 85 pass, lint 0, build 0, test 2175 pass / 0 fail,
structure gate OK, drift 44 OK / 3 DRIFT / 5 note citations resolve.
* docs: open and deferred items.

* docs: purge the retirement tables from both registers

`docs/open-items.md` and `docs/deferred-items.md` each carried a retirement
table -- one compact row per resolved item, one per discharged deferral. Both
are deleted. What each register holds now is what is still open: 61 live items
and 10 live deferrals.

The tables are reproduced verbatim in
`docs/work/mvp/2026-09-04-register-retirement-purge.md`, along with Sections Q,
S and T, three relocated validation reviews whose every row had been retired and
whose remaining value was the reasoning, not the rows. That note is a dated
record, not a register: nothing is appended to it as work proceeds.

The registers' rule was that a retired ID is never released -- a source comment
citing `K10` or `T.F9` still had to resolve, and the housekeeping probe's
citation check read the retirement table as a second ID namespace. Deleting the
tables took that namespace with it, so:

- `.claude/skills/housekeeping/probe.mjs` reads the note's `## Purged item IDs`
  table instead. Only that table -- a test pins the section slice with a decoy
  row in the note's third table -- and it degrades rather than throws when the
  note is absent.
- 34 dangling `docs/open-items.md <ID>` pointers are removed from comments
  across 27 files. The prose they annotated stays; only the pointer goes. Bare
  IDs inside test titles and inline shorthand -- `(V13)`, `(H14/P1, RECOV-12)`
  -- are left: they read as part of the code and never resolved through the
  check.
- `docs/work/` and `.changeset/` are untouched. Both are dated records that are
  never retro-edited, and their citations resolve through the note.
- The documents that describe the registers -- `CLAUDE.md`, `README.md`,
  `docs/README.md`, `docs/deviations.md`, three files under
  `docs/sdk-documentation/` -- no longer describe tables that do not exist.

Also re-derives the `tests/` partition measurement in `CLAUDE.md` and
`bunfig.toml`, which had gone stale: on pinned Bun 1.3.14, with the ignore key
165 files and exit 0, without it 179 -- the same 13-of-14-pass-silently split,
with the one failure now named.

All 18 blocking CI steps pass from a clean tree; the probe reports no drift.
docs/deferred-items.md held ten rows. A maintainer pass decided each one
rather than re-deferring it, and what remained no longer earned a file.

BREAKING: DomainModelError is removed as a class tier. Its ten leaves —
RequiredFieldError, HeaderValidationError, MediaTypeParseError,
ProtocolParseError, UrlConstructionError, RequestOptionsValidationError,
EtagParseError, HttpRangeValidationError, RequestConditionsValidationError,
RequestBodyNotAllowedError — now extend DexpaceError directly, and a new
@public isDomainModelError guard groups them. Migration is one line:
`error instanceof DomainModelError` becomes `isDomainModelError(error)`,
narrowing to the same union. The class was an empty marker that nothing in
the SDK ever narrowed on. Released as minor, not major: core is pre-1.0 at
0.0.0, per semver §4 and the precedent in the body-lifecycle changeset.

The taxonomy stays mixed, deliberately. TransportFailureError extends
IoError is still three levels because TRANSPORT-20 (MUST) requires the
subtyping and retry/classify.ts walks the cause chain on it. This removes
the one gratuitous middle tier, not every middle tier.

ProxyOptions.challengeHandler is kept and documented on the field itself:
why nothing dispatches through it (undici's ProxyAgent takes its credential
only from its constructor, which runs before any challenge exists), why the
type stays `unknown` (SEAM-1 forbids core naming a transport's response
type), and why removing it would break CFG-22's MUST and strand
TRANSPORT-30's warn clause.

The operation AuthTier and the #private-vs-private convention are both
closed. The #private row claimed its residue was cosmetic; it is not —
15 TS-private members remain against 75 #private fields — but all 15 are
@internal or module-local, so styleguide 6.7's reflective-unreachability
carve-out does not reach them and the rule is correctly scoped to http/.
Recorded as docs/knowledge/notes/data-modeling.md, which also settles a
corpus conflict open since 2026-07-25.

The register is then deleted. NFR-16 becomes docs/first-release.md, a live
release-readiness note; the five still-live deferrals are archived under
Live deferrals in the purge note, an archive of record that is never
appended to. Two registers remain, not three — a new deferral is now an
open item carrying its trigger.

Also:
- open-items.md gains Section W. W1 records that the operation-tier row was
  closed on a premise the petstore spike had already falsified: that spike
  is the per-operation layer the row said would never exist, and it measured
  the cost — AUTH-4's precedence chain reimplemented in consumer code, and
  core unable to tell a per-call override from an operation's requirement.
- R.E2 and V3 retired; their citations reworded at source.
- CLAUDE.md's "everything descends from DomainModelError" corrected, and the
  two-levels rule stated with its one required exception.
- Three dead links de-linked in the roadmap, words unchanged.
docs/open-items.md held 64 live items across 22 lettered sections. A
maintainer pass decided each one rather than re-triaging it, and what
remained no longer earned a register at the docs/ root.

BREAKING: the Serde SPI takes a DecodeTarget<T> and an options object.
deserialize(data, schema, typeName?) becomes deserialize(data, target);
deserializeFrom and serializeTo gain {signal}. Migration is mechanical —
d.deserialize(bytes, schema, 'Dto') reads d.deserialize(bytes, {schema,
typeName: 'Dto'}). Three items collapse into one break to one file: the
seam and the handler layer spelled one concept two ways (H10), the two
stream-driving methods drove a caller's stream with no way to abort
(H15), and DecodeTarget could not carry a nullability opt-in because
decodeResponse unpacked it before the SPI ever saw it (H9). Taken now
because @dexpace/core is 0.0.0 and semver's initial-development carve-out
stops applying at 1.0.

Published surface, all additive. clientIdentityStep (RECOV-33) was the
one step factory not on the barrel, installed by nothing, its own TSDoc
naming an action no caller could take. The whole of recovery/ was absent
too, so the RECOV execution model had no entry point at all — the chains
stay classes rather than free functions, because RECOV-14's text is
written about instances. RequestOptions.operationAuth gives AuthTiers.
operation a writer, so a generated client stops reimplementing AUTH-4's
precedence outside core. isIoError ships with the four flat I/O leaves it
narrows to, and SuppressedErrorLike as a type, since instanceof
SuppressedError is invalid on the >=20.3 floor. Runtime.send() opens one
span per logical operation (OBS-29), outside the pillars, guarded against
nesting because Runtime implements Transport.

Two new blocking CI steps, 20 to 22. verify:import-cycles is hand-written
and dependency-free like every other verify:* gate; type-only edges count.
The petstore canary ran in no step at all — gts lint reached examples/,
nothing executed it — so a witness that compiles and never runs was
degrading silently.

Closed on a reading rather than by work: K19's premise was false, because
fast-check prints the seed on every property failure unconditionally, so
testing.md:44 was already satisfied. K11's two arguments had both decayed
— config/ has three outbound edges, not one, and idempotencyKeyStep is a
RequestStep where clientIdentityStep is a StepDescriptor, so they are not
the pair the row assumed. H11 and K13 likewise.

The register moves whole to docs/work/mvp/2026-09-04-open-items-
dissolution.md, following the precedent deferred-items.md set the same
day. Item IDs stay reserved and still resolve: they are cited from source,
and the probe's citation check now matches both spellings — it went blind
to 52 of 61 citations when it matched only one, which is the failure mode
this repository treats as worse than a red build. Two registers remain,
deviations.md and first-release.md, and neither is a general-purpose one.
A finding now goes where it is enforced: a gate, a test, or a TSDoc
comment on the thing it concerns.

Not resolved, and not claimed to be: H8's foreign-stream-error residual
needs transport-level tagging; X4's two stale <sub> paths need a
re-harvest rather than an edit; G1's erratum is drafted into deviations.md
but applying it to a frozen spec is the specification owner's act. The
WATCH and RECORDED rows were never defects and survive as reasoning.
`DecodeTarget.admitsNull` (`packages/core/src/seams/serde.ts:126-145`) is the
caller's opt-in that `T` includes `null`, and `jsonSerde()` honours it at
`packages/codec-json/src/json-serde.ts:170`. Two `@public` TSDoc blocks still
said the rejection "cannot be conditional" and that `tristate(inner)` is a field
combinator only -- the text that predates the flag, shipped in both packages'
`.d.ts`. Both now describe the default and the opt-in.

Pinned already by `json-serde.test.ts:581,591`; no behaviour change.

Part of audit #67 / #68 (SERDE-5, SERDE-13).
Each was true when written and is not now:

- `context/instrumentation.ts` `tracerFactory` said it returns "the started
  span". It returns a **tracer**; both consumers narrow it and call
  `startSpan()` themselves (`pipeline/runtime.ts:52-57`,
  `observability/logging-step.ts:263-269`).
- `io/index.ts` said `isIoError`, `AllocationLimitError`,
  `ClosedResourceError` and `SourceContractViolationError` "remain internal".
  `1f48926` put all four on the barrel (`core/src/index.ts:39-47`) and in
  `core.api.md`.
- `http/request-conditions.ts` said reconciling HTTP-48's obs-text permission
  against HTTP-18 is "left to a later phase". Item 15 of `sdk-design-nodejs/10`
  decided it: the strict outbound path stays, no relaxed emit path.
- `body/request-body-logging.ts` ended mid-sentence before its `@internal` tag,
  which has been true since `e3ba885`. Completed with what `snapshot()` actually
  guarantees (`io/byte-queue.ts:96`).
- `retry/retry-step.ts` said `RequestOptionsBuilder.maxRetries` "rejects only a
  negative value ... it admits Infinity, NaN, and fractions".
  `http/request-options.ts:212-219` rejects all three, pinned by that file's
  `maxRetries validation (HTTP-35)` block. The `invariant` stays, and now says
  why.
- `pagination/strategies.ts` said the template "never changes across the walk".
  `pageNumberStrategy` returns the next one and `paginator.ts:213` installs it;
  `strategy.ts:10-15` already had it right.

Doc-only; no signature moved, so no `etc/*.api.md` changed.

Part of audit #67 / #68 (CTX-14, HTTP-18, HTTP-35, BODY-19, PAGE-17, TRANSPORT-20).
`buildRequest` assembles through `Request.Builder.build()`, so a descriptor
pairing a body with GET/HEAD/TRACE/CONNECT throws `RequestBodyNotAllowedError`
(`http/request.ts:221`). Its `@throws` list named only `OperationAssemblyError`
and `UrlConstructionError`, so the class was unreachable from the shipped
`.d.ts` for a caller trying to catch it.

No test pinned the path either. Added one; confirmed it fails when the
assertion is inverted to `.not.toThrow()`.

Part of audit #67 / #68 (HTTP-7, SEAM-26).
…er drives

The file header listed `TRANSPORT-20..27` and `registerFailureRows`'s describe
was named `TRANSPORT-4/5/6/20/22`, but no row in it forces an adaptation throw --
its four rows are a dead port and three timeout cases. Forcing one needs a hook
into the native response, so each adapter asserts it against its own
(`transport-fetch/src/fetch-transport.test.ts:118`,
`transport-undici/src/undici-transport.test.ts:503`). Both header and describe
now say so.

Comment-only; the suite runs the same rows.

Part of audit #67 / #68 (TRANSPORT-22).
Every `file:line` in the sections this audit touches now points at the text it
cites, verified line by line against the worktree:

- **Item 5** quoted `deserialize<T>(data, schema, typeName?)` at `serde.ts:145`
  and the `Serde` interface at `:182`. The signature took its witness in a
  `DecodeTarget` on 2026-09-04; it is `:194`, `Serde` is `:241`,
  `deserializeFrom` is `:221`, and `DecodeTarget` is `:122-124`. The codec
  anchors move from `json-serde.ts:236,242` to `:244,250`. The deviation is
  unchanged -- a schema value still stands in for a reflected type token.
- **Item 17**'s hierarchy lines were four to six off (`29,49,65,80` -> `29,51,67,83`;
  `isIoError` `102` -> `108`; `TransportFailureError` `126` -> `132`). Its
  "cause-walk returns retryable for any `IoError`" reads as covering all five
  I/O classes and does not: `classify.ts:73` tests `instanceof IoError`, which
  the flat leaves fail. Recorded as an anchor correction with the rule left to
  #78, which decides `instanceof` vs. `isIoError` and rewrites the rationale.
- **The OBS-29 row** said "the 1:1 binding is NOT met" and cited only the
  per-attempt span in `logging-step.ts`. `pipeline/runtime.ts:33-48,154-157,171-175`
  opens one span per logical operation outside every pillar, and `send`'s own
  `@remarks` states the binding. Marked IN PROGRESS for #80, which owns what a
  caller can reach.
- **Items 3 and 15** are re-anchored because this branch's own source edits
  shifted them (`retry-step.ts:137` -> `:142`,
  `request-conditions.ts:129-142` -> `:133-146`).

Two counts deleted rather than corrected, per CLAUDE.md: the section intro's
"Four rows as of 2026-09-02" above a five-row table, and the `invariant()` cell's
"fifteen" for a `pipeline/` that ships three. The cell now carries the command to
re-derive them instead.

Part of audit #67 / #68 (SERDE-5, SERDE-13, OBS-29, TRANSPORT-20, HTTP-18).
- `write-a-response-handler.md` said `decodeSuccessResponse` delegates to
  `toHttpError` "otherwise", and that `HttpStatusError` carries "the status, the
  headers and a bounded body preview". `serde/response-handlers.ts:189-201` routes
  4xx/5xx there and closes-then-raises a `DeserializationError` for every other
  non-2xx; `body/http-status-error.ts` has `status`, `body()` and `preview()` and
  no headers accessor.
- `write-a-paging-strategy.md` twice described `template` as "the request the
  walk started from". It is the request that fetched this page and it advances
  (`pagination/paginator.ts:165,213`, `strategy.ts:10-15`) -- the same wrong
  reading `strategies.ts` carried.
- `auth.md` named the source of `perCall` and `client` but not `operation`. It is
  `RequestOptions.operationAuth` (`auth/auth-step.ts:252,756`,
  `auth/resolve.ts:19`), which has had a source only since `1f48926`.

`probe.mjs` and `check-fences.mjs` both pass. The credential-shape example in
`auth.md` is left for #71.

Part of audit #67 / #68 (SERDE-28, PAGE-5, PAGE-17, AUTH-4).
`noopInstrumentationBundle.activeSpan` was `undefined`. CTX-15 asks for "a no-op
span" beside the no-op tracer factory, and `createInstrumentationBundle` has
always used `NOOP_SPAN` for the enabled bundle, so the two constructors of one
interface disagreed. Phase 4a shipped the gap deliberately and ledgered it as
partial: no `Span` type existed then. `Span`/`NOOP_SPAN` landed in Phase 7b, so
the reason expired.

The one-line value change needs a module split to land. `observability/
tracing.ts` imports `InstrumentationBundle` for `createInstrumentationBundle`'s
return type, so importing `NOOP_SPAN` back into `context/instrumentation.ts`
closes a cycle -- and `verify:import-cycles` counts type-only edges on purpose.
Its failure message prescribes the fix taken here: the inert declarations
(`SpanContext`, `Span`, `Tracer`, `NOOP_SPAN`, `NOOP_TRACER`) move to a leaf
module `observability/span.ts` that imports nothing, and `tracing.ts` re-exports
all five. No import path and no line of `etc/core.api.md` changes; `bun run api`
is unchanged.

`span.ts`'s module header is line comments that never write the internal-marker
JSDoc tag out. `stripInternal` (on, from gts) tests it by substring-scanning
every leading comment range of a declaration, line comments included, so a
header merely mentioning the tag deleted `SpanContext` from the emitted `.d.ts`
with no `tsc` diagnostic -- the build failed one package later on an unresolved
name inside core's own `dist/`.

Decided as D3 of the audit #67 decision ledger: fix, not a deviations.md row.
…nce reading (#69)

Two places where the port reads a MUST differently from the spec's literal text,
kept deliberately and now held by a test rather than by nobody. Both readings
land as rows in docs/deviations.md in the next commit.

REDIR-3 says eligibility is measured against the ORIGINAL request method; the
port measures it against the CURRENT hop's (`decide.ts:241` into
`codes.ts:69`). The readings diverge on exactly one chain: an opted-in 303
rewrites POST to GET, and a following 301 is then eligible under the default
{GET, HEAD} set. `decide.test.ts` now drives that chain end to end. Mutation-
checked: forcing `isEligibleByCode` back to the seed method turns the new case
red with `return-current`.

PAGE-19's conformance note offers `<not a url>; rel=next` as an example of
"stream ends, no exception". Under WHATWG URL -- the only RFC 3986 resolver
available without a runtime dependency (SEAM-1) -- a supplied base makes that a
valid relative path reference, so the port follows it to
`/repo/not%20a%20url`. The requirement's normative sentence is about a target
that CANNOT resolve, and that half was already pinned; this adds the half the
fixture disagrees with. Mutation-checked the same way.

Both files' `Exercises:` headers name the new readings and point at
docs/deviations.md.
#69)

`deviations.md`'s scope paragraph said its collection section was empty and that
the 2026-08-31 sweep found no unrecorded deviation. Both were true of that sweep
and neither is true now: that sweep read the registers, and the 2026-09-04 code
audit (#67) read the shipped code. It found MUST-level narrowings and undecided
readings that lived only in a phase design's ledger, only in a test comment, or
nowhere. The paragraph is rewritten to say so, and deliberately states no count
-- a count in a collection point is wrong on the next append.

Nine rows appended, each with verified `file:line` evidence:

- PIPE-37 -- the outermost pre-redirect status-mapping step was never built.
  `statusMappingStep` is a `ResponseStep`, not a staged pipeline `Step`; Phase
  4's checklist handed the wiring to Phase 5 and Phase 5 shipped without it.
  Ledgered, not implemented: it is public pipeline surface, and the petstore
  spike's finding 2 wants the same work from the other side. (D4)
- REDIR-3 -- eligibility reads the current hop's method, not the original's. (D5)
- PAGE-19 -- the spec's own `<not a url>` fixture resolves as a relative
  reference here and is followed; the normative clause is satisfied. (D6)
- HTTP-46 -- `Request.equals` compares the body by identity: reading a
  single-use body to compare it would make equality destructive.
- IO-13 -- write-side encodings are UTF-8 and ISO-8859-1 only.
- BODY-9 -- `StreamBody` is always single-use; the SHOULD's condition is
  unmeetable on `ReadableStream`.
- BODY-34 -- the shared preview cap covers the two logging tees, not
  `toHttpError`, whose cap HTTP-52 fixes.
- IO-38 -- cross-thread close visibility has no subject on this platform;
  recorded as not applicable rather than as satisfied.
- transport `reasonPhrase` -- fetch sets it, undici has no value to read. Sits
  beside item 13's `Protocol.HTTP_1_1` gap, which does not name it. (D7 for the
  last six.)

Also re-anchors the OBS-29 row's `Tracer` citation, which the previous commit
moved from `observability/tracing.ts` to `observability/span.ts`. Phase records
under docs/work/ keep their pre-move paths, as that tree is never retro-edited.
Completes the file against this task's own criterion — every `file:line` points
at the text it cites. Each replacement was pinned with `sed -n` before it was
written, and the whole file re-swept afterwards.

| Item | Was | Now |
|---|---|---|
| 1 | `context.ts:104,125,149` — `:104` unrelated prose, `:125` a signature close | `:112` is the `Symbol()` mint; `:128`/`:149` are the two per-flavor key defaults |
| 3 | `engine.ts:354`, inside `runWithRetry`'s TSDoc | `:358`, the declaration |
| 4 | `configuration.ts:72`, which is `.emit();` | `:100`, `export interface Configuration` |
| 8 | `"sideEffects": false` at `:20`/`:21`/`:21` — all three a bare `}` | `packages/core/package.json:25`, both transports at `:26` |
| 12 | `auth-step.ts:387-390`, the HTTPS-guard paragraph | the marker read at `:395` and the two branches it gates, `:401` and `:786`, plus `OutboundPlan.crossOrigin` at `:375` for why the answer survives the dispatch |

Item 4's "**61 interfaces** against 58 classes" is deleted rather than corrected,
per CLAUDE.md — the real figures had drifted to 65 and 71, and the sentence never
needed a number to make its point.

Part of audit #67 / #68, round 2.
Wahbeh-Mohammad and others added 25 commits September 5, 2026 12:08
…ext (#80)

Two `@remarks` on the one public method the guarantee is about: where the
tracer comes from now that there is a public route (`PipelineOptions`), and
that the active span and diagnostic fields are the caller's own again once
`send()` settles either way.

Refs #80, #67
Two prose corrections in `@dexpace/core`'s public surface, both of them claims the round-1 fixes
made incomplete. Round 2 of #79; these files were outside the task partition and are now in it.

`UrlConstructionError`'s surrogate bullet named `QueryParamsBuilder.add` as the site that rejects a
string with no UTF-8 form. Pagination's splice now rejects the same input for the same reason
through the same predicate, so the bullet names it too — with the part a consumer actually needs:
that path is reachable with no caller mistake, because the cursor is server-supplied, and its
message names the parameter rather than echoing the value. Backticked prose, not `{@link}`:
`spliceQueryParam` and `readQueryParam` are `@internal` and `api:ci` fails on the unresolved link
even where `api:local` only warns.

`Deserializer.deserializeFrom` and `Serializer.serializeTo` said the signal is "checked before the
lock is taken and between reads". That describes what an implementation may do and not what the
promise on the same line requires: only racing each pending operation makes "an aborted call never
leaves the caller's source locked" true, because a stalled source parks the drain inside a read that
a between-reads check can never reach again. The clause now says raced, and says which half is
load-bearing — a codec author reading only this seam had no way to know.

`core.api.md` is unchanged: the report carries signatures, not prose.
`write-a-paging-strategy.md` had four rules and no statement of the one obligation the engine
enforces with an assertion. A strategy that returns `undefined`, `null`, or `{items: null}` now
closes the response and throws, naming the invariant (#79) — and the guide should say so before an
author meets it, along with the reason it is not treated as a quiet end of stream: "the strategy
forgot to `return`" and "the server ran out of pages" must not look the same from the outside.

The rule also draws the line the guide left implicit, and which the `PaginationError` paragraph
below it only half states: ending a walk is `pageInfo(items)` with no next request, failing one is a
throw. Both are supported; neither is `undefined`.

Round 2 of #79; this file was outside the task partition and is now in it. Fences typecheck.
…adapters

`undiciTransport().send()` failed outright for four model-valid headers, where
TRANSPORT-12 requires the header to give way and the request to dispatch. undici
6.28.0 rejects `expect` (`NotSupportedError`), `keep-alive` and `upgrade`
(`InvalidArgumentError`) unconditionally, `connection` with any value but
`close`/`keep-alive`, and any name outside RFC 9110 `token` -- while
`@dexpace/core` admits every printable ASCII byte in a name, so `X Custom` is
model-valid and unsendable. `toDispatchError` mapped all five to a bare
non-retryable `TypeError`; nothing reached the wire.

The fetch twin was no better on the runtime that ships. Node's global `fetch`
is undici-backed and undici's `Headers` deliberately does not implement the
WHATWG forbidden-name list, so `expect`/`keep-alive`/`upgrade` reach the same
validation and `fetch()` rejects with a bare `TypeError: fetch failed` -- which
this transport can only classify as the RETRYABLE `TransportFailureError`, so a
permanent misconfiguration spends the caller's whole retry budget re-proving
itself. Bun 1.3.14 diverges a third way: it forwards `expect` and `keep-alive`
to the wire and hangs indefinitely on `upgrade`. Measured on both, 2026-09-05.

- `UNDICI_FORBIDDEN_HEADERS` gains `expect`, `keep-alive`, `upgrade`.
- `FETCH_FORBIDDEN_HEADERS` gains the same three (`connection` was already in
  it), which is the widening D19 left conditional on a row proving it.
- `toUndiciHeaders` validates each name against RFC 9110 `token` and each
  `connection` value against undici's own two, dropping and logging what fails
  -- the degrade transport-fetch gets for free from `try`/`catch` around
  `Headers.append`. Value grammar is not re-checked: undici's admits obs-text,
  which is strictly wider than the outbound rule `mapOutboundHeaders` applied.

Five conformance rows both transports run (`Expect`, `Keep-Alive`, `Upgrade`, a
non-token name, `Connection: upgrade`); all five were red on undici and three on
fetch beforehand. Plus two unit rows against a recording dispatcher, and one
Node-conformance case -- the Bun rows prove a weaker claim than Node's, because
only on Node does an undropped name reject the send.

Refs #81, #67. Deviation ledger: none -- TRANSPORT-11's drop set is
transport-specific by its own text, and TRANSPORT-12 is now satisfied rather
than departed from.
…Y-13)

The file branch of `prepareBody` handed `createReadStream(path, {start, end})`
straight to undici. That is one fewer userspace copy, and it skipped the
descriptor's `writeTo` entirely, so `@dexpace/body-file`'s
`transferred === count` invariant never ran. `content-length` is dropped
outbound, so undici framed the body chunked and the wire could not detect a
short write either: a file truncated between `stat` and `send` POSTed its
surviving bytes and resolved 200, where `@dexpace/transport-fetch` raised
`TransportFailureError`. The Phase 8a checklist marked BODY-13 done while
recording the bypass.

The branch is gone rather than rerouted, so `prepareBody` is now the same
function on both transports: buffered at or below 1,000,000 declared bytes,
streamed above. D19 said "takes the same `pumpBody` path the streamed case
uses"; deleting the branch is that for a large file and the buffered sibling
for a small one, and it is the only version in which the two adapters cannot
drift again. Framing changes for a small file body -- `content-length` where it
used to be chunked, matching the fetch twin.

TRANSPORT-28's zero-copy clause is a SHOULD no user-space path in either client
can honour (`docs/deviations.md` item 13, recorded since Phase 8a). Its MUSTs --
a file body is replayable, and exactly its declared byte range reaches the wire
-- are honoured by the descriptor, on both transports, by one code path. The
zero-count case needs no branch of its own: `isMaterializable` admits
`contentLength === 0` and `materializeBody` never opens a read stream.

Rows: a shared conformance row on the buffered path (intact ranged body through
`writeTo`, and truncate-after-stat), red on undici and green on fetch before
this; the streamed leg in `tests/node-conformance/transport.test.mjs` with a
real `fileBody()`, likewise red on undici only. The streamed leg cannot live in
the Bun suite -- Bun 1.3.14's `Readable.fromWeb` leaks the abort reason as
unhandled rejections when the web readable behind it is aborted mid-pull, on
both transports and with no SDK code involved (isolated; `node --test` is
clean). `run-suite.ts`'s constant says so, so nobody raises it back.

Refs #81, #67. Deviation ledger: none -- BODY-13 is now satisfied rather than
departed from, and TRANSPORT-28's SHOULD already has item 13.
…the factory

`toProxyAgentOptions` used `proxy.type` as the URI scheme with no check, so
`undiciTransport({proxy: createProxyOptions({type: 'socks5', …})})` reached
`new ProxyAgent({uri: 'socks5://…'})` and threw undici's
`InvalidArgumentError('Invalid URL protocol: socks5:')` straight out of a public
factory -- untyped, undocumented, and not in the SDK's error vocabulary. The
configuration can express it perfectly legitimately: core maps `ALL_PROXY`'s
`socks:`, `socks4:`, `socks4a:`, `socks5:` and `socks5h:` schemes onto
`ProxyType` (CFG-22, `config/proxy.ts:372-380`).

`selectDispatchers` now refuses anything but `http` with a `TypeError` naming
the type, matching its existing dispatcher-plus-proxy refusal and deliberately
outside the `IoError` tree, so `retry/classify.ts`'s allow-list makes it
non-retryable for free (RETRY-2). The check runs before `new undici.Agent(...)`,
so a refused construction allocates nothing -- there would be no transport left
to close it through. `@throws` on `undiciTransport` and on
`UndiciTransportOptions.proxy` say so.

`ProxyType` keeps `socks4`/`socks5`: narrowing a `@public` union is a
release-pass decision (D1/D19), and the gap is recorded in `docs/deviations.md`
instead. `fetchTransport()` needs no equivalent -- it ships no `proxy` option at
all, deliberately (design doc §6).

The conformance row runs on both transports. `TransportCapabilities` gains an
optional `unsupportedProxy: {type, build()}`, because only the adapter knows
which of `ProxyType`'s values its client refuses; where it is absent the row
asserts `supportsProxy === false` rather than skipping, so "no proxy surface"
and "an unasserted gap" cannot look the same. Red before this change: undici's
raw error is neither a `TypeError` nor names `socks5`. Plus a unit row covering
both SOCKS values and asserting no `Agent` was constructed on the way out.

Refs #81, #67.
`docs/deviations.md` gains one row at the end of "Deviations recorded outside a
phase" (D0): `CFG-22`'s proxy model carries SOCKS4/SOCKS5 in full, core resolves
both from `ALL_PROXY`'s five schemes, and neither shipped transport can send
over one -- undici's `ProxyAgent` is an HTTP CONNECT tunnel and
`@dexpace/transport-fetch` has no proxy option at all. The row records why
`ProxyType` keeps the two values (narrowing a `@public` union is a release-pass
decision, and `CFG-22`'s MUST is about the model, which would then stop
satisfying it) and where the refusal now happens instead.

`write-a-transport.md` grows from nine rules to eleven, both of them things #81
found the shipped transports getting wrong:

- Rule 3, split out of rule 2: whatever your native client refuses, drop that
  header, never the request -- and find out *where* it decides, because WHATWG
  `Headers.append` throws at construction while undici validates inside
  `dispatch`. Getting it wrong does not look like a transport bug; it looks like
  a retryable network failure.
- Rule 9, rewritten: recognise a file body structurally and still write it
  through `writeTo`, because reading `path` yourself skips BODY-13's
  `transferred === count` and `Content-Length` is dropped by rule 2, so nothing
  else can see a short write.
- Rule 10, new: refuse a proxy you cannot honour at construction, typed, named,
  and outside the `IoError` tree.

Plus the new optional `unsupportedProxy` capability in the "Prove it" example.
Fence check passes.

Refs #81, #67.
`fetch-transport.ts:146-152` for the `Headers.append` degrade became `:159-166`
when this branch widened the fetch drop set above it. And `prepareBody` is not
"identical in shape" to the fetch twin's -- the two return different structures;
what is identical is the two decisions it makes and their order, which is what
the comment meant and now says. Plus the undici version behind the
`lib/core/request.js` line numbers, in the one of the three places that omitted
it.

Refs #81, #67.
Two bare `64`s and a `.slice(10, 30)` that had to be read together to see they
agreed. Also re-anchors the header comment's TRANSPORT-22 pointer, which this
branch moved from `undici-transport.test.ts:503` to `:614`.

Refs #81, #67.
`README.md:53` still listed `Content-Length`, `Host`, `Transfer-Encoding` and
`Connection`. `FETCH_FORBIDDEN_HEADERS` gained `Expect`, `Keep-Alive` and
`Upgrade` earlier on this branch, so the sentence has been false since f02dd8e.

Rewritten as three bullets rather than one: which names the client computes and
which the layer underneath refuses; why the refused three are dropped rather
than forwarded (WHATWG names all four forbidden, the implementations enforce
none of it, and they disagree about what happens instead -- Node's undici-backed
`fetch` fails the send with the RETRYABLE `TransportFailureError`, Bun 1.3.14
forwards two to the wire and hangs on `Upgrade`); and that a non-token header
name degrades to the same drop, which the README had never said at all.

`packages/transport-undici/README.md` needed nothing: 6df9645 already widened
its enumeration, including the `Connection`-value split undici alone has.

Refs #81, #67.
`degradeInboundHeaders`' inbound-value gate read `/[\x00-\x08\x0B-\x1F\x7F]/u`,
which skips `\x0A` along with the intended `\x09`. Nothing observable changed —
`Headers.addInbound` applies core's own `hasForbiddenInboundValueByte`, which
does reject LF, and the `try`/`catch` two lines down records the same drop — so
the two gates were redundant and only one of them was right.

The class is now `/[\x00-\x08\x0A-\x1F\x7F]/u`, identical to core's. The
constant is exported from the module (not from the barrel) so its test can read
the character class directly: a test that went through `degradeInboundHeaders`
would have passed against the broken class, which is how this survived from
Phase 8a to audit #67 / #82.

Found by: audit #67 / #82.
`@dexpace/transport-fetch` wrapped every native rejection as
`TransportFailureError`, which `retry/classify.ts` reports retryable for being
an `IoError`. `Request` accepts any absolute URL, so `ftp://example.com` reached
`fetch`, was refused permanently, and spent the caller's whole retry budget
re-proving it. `@dexpace/transport-undici` refused the same condition through
`TERMINAL_ARGUMENT_CODES`: the two adapters classified one condition oppositely.

The decision moves to `@dexpace/transport-shared`'s new
`dispatch-classification.ts` — the precedent is `abort-mapping.ts` — as
`isPermanentDispatchFailure` plus `toDispatchFailure`, and both adapters call
it. It is an allow-list of three positive recognitions, so an unrecognised
rejection stays the retryable `TransportFailureError` TRANSPORT-20 makes a MUST:

- a terminal argument code on the error or its immediate cause, which is
  undici's whole dispatcher leg (`UND_ERR_INVALID_ARG`, `UND_ERR_NOT_SUPPORTED`)
  and Bun's `fetch` (`ERR_INVALID_ARG_VALUE` and friends);
- a `TypeError` with no `cause`, which is how undici's `fetch` — Node's global
  `fetch` — reports argument validation, network failures always carrying one;
- a cause naming one of three WHATWG scheme refusals, which is the only way that
  same `fetch` can report `ftp://` at all. `bad port` is deliberately excluded:
  port 1 is on WHATWG's blocked list, so TRANSPORT-20's own dead-port probe
  arrives with that reason and must stay retryable.

A `DexpaceError` is passed through unchanged — it was classified at its source.

Rows added, red against the unfixed fetch transport and already green against
undici: `an unsupported URL scheme fails outside the IoError tree` in the shared
suite (asserting `isIoError(e) === false`, which is exactly what the retry
engine asks), the Node-runtime twin in `tests/node-conformance/transport.test.mjs`
because the two runtimes use entirely different error shapes for it, five unit
rows in `transport-fetch`, and twelve in `transport-shared`.

Found by: audit #67 / #82.
…apters

204, 304, 205, 101, 103, every HEAD and a 2xx CONNECT can carry no body, and
three of the four native combinations the two adapters meet disagreed about how
to say so. undici's dispatcher always hands back a `BodyReadable`, so
`@dexpace/transport-undici` wrapped an empty stream; Node's `fetch` returns
`null` per the spec; Bun 1.3.14's `fetch` returns a live `ReadableStream` for
all three (measured 2026-09-05), so `@dexpace/transport-fetch` was reporting the
runtime's answer rather than the contract's.

`hasNoResponseBody(method, status)` in `@dexpace/transport-shared` is now the
rule and both adapters apply it, so `body === null` is a property of the SDK on
every runtime. It is the WHATWG shape and the one `http/response.ts:18` already
types; the rejected alternative, an empty stream on both, makes a consumer read
to learn there is nothing to read.

Each adapter releases the native handle it declines to expose — `cancel()` on
fetch's, `dump()` on undici's. `Response.close()` is a no-op on a null body, so
nobody else would, and an undrained `BodyReadable` holds the pooled connection
open until the dispatcher times it out (TRANSPORT-25, SEAM-30).

Rows: 204, 304 and HEAD in the shared suite, each asserting `body === null`, the
`content-length` the case does or does not justify, and `reasonPhrase` as
`undefined`-or-string — the fetch/undici divergence there is D7's ledger row
beside §10 item 13 and is not re-ledgered. A GET over the same route is the
twin, so nulling a body-less response cannot quietly null an ordinary one. All
six were red on both adapters. `tests/node-conformance/transport.test.mjs` gets
the runtime-divergent case, red on undici and green on fetch there, which is the
asymmetry the Bun rows cannot show.

`fixtures.ts`'s `route` passed the 70-line cap, so the three body-less fixtures
are their own function.

Found by: audit #67 / #82.
When a streaming request-body producer lost the race in `#dispatch`, `send()`
rejected while the native call was still pending, and nothing cancelled it. A
response arriving afterwards was dropped with its body neither read nor
released — TRANSPORT-9's leak, from the request side. `abandon` unwound the
producer; it could not reach the fork.

It could not reach the fork because for a send with no caller signal and no
composed timeout there was none: `forkSignal(undefined)` returned
`{signal: undefined}` and both transports dispatched with no signal at all,
which is exactly the case with nothing left to cancel with. `ForkedSignal.signal`
is now always a live `AbortSignal` — one controller nobody may ever abort, and
indistinguishable to the native client from no signal — and the interface gains
`abort(reason)`. `detach()` latches it, so the new direction cannot become the
SEAM-16 violation the fork's original direction exists to prevent.

Both transports read whether the *caller* aborted before pulling the fork
themselves; reading it after would surface every producer failure as a
`CancellationError`.

`producerFailure` now classifies its own rejection as the retryable
`TransportFailureError`, which is what both catches already produced for it.
That is not cosmetic: the same catch now runs native rejections through a table
that reads a bare `TypeError` as a permanent misconfiguration, and a producer
that threw one would have been mistaken for the wire refusing the request.
`prepareBody`'s buffered branch has classified the same failure at its source
since Phase 8a.

Instrumented rows on both transports — a `FetchLike` and a bring-your-own
`Dispatcher` whose native call resolves 30ms after the producer fails — assert
the dispatched signal is aborted with the producer's error and that the late
response never settles into the send. Both were red. Their twins assert a
delivered response leaves the fork unaborted. `signal-fork.test.ts` gains four
rows for the two-way fork and the latch. The fetch `defaultTimeoutMs` row is
rewritten: "a signal was handed over" no longer discriminates anything, so it
asserts the deadline is honoured instead.

Found by: audit #67 / #82.
`defaultTimeoutMs` was unchecked on both transports and reached
`AbortSignal.timeout()` untouched. Node throws `RangeError` on `1.5`, `2**32`
and `-1`; Bun 1.3.14 accepts the first two. The same misconfigured transport
therefore failed every send on one runtime and used a deadline nobody asked for
on the other.

It is also the last such path. `RequestOptionsBuilder.timeoutMs` has enforced
the integer `1 .. 2**32 - 1` range at its setter since audit #67 / #76, on
HTTP-35's reading that a timeout a setter accepted and a transport then refused
belongs at the call site. `requireValidDefaultTimeoutMs` in
`@dexpace/transport-shared` applies the identical rule with the identical
wording, and both factories call it first thing — before `selectDispatchers`
allocates, so a refusal cannot leak an `Agent` with no transport to close it
through.

A `TypeError`, matching the two construction-time refusals `undiciTransport`
already raises and asserted the same way. `@throws` on both factories, and the
`defaultTimeoutMs` TSDoc now states the range.

`TransportCapabilities` gains a required `buildWithDefaultTimeoutMs(value)`:
required rather than a flag because §17 assumes every transport has a default
(TRANSPORT-5 is written against one), and typed `number` because every value the
rows supply legitimately is one. Twelve rows, red on both adapters, plus the
in-range twin that proves narrowing did not reject a legitimate default, plus
the Node-runtime case — that one matters because Node is the runtime that used
to fail late and loudly where Bun failed silently.

Carried from audit #67 / #76 (D14's hand-off). Found by: audit #67 / #82.
`docs/deviations.md` gains one row for the reading the classification table
rests on: `TRANSPORT-20`'s "any transport failure that produced no HTTP
response" is read as an exchange that failed, not as a request the native
client refused to make, and such a refusal is reported outside the `IoError`
tree so `retry/classify.ts`'s allow-list makes it non-retryable. The reading is
not new — undici has applied it since Phase 8a — but it lived only in that
phase's checklist, and until this run `@dexpace/transport-fetch` did the
opposite for the identical condition. The row records that the MUST is still
the default: the table is an allow-list, and `bad port` is excluded by name
because TRANSPORT-20's own dead-port probe arrives with that reason on Node.

`reasonPhrase` is deliberately not re-ledgered — it is already a row beside
§10 item 13.

Two citations this branch moved are re-anchored: the SOCKS row's
`undici-transport.ts:138,151-158,192` -> `:147,160-167,201` and
`fetch-transport.ts:76-79` -> `:79-82`. `run-suite.ts`'s header re-anchors the
two TRANSPORT-22 test citations for the same reason, and says why TRANSPORT-9's
producer race is not a shared row: only an instrumented native client can show
that a pending call was cancelled.

READMEs: `transport-shared`'s module table gains the three new modules and the
fork's second direction; both adapters' behaviour lists gain the classification
rule, the body-less contract and the `defaultTimeoutMs` range.
Audit #67 remediation: milestones 1–5 (#68#82)
@fuad-daoud

Copy link
Copy Markdown

I was forced to

@fuad-daoud fuad-daoud left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@Wahbeh-Mohammad
Wahbeh-Mohammad merged commit c1eb3aa into main Sep 7, 2026
3 checks passed
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.

2 participants