feat: the v1 MVP — eleven packages, the full pipeline, and the gate suite - #99
Merged
Conversation
* 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.
…pInternal trap
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.
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.
`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.
|
I was forced to |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
maincarries one package,@dexpace/core, with two subsystems in it:http/andseams/. This branchcarries 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/,cancellationand
testing/joinhttp/andseams/. Zero runtime dependencies throughout, whichverify:seam-1enforces per package.
Ten packages are new.
transport-fetchandtransport-undicibehind one transport seam, withtransport-sharedholding the plumbing they need identically and the privatetransport-conformanceholding the
TRANSPORT-Nsuite they both run;codec-json,body-file,logging-pino,logging-debug,rx; and the privateshrink-test, which proves each published bundle survives minify and tree-shake.@dexpace/coreis a peer of every one of them, never a dependency — the dual-package hazard is what thatrule is about.
Testing is now three suites. Colocated unit tests under
packages/*/src/; the cross-cutting Bunconformance tree at
tests/conformance/xcut/; andtests/node-conformance/, fourteen files run undernode --testagainst the builtdist/, because Bun's Web Streams,AbortSignalandUint8Arrayare anindependent implementation of Node's. The two
tests/suites must never run together, and five files holdthat partition apart —
verify:test-partitionis 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, andtest:scripts— the gates' own tests. Nine committedapi-extractorreports, one per publishable package.Docs.
docs/sdk-documentation/is the as-built tree, eleven files.docs/knowledge/splits intoharvested/andnotes/, with a gate keeping them apart. The three registers were dissolved on2026-09-04:
docs/open-items.mdis gone, and what survives it lives indocs/deviations.md,docs/first-release.md, and two dated archives whose item IDs stay reserved because source comments stillcite them.
docs/audit-67-decisions.mdrecords the audit #67 remediation run — waves 1 through 6b, whichis the last 60-odd commits here.
Also: 48 changesets,
examples/,CONTRIBUTING.md,SECURITY.md,CODE_OF_CONDUCT.md, and theci-preflightandhousekeepingskills under.claude/.