From 19076b5e6dba45d8214eca5d179edf2fcb34dfd7 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 2 Sep 2026 19:37:31 +0300 Subject: [PATCH 1/3] docs: add the community health files and the bun dependabot config (#58). --- .github/dependabot.yml | 66 +++++++++++++++++++++++++++++++++++++ CODE_OF_CONDUCT.md | 40 ++++++++++++++++++++++ CONTRIBUTING.md | 75 ++++++++++++++++++++++++++++++++++++++++++ LICENSE => LICENSE.md | 12 +++---- SECURITY.md | 41 +++++++++++++++++++++++ package.json | 1 + 6 files changed, 229 insertions(+), 6 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md rename LICENSE => LICENSE.md (86%) create mode 100644 SECURITY.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f08d440 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,66 @@ +# .github/dependabot.yml +# +# Version updates for the Bun workspace. +# +# Preconditions this repository meets. Dependabot's `bun` ecosystem went GA on +# 2025-02-13 -- https://github.blog/changelog/2025-02-13-dependabot-version-updates-now-support-the-bun-package-manager-ga/ +# -- and needs Bun >= 1.1.39 and the text-based `bun.lock`, not the legacy +# binary `bun.lockb`. `.bun-version` pins 1.3.14 and `bun.lock` is JSON +# (`"lockfileVersion": 1` on its second line). +# +# One hard limit and one caution, stated here rather than diagnosed later from +# a confusing PR: +# +# * HARD: `bun` covers VERSION updates only. Dependabot issues no SECURITY +# updates for this ecosystem, so an advisory against a dependency will not +# arrive as a PR. `bun run audit` (`bun audit --audit-level=high --prod`) +# is a CI step and stays the thing that catches those. +# * CAUTION: treat lockfile updates under a workspace layout as unproven. +# dependabot/dependabot-core#14223 is open (2026-02-19), titled "Dependabot +# does not fix bun.lock in environment which using npm workspace", and the +# symptom matches this repository's shape -- `workspaces.packages: +# ["packages/*"]` in the root package.json, eleven packages. It is NOT a +# confirmed defect of the configuration below: the reporter's linked config +# declares `package-ecosystem: "npm"` with `enable-beta-ecosystems: true`, +# not `bun`, so the published repro does not exercise this file. (#11602, +# closed, is the older single-package report.) If it does bite, it shows up +# as a no-op PR, or as a manifest bump with a stale lockfile -- the second +# kind fails CI at `bun install --frozen-lockfile`, the first step of the +# run, and that failure is the tooling, not the bump. Re-run `bun install` +# locally and commit `bun.lock` onto the PR branch. +# +# This file is inert until it reaches the repository's DEFAULT branch -- +# Dependabot reads its configuration only from there, and the default is `main` +# while the MVP work integrates on `mvp` (see CONTRIBUTING.md, "Branching"). +# To activate it before that merge, cherry-pick it onto `main` AND add +# `target-branch: 'mvp'` to the entry below, so the PRs land where the work is. +# Drop that line again once `mvp` has merged. +# +# No `github-actions` ecosystem block: deliberately out of scope for the ticket +# that added this file. The three actions in use -- actions/checkout@v4, +# oven-sh/setup-bun@v2, actions/setup-node@v4 -- are pinned by major tag. +version: 2 + +updates: + - package-ecosystem: 'bun' + directory: '/' + schedule: + interval: 'weekly' + day: 'monday' + # 5 is Dependabot's own default, written out so it reads as a decision + # rather than an omission. Deliberately not RAISED: every PR here runs the + # full 20-step CI, including a double clean build for the reproducibility + # gate, and the caution above means some fraction of them may be no-ops. + open-pull-requests-limit: 5 + commit-message: + prefix: 'chore' + include: 'scope' + groups: + # One PR for the routine drift. A major bump is excluded, so it arrives on + # its own branch and the breaking change gets reviewed alone. + minor-and-patch: + patterns: + - '*' + update-types: + - 'minor' + - 'patch' diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..594b673 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,40 @@ +# Code of Conduct + +## Our pledge + +We as members, contributors, and maintainers pledge to make participation in +the dexpace Node.js SDK a harassment-free experience for everyone, regardless +of age, body size, visible or invisible disability, ethnicity, sex +characteristics, gender identity and expression, level of experience, +education, socio-economic status, nationality, personal appearance, race, +religion, or sexual identity and orientation. + +## Our standards + +Examples of behavior that contributes to a positive environment: + +- Showing empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Focusing on what is best for the community + +Examples of unacceptable behavior: + +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information without explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the maintainers at +[oaljarrah@dexpace.org](mailto:oaljarrah@dexpace.org). All complaints will be +reviewed and investigated promptly and fairly. Maintainers are obligated to +respect the privacy and security of the reporter of any incident. + +## Attribution + +This Code of Conduct is adapted from the +[Contributor Covenant](https://www.contributor-covenant.org), version 2.1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..565f107 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,75 @@ +# Contributing + +Thanks for your interest in the Dexpace Node.js SDK. External pull requests +are welcome — this page covers everything you need to get a change merged. + +## Setup + +The repository is a [Bun](https://bun.sh)-managed workspace of eleven +packages, nine of them published. One install provisions everything along +with the dev toolchain. The Bun version is pinned in `.bun-version`, which +CI resolves — use it: + +```bash +git clone https://github.com/dexpace/nodejs-sdk.git +cd nodejs-sdk +bun install --frozen-lockfile +``` + +## Quality gates + +Every pull request must pass the same 20 steps CI runs, across two jobs and +on both Node 20.3 and current LTS. One command runs all of them locally, in +CI's own order: + +```bash +node .claude/skills/ci-preflight/run-ci.mjs --clean +``` + +Run it before opening a PR; `--clean` starts it from the tree CI checks out +rather than a warm one. A consumer-facing change also needs a changeset — +`bun run changeset`, not `bunx changeset`, because the wrapper renames the +generated file — and a change to a package's exports needs its API report +regenerated with `api:local` in that package and committed. + +## Conventions + +The full convention set lives in [`CLAUDE.md`](CLAUDE.md). The essentials: + +- **Branch off `mvp`, not `main`.** `mvp` is the integration branch and + merges into `main` when the MVP is complete; GitHub still offers `main` + as the base, so change it. +- **`bun run build` before `bun run test`.** Every package reaches + `@dexpace/core` through `packages/core/dist/`; without a build the tests + cannot resolve it, and against a stale one they pass over yesterday's core. +- **`bun run test` is the only invocation that reaches both test trees** + (`bun test ./packages ./tests`) — a bare `bun test` silently runs + `packages/` alone. `bun run test:node` is the separate Node-runtime suite. +- **ESM-only, NodeNext**: relative imports carry `.js` even in `.ts` source, + type-only imports need `import type`, and `erasableSyntaxOnly` rules out + enums and namespaces. +- **No new runtime dependencies.** Every published package ships a + hard-committed empty `dependencies`; new third-party needs belong behind + the `Transport` or `Serde` seams, or in a new adapter package (SEAM-1, + gate-enforced). +- **MIT licence header** (`// SPDX-License-Identifier: MIT`) on line 1 of + every source file, src and tests alike; functions capped at 70 lines. + +## Commit messages + +Use the prefixes the history already follows: + +| Prefix | Use for | +|----------|----------------------------------| +| `feat:` | new features | +| `fix:` | bug fixes | +| `chore:` | refactors and cleanup | +| `docs:` | documentation-only changes | +| `test:` | tests only | +| `ci:` | CI configuration | + +## Reporting issues + +Open one at [github.com/dexpace/nodejs-sdk/issues](https://github.com/dexpace/nodejs-sdk/issues). +For security vulnerabilities, follow [`SECURITY.md`](SECURITY.md) instead of +opening a public issue. diff --git a/LICENSE b/LICENSE.md similarity index 86% rename from LICENSE rename to LICENSE.md index d75d8bf..1724c32 100644 --- a/LICENSE +++ b/LICENSE.md @@ -1,6 +1,6 @@ -MIT License +# MIT License -Copyright (c) 2026 dexpace +Copyright (c) 2026 dexpace and Omar Aljarrah Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -9,13 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..4759430 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,41 @@ +# Security Policy + +## Supported versions + +Nothing has shipped yet: every package in the workspace is at `0.0.0` and +none has been published to npm, so there is no released version to support +and no patched release to point at. Until the first release, the supported +revision is the tip of `mvp` — report against a commit SHA. + +## Reporting a vulnerability + +Please **do not** open a public issue for security vulnerabilities. + +Instead, report privately by email to +[oaljarrah@dexpace.org](mailto:oaljarrah@dexpace.org) with `[SECURITY]` in +the subject line. + +Include what you can of the following: + +- The affected package(s), and the commit SHA and Bun/Node.js versions you + reproduced against +- A description of the vulnerability and its impact +- Steps or a proof of concept to reproduce it + +You can expect an acknowledgement within a few days. Please allow time for +a fix to land and be released before disclosing publicly. + +## Scope notes + +- The SDK is a **toolkit**, not a service: `@dexpace/core` executes no + network I/O of its own, and reaches into `node:` exactly once, for + `AsyncLocalStorage`. Transport-level vulnerabilities (TLS, connection + handling, message parsing) belong to whatever sits behind the `Transport` + seam — the runtime's global `fetch`, or `undici` for + `@dexpace/transport-undici` — report those upstream. +- In scope here: credential handling and challenge parsing + (`packages/core/src/auth/`), header/URL redaction in logging + (`packages/core/src/observability/redaction.ts`), redirect safety + (`Authorization` stripped on every re-issue, `Cookie` and + `Proxy-Authorization` cross-origin — `packages/core/src/redirect/decide.ts`), + and body capture (`packages/core/src/body/`, `@dexpace/body-file`). diff --git a/package.json b/package.json index acc6fc1..df86a9d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "nodejs-sdk", "private": true, + "license": "MIT", "type": "module", "workspaces": { "packages": [ From c4ed5746f004c5cf4a0ef9b1781120dddb828e8d Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 2 Sep 2026 20:31:33 +0300 Subject: [PATCH 2/3] docs: restructure docs/ and add a housekeeping skill to prevent drift (#57) --- .claude/skills/ci-preflight/SKILL.md | 10 +- .claude/skills/housekeeping/SKILL.md | 173 ++++ .claude/skills/housekeeping/apply.mjs | 222 ++++ .claude/skills/housekeeping/apply.test.mjs | 351 +++++++ .claude/skills/housekeeping/check-fences.mjs | 226 +++++ .../skills/housekeeping/check-fences.test.mjs | 159 +++ .claude/skills/housekeeping/fixture.mjs | 154 +++ .claude/skills/housekeeping/guard.mjs | 132 +++ .claude/skills/housekeeping/guard.test.mjs | 194 ++++ .claude/skills/housekeeping/probe.mjs | 770 ++++++++++++++ .claude/skills/housekeeping/probe.test.mjs | 646 ++++++++++++ .claude/skills/knowledge-lookup/SKILL.md | 4 +- .gitignore | 5 + CLAUDE.md | 144 ++- README.md | 276 ++++- docs/README.md | 109 ++ docs/assets/dexpace-wordmark-dark.svg | 6 + docs/assets/dexpace-wordmark-light.svg | 6 + docs/deferred-items.md | 130 +++ docs/deviations.md | 58 +- docs/open-items.md | 958 +++++++++++++++++- docs/sdk-documentation/architecture.md | 192 ++++ docs/sdk-documentation/auth.md | 146 +++ docs/sdk-documentation/bodies.md | 202 ++++ docs/sdk-documentation/errors.md | 179 ++++ docs/sdk-documentation/http.md | 170 ++++ docs/sdk-documentation/pipelines.md | 198 ++++ docs/sdk-documentation/quality-gates.md | 108 ++ .../write-a-paging-strategy.md | 166 +++ .../write-a-response-handler.md | 143 +++ docs/sdk-documentation/write-a-serde.md | 222 ++++ docs/sdk-documentation/write-a-transport.md | 138 +++ docs/superpowers/README.md | 35 + ...2026-07-23-nodejs-sdk-v1-roadmap-design.md | 584 ----------- ...2026-07-23-nodejs-sdk-v1-roadmap-design.md | 294 ++++++ ...-25-checkpoint-scaffold-through-phase3a.md | 10 +- ...phase1-core-http-domain-model-checklist.md | 0 ...23-phase1-core-http-domain-model-design.md | 4 +- ...026-07-23-phase1-core-http-domain-model.md | 2 +- ...phase10-deviation-reconciliation-design.md | 4 +- ...-07-28-phase10-deviation-reconciliation.md | 50 +- ...07-23-phase2-seam-foundations-checklist.md | 2 +- ...26-07-23-phase2-seam-foundations-design.md | 2 +- .../2026-07-23-phase2-seam-foundations.md | 4 +- ...26-07-24-phase3a-io-contracts-checklist.md | 2 +- .../2026-07-24-phase3a-io-contracts-design.md | 4 +- .../2026-07-24-phase3a-io-contracts.md | 2 +- ...-07-25-phase3b-body-lifecycle-checklist.md | 2 +- ...026-07-25-phase3b-body-lifecycle-design.md | 8 +- .../2026-07-25-phase3b-body-lifecycle.md | 6 +- ...ecution-context-and-pipelines-checklist.md | 6 +- ...-07-25-phase4a-execution-context-design.md | 0 .../2026-07-25-phase4a-execution-context.md | 2 +- ...026-07-25-phase4b-recovery-chain-design.md | 0 .../2026-07-25-phase4b-recovery-chain.md | 2 +- ...026-07-25-phase4c-stage-pipeline-design.md | 2 +- .../2026-07-25-phase4c-stage-pipeline.md | 4 +- .../2026-07-26-phase5a-retry-checklist.md | 4 +- .../2026-07-26-phase5a-retry-design.md | 4 +- .../phase5a}/2026-07-26-phase5a-retry.md | 20 +- .../2026-07-26-phase5b-redirect-checklist.md | 2 +- .../2026-07-26-phase5b-redirect-design.md | 0 .../phase5b}/2026-07-26-phase5b-redirect.md | 12 +- .../2026-07-26-phase5c-auth-checklist.md | 2 +- .../2026-07-26-phase5c-auth-design.md | 4 +- .../phase5c}/2026-07-26-phase5c-auth.md | 10 +- .../2026-07-28-phase6-segmentation-design.md | 0 .../2026-07-28-phase6a-serde-checklist.md | 4 +- .../2026-07-28-phase6a-serde-design.md | 2 +- .../phase6a}/2026-07-28-phase6a-serde.md | 8 +- .../2026-07-28-phase6b-sse-checklist.md | 2 +- .../phase6b}/2026-07-28-phase6b-sse-design.md | 2 +- .../phase6/phase6b}/2026-07-28-phase6b-sse.md | 8 +- ...2026-07-28-phase6c-pagination-checklist.md | 2 +- .../2026-07-28-phase6c-pagination-design.md | 2 +- .../phase6c}/2026-07-28-phase6c-pagination.md | 8 +- .../2026-07-28-phase7-segmentation-design.md | 4 +- ...6-07-28-phase7a-configuration-checklist.md | 6 +- ...2026-07-28-phase7a-configuration-design.md | 2 +- .../2026-07-28-phase7a-configuration.md | 10 +- ...6-07-28-phase7b-observability-checklist.md | 0 ...2026-07-28-phase7b-observability-design.md | 2 +- .../2026-07-28-phase7b-observability.md | 8 +- .../2026-07-28-phase8-segmentation-design.md | 2 +- .../2026-07-28-phase8a-transport-checklist.md | 0 .../2026-07-28-phase8a-transport-design.md | 8 +- .../phase8a}/2026-07-28-phase8a-transport.md | 2 +- ...6-07-28-phase8b-async-runtime-checklist.md | 0 ...2026-07-28-phase8b-async-runtime-design.md | 8 +- .../2026-07-28-phase8b-async-runtime.md | 2 +- ...se9-cross-cutting-conformance-checklist.md | 0 ...phase9-cross-cutting-conformance-design.md | 4 +- ...-07-28-phase9-cross-cutting-conformance.md | 6 +- ...2026-07-23-scaffold-milestone-checklist.md | 2 +- .../2026-07-23-scaffold-milestone-design.md | 2 +- .../2026-07-23-scaffold-milestone.md | 2 +- open-items.md | 182 ---- packages/codec-json/README.md | 4 +- packages/core/README.md | 171 ++++ packages/core/src/auth/preset.ts | 5 +- packages/core/src/config/build-info.ts | 4 +- .../core/src/config/configuration.test.ts | 2 +- packages/core/src/config/configuration.ts | 2 +- packages/core/src/config/equality.test.ts | 2 +- packages/core/src/redirect/redirect-step.ts | 7 +- packages/logging-debug/README.md | 68 +- packages/logging-pino/README.md | 68 +- packages/rx/README.md | 5 +- packages/transport-fetch/README.md | 9 +- packages/transport-undici/README.md | 21 +- scripts/changeset.mjs | 4 +- scripts/verify-knowledge-structure.mjs | 2 +- tests/node-conformance/README.md | 4 +- 113 files changed, 7323 insertions(+), 1031 deletions(-) create mode 100644 .claude/skills/housekeeping/SKILL.md create mode 100644 .claude/skills/housekeeping/apply.mjs create mode 100644 .claude/skills/housekeeping/apply.test.mjs create mode 100644 .claude/skills/housekeeping/check-fences.mjs create mode 100644 .claude/skills/housekeeping/check-fences.test.mjs create mode 100644 .claude/skills/housekeeping/fixture.mjs create mode 100644 .claude/skills/housekeeping/guard.mjs create mode 100644 .claude/skills/housekeeping/guard.test.mjs create mode 100644 .claude/skills/housekeeping/probe.mjs create mode 100644 .claude/skills/housekeeping/probe.test.mjs create mode 100644 docs/README.md create mode 100644 docs/assets/dexpace-wordmark-dark.svg create mode 100644 docs/assets/dexpace-wordmark-light.svg create mode 100644 docs/deferred-items.md create mode 100644 docs/sdk-documentation/architecture.md create mode 100644 docs/sdk-documentation/auth.md create mode 100644 docs/sdk-documentation/bodies.md create mode 100644 docs/sdk-documentation/errors.md create mode 100644 docs/sdk-documentation/http.md create mode 100644 docs/sdk-documentation/pipelines.md create mode 100644 docs/sdk-documentation/quality-gates.md create mode 100644 docs/sdk-documentation/write-a-paging-strategy.md create mode 100644 docs/sdk-documentation/write-a-response-handler.md create mode 100644 docs/sdk-documentation/write-a-serde.md create mode 100644 docs/sdk-documentation/write-a-transport.md create mode 100644 docs/superpowers/README.md delete mode 100644 docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md create mode 100644 docs/work/mvp/2026-07-23-nodejs-sdk-v1-roadmap-design.md rename docs/{superpowers/plans => work/mvp}/2026-07-25-checkpoint-scaffold-through-phase3a.md (98%) rename docs/{superpowers/plans => work/mvp/phase1}/2026-07-23-phase1-core-http-domain-model-checklist.md (100%) rename docs/{superpowers/specs => work/mvp/phase1}/2026-07-23-phase1-core-http-domain-model-design.md (98%) rename docs/{superpowers/plans => work/mvp/phase1}/2026-07-23-phase1-core-http-domain-model.md (99%) rename docs/{superpowers/specs => work/mvp/phase10}/2026-07-28-phase10-deviation-reconciliation-design.md (99%) rename docs/{superpowers/plans => work/mvp/phase10}/2026-07-28-phase10-deviation-reconciliation.md (94%) rename docs/{superpowers/plans => work/mvp/phase2}/2026-07-23-phase2-seam-foundations-checklist.md (97%) rename docs/{superpowers/specs => work/mvp/phase2}/2026-07-23-phase2-seam-foundations-design.md (99%) rename docs/{superpowers/plans => work/mvp/phase2}/2026-07-23-phase2-seam-foundations.md (99%) rename docs/{superpowers/plans => work/mvp/phase3/phase3a}/2026-07-24-phase3a-io-contracts-checklist.md (99%) rename docs/{superpowers/specs => work/mvp/phase3/phase3a}/2026-07-24-phase3a-io-contracts-design.md (99%) rename docs/{superpowers/plans => work/mvp/phase3/phase3a}/2026-07-24-phase3a-io-contracts.md (99%) rename docs/{superpowers/plans => work/mvp/phase3/phase3b}/2026-07-25-phase3b-body-lifecycle-checklist.md (99%) rename docs/{superpowers/specs => work/mvp/phase3/phase3b}/2026-07-25-phase3b-body-lifecycle-design.md (99%) rename docs/{superpowers/plans => work/mvp/phase3/phase3b}/2026-07-25-phase3b-body-lifecycle.md (99%) rename docs/{superpowers/plans => work/mvp/phase4}/2026-07-26-phase4-execution-context-and-pipelines-checklist.md (99%) rename docs/{superpowers/specs => work/mvp/phase4/phase4a}/2026-07-25-phase4a-execution-context-design.md (100%) rename docs/{superpowers/plans => work/mvp/phase4/phase4a}/2026-07-25-phase4a-execution-context.md (99%) rename docs/{superpowers/specs => work/mvp/phase4/phase4b}/2026-07-25-phase4b-recovery-chain-design.md (100%) rename docs/{superpowers/plans => work/mvp/phase4/phase4b}/2026-07-25-phase4b-recovery-chain.md (99%) rename docs/{superpowers/specs => work/mvp/phase4/phase4c}/2026-07-25-phase4c-stage-pipeline-design.md (99%) rename docs/{superpowers/plans => work/mvp/phase4/phase4c}/2026-07-25-phase4c-stage-pipeline.md (99%) rename docs/{superpowers/plans => work/mvp/phase5/phase5a}/2026-07-26-phase5a-retry-checklist.md (99%) rename docs/{superpowers/specs => work/mvp/phase5/phase5a}/2026-07-26-phase5a-retry-design.md (99%) rename docs/{superpowers/plans => work/mvp/phase5/phase5a}/2026-07-26-phase5a-retry.md (99%) rename docs/{superpowers/plans => work/mvp/phase5/phase5b}/2026-07-26-phase5b-redirect-checklist.md (99%) rename docs/{superpowers/specs => work/mvp/phase5/phase5b}/2026-07-26-phase5b-redirect-design.md (100%) rename docs/{superpowers/plans => work/mvp/phase5/phase5b}/2026-07-26-phase5b-redirect.md (99%) rename docs/{superpowers/plans => work/mvp/phase5/phase5c}/2026-07-26-phase5c-auth-checklist.md (99%) rename docs/{superpowers/specs => work/mvp/phase5/phase5c}/2026-07-26-phase5c-auth-design.md (99%) rename docs/{superpowers/plans => work/mvp/phase5/phase5c}/2026-07-26-phase5c-auth.md (99%) rename docs/{superpowers/specs => work/mvp/phase6}/2026-07-28-phase6-segmentation-design.md (100%) rename docs/{superpowers/plans => work/mvp/phase6/phase6a}/2026-07-28-phase6a-serde-checklist.md (99%) rename docs/{superpowers/specs => work/mvp/phase6/phase6a}/2026-07-28-phase6a-serde-design.md (99%) rename docs/{superpowers/plans => work/mvp/phase6/phase6a}/2026-07-28-phase6a-serde.md (99%) rename docs/{superpowers/plans => work/mvp/phase6/phase6b}/2026-07-28-phase6b-sse-checklist.md (99%) rename docs/{superpowers/specs => work/mvp/phase6/phase6b}/2026-07-28-phase6b-sse-design.md (99%) rename docs/{superpowers/plans => work/mvp/phase6/phase6b}/2026-07-28-phase6b-sse.md (99%) rename docs/{superpowers/plans => work/mvp/phase6/phase6c}/2026-07-28-phase6c-pagination-checklist.md (99%) rename docs/{superpowers/specs => work/mvp/phase6/phase6c}/2026-07-28-phase6c-pagination-design.md (99%) rename docs/{superpowers/plans => work/mvp/phase6/phase6c}/2026-07-28-phase6c-pagination.md (99%) rename docs/{superpowers/specs => work/mvp/phase7}/2026-07-28-phase7-segmentation-design.md (97%) rename docs/{superpowers/plans => work/mvp/phase7/phase7a}/2026-07-28-phase7a-configuration-checklist.md (98%) rename docs/{superpowers/specs => work/mvp/phase7/phase7a}/2026-07-28-phase7a-configuration-design.md (99%) rename docs/{superpowers/plans => work/mvp/phase7/phase7a}/2026-07-28-phase7a-configuration.md (99%) rename docs/{superpowers/plans => work/mvp/phase7/phase7b}/2026-07-28-phase7b-observability-checklist.md (100%) rename docs/{superpowers/specs => work/mvp/phase7/phase7b}/2026-07-28-phase7b-observability-design.md (99%) rename docs/{superpowers/plans => work/mvp/phase7/phase7b}/2026-07-28-phase7b-observability.md (99%) rename docs/{superpowers/specs => work/mvp/phase8}/2026-07-28-phase8-segmentation-design.md (99%) rename docs/{superpowers/plans => work/mvp/phase8/phase8a}/2026-07-28-phase8a-transport-checklist.md (100%) rename docs/{superpowers/specs => work/mvp/phase8/phase8a}/2026-07-28-phase8a-transport-design.md (99%) rename docs/{superpowers/plans => work/mvp/phase8/phase8a}/2026-07-28-phase8a-transport.md (99%) rename docs/{superpowers/plans => work/mvp/phase8/phase8b}/2026-07-28-phase8b-async-runtime-checklist.md (100%) rename docs/{superpowers/specs => work/mvp/phase8/phase8b}/2026-07-28-phase8b-async-runtime-design.md (97%) rename docs/{superpowers/plans => work/mvp/phase8/phase8b}/2026-07-28-phase8b-async-runtime.md (99%) rename docs/{superpowers/plans => work/mvp/phase9}/2026-07-28-phase9-cross-cutting-conformance-checklist.md (100%) rename docs/{superpowers/specs => work/mvp/phase9}/2026-07-28-phase9-cross-cutting-conformance-design.md (99%) rename docs/{superpowers/plans => work/mvp/phase9}/2026-07-28-phase9-cross-cutting-conformance.md (99%) rename docs/{superpowers/plans => work/mvp/scaffold}/2026-07-23-scaffold-milestone-checklist.md (98%) rename docs/{superpowers/specs => work/mvp/scaffold}/2026-07-23-scaffold-milestone-design.md (98%) rename docs/{superpowers/plans => work/mvp/scaffold}/2026-07-23-scaffold-milestone.md (99%) delete mode 100644 open-items.md create mode 100644 packages/core/README.md diff --git a/.claude/skills/ci-preflight/SKILL.md b/.claude/skills/ci-preflight/SKILL.md index 1d1233c..1a59a78 100644 --- a/.claude/skills/ci-preflight/SKILL.md +++ b/.claude/skills/ci-preflight/SKILL.md @@ -7,7 +7,8 @@ description: Use before pushing a branch, opening or updating a PR, or whenever ## Overview -`.github/workflows/ci.yml` is 17 blocking steps across two jobs. Every one of them can run +`.github/workflows/ci.yml` is 20 named steps across two jobs — 17 in `ci`, 3 in the +`node-conformance` matrix — and every one of them is blocking. Every one of them can run locally, so a red CI run is always avoidable — `bun test` passing is not evidence, and it is the single most common reason work gets handed over broken. @@ -19,10 +20,10 @@ node .claude/skills/ci-preflight/run-ci.mjs ~2.5 minutes warm on a green tree. Full output per step goes to `node_modules/.cache/ci-preflight/.log`; only a summary and a tail of each failure -reach stdout, so a red run costs a few hundred tokens rather than the ~40k that sixteen +reach stdout, so a red run costs a few hundred tokens rather than the ~40k that all the raw `bun run` calls would. -Do not hand-run the sixteen commands instead. Two things go wrong when you do: +Do not hand-run the individual commands instead. Two things go wrong when you do: - **Order is load-bearing.** `test`, `api`, `lint:publish` and every `verify:*` gate resolve `@dexpace/core` by package name, which lands in `packages/core/dist/`. Run any of them @@ -35,7 +36,8 @@ Do not hand-run the sixteen commands instead. Two things go wrong when you do: 1. **Run it.** Add `--skip-install` only if you have not touched `package.json` since the last install. **Before you push, add `--clean`** — a warm tree is blind to a whole class of defect CI hits on its first step. (The pinned Bun needs no flag; it is the default.) -2. **All green** → say so plainly: CI is all good, naming the count (`all 17 steps passed`). +2. **All green** → say so plainly: CI is all good, naming the count the runner itself prints + (`CI preflight: all N steps passed.`), not a number from this file. Nothing else to do. 3. **Anything red** → report the findings to the user *first*: which gates failed, what each one means, and the fix you intend. One line per finding, not a transcript dump. diff --git a/.claude/skills/housekeeping/SKILL.md b/.claude/skills/housekeeping/SKILL.md new file mode 100644 index 0000000..42d1995 --- /dev/null +++ b/.claude/skills/housekeeping/SKILL.md @@ -0,0 +1,173 @@ +--- +name: housekeeping +description: Use when asked to tidy docs/, check whether CLAUDE.md or README.md still match the code, file phase documents left in docs/superpowers/, find broken links or dangling open-items citations, or verify the documentation before handing work over. Probes the repository for documentation drift, reports it, and applies the mechanical repairs. +--- + +# Housekeeping + +## Overview + +Documentation drifts because nothing checks it. `CLAUDE.md` claimed "two published packages +today" for nine phases while the workspace grew to eleven; `README.md` was two lines with a +spelling error; two shipped package READMEs opened with a code sample that had stopped +compiling. Every one of those is checkable against the repository in a few lines of script, +and none of them was checked. + +This skill is that check. Two stages, and the order is not optional. + +```bash +node .claude/skills/housekeeping/probe.mjs # read-only. Report. Always first. +node .claude/skills/housekeeping/apply.mjs # dry run: prints the moves it would make +node .claude/skills/housekeeping/apply.mjs --write +``` + +It is a hand-run tool, not a CI step. Run it before claiming documentation is current, +after landing a phase, and whenever `docs/superpowers/` has something in it. + +## Stage 1 — probe + +Read-only, and tested to be: `probe.test.mjs` snapshots `git status --porcelain` around a +run and asserts it did not move. Exit code is 0 by default; `--strict` exits 1 when +anything is found, so it can be promoted to a gate without changing what it reports. + +```bash +node .claude/skills/housekeeping/probe.mjs +node .claude/skills/housekeeping/probe.mjs --strict +node .claude/skills/housekeeping/probe.mjs --only=links,citations +``` + +Eight checks. Each derives the repository fact **once, from the repository**, and compares +every document that states it against that one derivation — never one document against +another. + +| Check | Finds | +|---|---| +| `inbox` | Files in `docs/superpowers/` that belong under `docs/work//phaseN/` | +| `root` | Markdown at the repository root that belongs under `docs/` | +| `claims` | `CLAUDE.md` and `README.md` against the real package list, the real `verify:*` gate list, the real named-CI-step count, the real API-report count and the real `docs/` tree; plus `docs/README.md` against the tree it indexes | +| `readmes` | A publishable package with no README, one under 800 bytes, or one declaring `@dexpace/core` as a dependency rather than a peer | +| `links` | Broken relative links in `docs/`, `CLAUDE.md`, `README.md` and every package README | +| `registers` | An aggregate `## Open Findings` / `## Deferred Items Log` / `## Open Items` left in a specification document instead of a register at the `docs/` root | +| `citations` | A `docs/open-items.md ` citation with no matching `### ` heading | +| `guard` | The frozen list and the writable surface overlapping — the one way the apply stage could eat a normative document | + +A **separate** check needs the built packages and so runs on its own: + +```bash +bun run build && node .claude/skills/housekeeping/check-fences.mjs +``` + +It extracts every ` ```typescript ` fence that imports from `@dexpace/*` and typechecks the +lot against `dist/`. A fence with no such import is an illustrative fragment; a fence with a +relative import is package-local. Both are skipped, and an import of a package absent from +this workspace (`pino`, `debug`, `zod`) is reported rather than failed. + +## Stage 2 — apply + +**Only after reading the probe's report.** The apply stage does exactly one thing: drains +`docs/superpowers/` into `docs/work//phaseN/` with `git mv`, so `git log --follow` +resolves each file across the move. + +```bash +node .claude/skills/housekeeping/apply.mjs # dry run +node .claude/skills/housekeeping/apply.mjs --write +node .claude/skills/housekeeping/apply.mjs --write --delivery=v2 +``` + +It refuses the whole batch if any path is frozen, and refuses if a target already exists, +rather than half-applying. + +Everything else the probe reports — a stale count in `CLAUDE.md`, a missing README, a broken +link, a dangling citation — is **prose, and you edit it**. That is deliberate. A tool that +rewrites a sentence to make its own check pass produces documentation that is true and +useless at the same time. The probe tells you what is wrong and where; the judgement about +what the sentence should say is yours. + +Two things `--write` does not do, and says so when it finishes: + +1. **Repoint references.** Re-run the probe's `links` and `citations` checks and fix what + they report, in the same commit — a comment that no longer matches the code is corrected + with the change that staled it (`docs/knowledge/harvested/documentation.md:34`). +2. **Commit.** A migration is its own commit, `git mv` only, so history follows every file. + +## What it must never write + +``` +docs/knowledge/ docs/product-spec/ docs/product-spec.md + docs/sdk-design-nodejs/ docs/sdk-design-nodejs.md +``` + +This is a guard, not a promise. `guard.mjs` exports `assertWritable` and +`assertAllWritable`, and the two places this skill writes both go through one of them: +`apply.mjs`'s batch pre-check before any `git mv`, and `check-fences.mjs`'s scratch +directory, which it opens by deleting. `guard.test.mjs` proves the four ways a naive +implementation fails — a sibling whose name merely starts with a frozen one +(`docs/product-spec-draft/`), a `..` segment that lands inside after normalization, an +absolute path, and a **symlink** whose target is inside a frozen tree while its own path is +not. The frozen list itself is pinned by a test, so widening it is a reviewed diff rather +than a silent constant change. + +Both call sites are covered by a test that fails when the call is deleted: +`apply.test.mjs`'s `--delivery=../product-spec` case and `check-fences.test.mjs`'s frozen +`dir` case. That matters because deleting `apply.mjs`'s `assertAllWritable` once left the +entire suite green. + +The reasons are per-tree and are in [`docs/README.md`](../../../docs/README.md). The one +worth repeating: `docs/knowledge/harvested/` **cannot** absorb a hand edit, because a +`` sha digests the whole source file rather than the entry — an edit inside an entry +changes no sha, and the next harvest regenerates or duplicates it with nothing to notice. +A finding about a harvested rule goes in `docs/knowledge/notes/`, by hand, by a human. + +## Where the rules come from + +Not from this skill's opinion. The documentation rules are the corpus's: + +```bash +bun run knowledge --topic documentation # 21 harvested styleguide rules +``` + +The four this skill mechanises: + +- `documentation.md:28` — every publishable package ships a README whose top gets a new + engineer from zero to one working call, without reading source, in about 30 seconds. +- `documentation.md:32` — each fact in exactly one authoritative place, linked from + everywhere else. This is why `docs/sdk-documentation/` does not restate the API report or + the TSDoc, and why the probe checks links rather than duplicating content. +- `documentation.md:34` — a comment that no longer matches the code is updated or deleted in + the same commit as the change that staled it, never deferred. +- `documentation.md:50` — the documentation build typechecks the code fences, so worked + examples cannot silently drift. That is `check-fences.mjs`. + +## Its own tests + +```bash +node --test .claude/skills/housekeeping/*.test.mjs +``` + +Seventy-seven cases across the guard, the probe, the apply stage and the fence check. Each +check has a **pair**: a throwaway fixture tree it reports clean over, and a mutation of that +tree it must fire on — the shape `scripts/verify-seam-1.test.mjs:6`, +`verify-knowledge-structure.test.mjs:4` and `verify-test-partition.test.mjs:4` already use +here. An earlier version asserted only that the live tree was clean, and replacing the +bodies of seven of the eight checks with `return;` left it fully green. They are +**not** in `bun run test:scripts`, which globs `scripts/*.test.mjs` — promoting them is a +one-line glob change, and the argument for it is the same one that made `test:scripts` +blocking in Phase 10: a gate whose own logic degrades still exits 0, so nothing else +notices. Tracked in `docs/open-items.md` U5, under "The skill's own 77 tests are not run by +any CI step either". + +## Structure + +``` +.claude/skills/housekeeping/ + SKILL.md this file + fixture.mjs builds the throwaway repositories the tests probe + guard.mjs the frozen-path guard; both write sites go through it + guard.test.mjs 13 cases: prefix, traversal, absolute, symlink + probe.mjs stage 1 — eight read-only checks + probe.test.mjs 38 cases; every check has a fixture that must fire + apply.mjs stage 2 — git mv only, guarded, dry by default + apply.test.mjs 17 cases; the CLI half spawns the real script + check-fences.mjs typechecks the documentation's code fences against dist/ + check-fences.test.mjs 9 cases over the fence classifier +``` diff --git a/.claude/skills/housekeeping/apply.mjs b/.claude/skills/housekeeping/apply.mjs new file mode 100644 index 0000000..3e61f65 --- /dev/null +++ b/.claude/skills/housekeeping/apply.mjs @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: MIT +// .claude/skills/housekeeping/apply.mjs +// +// The only stage that writes. It does exactly one mechanical thing — drain the +// `docs/superpowers/` inbox into `docs/work//phaseN/` with `git mv`, so history +// follows each file — and refuses everything else. +// +// node .claude/skills/housekeeping/apply.mjs # dry run, prints the plan +// node .claude/skills/housekeeping/apply.mjs --write # performs it +// node .claude/skills/housekeeping/apply.mjs --write --delivery=v2 +// node .claude/skills/housekeeping/apply.mjs --root=/fixture # for the tests +// +// Everything the probe reports that is NOT a file move — a stale count in `CLAUDE.md`, a +// missing package README, a broken link — is prose, and prose is edited by whoever ran the +// probe. A tool that rewrites a sentence to make its own check pass is how documentation +// becomes true and useless at the same time. + +import {existsSync, mkdirSync} from 'node:fs'; +import {execFileSync} from 'node:child_process'; +import {dirname, join, posix} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {assertAllWritable} from './guard.mjs'; + +function resolveRepoRoot() { + const here = dirname(fileURLToPath(import.meta.url)); + return execFileSync('git', ['rev-parse', '--show-toplevel'], { + cwd: here, + encoding: 'utf8', + }).trim(); +} + +function git(root, ...args) { + return execFileSync('git', ['-c', 'core.quotePath=false', ...args], { + cwd: root, + encoding: 'utf8', + }); +} + +/** + * Where a phase document belongs, from its own name. + * + * `2026-07-28-phase8a-transport-design.md` → `phase8/phase8a/`. A file naming a whole phase + * with no sub-phase letter — a segmentation design, a shared checklist — sits at the + * `phaseN/` level. A file naming no phase at all sits directly under the delivery. + * + * Exported for the tests: the mapping is the part of this stage that can be wrong quietly. + */ +export function targetDirectory(filename, delivery = 'mvp') { + const base = posix.basename(filename); + const sub = /-phase(\d+)([a-z])-/.exec(base); + if (sub) + return `docs/work/${delivery}/phase${sub[1]}/phase${sub[1]}${sub[2]}`; + const whole = /-phase(\d+)[-.]/.exec(base); + if (whole) return `docs/work/${delivery}/phase${whole[1]}`; + if (/-scaffold-milestone/.test(base)) return `docs/work/${delivery}/scaffold`; + return `docs/work/${delivery}`; +} + +/** + * The inbox, tracked and untracked alike. + * + * `--others --exclude-standard` is the point: the inbox's NORMAL state is a file + * `brainstorming` has just written and nobody has staged. A tracked-only listing reported + * "the inbox is empty" over exactly the case this stage exists for. + */ +function inboxFiles(root) { + return git( + root, + 'ls-files', + '--cached', + '--others', + '--exclude-standard', + '--', + 'docs/superpowers/**', + ) + .trim() + .split('\n') + .filter(Boolean) + .filter(f => posix.basename(f) !== 'README.md'); +} + +/** Which of `files` git does not track yet. `git mv` cannot move those. */ +function untrackedAmong(root, files) { + if (files.length === 0) return []; + const cached = new Set( + git(root, 'ls-files', '--cached', '--', 'docs/superpowers/**') + .trim() + .split('\n') + .filter(Boolean), + ); + return files.filter(f => !cached.has(f)); +} + +/** The moves this run would perform, unperformed. */ +export function plan(delivery = 'mvp', root = resolveRepoRoot()) { + return inboxFiles(root).map(from => ({ + from, + to: posix.join(targetDirectory(from, delivery), posix.basename(from)), + })); +} + +/** + * Every reason this batch cannot be performed, as messages. + * + * Two collision classes, not one. `existsSync` catches a target already in the tree; the + * `seen` map catches two inbox files that land on the SAME target — which is the likely + * one, because `specs/` and `plans/` are the two directories the inbox actually uses and a + * design and its plan can share a basename. Without it the dry run printed "2 move(s) + * planned" and `--write` performed the first, then died on `git mv: destination exists` + * with an uncaught stack and a half-applied index. + * + * Exported so the tests can drive it without moving anything. + */ +export function batchRefusals(moves, root) { + const refusals = []; + const seen = new Map(); + for (const move of moves) { + if (seen.has(move.to)) { + refusals.push( + `two inbox files collide on ${move.to}: ${seen.get(move.to)} and ${move.from}. ` + + 'Rename one before collecting; the date prefix is what usually differs.', + ); + continue; + } + seen.set(move.to, move.from); + if (existsSync(join(root, move.to))) { + refusals.push(`${move.to} already exists (from ${move.from})`); + } + } + return refusals; +} + +function main(argv) { + const write = argv.includes('--write'); + const deliveryArg = argv.find(a => a.startsWith('--delivery=')); + const delivery = deliveryArg?.slice('--delivery='.length) ?? 'mvp'; + const rootArg = argv.find(a => a.startsWith('--root=')); + const root = rootArg?.slice('--root='.length) ?? resolveRepoRoot(); + + const moves = plan(delivery, root); + if (moves.length === 0) { + process.stdout.write('the inbox is empty; nothing to collect.\n'); + return 0; + } + + // Guard the WHOLE batch before performing any of it, so a refusal cannot leave the tree + // between two states. `--delivery=../product-spec` is what this stops. + assertAllWritable( + moves.flatMap(m => [m.from, m.to]), + root, + ); + + const untracked = untrackedAmong( + root, + moves.map(m => m.from), + ); + if (untracked.length > 0) { + for (const file of untracked) { + process.stderr.write( + `refusing: ${file} is not tracked; \`git mv\` cannot move it\n`, + ); + } + process.stderr.write( + `run \`git add ${untracked.join(' ')}\` first, then re-run. A phase document is worth ` + + 'a commit of its own before it moves, so history follows it across the collection.\n', + ); + return 1; + } + + const refusals = batchRefusals(moves, root); + if (refusals.length > 0) { + for (const message of refusals) + process.stderr.write(`refusing: ${message}\n`); + return 1; + } + + const done = []; + try { + for (const {from, to} of moves) { + process.stdout.write( + `${write ? 'git mv' : ' would move'} ${from} -> ${to}\n`, + ); + if (!write) continue; + mkdirSync(join(root, dirname(to)), {recursive: true}); + git(root, 'mv', from, to); + done.push(`${from} -> ${to}`); + } + } catch (error) { + // A mid-batch failure must say how far it got. Without this the operator is left with a + // raw stack and an index in an unknown state. + process.stderr.write( + `\n${String(done.length)} of ${String(moves.length)} move(s) were performed before ` + + 'this failed:\n', + ); + for (const line of done) process.stderr.write(` ${line}\n`); + process.stderr.write( + `\n${error instanceof Error ? error.message : String(error)}\n` + + 'The tree is half-collected. `git status` shows the completed moves; finish or ' + + 'revert them before re-running.\n', + ); + return 1; + } + + if (!write) { + process.stdout.write( + `\n${String(moves.length)} move(s) planned. Re-run with --write to perform them.\n`, + ); + return 0; + } + + process.stdout.write( + `\n${String(moves.length)} file(s) collected. Two things this stage did NOT do:\n` + + " 1. Repoint references to the old paths. Run the probe's link and citation checks,\n" + + ' then fix what they report — in the same commit, per documentation.md:34.\n' + + ' 2. Commit. A migration is its own commit, git mv only, so `git log --follow` works.\n', + ); + return 0; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + process.exitCode = main(process.argv.slice(2)); +} diff --git a/.claude/skills/housekeeping/apply.test.mjs b/.claude/skills/housekeeping/apply.test.mjs new file mode 100644 index 0000000..3b215da --- /dev/null +++ b/.claude/skills/housekeeping/apply.test.mjs @@ -0,0 +1,351 @@ +// SPDX-License-Identifier: MIT +// .claude/skills/housekeeping/apply.test.mjs +// +// Two halves, for the two ways this stage goes wrong. +// +// `targetDirectory` decides where a phase document lands, and getting it wrong is quiet: +// the file moves, `git log --follow` still works, and it is simply in the wrong place. The +// cases below are every shape the 62-file migration of 2026-08-31 actually produced. +// +// The rest spawns the real script against throwaway fixture trees, following +// `scripts/verify-seam-1.test.mjs:6` — because a suite that only calls the exported helpers +// passes just as happily when the CLI has stopped refusing anything, and that is exactly +// what happened: deleting `assertAllWritable` and its import, the guard's only production +// call site, left the whole suite green. + +import assert from 'node:assert/strict'; +import {test} from 'node:test'; +import {spawnSync} from 'node:child_process'; +import {existsSync, mkdirSync, writeFileSync} from 'node:fs'; +import {dirname, join} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {batchRefusals, plan, targetDirectory} from './apply.mjs'; +import {makeFixture, removeFixture} from './fixture.mjs'; + +const SCRIPT = fileURLToPath(new URL('./apply.mjs', import.meta.url)); + +function run(root, ...args) { + return spawnSync(process.execPath, [SCRIPT, `--root=${root}`, ...args], { + encoding: 'utf8', + }); +} + +function inbox(root, path, text = '# doc\n') { + mkdirSync(join(root, dirname(path)), {recursive: true}); + writeFileSync(join(root, path), text); +} + +function stage(root, ...paths) { + spawnSync('git', ['add', ...paths], {cwd: root, encoding: 'utf8'}); +} + +// --- the mapping -------------------------------------------------------------------------- + +test('a sub-phase document nests under its phase', () => { + const cases = [ + ['2026-07-28-phase8a-transport-design.md', 'docs/work/mvp/phase8/phase8a'], + ['2026-07-28-phase8b-async-runtime.md', 'docs/work/mvp/phase8/phase8b'], + [ + '2026-07-24-phase3a-io-contracts-checklist.md', + 'docs/work/mvp/phase3/phase3a', + ], + ['2026-07-26-phase5c-auth.md', 'docs/work/mvp/phase5/phase5c'], + [ + 'docs/superpowers/specs/2026-07-25-phase4b-recovery-chain-design.md', + 'docs/work/mvp/phase4/phase4b', + ], + ]; + for (const [name, expected] of cases) { + assert.equal(targetDirectory(name), expected, name); + } +}); + +test('a whole-phase document sits at the phase level, not inside a sub-phase', () => { + const cases = [ + ['2026-07-28-phase8-segmentation-design.md', 'docs/work/mvp/phase8'], + [ + '2026-07-26-phase4-execution-context-and-pipelines-checklist.md', + 'docs/work/mvp/phase4', + ], + ['2026-07-23-phase1-core-http-domain-model.md', 'docs/work/mvp/phase1'], + ['2026-07-28-phase10-deviation-reconciliation.md', 'docs/work/mvp/phase10'], + ]; + for (const [name, expected] of cases) { + assert.equal(targetDirectory(name), expected, name); + } +}); + +test('phase10 is not read as phase1', () => { + // The reason the whole-phase pattern anchors on `[-.]` after the digits: `\d+` is greedy, + // but a lazier reading of `-phase1` inside `-phase10-` would file ten phases under one. + assert.equal( + targetDirectory('2026-07-28-phase10-deviation-reconciliation-design.md'), + 'docs/work/mvp/phase10', + ); + assert.notEqual( + targetDirectory('2026-07-28-phase10-x.md'), + 'docs/work/mvp/phase1', + ); +}); + +test('the scaffold milestone gets its own directory', () => { + for (const name of [ + '2026-07-23-scaffold-milestone.md', + '2026-07-23-scaffold-milestone-design.md', + '2026-07-23-scaffold-milestone-checklist.md', + ]) { + assert.equal(targetDirectory(name), 'docs/work/mvp/scaffold', name); + } +}); + +test('a document belonging to no phase sits directly under the delivery', () => { + assert.equal( + targetDirectory('2026-07-23-nodejs-sdk-v1-roadmap-design.md'), + 'docs/work/mvp', + ); + assert.equal( + targetDirectory('2026-07-25-checkpoint-scaffold-through-phase3a.md'), + 'docs/work/mvp', + ); +}); + +test('the delivery is a parameter, so a later effort is a sibling of mvp', () => { + assert.equal( + targetDirectory('2026-09-01-phase1-x-design.md', 'v2'), + 'docs/work/v2/phase1', + ); + assert.equal( + targetDirectory('2026-09-01-phase1a-x-design.md', 'v2'), + 'docs/work/v2/phase1/phase1a', + ); + assert.equal(targetDirectory('2026-09-01-roadmap.md', 'v2'), 'docs/work/v2'); +}); + +// --- batch refusals --------------------------------------------------------------------- + +test('batchRefusals catches two inbox files landing on ONE target', () => { + const root = makeFixture(); + try { + const moves = [ + { + from: 'docs/superpowers/specs/2026-09-01-phase1-x.md', + to: 'docs/work/mvp/phase1/2026-09-01-phase1-x.md', + }, + { + from: 'docs/superpowers/plans/2026-09-01-phase1-x.md', + to: 'docs/work/mvp/phase1/2026-09-01-phase1-x.md', + }, + ]; + const refusals = batchRefusals(moves, root); + assert.equal(refusals.length, 1, JSON.stringify(refusals)); + assert.match(refusals[0], /two inbox files collide on/); + assert.match( + refusals[0], + /specs\/2026-09-01-phase1-x\.md and .*plans\/2026-09-01-phase1-x\.md/, + ); + } finally { + removeFixture(root); + } +}); + +test('batchRefusals catches a target already in the tree', () => { + const root = makeFixture({ + overrides: { + 'docs/work/mvp/phase1/2026-09-01-phase1-x.md': '# already here\n', + }, + }); + try { + const refusals = batchRefusals( + [ + { + from: 'docs/superpowers/specs/2026-09-01-phase1-x.md', + to: 'docs/work/mvp/phase1/2026-09-01-phase1-x.md', + }, + ], + root, + ); + assert.equal(refusals.length, 1); + assert.match(refusals[0], /already exists/); + } finally { + removeFixture(root); + } +}); + +// --- the CLI ------------------------------------------------------------------------------ + +test('an empty inbox collects nothing', () => { + const root = makeFixture(); + try { + const {status, stdout} = run(root); + assert.equal(status, 0); + assert.match(stdout, /the inbox is empty/); + } finally { + removeFixture(root); + } +}); + +test('a dry run plans without moving', () => { + const root = makeFixture(); + try { + inbox(root, 'docs/superpowers/specs/2026-09-01-phase11-thing-design.md'); + stage(root, 'docs/superpowers/specs/2026-09-01-phase11-thing-design.md'); + const {status, stdout} = run(root); + assert.equal(status, 0); + assert.match( + stdout, + /would move .*phase11-thing-design\.md -> docs\/work\/mvp\/phase11\//, + ); + assert.ok( + existsSync( + join(root, 'docs/superpowers/specs/2026-09-01-phase11-thing-design.md'), + ), + 'a dry run must not move anything', + ); + } finally { + removeFixture(root); + } +}); + +test('--write performs the move with git mv', () => { + const root = makeFixture(); + try { + inbox(root, 'docs/superpowers/specs/2026-09-01-phase11-thing-design.md'); + stage(root, 'docs/superpowers/specs/2026-09-01-phase11-thing-design.md'); + const {status, stdout} = run(root, '--write'); + assert.equal(status, 0, stdout); + assert.ok( + existsSync( + join(root, 'docs/work/mvp/phase11/2026-09-01-phase11-thing-design.md'), + ), + ); + assert.ok( + !existsSync( + join(root, 'docs/superpowers/specs/2026-09-01-phase11-thing-design.md'), + ), + ); + assert.match(stdout, /did NOT do/); + } finally { + removeFixture(root); + } +}); + +test('an UNTRACKED inbox file is refused with the git add to run', () => { + // `git mv` cannot move what git does not track, and the inbox's normal state is exactly + // that. The old code neither reported nor refused it — `plan()` never saw the file. + const root = makeFixture(); + try { + inbox(root, 'docs/superpowers/specs/2026-09-01-phase11-thing-design.md'); + const {status, stderr} = run(root, '--write'); + assert.equal(status, 1); + assert.match(stderr, /is not tracked; `git mv` cannot move it/); + assert.match( + stderr, + /run `git add docs\/superpowers\/specs\/2026-09-01-phase11-thing-design\.md`/, + ); + assert.ok( + existsSync( + join(root, 'docs/superpowers/specs/2026-09-01-phase11-thing-design.md'), + ), + ); + } finally { + removeFixture(root); + } +}); + +test('two same-basename inbox files are refused BEFORE anything moves', () => { + // Reproduced end to end before the fix: the dry run printed "2 move(s) planned", `--write` + // performed the first, then `git mv` fatalled with an uncaught stack and a half-applied + // index. `specs/` and `plans/` are the two directories the inbox actually uses. + const root = makeFixture(); + try { + inbox(root, 'docs/superpowers/specs/2026-09-01-phase11-thing.md'); + inbox(root, 'docs/superpowers/plans/2026-09-01-phase11-thing.md'); + stage( + root, + 'docs/superpowers/specs/2026-09-01-phase11-thing.md', + 'docs/superpowers/plans/2026-09-01-phase11-thing.md', + ); + + const {status, stderr} = run(root, '--write'); + assert.equal(status, 1, 'the batch must be refused'); + assert.match(stderr, /two inbox files collide on/); + assert.ok( + existsSync( + join(root, 'docs/superpowers/specs/2026-09-01-phase11-thing.md'), + ) && + existsSync( + join(root, 'docs/superpowers/plans/2026-09-01-phase11-thing.md'), + ), + 'nothing may move when the batch is refused', + ); + assert.ok( + !existsSync(join(root, 'docs/work/mvp/phase11')), + 'no target may be created', + ); + } finally { + removeFixture(root); + } +}); + +test('a delivery that escapes into a frozen tree is refused by the guard', () => { + // The guard's only production call site. Deleting it left every other test green. + const root = makeFixture({ + overrides: {'docs/product-spec/04-core.md': '# normative\n'}, + }); + try { + inbox(root, 'docs/superpowers/specs/2026-09-01-phase11-thing-design.md'); + stage(root, 'docs/superpowers/specs/2026-09-01-phase11-thing-design.md'); + + const {status, stderr} = run(root, '--write', '--delivery=../product-spec'); + assert.notEqual(status, 0, 'a frozen destination must not be written'); + assert.match(stderr, /FrozenPathError/); + assert.match(stderr, /refusing to write/); + assert.ok( + existsSync( + join(root, 'docs/superpowers/specs/2026-09-01-phase11-thing-design.md'), + ), + 'nothing may move when the guard refuses', + ); + } finally { + removeFixture(root); + } +}); + +// --- plan() ------------------------------------------------------------------------------- + +test('the plan keeps the filename, date prefix included', () => { + const root = makeFixture(); + try { + inbox(root, 'docs/superpowers/specs/2026-09-01-phase11-thing-design.md'); + inbox(root, 'docs/superpowers/plans/2026-09-01-roadmap-v2.md'); + stage(root, 'docs/superpowers'); + + const moves = plan('mvp', root); + assert.equal(moves.length, 2, JSON.stringify(moves)); + for (const {from, to} of moves) { + assert.equal(to.split('/').pop(), from.split('/').pop(), from); + assert.ok(to.startsWith('docs/work/mvp/'), to); + } + } finally { + removeFixture(root); + } +}); + +test('plan() sees an UNTRACKED inbox file', () => { + const root = makeFixture(); + try { + inbox(root, 'docs/superpowers/specs/2026-09-01-phase11-thing-design.md'); + assert.equal(plan('mvp', root).length, 1); + } finally { + removeFixture(root); + } +}); + +test('the inbox README is never collected', () => { + const root = makeFixture(); + try { + assert.deepEqual(plan('mvp', root), []); + } finally { + removeFixture(root); + } +}); diff --git a/.claude/skills/housekeeping/check-fences.mjs b/.claude/skills/housekeeping/check-fences.mjs new file mode 100644 index 0000000..2e1e77c --- /dev/null +++ b/.claude/skills/housekeeping/check-fences.mjs @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: MIT +// .claude/skills/housekeeping/check-fences.mjs +// +// Typechecks the code fences in the documentation against the BUILT packages. +// +// bun run build && node .claude/skills/housekeeping/check-fences.mjs +// +// The harvested styleguide asks for exactly this — "the documentation build typechecks the +// code fences inside `@example` tags so worked examples cannot silently drift out of sync +// with the API" (docs/knowledge/harvested/documentation.md:50). Nothing else in this +// repository does it: `api:ci` diffs signatures, `verify:consumer-types` compiles the +// emitted `.d.ts`, and neither reads a README. Two shipped transport READMEs opened with a +// sample that had not compiled since Phase 10 (docs/open-items.md U8), which is why this +// exists. +// +// Two kinds of fence are skipped, and the rules are deliberate: +// +// - **No `@dexpace/*` specifier** — an illustrative fragment: an interface quote, an +// expression sample. Compiling it in isolation would prove nothing. +// - **A relative import** — package-local. It only means anything from inside the +// package it documents, and cannot resolve from a scratch directory. +// +// The first rule tests for the SPECIFIER, not for an `import` line carrying it. A +// single-line `/^import .*'@dexpace\//m` silently reclassified every fence whose import +// list wraps — which was the documentation's four largest worked examples, `write-a-transport.md`, +// `errors.md`, `write-a-paging-strategy.md` and `write-a-serde.md`. Breaking one of them +// still printed PASS. +// +// An import of an uninstalled OPTIONAL peer (`pino`, `debug`) or of a schema library named +// only as an illustration (`zod`) is reported and not counted as a failure: those packages +// are legitimately absent from this workspace. + +import {mkdirSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {execFileSync} from 'node:child_process'; +import {basename, dirname, join} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {assertWritable} from './guard.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = execFileSync('git', ['rev-parse', '--show-toplevel'], { + cwd: HERE, + encoding: 'utf8', +}).trim(); + +const SCRATCH = '.housekeeping-fences'; +const FENCE = /```(?:typescript|ts)\n([\s\S]*?)```/g; +const ABSENT_MODULES = ['pino', 'debug', 'zod']; + +const DEFAULT_FILES = () => + execFileSync( + 'git', + [ + 'ls-files', + '--', + 'README.md', + 'docs/sdk-documentation/*.md', + 'packages/*/README.md', + ], + {cwd: ROOT, encoding: 'utf8'}, + ) + .trim() + .split('\n') + .filter(Boolean); + +/** + * Extracts the runnable fences into `dir`, returning one entry per written file. + * + * `root` is a parameter so `check-fences.test.mjs` can drive this over a throwaway tree + * rather than over the repository it is testing. + */ +export function extract(dir, files, root = ROOT) { + // This function opens by DELETING `dir`. `main` only ever passes the module constant, so + // no caller reaches it with anything else today — but an exported function whose first + // act is a recursive remove must not be one guard away from eating a normative tree, and + // the tests below pass a `dir` of their own. + assertWritable(dir, root); + rmSync(join(root, dir), {recursive: true, force: true}); + mkdirSync(join(root, dir), {recursive: true}); + + const written = []; + for (const file of files) { + const text = readFileSync(join(root, file), 'utf8'); + let index = 0; + for (const match of text.matchAll(FENCE)) { + index++; + const code = match[1]; + if (!/'@dexpace\//.test(code)) continue; + if (/from '\.\.?\//.test(code)) continue; + const line = text.slice(0, match.index).split('\n').length; + const name = + `${basename(dirname(file))}-${basename(file, '.md')}-${index}.ts` + .replace(/[^\w.-]/g, '_') + .replace(/^[.-]+/, 'root-'); + writeFileSync(join(root, dir, name), code); + written.push({ + name, + from: `${file}:${line}`, + importLines: importLines(code), + }); + } + } + + writeFileSync( + join(root, dir, 'tsconfig.json'), + `${JSON.stringify( + { + compilerOptions: { + module: 'NodeNext', + moduleResolution: 'NodeNext', + target: 'ES2022', + lib: ['ES2022', 'DOM', 'DOM.AsyncIterable'], + strict: true, + // On for the IMPORT diagnostics only — see `importDiagnostics`. An unused import + // in a worked example is a defect: it tells a reader they need a symbol they do + // not. An unused *local* is not: `const body = serdeBody(value, serde);` exists + // to show the shape of what comes back, and has nothing to do afterwards. + noUnusedLocals: true, + noEmit: true, + skipLibCheck: true, + types: [], + }, + include: ['*.ts'], + }, + null, + 1, + )}\n`, + ); + return written; +} + +/** `1`-based line numbers of every physical line inside an import declaration. */ +function importLines(code) { + const lines = code.split('\n'); + const inside = new Set(); + let open = false; + for (const [index, line] of lines.entries()) { + if (open || /^\s*import\b/.test(line)) { + inside.add(index + 1); + // A declaration ends at the `;`, or at the `from '…'` for a braceless one. + open = !/;\s*$/.test(line); + } + } + return inside; +} + +const UNUSED = /^(.+?)\((\d+),\d+\): error TS(6133|6192):/; + +/** + * Is this diagnostic about an unused LOCAL rather than an unused import? + * + * `noUnusedLocals` covers both and TypeScript has no flag that separates them, so the split + * happens here: TS6192 is always an import declaration; TS6133 is one only when the symbol + * it names sits on a line inside one. + */ +function isUnusedLocal(line, written) { + const match = UNUSED.exec(line); + if (match === null) return false; + if (match[3] === '6192') return false; // "All imports in import declaration are unused" + const entry = written.find(w => match[1].endsWith(w.name)); + if (entry === undefined) return false; + return !entry.importLines.has(Number(match[2])); +} + +function main() { + const files = process.argv.slice(2).filter(a => !a.startsWith('--')); + const targets = files.length > 0 ? files : DEFAULT_FILES(); + + // `finally`, so an interrupted or throwing run does not leave `.housekeeping-fences/` + // behind. `.gitignore` hides it either way, which is why this ranks where it does. + try { + const written = extract(SCRATCH, targets); + + let output = ''; + try { + execFileSync( + './node_modules/.bin/tsc', + ['-p', `${SCRATCH}/tsconfig.json`], + { + cwd: ROOT, + encoding: 'utf8', + }, + ); + } catch (error) { + output = `${error.stdout ?? ''}${error.stderr ?? ''}`; + } + + const lines = output.split('\n').filter(Boolean); + const absent = lines.filter(l => + ABSENT_MODULES.some(m => l.includes(`TS2307: Cannot find module '${m}'`)), + ); + const unusedLocal = lines.filter(l => isUnusedLocal(l, written)); + const real = lines.filter( + l => !absent.includes(l) && !unusedLocal.includes(l), + ); + + process.stdout.write( + `${String(written.length)} runnable fence(s) from ${String(targets.length)} file(s)\n`, + ); + for (const line of real) process.stdout.write(`${line}\n`); + if (absent.length > 0) { + process.stdout.write( + `\n${String(absent.length)} import(s) of a package absent from this workspace, ` + + `ignored: ${ABSENT_MODULES.join(', ')}\n`, + ); + } + if (unusedLocal.length > 0) { + process.stdout.write( + `${String(unusedLocal.length)} unused local(s), ignored: a worked example may bind a ` + + 'value to show its shape. Unused IMPORTS are still failures.\n', + ); + } + + if (real.length > 0) { + process.stdout.write('\nFENCE CHECK: FAIL\n'); + return 1; + } + process.stdout.write('\nFENCE CHECK: PASS\n'); + return 0; + } finally { + rmSync(join(ROOT, SCRATCH), {recursive: true, force: true}); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + process.exitCode = main(); +} diff --git a/.claude/skills/housekeeping/check-fences.test.mjs b/.claude/skills/housekeeping/check-fences.test.mjs new file mode 100644 index 0000000..464b6c8 --- /dev/null +++ b/.claude/skills/housekeeping/check-fences.test.mjs @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: MIT +// .claude/skills/housekeeping/check-fences.test.mjs +// +// The classifier decides which fences are checked at all, so getting it wrong is silent by +// construction: the run reports PASS over the examples it skipped. It did. A single-line +// `/^import .*'@dexpace\//m` reclassified every fence whose import list wraps — the +// documentation's four largest worked examples — and breaking one of them still printed +// PASS with exit 0. + +import assert from 'node:assert/strict'; +import {test} from 'node:test'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {dirname, join} from 'node:path'; +import {extract} from './check-fences.mjs'; +import {FrozenPathError} from './guard.mjs'; + +function withTree(files, body) { + const root = mkdtempSync(join(tmpdir(), 'fences-')); + try { + for (const [path, text] of Object.entries(files)) { + mkdirSync(join(root, dirname(path)), {recursive: true}); + writeFileSync(join(root, path), text); + } + return body(root); + } finally { + rmSync(root, {recursive: true, force: true}); + } +} + +const WRAPPED = `# doc + +\`\`\`typescript +import { + Request, + Response, + type Transport, +} from '@dexpace/core'; + +export const t: Transport = null as never; +\`\`\` +`; + +const SINGLE_LINE = `# doc + +\`\`\`typescript +import {Request} from '@dexpace/core'; +\`\`\` +`; + +const FRAGMENT = `# doc + +\`\`\`typescript +interface Transport { + close(): Promise; +} +\`\`\` +`; + +const RELATIVE = `# doc + +\`\`\`typescript +import {runTransportConformanceSuite} from '@dexpace/transport-conformance'; +import {myTransport} from '../src/index.js'; +\`\`\` +`; + +test('a fence whose import list WRAPS is runnable, not a fragment', () => { + withTree({'a.md': WRAPPED}, root => { + const written = extract('out', ['a.md'], root); + assert.equal(written.length, 1, 'a wrapped import must not be skipped'); + assert.match( + readFileSync(join(root, 'out', written[0].name), 'utf8'), + /@dexpace\/core/, + ); + }); +}); + +test('a single-line import is runnable', () => { + withTree({'a.md': SINGLE_LINE}, root => { + assert.equal(extract('out', ['a.md'], root).length, 1); + }); +}); + +test('a fence with no @dexpace specifier is an illustrative fragment', () => { + withTree({'a.md': FRAGMENT}, root => { + assert.deepEqual(extract('out', ['a.md'], root), []); + }); +}); + +test('a fence importing a RELATIVE path is package-local and skipped', () => { + withTree({'a.md': RELATIVE}, root => { + assert.deepEqual(extract('out', ['a.md'], root), []); + }); +}); + +test('every written entry records where it came from, with a line number', () => { + withTree({'docs/a.md': `intro\n\n${SINGLE_LINE}`}, root => { + const [entry] = extract('out', ['docs/a.md'], root); + assert.equal(entry.from, 'docs/a.md:5'); + assert.ok(entry.name.endsWith('.ts')); + }); +}); + +test('a snippet from a root-level file gets a non-dotfile name', () => { + // `basename(dirname('README.md'))` is `.`, and tsconfig's `include: ["*.ts"]` does not + // match a dotfile — the whole run reported "No inputs were found". + withTree({'README.md': SINGLE_LINE}, root => { + const [entry] = extract('out', ['README.md'], root); + assert.ok(!entry.name.startsWith('.'), entry.name); + assert.match(entry.name, /^root-README-1\.ts$/); + }); +}); + +test('the generated tsconfig turns on the flags the check depends on', () => { + withTree({'a.md': SINGLE_LINE}, root => { + extract('out', ['a.md'], root); + const config = JSON.parse( + readFileSync(join(root, 'out', 'tsconfig.json'), 'utf8'), + ); + assert.equal(config.compilerOptions.strict, true); + assert.equal(config.compilerOptions.noUnusedLocals, true); + assert.equal(config.compilerOptions.module, 'NodeNext'); + assert.deepEqual(config.compilerOptions.types, []); + }); +}); + +test('importLines marks a wrapped declaration, so an unused LOCAL is told from an unused IMPORT', () => { + withTree({'a.md': WRAPPED}, root => { + const [entry] = extract('out', ['a.md'], root); + // The declaration spans lines 1-5; the binding on 7 is a local. + assert.deepEqual( + [...entry.importLines].sort((a, b) => a - b), + [1, 2, 3, 4, 5], + ); + assert.ok(!entry.importLines.has(7)); + }); +}); + +test('extract refuses a frozen output directory', () => { + // It opens by deleting `dir`. No caller passes anything but the module constant today — + // these tests are the first to pass a `dir` at all, which is why the guard is here. + withTree({'a.md': SINGLE_LINE}, root => { + assert.throws( + () => extract('docs/product-spec', ['a.md'], root), + FrozenPathError, + ); + assert.throws( + () => extract('docs/knowledge/harvested', ['a.md'], root), + FrozenPathError, + ); + }); +}); diff --git a/.claude/skills/housekeeping/fixture.mjs b/.claude/skills/housekeeping/fixture.mjs new file mode 100644 index 0000000..1a07999 --- /dev/null +++ b/.claude/skills/housekeeping/fixture.mjs @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: MIT +// .claude/skills/housekeeping/fixture.mjs +// +// Builds a throwaway repository the probe and the apply stage can be pointed at, so a test +// can assert a check FIRES rather than asserting the live tree happens to be clean. +// +// The distinction is not academic. Before these fixtures existed, replacing the bodies of +// seven of the eight checks with `return;` left the whole suite green, and so did deleting +// `apply.mjs`'s only `assertAllWritable` call. `scripts/verify-seam-1.test.mjs:6`, +// `verify-knowledge-structure.test.mjs:4` and `verify-test-partition.test.mjs:4` had each +// already reached that conclusion in this repository and say so in their own headers. +// +// Only used by tests, so `node:fs` and `node:child_process` are fine here — the +// zero-`node:` invariant governs `packages/*/src`, not tooling. + +import {execFileSync} from 'node:child_process'; +import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {dirname, join} from 'node:path'; + +/** A CI workflow with a known shape: two jobs, three named steps. */ +const WORKFLOW = `name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install + run: bun install + + - name: Verify seam + run: bun run verify:seam-1 + + node-conformance: + needs: ci + runs-on: ubuntu-latest + steps: + - name: Node conformance + run: bun run test:node +`; + +/** + * The counts a fixture's own documents must state to be clean: + * 2 packages (1 publishable, 1 private), 1 API report, 3 named CI steps, 2 jobs. + */ +export const CLEAN_CLAIMS = [ + 'Two packages, one is published and one is `private`.', + 'One committed report.', + 'Three named steps across two jobs.', + 'Gates: `verify:seam-1`.', + '`@dexpace/thing` and `@dexpace/secret` are the packages.', +].join('\n\n'); + +function write(root, path, text) { + mkdirSync(join(root, dirname(path)), {recursive: true}); + writeFileSync(join(root, path), text); +} + +/** + * A minimal repository the probe reports clean over. + * + * `overrides` replaces or adds files after the clean tree is written; a value of `null` + * deletes. `untracked` is written after `git add`, so it stays untracked. + */ +export function makeFixture({overrides = {}, untracked = {}} = {}) { + const root = mkdtempSync(join(tmpdir(), 'housekeeping-fixture-')); + + write( + root, + 'package.json', + `${JSON.stringify( + { + name: 'fixture', + private: true, + scripts: {'verify:seam-1': 'true', test: 'true'}, + }, + null, + 2, + )}\n`, + ); + write(root, '.github/workflows/ci.yml', WORKFLOW); + + write( + root, + 'packages/thing/package.json', + `${JSON.stringify( + {name: '@dexpace/thing', peerDependencies: {'@dexpace/core': '*'}}, + null, + 2, + )}\n`, + ); + write(root, 'packages/thing/etc/thing.api.md', '# API\n'); + write( + root, + 'packages/thing/README.md', + `# @dexpace/thing\n\n${'Long enough to clear the thin-README floor. '.repeat(25)}\n`, + ); + write( + root, + 'packages/secret/package.json', + `${JSON.stringify({name: '@dexpace/secret', private: true}, null, 2)}\n`, + ); + + write( + root, + 'CLAUDE.md', + `# CLAUDE.md\n\n${CLEAN_CLAIMS}\n\ndocs/README.md, docs/open-items.md, docs/work, docs/sdk-documentation, docs/superpowers.\n`, + ); + write(root, 'README.md', `# fixture\n\n${CLEAN_CLAIMS}\n`); + write( + root, + 'docs/README.md', + '# docs\n\nEntries: README.md, open-items.md, work, sdk-documentation, superpowers.\n', + ); + write( + root, + 'docs/open-items.md', + '# Open Items\n\n### A1 — a real item — **WATCH**\n\nBody.\n', + ); + write(root, 'docs/superpowers/README.md', '# inbox\n'); + write(root, 'docs/work/mvp/phase1/2026-01-01-phase1-thing.md', '# phase 1\n'); + write(root, 'docs/sdk-documentation/architecture.md', '# architecture\n'); + + for (const [path, text] of Object.entries(overrides)) { + if (text === null) { + rmSync(join(root, path), {force: true, recursive: true}); + continue; + } + write(root, path, text); + } + + execFileSync('git', ['init', '-q'], {cwd: root}); + execFileSync('git', ['config', 'user.email', 'fixture@example.invalid'], { + cwd: root, + }); + execFileSync('git', ['config', 'user.name', 'fixture'], {cwd: root}); + execFileSync('git', ['add', '-A'], {cwd: root}); + execFileSync('git', ['commit', '-qm', 'fixture'], {cwd: root}); + + for (const [path, text] of Object.entries(untracked)) write(root, path, text); + + return root; +} + +export function removeFixture(root) { + rmSync(root, {recursive: true, force: true}); +} diff --git a/.claude/skills/housekeeping/guard.mjs b/.claude/skills/housekeeping/guard.mjs new file mode 100644 index 0000000..07f08b4 --- /dev/null +++ b/.claude/skills/housekeeping/guard.mjs @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: MIT +// .claude/skills/housekeeping/guard.mjs +// +// The frozen-path guard. Three trees and two files in `docs/` are read-only to this +// skill, and that has to be a check rather than a paragraph of good intent: the apply +// stage moves files and rewrites Markdown, and a glob that widens by one segment is +// exactly how a maintenance tool eats a normative document. +// +// Every write the skill performs goes through `assertWritable` first. `guard.test.mjs` +// is what proves it, including the three ways a naive prefix test gets it wrong: a +// sibling whose name merely starts with a frozen one, a `..` segment that lands inside +// after normalization, and an absolute path. + +import {realpathSync} from 'node:fs'; +import {dirname, relative, resolve, sep} from 'node:path'; + +/** + * The five entries this skill must never write to. + * + * `docs/knowledge/` covers both `harvested/` and `notes/`. `harvested/` cannot absorb a + * hand edit at all — a `` sha digests the whole source file rather than the entry, so + * an edit inside one changes no sha and the next harvest regenerates or duplicates it + * silently. `notes/` is hand-written and could in principle be edited; it is frozen here + * because the CLI reads the two as one corpus and a note's key citation couples them. + * Whether that grouping is right is `docs/open-items.md` U1. + */ +export const FROZEN = Object.freeze([ + 'docs/knowledge', + 'docs/product-spec', + 'docs/sdk-design-nodejs', + 'docs/product-spec.md', + 'docs/sdk-design-nodejs.md', +]); + +/** Raised instead of writing. Carries the offending path so a caller can report it. */ +export class FrozenPathError extends Error { + constructor(path, frozen) { + super( + `refusing to write ${path}: it is under the frozen entry '${frozen}'. ` + + 'The housekeeping skill reads the normative and harvested trees; it never ' + + 'writes to them. See docs/README.md.', + ); + this.name = 'FrozenPathError'; + this.path = path; + this.frozen = frozen; + } +} + +/** + * `path` with every symlink in it resolved, as far as the filesystem actually goes. + * + * `resolve()` is purely lexical, so on its own it answers the wrong question: with + * `docs/work` a symlink to `docs/product-spec`, `docs/work/mvp/x.md` lexically escapes the + * frozen tree and physically lands inside it — and `mkdirSync(…, {recursive: true})` + * follows the link, so a `git mv` would write there while the guard said yes. + * + * A target that does not exist yet is the normal case for a move, so this walks up to the + * nearest ancestor that does, resolves that, and re-attaches the tail. + */ +function realpathOfNearestAncestor(path) { + const segments = []; + let current = path; + for (;;) { + try { + return resolve(realpathSync.native(current), ...segments.reverse()); + } catch { + const parent = dirname(current); + // Root reached without anything existing: nothing to resolve, answer lexically. + if (parent === current) return path; + segments.push(current.slice(parent.length + 1)); + current = parent; + } + } +} + +/** + * Which frozen entry `candidate` falls under, or `null`. + * + * Resolved against `repoRoot` and compared **segment-wise**, never as a raw string + * prefix: `docs/product-spec-draft/x.md` starts with `docs/product-spec` as characters + * and is not under it as a path. `..` is normalized away first, so a path that spells its + * way in cannot spell its way past the check — and symlinks are resolved, so a path that + * *links* its way in cannot either. + */ +export function frozenEntryFor(candidate, repoRoot = process.cwd()) { + const root = resolve(repoRoot); + const target = realpathOfNearestAncestor(resolve(root, candidate)); + for (const entry of FROZEN) { + const frozenAbs = realpathOfNearestAncestor(resolve(root, entry)); + if (target === frozenAbs) return entry; + const rel = relative(frozenAbs, target); + // Inside iff the relative path neither escapes upward nor is absolute. + if ( + rel !== '' && + !rel.startsWith(`..${sep}`) && + rel !== '..' && + !rel.startsWith(sep) + ) { + return entry; + } + } + return null; +} + +/** `true` when `candidate` is a frozen entry or lives under one. */ +export function isFrozen(candidate, repoRoot = process.cwd()) { + return frozenEntryFor(candidate, repoRoot) !== null; +} + +/** + * Throws `FrozenPathError` when `candidate` is frozen; returns it otherwise, so a call + * site reads `writeFileSync(assertWritable(p), text)` and cannot forget the check. + */ +export function assertWritable(candidate, repoRoot = process.cwd()) { + const frozen = frozenEntryFor(candidate, repoRoot); + if (frozen !== null) throw new FrozenPathError(candidate, frozen); + return candidate; +} + +/** + * Guards a whole batch before performing any of it, so a run cannot half-apply and leave + * the tree between two states. + */ +export function assertAllWritable(candidates, repoRoot = process.cwd()) { + const refused = candidates + .map(path => ({path, frozen: frozenEntryFor(path, repoRoot)})) + .filter(({frozen}) => frozen !== null); + if (refused.length > 0) { + throw new FrozenPathError(refused[0].path, refused[0].frozen); + } + return candidates; +} diff --git a/.claude/skills/housekeeping/guard.test.mjs b/.claude/skills/housekeeping/guard.test.mjs new file mode 100644 index 0000000..c5b978c --- /dev/null +++ b/.claude/skills/housekeeping/guard.test.mjs @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: MIT +// .claude/skills/housekeeping/guard.test.mjs +// +// The guard is the one part of this skill that must not be wrong, because everything it +// protects is a document no other copy of exists. These cases are the three ways a naive +// `startsWith` implementation fails, plus proof that the mutable half stays mutable. + +import assert from 'node:assert/strict'; +import {test} from 'node:test'; +import {mkdirSync, mkdtempSync, rmSync, symlinkSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join, resolve} from 'node:path'; +import { + FROZEN, + FrozenPathError, + assertAllWritable, + assertWritable, + frozenEntryFor, + isFrozen, +} from './guard.mjs'; + +const ROOT = '/repo'; + +test('every frozen entry is itself refused', () => { + for (const entry of FROZEN) { + assert.equal(frozenEntryFor(entry, ROOT), entry, entry); + } +}); + +test('a file under a frozen tree is refused, at any depth', () => { + assert.equal( + frozenEntryFor('docs/product-spec/04-core.md', ROOT), + 'docs/product-spec', + ); + assert.equal( + frozenEntryFor('docs/knowledge/harvested/documentation.md', ROOT), + 'docs/knowledge', + ); + assert.equal( + frozenEntryFor('docs/knowledge/notes/pagination.md', ROOT), + 'docs/knowledge', + ); + assert.equal( + frozenEntryFor('docs/sdk-design-nodejs/10-deliberate-deviations.md', ROOT), + 'docs/sdk-design-nodejs', + ); +}); + +test('a SIBLING whose name merely starts with a frozen one is writable', () => { + // The failure a raw string prefix test would produce, and the reason the comparison is + // segment-wise. `verify-knowledge-structure.mjs` guards the same shape for source roots. + assert.equal(isFrozen('docs/product-spec-draft/04-core.md', ROOT), false); + assert.equal(isFrozen('docs/knowledge-notes.md', ROOT), false); + assert.equal(isFrozen('docs/sdk-design-nodejs-old/01.md', ROOT), false); + assert.equal(isFrozen('docs/product-spec.md.bak', ROOT), false); +}); + +test('a `..` segment that lands inside is refused', () => { + assert.equal( + frozenEntryFor('docs/work/../product-spec/04-core.md', ROOT), + 'docs/product-spec', + ); + assert.equal( + frozenEntryFor('docs/sdk-documentation/../knowledge/x.md', ROOT), + 'docs/knowledge', + ); +}); + +test('a `..` segment that escapes upward is not mistaken for containment', () => { + assert.equal( + isFrozen('docs/product-spec/../work/mvp/phase1/x.md', ROOT), + false, + ); + assert.equal(isFrozen('docs/knowledge/../README.md', ROOT), false); +}); + +test('an absolute path is resolved, not treated as relative', () => { + assert.equal( + frozenEntryFor(resolve(ROOT, 'docs/product-spec/04.md'), ROOT), + 'docs/product-spec', + ); + // An absolute path outside the repository is nobody's business but is certainly not frozen. + assert.equal(isFrozen('/elsewhere/docs/product-spec/04.md', ROOT), false); +}); + +test('everything the skill is allowed to write stays writable', () => { + for (const path of [ + 'docs/README.md', + 'docs/open-items.md', + 'docs/deferred-items.md', + 'docs/deviations.md', + 'docs/sdk-documentation/architecture.md', + 'docs/work/mvp/phase1/2026-07-23-phase1-core-http-domain-model.md', + 'docs/superpowers/specs/2026-09-01-x-design.md', + 'docs/assets/dexpace-wordmark-dark.svg', + 'CLAUDE.md', + 'README.md', + 'packages/core/README.md', + ]) { + assert.equal(isFrozen(path, ROOT), false, path); + assert.equal(assertWritable(path, ROOT), path); + } +}); + +test('assertWritable throws a FrozenPathError naming both paths', () => { + assert.throws( + () => assertWritable('docs/product-spec/04-core.md', ROOT), + error => { + assert.ok(error instanceof FrozenPathError); + assert.equal(error.name, 'FrozenPathError'); + assert.equal(error.frozen, 'docs/product-spec'); + assert.match(error.message, /refusing to write/); + return true; + }, + ); +}); + +test('assertAllWritable refuses the whole batch before performing any of it', () => { + const batch = ['docs/README.md', 'docs/product-spec/04-core.md', 'CLAUDE.md']; + assert.throws(() => assertAllWritable(batch, ROOT), FrozenPathError); + assert.deepEqual(assertAllWritable(['docs/README.md', 'CLAUDE.md'], ROOT), [ + 'docs/README.md', + 'CLAUDE.md', + ]); +}); + +test('the frozen list is exactly the five docs/README.md names', () => { + // Pinned deliberately: widening this list is a decision about what a maintenance tool + // may edit, and it must be a reviewed diff here rather than a silent constant change. + assert.deepEqual( + [...FROZEN], + [ + 'docs/knowledge', + 'docs/product-spec', + 'docs/sdk-design-nodejs', + 'docs/product-spec.md', + 'docs/sdk-design-nodejs.md', + ], + ); +}); + +test('a SYMLINK into a frozen tree is refused', () => { + // Lexically `docs/work/...` escapes every frozen entry; physically it lands inside + // `docs/product-spec`. `apply.mjs`'s `mkdirSync(…, {recursive: true})` follows the link, + // so a purely lexical guard says yes and `git mv` writes into the normative tree. + const root = mkdtempSync(join(tmpdir(), 'guard-symlink-')); + try { + mkdirSync(join(root, 'docs/product-spec'), {recursive: true}); + symlinkSync( + join(root, 'docs/product-spec'), + join(root, 'docs/work'), + 'dir', + ); + + assert.equal( + frozenEntryFor('docs/work/mvp/phase9/x.md', root), + 'docs/product-spec', + 'a symlinked path into a frozen tree must be refused', + ); + assert.throws( + () => assertWritable('docs/work/mvp/phase9/x.md', root), + FrozenPathError, + ); + } finally { + rmSync(root, {recursive: true, force: true}); + } +}); + +test('a real docs/work directory stays writable', () => { + // The other half: resolving symlinks must not make the ordinary tree frozen. + const root = mkdtempSync(join(tmpdir(), 'guard-real-')); + try { + mkdirSync(join(root, 'docs/product-spec'), {recursive: true}); + mkdirSync(join(root, 'docs/work/mvp/phase9'), {recursive: true}); + assert.equal(isFrozen('docs/work/mvp/phase9/x.md', root), false); + assert.equal(isFrozen('docs/product-spec/04.md', root), true); + } finally { + rmSync(root, {recursive: true, force: true}); + } +}); + +test('a target whose ancestors do not exist yet is still judged', () => { + // The normal case for a move: nothing at the destination. + const root = mkdtempSync(join(tmpdir(), 'guard-absent-')); + try { + assert.equal( + frozenEntryFor('docs/product-spec/new/deep/x.md', root), + 'docs/product-spec', + ); + assert.equal(isFrozen('docs/work/mvp/phase1/x.md', root), false); + } finally { + rmSync(root, {recursive: true, force: true}); + } +}); diff --git a/.claude/skills/housekeeping/probe.mjs b/.claude/skills/housekeeping/probe.mjs new file mode 100644 index 0000000..e0bd883 --- /dev/null +++ b/.claude/skills/housekeeping/probe.mjs @@ -0,0 +1,770 @@ +// SPDX-License-Identifier: MIT +// .claude/skills/housekeeping/probe.mjs +// +// Read-only. Reports drift; never writes. Eight checks, each of which caught something +// real the first time it ran (`docs/open-items.md` Section U). +// +// node .claude/skills/housekeeping/probe.mjs # report, exit 0 +// node .claude/skills/housekeeping/probe.mjs --strict # exit 1 when anything is found +// node .claude/skills/housekeeping/probe.mjs --only=links,claims +// node .claude/skills/housekeeping/probe.mjs --root=/path/to/a/fixture/tree +// +// The eight are deliberately independent: a repository fact is derived once, from the +// repository, and every document that states it is checked against that one derivation. +// Nothing here reads a number out of one document and compares it to another. +// +// `--root` exists for `probe.test.mjs`, which builds throwaway fixture trees and asserts +// each check FIRES — the shape `scripts/verify-seam-1.test.mjs:6` and +// `verify-test-partition.test.mjs:4` already use here. A suite that only asserts the live +// tree is clean passes just as happily over a check whose body has become `return;`, and +// seven of these eight were in exactly that state when it was written. + +import {existsSync, readFileSync, readdirSync, statSync} from 'node:fs'; +import {execFileSync} from 'node:child_process'; +import {dirname, join, posix} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {isFrozen} from './guard.mjs'; + +function resolveRepoRoot() { + const here = dirname(fileURLToPath(import.meta.url)); + return execFileSync('git', ['rev-parse', '--show-toplevel'], { + cwd: here, + encoding: 'utf8', + }).trim(); +} + +/** + * Everything a check needs, bound to one repository root. + * + * The root is a parameter rather than a module constant so a fixture tree can be probed; + * that is the only reason this indirection exists. + */ +function createContext(root) { + const findings = []; + return { + root, + findings, + read: path => readFileSync(join(root, path), 'utf8'), + exists: path => existsSync(join(root, path)), + /** + * Tracked paths matching `globs`. + * + * `-c core.quotePath=false` because `git ls-files` C-quotes any path with a non-ASCII + * byte by default (`"docs/caf\303\251.md"`), and a quoted path fed back to `readFileSync` + * is an `ENOENT` that takes the whole run down with a raw stack instead of a finding. + */ + tracked: (...globs) => + execFileSync( + 'git', + ['-c', 'core.quotePath=false', 'ls-files', '--', ...globs], + {cwd: root, encoding: 'utf8'}, + ) + .trim() + .split('\n') + .filter(Boolean), + /** Tracked paths PLUS untracked, non-ignored ones. */ + present: (...globs) => + execFileSync( + 'git', + [ + '-c', + 'core.quotePath=false', + 'ls-files', + '--others', + '--exclude-standard', + '--cached', + '--', + ...globs, + ], + {cwd: root, encoding: 'utf8'}, + ) + .trim() + .split('\n') + .filter(Boolean), + finding: (check, severity, message) => + findings.push({check, severity, message}), + }; +} + +// --------------------------------------------------------------------------------------- +// Spelled-out numerals. +// +// Every count claim in `CLAUDE.md` and `README.md` is written as an English word -- +// "eleven packages", "nine committed reports", "Twenty named CI steps". The first version +// of this file matched `(\d+)` only, so it protected exactly one sentence in the whole +// repository, and appending "Two published packages today, and that is the whole +// workspace." to `CLAUDE.md` -- the precise drift SKILL.md names as this tool's reason for +// existing -- still printed `no drift found`. +// --------------------------------------------------------------------------------------- + +const ONES = [ + 'zero', + 'one', + 'two', + 'three', + 'four', + 'five', + 'six', + 'seven', + 'eight', + 'nine', + 'ten', + 'eleven', + 'twelve', + 'thirteen', + 'fourteen', + 'fifteen', + 'sixteen', + 'seventeen', + 'eighteen', + 'nineteen', +]; +const TENS = { + twenty: 20, + thirty: 30, + forty: 40, + fifty: 50, + sixty: 60, + seventy: 70, + eighty: 80, + ninety: 90, +}; + +/** The number `token` denotes, or `null` when it is not a numeral at all. */ +export function parseNumeral(token) { + const word = token.toLowerCase(); + if (/^\d+$/.test(word)) return Number(word); + const ones = ONES.indexOf(word); + if (ones !== -1) return ones; + if (Object.hasOwn(TENS, word)) return TENS[word]; + const compound = /^([a-z]+)-([a-z]+)$/.exec(word); + if (compound && Object.hasOwn(TENS, compound[1])) { + const unit = ONES.indexOf(compound[2]); + if (unit > 0 && unit < 10) return TENS[compound[1]] + unit; + } + return null; +} + +/** A digit run or a word that might be a numeral; `parseNumeral` decides which. */ +const NUMBER = String.raw`(\d+|[A-Za-z]+(?:-[A-Za-z]+)?)`; + +function numberedPattern(tail) { + return new RegExp(`${NUMBER}\\s+${tail}`, 'gi'); +} + +/** + * The count claims the two documents make, each anchored on its own subject. + * + * `required` is the point. A count that is merely *checked when found* is not checked at + * all: deleting the sentence passes, and so does rewording it past the pattern. Every row + * here must appear in each document that lists it. + */ +function countClaims(facts) { + return [ + { + id: 'packages', + label: 'packages in the workspace', + actual: facts.packages.length, + pattern: numberedPattern(String.raw`(?:\*\*)?packages\b`), + required: ['CLAUDE.md', 'README.md'], + }, + { + id: 'publishable', + label: 'publishable packages', + actual: facts.publishable.length, + pattern: numberedPattern( + String.raw`(?:(?:is|are)\s+)?publish(?:ed|able)\b`, + ), + required: ['CLAUDE.md', 'README.md'], + }, + { + id: 'private', + label: 'private packages', + actual: facts.privatePackages.length, + pattern: numberedPattern( + String.raw`(?:more\s+)?(?:is|are)\s+(?:\*\*)?\x60?private\b`, + ), + required: ['CLAUDE.md', 'README.md'], + }, + { + id: 'api-reports', + label: 'committed API reports', + actual: facts.apiReports.length, + pattern: numberedPattern(String.raw`committed\s+(?:API\s+)?reports?\b`), + required: ['CLAUDE.md'], + }, + { + id: 'ci-steps', + label: 'named CI steps', + actual: facts.namedSteps.length, + pattern: numberedPattern(String.raw`named\s+(?:CI\s+)?steps?\b`), + required: ['CLAUDE.md', 'README.md'], + }, + { + id: 'ci-jobs', + label: 'CI jobs', + actual: facts.jobs.length, + pattern: numberedPattern(String.raw`jobs?\b`), + required: ['CLAUDE.md', 'README.md'], + }, + ]; +} + +// --------------------------------------------------------------------------------------- +// The repository facts. Derived once; every check below compares a document to THESE. +// --------------------------------------------------------------------------------------- + +function repositoryFacts(ctx) { + const packages = readdirSync(join(ctx.root, 'packages')) + .filter(name => ctx.exists(`packages/${name}/package.json`)) + .map(dir => { + const manifest = JSON.parse(ctx.read(`packages/${dir}/package.json`)); + return { + dir, + name: manifest.name, + private: manifest.private === true, + hasReadme: ctx.exists(`packages/${dir}/README.md`), + readmeBytes: ctx.exists(`packages/${dir}/README.md`) + ? statSync(join(ctx.root, 'packages', dir, 'README.md')).size + : 0, + apiReport: ctx.tracked(`packages/${dir}/etc/*.api.md`), + peersCore: Object.hasOwn( + manifest.peerDependencies ?? {}, + '@dexpace/core', + ), + dependsOnCore: Object.hasOwn( + manifest.dependencies ?? {}, + '@dexpace/core', + ), + }; + }); + + const workflow = ctx.read('.github/workflows/ci.yml'); + // A named step is a `- name:` under `steps:`. Counting `run:` would miss the matrix + // legs and counting `-` would count `uses:` setup steps, which are not gates. + // Jobs are the 2-space keys inside the `jobs:` block only. Counting every 2-space key + // in the file also counts `on:`'s `pull_request:`, which is how this first read 3. + const jobsBlock = workflow.slice(workflow.indexOf('\njobs:\n')); + const jobs = [...jobsBlock.matchAll(/^ {2}([a-z][a-z0-9-]*):$/gm)].map( + m => m[1], + ); + const namedSteps = [...workflow.matchAll(/^ {6}- name: (.+)$/gm)].map(m => + m[1].trim(), + ); + + const scripts = Object.keys( + JSON.parse(ctx.read('package.json')).scripts ?? {}, + ); + + // Tracked, so an editor swap file or an untracked scratch directory in `docs/` cannot + // manufacture an `act` finding against every document that "omits" it. + const docsEntries = [ + ...new Set(ctx.tracked('docs/*.md', 'docs/**').map(f => f.split('/')[1])), + ].sort(); + + return { + packages, + publishable: packages.filter(p => !p.private), + privatePackages: packages.filter(p => p.private), + apiReports: ctx.tracked('packages/*/etc/*.api.md'), + jobs, + namedSteps, + scripts, + verifyScripts: scripts.filter(s => s.startsWith('verify:')), + docsEntries, + }; +} + +// --------------------------------------------------------------------------------------- +// 1. docs/superpowers/ is an inbox. Anything in it is unfiled. +// --------------------------------------------------------------------------------------- + +function checkInbox(ctx) { + // `present`, not `tracked`: the inbox's NORMAL state is a file `brainstorming` has just + // written and nobody has staged. A tracked-only sweep reports the empty tree the skill + // exists to notice. + const stray = ctx + .present('docs/superpowers/**') + .filter(f => posix.basename(f) !== 'README.md'); + for (const file of stray) { + ctx.finding( + 'inbox', + 'act', + `${file} is still in the inbox. It belongs under docs/work//phaseN/ — ` + + 'see the collection rules in docs/README.md.', + ); + } +} + +// --------------------------------------------------------------------------------------- +// 2. Documents at the repository root that belong under docs/. +// --------------------------------------------------------------------------------------- + +const ROOT_MARKDOWN_ALLOWED = new Set([ + 'README.md', + 'CLAUDE.md', + // #58's scope; permitted the moment they exist. + 'CONTRIBUTING.md', + 'CODE_OF_CONDUCT.md', + 'SECURITY.md', + 'CHANGELOG.md', + 'LICENSE.md', +]); + +function checkRootDocuments(ctx) { + // `git ls-files -- '*.md'` matches at any depth: a pathspec glob crosses `/`. The root + // is what this check is about, so filter to files with no directory component. + for (const file of ctx.tracked('*.md').filter(f => !f.includes('/'))) { + if (ROOT_MARKDOWN_ALLOWED.has(file)) continue; + ctx.finding( + 'root', + 'act', + `${file} sits at the repository root. A register belongs in docs/, a phase record ` + + 'under docs/work/. The root carries README.md, CLAUDE.md and the community-health ' + + 'files only.', + ); + } +} + +// --------------------------------------------------------------------------------------- +// 3+4. Claims in CLAUDE.md, README.md and the community-health files, against the facts. +// --------------------------------------------------------------------------------------- + +/** + * `CONTRIBUTING.md` and `SECURITY.md` make the same class of ungated claim about this + * repository — the CI step count, the gate list, the package facts. They do not exist on + * this branch (issue #58 adds them), so each is checked only if present. + */ +const CLAIM_DOCUMENTS = [ + 'CLAUDE.md', + 'README.md', + 'CONTRIBUTING.md', + 'SECURITY.md', +]; + +/** + * The documents that must name every package, for the same reason `required` exists on a + * count claim: these two carry the workspace table, so a package missing from them is drift. + * + * `CONTRIBUTING.md` and `SECURITY.md` are deliberately not here. Neither enumerates the + * workspace — `CONTRIBUTING.md` states the shape once and points at `CLAUDE.md` for the + * table, and `SECURITY.md` names only the packages that carry a security surface. Requiring + * the full list of them would report eighteen findings against two files that are correct, + * which is how a checker teaches people to ignore it. + */ +const PACKAGE_ROSTER_DOCUMENTS = ['CLAUDE.md', 'README.md']; + +/** + * The prose a document asserts in its own voice. + * + * Fenced code is not prose, and a double-quoted span is reported speech: `CLAUDE.md`'s + * documentation-upkeep section quotes the historical drift it fixed — `"two published + * packages" against eleven` — which is a description of a past claim, not a present one. + * Matching inside either turns a document that explains its own history into a document + * that fails its own check. + */ +function assertedProse(text) { + return text.replace(/^```[\s\S]*?^```$/gm, '').replace(/"[^"]*"/g, ' '); +} + +function checkClaims(ctx, facts) { + const claims = countClaims(facts); + + for (const doc of CLAIM_DOCUMENTS) { + if (!ctx.exists(doc)) continue; + const text = ctx.read(doc); + const prose = assertedProse(text); + + for (const pkg of PACKAGE_ROSTER_DOCUMENTS.includes(doc) ? facts.packages : []) { + if (!text.includes(pkg.name)) { + ctx.finding( + 'claims', + pkg.private ? 'note' : 'act', + `${doc} never names ${pkg.name}${pkg.private ? ' (private)' : ''}. ` + + `The workspace has ${String(facts.packages.length)} packages: ` + + `${String(facts.publishable.length)} publishable, ` + + `${String(facts.privatePackages.length)} private.`, + ); + } + } + + for (const claim of claims) { + // Every document that states a count is checked; only the documents in `required` + // must state it. `CONTRIBUTING.md` need not carry a package count — but if it does, + // being wrong is the same defect it is anywhere else. + const required = claim.required.includes(doc); + let stated = 0; + for (const match of prose.matchAll(claim.pattern)) { + const value = parseNumeral(match[1]); + if (value === null) continue; // an adjective, not a numeral + stated++; + if (value !== claim.actual) { + ctx.finding( + 'claims', + 'act', + `${doc} states "${match[0].trim()}" but the repository has ` + + `${String(claim.actual)} ${claim.label}.`, + ); + } + } + if (stated === 0 && required) { + ctx.finding( + 'claims', + 'act', + `${doc} states no count of ${claim.label} (${String(claim.actual)}). ` + + 'A count that is only checked when it happens to be found is not checked: ' + + 'deleting or rewording the sentence passes.', + ); + } + } + + // Only enforced on a document that claims to enumerate the gates. README.md delegates + // to CLAUDE.md and the preflight command by design, so listing them there is optional — + // but a document that lists SOME must list all, which is exactly how `verify:sse-37` + // went unmentioned for four phases. + // Two or more is a list; one is a citation. README.md naming `verify:seam-1` once as + // an example of the zero-dependency rule is not a claim to enumerate the gates. + const mentioned = facts.verifyScripts.filter(s => text.includes(s)).length; + if (doc === 'CLAUDE.md' || mentioned >= 2) { + for (const script of facts.verifyScripts) { + if (!text.includes(script)) { + ctx.finding( + 'claims', + 'act', + `${doc} lists verification gates but not \`${script}\`, which is blocking in ` + + '.github/workflows/ci.yml.', + ); + } + } + } + + if (doc === 'CLAUDE.md') { + for (const entry of facts.docsEntries) { + if (!text.includes(`docs/${entry}`)) { + ctx.finding( + 'claims', + 'note', + `CLAUDE.md's documentation map omits docs/${entry}.`, + ); + } + } + } + } + + // docs/README.md is the index; every entry in docs/ must appear in it. + const index = ctx.read('docs/README.md'); + for (const entry of facts.docsEntries) { + if (!index.includes(entry)) { + ctx.finding( + 'claims', + 'act', + `docs/README.md does not list docs/${entry}.`, + ); + } + } +} + +// --------------------------------------------------------------------------------------- +// 5. A README on every publishable package. Private ones are exempt. +// --------------------------------------------------------------------------------------- + +const README_THIN_BYTES = 800; + +function checkPackageReadmes(ctx, facts) { + for (const pkg of facts.publishable) { + if (!pkg.hasReadme) { + ctx.finding( + 'readmes', + 'act', + `packages/${pkg.dir}/README.md is missing. The harvested styleguide requires one on ` + + 'every publishable package (docs/knowledge/harvested/documentation.md:28).', + ); + continue; + } + if (pkg.readmeBytes < README_THIN_BYTES) { + ctx.finding( + 'readmes', + 'note', + `packages/${pkg.dir}/README.md is ${String(pkg.readmeBytes)} bytes. The bar is ` + + 'zero to one working call in about 30 seconds, without reading source ' + + '(documentation.md:28-30).', + ); + } + if (pkg.dependsOnCore) { + ctx.finding( + 'readmes', + 'act', + `packages/${pkg.dir} declares @dexpace/core as a dependency. It must be a peer ` + + '(SEAM-1, the dual-package hazard).', + ); + } + } +} + +// --------------------------------------------------------------------------------------- +// 6. Broken relative links in docs/, CLAUDE.md, README.md and the package READMEs. +// --------------------------------------------------------------------------------------- + +const LINK = /\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g; + +function linkedFiles(ctx) { + // BOTH `docs/` globs, through a Set. Git's pathspec `**/` does not match zero + // directories, so `docs/**/*.md` alone silently drops every file at the TOP of `docs/` — + // the index this skill ships and all three registers, 67 relative links unchecked. Git's + // `*` does cross `/`, so `docs/*.md` alone happens to cover both; listing the pair and + // deduping says which coverage is intended instead of resting on that subtlety, and the + // Set is what stops the overlap reporting one broken link twice. + return [ + ...new Set([ + ...ctx.tracked('docs/*.md', 'docs/**/*.md'), + ...ctx.tracked('*.md').filter(f => !f.includes('/')), + ...ctx.tracked('packages/*/README.md'), + ]), + ]; +} + +function checkLinks(ctx) { + for (const file of linkedFiles(ctx)) { + const text = ctx.read(file); + // A regex literal inside a fenced block is not a link. + const parts = text.split(/(^```[\s\S]*?^```$)/m); + for (let i = 0; i < parts.length; i += 2) { + for (const match of parts[i].matchAll(LINK)) { + const raw = match[1]; + if (/^(https?:|mailto:|#)/.test(raw)) continue; + const target = raw.split('#')[0]; + if (target === '') continue; + const resolved = posix.normalize( + posix.join(posix.dirname(file), decodeURIComponent(target)), + ); + if (!ctx.exists(resolved)) { + ctx.finding( + 'links', + 'act', + `${file} links ${raw}, which resolves to a path that does not exist.`, + ); + } + } + } + } +} + +// --------------------------------------------------------------------------------------- +// 7. Register text that landed in a specification document instead of a register. +// --------------------------------------------------------------------------------------- + +const SPEC_TREES = ['docs/work/**/*.md', 'docs/sdk-documentation/*.md']; +const AGGREGATE_HEADINGS = [ + /^##\s+Open Findings\b/m, + /^##\s+Deferred Items Log\s*$/m, + /^##\s+Open Items\s*$/m, +]; + +function checkRegisterLeakage(ctx) { + for (const file of ctx.tracked(...SPEC_TREES)) { + const text = ctx.read(file); + for (const heading of AGGREGATE_HEADINGS) { + const match = heading.exec(text); + if (!match) continue; + // The roadmap keeps pointer stubs under these names; a stub is a paragraph, not a table. + const after = text.slice(match.index, match.index + 600); + if (/^\*\*Moved out on /m.test(after)) continue; + const line = text.slice(0, match.index).split('\n').length; + ctx.finding( + 'registers', + 'act', + `${file}:${String(line)} carries "${match[0].trim()}". An aggregate register belongs ` + + "at the docs/ root — open-items.md, deferred-items.md or deviations.md. A phase's " + + 'own dated `## Deferred Items` section stays in place; the aggregate does not.', + ); + } + } +} + +// --------------------------------------------------------------------------------------- +// 8. Every `open-items.md ` citation resolves to a real item. +// --------------------------------------------------------------------------------------- + +const CITATION = /open-items\.md`?[ ]*(?:§)?\s*([A-Z]\d+)/g; + +function citedFiles(ctx) { + return ctx + .tracked( + 'packages/**', + 'tests/**', + 'scripts/**', + 'docs/**', + '*.md', + '.claude/**', + ) + .filter(f => /\.(md|mts|ts|mjs|js)$/.test(f)) + .filter(f => !f.startsWith('.changeset/')); // frozen release history +} + +/** + * Every register citation in the repository, with where it sits. + * + * Exported because three documents used to state three different, all-wrong counts of it. + * There is one derivation, and `--only=citations` prints it. + */ +export function registerCitations(ctx) { + const register = ctx.read('docs/open-items.md'); + const ids = new Set( + [...register.matchAll(/^### ([A-Z]\d+)\b/gm)].map(m => m[1]), + ); + const sites = []; + for (const file of citedFiles(ctx)) { + const text = ctx.read(file); + for (const match of text.matchAll(CITATION)) { + sites.push({ + file, + line: text.slice(0, match.index).split('\n').length, + id: match[1], + resolves: ids.has(match[1]), + }); + } + } + return {ids, sites}; +} + +function checkRegisterCitations(ctx) { + const {sites} = registerCitations(ctx); + for (const site of sites.filter(s => !s.resolves)) { + ctx.finding( + 'citations', + 'act', + `${site.file}:${String(site.line)} cites docs/open-items.md ${site.id}, which has no ` + + '`### ` heading. Item IDs are permanent; a dangling one means the citation, ' + + 'not the register, is wrong.', + ); + } +} + +// --------------------------------------------------------------------------------------- +// 9. The frozen guard is intact, and nothing the skill may write is frozen. +// --------------------------------------------------------------------------------------- + +const WRITABLE_SURFACE = [ + 'docs/README.md', + 'docs/open-items.md', + 'docs/deferred-items.md', + 'docs/deviations.md', + 'docs/sdk-documentation', + 'docs/work', + 'docs/superpowers', + 'CLAUDE.md', + 'README.md', +]; + +function checkGuard(ctx) { + for (const path of WRITABLE_SURFACE) { + if (isFrozen(path, ctx.root)) { + ctx.finding( + 'guard', + 'act', + `${path} is on the writable surface AND matches a frozen entry.`, + ); + } + } + for (const path of [ + 'docs/product-spec/04.md', + 'docs/knowledge/notes/x.md', + 'docs/sdk-design-nodejs.md', + ]) { + if (!isFrozen(path, ctx.root)) { + ctx.finding( + 'guard', + 'act', + `the guard does not refuse ${path}. Run guard.test.mjs.`, + ); + } + } +} + +// --------------------------------------------------------------------------------------- + +const CHECKS = { + inbox: checkInbox, + root: checkRootDocuments, + claims: checkClaims, + readmes: checkPackageReadmes, + links: checkLinks, + registers: checkRegisterLeakage, + citations: checkRegisterCitations, + guard: checkGuard, +}; + +export const CHECK_NAMES = Object.freeze(Object.keys(CHECKS)); + +export function probe(only, root = resolveRepoRoot()) { + const ctx = createContext(root); + const facts = repositoryFacts(ctx); + for (const name of only ?? CHECK_NAMES) { + const check = CHECKS[name]; + if (check === undefined) throw new Error(`unknown check '${name}'`); + check(ctx, facts); + } + return {facts, findings: ctx.findings, ctx}; +} + +function main(argv) { + const strict = argv.includes('--strict'); + const onlyArg = argv.find(a => a.startsWith('--only=')); + const only = onlyArg?.slice('--only='.length).split(','); + const rootArg = argv.find(a => a.startsWith('--root=')); + + const { + facts, + findings: found, + ctx, + } = probe(only, rootArg?.slice('--root='.length) ?? resolveRepoRoot()); + + process.stdout.write('housekeeping probe — read-only\n\n'); + process.stdout.write( + `repository: ${String(facts.packages.length)} packages ` + + `(${String(facts.publishable.length)} publishable, ${String(facts.privatePackages.length)} private), ` + + `${String(facts.apiReports.length)} API reports, ` + + `${String(facts.namedSteps.length)} named CI steps across ${String(facts.jobs.length)} jobs, ` + + `${String(facts.scripts.length)} package scripts\n`, + ); + + if (only?.includes('citations')) { + const {ids, sites} = registerCitations(ctx); + const outside = sites.filter(s => s.file !== 'docs/open-items.md'); + const core = sites.filter(s => s.file.startsWith('packages/core/src/')); + process.stdout.write( + `citations: ${String(sites.length)} total, ${String(outside.length)} outside the ` + + `register, ${String(core.length)} in packages/core/src/, ` + + `${String(new Set(sites.map(s => s.id)).size)} distinct IDs against ` + + `${String(ids.size)} items\n`, + ); + } + process.stdout.write('\n'); + + if (found.length === 0) { + process.stdout.write('no drift found.\n'); + return 0; + } + + const byCheck = new Map(); + for (const f of found) { + if (!byCheck.has(f.check)) byCheck.set(f.check, []); + byCheck.get(f.check).push(f); + } + for (const [check, items] of byCheck) { + process.stdout.write(`## ${check} (${String(items.length)})\n`); + for (const item of items) { + process.stdout.write(` [${item.severity}] ${item.message}\n`); + } + process.stdout.write('\n'); + } + process.stdout.write( + `${String(found.length)} finding(s). This stage writes nothing — read them, then run ` + + 'apply.mjs for the mechanical ones.\n', + ); + return strict ? 1 : 0; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + process.exitCode = main(process.argv.slice(2)); +} diff --git a/.claude/skills/housekeeping/probe.test.mjs b/.claude/skills/housekeeping/probe.test.mjs new file mode 100644 index 0000000..070aa8e --- /dev/null +++ b/.claude/skills/housekeeping/probe.test.mjs @@ -0,0 +1,646 @@ +// SPDX-License-Identifier: MIT +// .claude/skills/housekeeping/probe.test.mjs +// +// Tests the CHECKS, not a copy of their logic, and not the live tree's cleanliness. +// +// The first version of this file asserted only that each check returned no findings +// against the real repository. That passes just as happily over a check whose body has +// become `return;` — proved by mutation: seven of the eight were replaced with `return;` +// and the suite stayed 29/29 green. `scripts/verify-seam-1.test.mjs:6`, +// `verify-knowledge-structure.test.mjs:4` and `verify-test-partition.test.mjs:4` each +// reached the same conclusion earlier in this repository and build fixture trees instead. +// So does this. +// +// Every check therefore has a pair: a fixture that must be clean, and a mutation of it +// that must fire. The live-tree assertions stay at the end, because they are still what +// says the repository is in order today. + +import assert from 'node:assert/strict'; +import {test} from 'node:test'; +import {execFileSync} from 'node:child_process'; +import {dirname} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {CHECK_NAMES, parseNumeral, probe, registerCitations} from './probe.mjs'; +import {CLEAN_CLAIMS, makeFixture, removeFixture} from './fixture.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = execFileSync('git', ['rev-parse', '--show-toplevel'], { + cwd: HERE, + encoding: 'utf8', +}).trim(); + +/** Runs `checks` over a fixture built from `spec` and returns its findings. */ +function onFixture(spec, checks) { + const root = makeFixture(spec); + try { + return probe(checks, root).findings; + } finally { + removeFixture(root); + } +} + +function messages(findings) { + return findings.map(f => f.message); +} + +// --- numerals --------------------------------------------------------------------------- + +test('parseNumeral reads digits, words and compounds, and rejects prose', () => { + assert.equal(parseNumeral('0'), 0); + assert.equal(parseNumeral('20'), 20); + assert.equal(parseNumeral('nine'), 9); + assert.equal(parseNumeral('Eleven'), 11); + assert.equal(parseNumeral('Twenty'), 20); + assert.equal(parseNumeral('twenty-four'), 24); + assert.equal(parseNumeral('ninety-nine'), 99); + // The words that made the digits-only version protect one sentence in the repository. + assert.equal(parseNumeral('published'), null); + assert.equal(parseNumeral('several'), null); + assert.equal(parseNumeral('twenty-zero'), null); +}); + +// --- claims ----------------------------------------------------------------------------- + +test('a clean fixture reports nothing', () => { + assert.deepEqual(messages(onFixture({}, undefined)), []); +}); + +test('claims: a count stated as a WORD and wrong is caught', () => { + // The exact drift SKILL.md names as this tool's reason for existing. + const found = onFixture( + { + overrides: { + 'CLAUDE.md': `# CLAUDE.md\n\n${CLEAN_CLAIMS}\n\nSeven packages, actually.\n\ndocs/README.md docs/open-items.md docs/work docs/sdk-documentation docs/superpowers\n`, + }, + }, + ['claims'], + ); + assert.ok( + found.some(f => + /states "Seven packages" but the repository has 2/.test(f.message), + ), + `expected a word-count finding, got: ${JSON.stringify(messages(found))}`, + ); +}); + +test('claims: a count stated as a DIGIT and wrong is caught', () => { + const found = onFixture( + { + overrides: { + 'README.md': `# fixture\n\n${CLEAN_CLAIMS.replace('Three named steps', '7 named steps')}\n`, + }, + }, + ['claims'], + ); + assert.ok( + found.some(f => + /states "7 named steps" but the repository has 3 named CI steps/.test( + f.message, + ), + ), + JSON.stringify(messages(found)), + ); +}); + +test('claims: a DELETED count claim is caught — presence is asserted', () => { + const found = onFixture( + { + overrides: { + 'CLAUDE.md': `# CLAUDE.md\n\n${CLEAN_CLAIMS.replace('Three named steps across two jobs.', 'Some steps across two jobs.')}\n\ndocs/README.md docs/open-items.md docs/work docs/sdk-documentation docs/superpowers\n`, + }, + }, + ['claims'], + ); + assert.ok( + found.some(f => + /CLAUDE\.md states no count of named CI steps \(3\)/.test(f.message), + ), + JSON.stringify(messages(found)), + ); +}); + +test('claims: a quoted historical count is reported speech, not a claim', () => { + // CLAUDE.md's own documentation-upkeep section quotes the drift it fixed. + const found = onFixture( + { + overrides: { + 'CLAUDE.md': `# CLAUDE.md\n\n${CLEAN_CLAIMS}\n\nIt used to say "two published packages" and that was wrong.\n\ndocs/README.md docs/open-items.md docs/work docs/sdk-documentation docs/superpowers\n`, + }, + }, + ['claims'], + ); + assert.deepEqual(messages(found), []); +}); + +test("claims: a community-health file's counts are checked when it exists", () => { + // #58 adds CONTRIBUTING.md and SECURITY.md; they make the same class of claim about this + // repository. Neither must STATE a count — but a count they do state is checked. + const found = onFixture( + { + overrides: { + 'CONTRIBUTING.md': + '# contributing\n\n`@dexpace/thing` and `@dexpace/secret`. Seven named steps across two jobs.\n', + }, + }, + ['claims'], + ); + assert.ok( + found.some(f => + /^CONTRIBUTING\.md states "Seven named steps"/.test(f.message), + ), + JSON.stringify(messages(found)), + ); + assert.ok( + !found.some(f => /CONTRIBUTING\.md states no count/.test(f.message)), + 'a community-health file must not be REQUIRED to carry a count', + ); +}); + +test("claims: a community-health file is not required to NAME every package", () => { + // The roster lives in CLAUDE.md and README.md. CONTRIBUTING.md states the shape once and + // points at CLAUDE.md for the table; SECURITY.md names only the packages that carry a + // security surface. Requiring the full list of either reported eighteen findings against + // two correct files, which is how a checker teaches people to ignore it. + const found = onFixture( + { + overrides: { + 'CONTRIBUTING.md': '# contributing\n\nTwo packages, one of them published.\n', + 'SECURITY.md': '# security\n\nReport privately.\n', + }, + }, + ['claims'], + ); + assert.ok( + !found.some(f => /CONTRIBUTING\.md never names|SECURITY\.md never names/.test(f.message)), + JSON.stringify(messages(found)), + ); +}); + +test('claims: an absent community-health file fires nothing', () => { + // Neither exists on this branch. The check must not report on a file that is not there. + const found = onFixture({}, ['claims']); + assert.ok(!found.some(f => /CONTRIBUTING\.md|SECURITY\.md/.test(f.message))); +}); + +test('claims: an unnamed package is caught', () => { + const found = onFixture( + { + overrides: { + 'README.md': `# fixture\n\n${CLEAN_CLAIMS.replace('`@dexpace/thing` and ', '')}\n`, + }, + }, + ['claims'], + ); + assert.ok( + found.some(f => /README\.md never names @dexpace\/thing/.test(f.message)), + JSON.stringify(messages(found)), + ); +}); + +test('claims: a gate list that omits a blocking gate is caught', () => { + const found = onFixture( + { + overrides: { + 'package.json': `${JSON.stringify( + { + name: 'fixture', + private: true, + scripts: { + 'verify:seam-1': 'true', + 'verify:brand-new': 'true', + test: 'true', + }, + }, + null, + 2, + )}\n`, + }, + }, + ['claims'], + ); + assert.ok( + found.some(f => + /CLAUDE\.md lists verification gates but not `verify:brand-new`/.test( + f.message, + ), + ), + JSON.stringify(messages(found)), + ); +}); + +test('claims: docs/README.md omitting an entry is caught', () => { + const found = onFixture( + { + overrides: { + 'docs/README.md': '# docs\n\nEntries: README.md, open-items.md.\n', + }, + }, + ['claims'], + ); + assert.ok( + found.some(f => + /docs\/README\.md does not list docs\/work/.test(f.message), + ), + JSON.stringify(messages(found)), + ); +}); + +test('claims: an UNTRACKED entry in docs/ does not manufacture findings', () => { + // Derived from `git ls-files`, so an editor swap file cannot fire four `act` findings. + const found = onFixture( + { + untracked: { + 'docs/.notes.md.swp': 'x', + 'docs/validation-prompts/a.md': '# scratch\n', + }, + }, + ['claims'], + ); + assert.deepEqual(messages(found), []); +}); + +// --- inbox ------------------------------------------------------------------------------ + +test('inbox: an UNTRACKED phase document is caught', () => { + // The inbox's normal state: `brainstorming` has just written a file and nobody staged it. + const found = onFixture( + { + untracked: { + 'docs/superpowers/specs/2026-09-01-phase11-thing-design.md': + '# design\n', + }, + }, + ['inbox'], + ); + assert.ok( + found.some(f => + /phase11-thing-design\.md is still in the inbox/.test(f.message), + ), + JSON.stringify(messages(found)), + ); +}); + +test('inbox: a tracked phase document is caught too', () => { + const found = onFixture( + { + overrides: { + 'docs/superpowers/plans/2026-09-01-phase11-thing.md': '# plan\n', + }, + }, + ['inbox'], + ); + assert.equal(found.length, 1, JSON.stringify(messages(found))); +}); + +test('inbox: the inbox README is never reported', () => { + assert.deepEqual(messages(onFixture({}, ['inbox'])), []); +}); + +// --- root ------------------------------------------------------------------------------- + +test('root: a stray register at the repository root is caught', () => { + const found = onFixture( + {overrides: {'open-items.md': '# a second register\n'}}, + ['root'], + ); + assert.ok( + found.some(f => + /^open-items\.md sits at the repository root/.test(f.message), + ), + JSON.stringify(messages(found)), + ); +}); + +test('root: the allowed root files are not reported', () => { + const found = onFixture( + { + overrides: { + 'CONTRIBUTING.md': '# contributing\n', + 'SECURITY.md': '# security\n', + }, + }, + ['root'], + ); + assert.deepEqual(messages(found), []); +}); + +// --- readmes ---------------------------------------------------------------------------- + +test('readmes: a publishable package with no README is caught', () => { + const found = onFixture({overrides: {'packages/thing/README.md': null}}, [ + 'readmes', + ]); + assert.ok( + found.some(f => /packages\/thing\/README\.md is missing/.test(f.message)), + JSON.stringify(messages(found)), + ); +}); + +test('readmes: a thin README is a note, not an act', () => { + const found = onFixture( + {overrides: {'packages/thing/README.md': '# thing\n'}}, + ['readmes'], + ); + assert.equal(found.length, 1); + assert.equal(found[0].severity, 'note'); + assert.match(found[0].message, /bytes\. The bar is/); +}); + +test('readmes: core declared as a dependency rather than a peer is caught', () => { + const found = onFixture( + { + overrides: { + 'packages/thing/package.json': `${JSON.stringify( + {name: '@dexpace/thing', dependencies: {'@dexpace/core': '*'}}, + null, + 2, + )}\n`, + }, + }, + ['readmes'], + ); + assert.ok( + found.some(f => /declares @dexpace\/core as a dependency/.test(f.message)), + JSON.stringify(messages(found)), + ); +}); + +test('readmes: a private package needs no README', () => { + assert.deepEqual(messages(onFixture({}, ['readmes'])), []); +}); + +// --- links ------------------------------------------------------------------------------ + +test('links: a broken link in a nested docs file is caught', () => { + const found = onFixture( + { + overrides: { + 'docs/sdk-documentation/architecture.md': + '# a\n\n[gone](./nowhere.md)\n', + }, + }, + ['links'], + ); + assert.ok( + found.some(f => /architecture\.md links \.\/nowhere\.md/.test(f.message)), + JSON.stringify(messages(found)), + ); +}); + +test('links: a broken link at the TOP of docs/ is caught', () => { + // `docs/**/*.md` alone misses every file here — git's `**/` does not match zero + // directories — which left the index and all three registers unchecked. + const found = onFixture( + { + overrides: { + 'docs/open-items.md': + '# Open Items\n\n### A1 — x — **WATCH**\n\n[gone](./nowhere.md)\n', + }, + }, + ['links'], + ); + assert.ok( + found.some(f => + /^docs\/open-items\.md links \.\/nowhere\.md/.test(f.message), + ), + JSON.stringify(messages(found)), + ); +}); + +test('links: a broken link in a package README is caught', () => { + const found = onFixture( + { + overrides: { + 'packages/thing/README.md': `# thing\n\n[gone](./etc/nope.md)\n${'x '.repeat(500)}`, + }, + }, + ['links'], + ); + assert.ok( + found.some(f => + /packages\/thing\/README\.md links \.\/etc\/nope\.md/.test(f.message), + ), + JSON.stringify(messages(found)), + ); +}); + +test('links: a link inside a fenced block is not a link', () => { + const found = onFixture( + { + overrides: { + 'docs/sdk-documentation/architecture.md': + '# a\n\n```js\nconst LINK = /\\[[^\\]]*\\]\\(([^)]+)\\)/g;\n```\n', + }, + }, + ['links'], + ); + assert.deepEqual(messages(found), []); +}); + +test('links: an external or anchor-only link is skipped', () => { + const found = onFixture( + { + overrides: { + 'docs/sdk-documentation/architecture.md': + '# a\n\n[x](https://example.invalid/nope) [y](#section) [z](mailto:a@b.invalid)\n', + }, + }, + ['links'], + ); + assert.deepEqual(messages(found), []); +}); + +// --- registers -------------------------------------------------------------------------- + +test('registers: an aggregate register in a specification document is caught', () => { + const found = onFixture( + { + overrides: { + 'docs/work/mvp/phase1/2026-01-01-phase1-thing.md': + '# phase 1\n\n## Deferred Items Log\n\n| Item |\n|---|\n| a |\n', + }, + }, + ['registers'], + ); + assert.ok( + found.some(f => /carries "## Deferred Items Log"/.test(f.message)), + JSON.stringify(messages(found)), + ); +}); + +test('registers: a "Moved out on" pointer stub is not a register', () => { + const found = onFixture( + { + overrides: { + 'docs/work/mvp/phase1/2026-01-01-phase1-thing.md': + '# phase 1\n\n## Deferred Items Log\n\n**Moved out on 2026-08-31.** See docs/deferred-items.md.\n', + }, + }, + ['registers'], + ); + assert.deepEqual(messages(found), []); +}); + +// --- citations -------------------------------------------------------------------------- + +// Assembled at run time, never written as one literal. The citation check scans +// `.claude/**`, so a contiguous `open-items.md ` in THIS file is a citation the live +// tree sees — and a deliberately-dangling one would make the suite fail on itself. +const REGISTER = 'open-items.md'; +const cite = id => `See \`docs/${REGISTER}\` ${id} for the rest.`; + +test('citations: a dangling register citation is caught', () => { + const found = onFixture( + { + overrides: { + 'docs/sdk-documentation/architecture.md': `# a\n\n${cite('Z9')}\n`, + }, + }, + ['citations'], + ); + assert.ok( + found.some(f => + /cites docs\/open-items\.md Z9, which has no/.test(f.message), + ), + JSON.stringify(messages(found)), + ); +}); + +test('citations: a resolving citation is not reported', () => { + const found = onFixture( + { + overrides: { + 'docs/sdk-documentation/architecture.md': `# a\n\n${cite('A1')}\n`, + }, + }, + ['citations'], + ); + assert.deepEqual(messages(found), []); +}); + +test('registerCitations is the single derivation, and reports where each site is', () => { + const root = makeFixture({ + overrides: { + 'docs/sdk-documentation/architecture.md': `# a\n\n${cite('A1')}\n`, + }, + }); + try { + const {ctx} = probe([], root); + const {ids, sites} = registerCitations(ctx); + assert.deepEqual([...ids], ['A1']); + assert.equal(sites.length, 1); + assert.deepEqual(sites[0], { + file: 'docs/sdk-documentation/architecture.md', + line: 3, + id: 'A1', + resolves: true, + }); + } finally { + removeFixture(root); + } +}); + +// --- non-ASCII paths -------------------------------------------------------------------- + +test('a non-ASCII filename does not take the run down', () => { + // `git ls-files` C-quotes it by default, and a quoted path fed to readFileSync is an + // ENOENT that replaces every finding with a raw stack. + const found = onFixture( + { + overrides: { + 'docs/sdk-documentation/café.md': '# café\n\n[gone](./nowhere.md)\n', + }, + }, + ['links'], + ); + assert.ok( + found.some(f => /café\.md links \.\/nowhere\.md/.test(f.message)), + JSON.stringify(messages(found)), + ); +}); + +// --- plumbing --------------------------------------------------------------------------- + +test('every check name is selectable, and an unknown one is refused', () => { + const root = makeFixture(); + try { + for (const name of CHECK_NAMES) { + assert.doesNotThrow(() => probe([name], root), name); + } + assert.throws(() => probe(['nope'], root), /unknown check 'nope'/); + } finally { + removeFixture(root); + } +}); + +test('CHECK_NAMES is the eight the documentation states', () => { + assert.deepEqual( + [...CHECK_NAMES], + [ + 'inbox', + 'root', + 'claims', + 'readmes', + 'links', + 'registers', + 'citations', + 'guard', + ], + ); +}); + +// --- the live tree ---------------------------------------------------------------------- + +function gitStatus() { + return execFileSync('git', ['status', '--porcelain'], { + cwd: ROOT, + encoding: 'utf8', + }); +} + +test('the probe writes nothing', () => { + const before = gitStatus(); + probe(); + assert.equal( + gitStatus(), + before, + 'the working tree changed during a probe run', + ); +}); + +test('facts are derived from the repository, not from a document', () => { + const {facts} = probe([]); + assert.ok(facts.packages.length >= 2, 'found no packages'); + assert.equal( + facts.packages.length, + facts.publishable.length + facts.privatePackages.length, + 'every package is publishable or private, never both or neither', + ); + assert.ok(facts.publishable.every(p => p.name.startsWith('@dexpace/'))); + assert.ok(facts.namedSteps.length > 0, 'parsed no named CI steps'); + assert.ok(facts.jobs.length >= 1, 'parsed no CI jobs'); + assert.ok( + facts.jobs.every(j => !j.includes(' ')), + 'a parsed job name looks like prose, so the jobs: block regex has drifted', + ); + assert.ok(facts.scripts.includes('test'), 'parsed no package scripts'); + assert.ok(facts.verifyScripts.every(s => s.startsWith('verify:'))); + assert.ok(facts.docsEntries.includes('README.md'), 'docs/ has no index'); +}); + +test('every finding names a check and a severity the report can group by', () => { + for (const f of probe().findings) { + assert.ok(CHECK_NAMES.includes(f.check), `bad check: ${f.check}`); + assert.ok( + ['act', 'note'].includes(f.severity), + `bad severity: ${f.severity}`, + ); + assert.ok(f.message.length > 20, 'a finding must say what to do'); + } +}); + +test('the live tree is clean on every check', () => { + for (const name of CHECK_NAMES) { + assert.deepEqual(messages(probe([name]).findings), [], name); + } +}); diff --git a/.claude/skills/knowledge-lookup/SKILL.md b/.claude/skills/knowledge-lookup/SKILL.md index 91d373f..218b4e3 100644 --- a/.claude/skills/knowledge-lookup/SKILL.md +++ b/.claude/skills/knowledge-lookup/SKILL.md @@ -1,6 +1,6 @@ --- name: knowledge-lookup -description: Use when starting a phase or a numbered task from a docs/superpowers/plans/ file, implementing or reviewing against a requirement ID (HTTP-7, SEAM-1, RETRY-13, NFR-5), resolving a styleguide citation such as "styleguide 6.7" or "ch08", auditing a subsystem against every rule the corpus holds for it, or recording what an implementation found in docs/knowledge/ — which is a note under notes/, never an edit to harvested/. +description: Use when starting a phase or a numbered task from a docs/work/mvp/ plan file, implementing or reviewing against a requirement ID (HTTP-7, SEAM-1, RETRY-13, NFR-5), resolving a styleguide citation such as "styleguide 6.7" or "ch08", auditing a subsystem against every rule the corpus holds for it, or recording what an implementation found in docs/knowledge/ — which is a note under notes/, never an edit to harvested/. --- # Knowledge Lookup @@ -182,7 +182,7 @@ implementation found, and it wins. ## Superseded - **What we found**, superseding `pagination/81881061`. … - review · `docs/superpowers/specs/2026-07-28-phase6c-pagination-design.md` · high · sha:manual-6c-erratum + review · `docs/work/mvp/phase6/phase6c/2026-07-28-phase6c-pagination-design.md` · high · sha:manual-6c-erratum ``` Which section: `## Superseded` when following the harvested rule would cause damage, diff --git a/.gitignore b/.gitignore index 4a4078f..fab9800 100644 --- a/.gitignore +++ b/.gitignore @@ -115,3 +115,8 @@ dist # api-extractor scratch output (the committed report lives in packages/*/etc/) packages/*/temp/ + +# Scratch directory the housekeeping skill's fence check writes and removes +# (.claude/skills/housekeeping/check-fences.mjs). Present only mid-run; ignored so an +# interrupted run cannot leave an untracked tree. +.housekeeping-fences/ diff --git a/CLAUDE.md b/CLAUDE.md index 81102b2..b149048 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,10 +8,29 @@ A Node.js/TypeScript HTTP SDK platform, built as a **port of a language-agnostic spec in `docs/product-spec/` is normative and numbered; the code exists to satisfy it. Work here is spec-driven, not feature-driven: before implementing anything, find the requirement IDs it must satisfy. -Bun workspace. Two published packages today — `@dexpace/core` (`packages/core`) and the reference wire codec -`@dexpace/codec-json` (`packages/codec-json`, a `@dexpace/core` **peer**, never a dependency) — with more -planned per `docs/sdk-design-nodejs/02-package-and-workspace-layout.md`. Every gate below runs over all of -them, not over core alone. +Bun workspace, **eleven packages**. Nine are published; two are `private` and exist only to serve the build. +Every gate below runs over all of them, not over core alone — `verify:seam-1` in particular asserts zero +runtime dependencies for each, which is `NFR-2`'s "core plus at most one external library per optional +capability". + +| Package | Provides | External dependencies | +|---|---|---| +| `@dexpace/core` | Models, pipeline, seams, resilience pillars, SSE, pagination, configuration, observability | **none** | +| `@dexpace/transport-fetch` | `fetchTransport()` over the runtime's global `fetch` | none | +| `@dexpace/transport-undici` | `undiciTransport()` — pools, proxies, real `close()` | `undici` | +| `@dexpace/transport-shared` | `@internal` plumbing both transports need identically | none | +| `@dexpace/codec-json` | `jsonSerde()` — the reference wire codec | none | +| `@dexpace/body-file` | `fileBody()` over `node:fs` | none | +| `@dexpace/logging-pino` | `createPinoLogger()` | `pino` (optional peer) | +| `@dexpace/logging-debug` | `createDebugLogger()` | `debug` (optional peer) | +| `@dexpace/rx` | `Observable` views of SSE and pagination | `rxjs` (peer) | +| `@dexpace/shrink-test` | **private.** Proves the published bundles survive minify + tree-shake (`NFR-9`) | — | +| `@dexpace/transport-conformance` | **private.** The shared `TRANSPORT-N` suite both transports run | — | + +**`@dexpace/core` is a peer of every other package, never a dependency.** Two copies of core in one install +defeat the branded symbols and identity checks the seams rely on — the dual-package hazard — and +`verify:seam-1` is what enforces it. The layout is `docs/sdk-design-nodejs/02-package-and-workspace-layout.md`; +what each package is *for*, and how they compose, is `docs/sdk-documentation/architecture.md`. ## Commands @@ -104,15 +123,18 @@ bun test -t 'rejects blank input' # filter by test name bun test ./tests/conformance/xcut # a tests/ path needs the ./ prefix ``` -API surface — one committed report per package (`packages/core/etc/core.api.md`, -`packages/codec-json/etc/codec-json.api.md`): +API surface — **nine committed reports**, one per publishable package, at `packages//etc/.api.md`. +The two private packages have none: `api-extractor` runs only where something is published. ```bash cd packages/core && bun run api:local # regenerate that package's report after changing its exports -cd packages/codec-json && bun run api:local -bun run api # verify BOTH match — this is what CI runs +bun run api # verify all NINE match — this is what CI runs ``` +`bun run api` chains `api:ci` across `core`, `codec-json`, `logging-pino`, `logging-debug`, `body-file`, +`transport-shared`, `transport-fetch`, `transport-undici` and `rx`, in that order. A new publishable package +adds itself to that chain and to `lint:publish`. + Release-shape and invariant gates: ```bash @@ -126,13 +148,24 @@ bun run verify:sse-37 # no serde dependency and no reconnect path in bun run verify:runtime-floor # tsconfig target vs package engines.node consistency bun run verify:test-partition # the five files that keep tests/ and tests/node-conformance/ apart bun run verify:knowledge-structure # docs/knowledge/'s two trees stay separate (see below) -bun run verify:reproducible-build # two clean builds of one source tree agree, dist/ and tarball (NFR-12) +bun run verify:reproducible-build # two clean builds of one source tree agree, dist/ and tarball (NFR-12); + # in CI it runs after every step that resolves a package through dist/, + # because it sweeps and rebuilds them all bun run test:scripts # the gates' OWN tests (node --test scripts/*.test.mjs) bun run audit # bun audit --audit-level=high --prod ``` -**Every one of these is a blocking CI step** (`.github/workflows/ci.yml`). Run the full set before claiming -work is done — `bun run test` passing is not sufficient evidence. +**Every one of these is a blocking CI step.** `.github/workflows/ci.yml` is **20 named steps across two +jobs** — 17 in `ci`, 3 in the `node-conformance` matrix that runs after it. Run the full set before claiming +work is done; `bun run test` passing is not sufficient evidence, and the one command that runs all twenty in +CI's order is: + +```bash +node .claude/skills/ci-preflight/run-ci.mjs --clean +``` + +The per-step reasoning, including why `verify:reproducible-build` must run last and why `test:scripts` is +blocking at all, is `docs/sdk-documentation/quality-gates.md`. `test:scripts` tests the *gates themselves* — the knowledge CLI, `verify-seam-1.mjs`, `verify-sse-37.mjs`, `verify-knowledge-structure.mjs`, `verify-test-partition.mjs`. Phase 10 made it a blocking CI step, closing @@ -180,15 +213,45 @@ passes. The gate checks this too. ## Documentation hierarchy -Five distinct trees, easy to confuse — `docs/knowledge/` being two of them: +`docs/README.md` is the index and the contract; this is the working summary. Every entry in `docs/` is below, +because the one that used to be omitted — `open-items.md` — is the largest file in the tree. + +| Path | Role | Writable? | +|---|---|---| +| `docs/product-spec/` + `docs/product-spec.md` | **Normative.** Numbered requirements (`HTTP-7`, `SEAM-1`, `RETRY-13`, `NFR-5`, …). The source of truth; the `.md` is its table of contents. | **frozen** | +| `docs/sdk-design-nodejs/` + `docs/sdk-design-nodejs.md` | How each spec area maps to idiomatic TypeScript. Non-normative but binding by convention. §10 is the **normative deviation ledger**. | **frozen** | +| `docs/knowledge/harvested/` | Harvested styleguide + spec knowledge, topic-indexed (`INDEX.md`). Cited as "styleguide 6.7", "ch08". Generated; never hand-edited. | **frozen** | +| `docs/knowledge/notes/` | What the implementation found, hand-written, role `review`. Overrides a harvested entry. | **frozen** | +| `docs/sdk-documentation/` | **As-built.** How the packages compose, which one to install, worked cross-package examples. Eleven files; `architecture.md` is the front door. | yes | +| `docs/work//phaseN/` | Per-phase design doc, implementation plan and requirement-coverage checklist. `mvp/` is the only delivery so far. | yes | +| `docs/superpowers/` | The **inbox** the `brainstorming` and `writing-plans` skills hard-code. Drained into `docs/work/`; never a citation target. | yes | +| `docs/open-items.md` | **Register.** Everything unmet, unverified, misreported or surprising. Sections A–U; letters and item numbers are permanent. | yes | +| `docs/deferred-items.md` | **Register.** Work a phase decided not to do yet, with the phase that owns it. | yes | +| `docs/deviations.md` | **Register.** The as-built audit of §10, and where a deviation found outside a phase lands. | yes | +| `docs/assets/` | Vendored wordmark SVGs the root `README.md` renders. | yes | + +**Frozen means a maintenance tool refuses to write there**, not merely that you should not. The +`housekeeping` skill's guard (`.claude/skills/housekeeping/guard.mjs`) enforces it and `guard.test.mjs` +proves it; the per-tree reasons are in `docs/README.md`. + +**Which register.** The boundary is *when* an item was created. A **deferral** is a decision made before the +work ("not this phase, that one") → `deferred-items.md`. An **open item** is a discovery made after ("this is +not what the checklist says it is") → `open-items.md`. A **deviation** goes in the owning phase spec's own +`## Deviation Ledger` section, is consolidated into §10, and is audited by `deviations.md` — which is also +where a deviation with no owning phase lands, since §10 sits in a frozen tree. + +**Never renumber `open-items.md`.** Its item IDs are cited from source comments, tests, changesets and the +`docs/` tree — `docs/open-items.md K11` at `packages/core/src/index.ts:248`, `H12` at `seams/index.ts:11`, and +so on. A new review appends the next letter; nothing is ever renumbered or reused. + +**Do not write the number of them into a document.** Three documents once stated three different, all-wrong +counts (`docs/open-items.md` U10). One command derives it, from the same regex and file set the check uses: -| Path | Role | -|---|---| -| `docs/product-spec/` | **Normative.** Numbered requirements (`HTTP-7`, `SEAM-1`, `RETRY-13`, `NFR-5`, …). The source of truth. | -| `docs/sdk-design-nodejs/` | How each spec area maps to idiomatic TypeScript. Non-normative but binding by convention. | -| `docs/knowledge/harvested/` | Harvested styleguide + spec knowledge, topic-indexed (`INDEX.md`). Cited as "styleguide 6.7", "ch08". Generated; never hand-edited. | -| `docs/knowledge/notes/` | What the implementation found, hand-written, role `review`. Overrides a harvested entry. | -| `docs/superpowers/specs/` + `plans/` | Per-phase design doc, task-by-task implementation plan, and a requirement-coverage checklist. | +```bash +node .claude/skills/housekeeping/probe.mjs --only=citations +``` + +That is also the check that every citation still resolves. U6 records what it found the first time it ran. `docs/product-spec/appendix-c-consolidated-normative-requirement-index.md` is the fastest way to locate a requirement ID. @@ -243,9 +306,10 @@ it. Read them directly; `notes/deliberate-deviations.md` is the pointer. When yo at the harvested tree — `--corpus docs/knowledge/harvested` — and hand-move any `supersede` entry it emits into `notes/`. -**Citations into the corpus** are written `docs/knowledge/harvested/.md:`. `docs/superpowers/` -is the exception: its plans and specs are dated records of what was true when they were written, are never -retro-edited, and so still carry pre-split paths. +**Citations into the corpus** are written `docs/knowledge/harvested/.md:`. `docs/work/` is the +exception: its phase designs, plans and checklists are dated records of what was true when they were written, +are never retro-edited, and so still carry pre-split paths — 207 of them, across 33 files +(`docs/open-items.md` O3). ## Requirement-ID conventions (enforced by review, not tooling) @@ -314,14 +378,14 @@ class on operations that throw. `api-extractor` will otherwise flag it, and the Consumer-facing changes need a changeset — `bun run changeset`, not `bunx changeset`. The wrapper (`scripts/changeset.mjs`) forwards every argument to the CLI, then renames the file it generates from -`@changesets/write`'s random `human-id` name to `YYYY-MM-DD-.md`, matching -`docs/superpowers/{specs,plans}`. The slug is prompted for, defaulting to the changeset's own first +`@changesets/write`'s random `human-id` name to `YYYY-MM-DD-.md`, the same name shape every document +under `docs/work/mvp/` carries. The slug is prompted for, defaulting to the changeset's own first sentence. Nothing reads the filename back — the CLI globs `.changeset/*.md` and decides from the frontmatter — so a hand-written changeset just needs to be named the same way. ## Phase workflow -Work proceeds phase by phase against `docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md`. Each +Work proceeds phase by phase against `docs/work/mvp/2026-07-23-nodejs-sdk-v1-roadmap-design.md`. Each phase has a design spec, an implementation plan with numbered tasks (TDD: write the failing test, confirm it fails, implement, confirm it passes, commit), and a checklist mapping every requirement ID to the task that satisfies it. When asked to implement or validate a phase, read all three before touching code. @@ -329,3 +393,33 @@ satisfies it. When asked to implement or validate a phase, read all three before Starting a numbered task means starting with what the corpus already knows about its requirement IDs — invoke the `knowledge-lookup` skill, which carries both entry points (ID-first via appendix C, topic-first for the styleguide-derived areas that carry no IDs). + +**A phase's documents are written into `docs/superpowers/` and do not stay there.** The Superpowers +`brainstorming` and `writing-plans` skills hard-code `docs/superpowers/{specs,plans}/` +(`brainstorming/SKILL.md:100`, `writing-plans/SKILL.md:18`); they are installed globally, shared across +projects, and this repository cannot change them. So that directory is an inbox, and the `housekeeping` skill +collects from it into `docs/work//phaseN/`. Cite the `docs/work/` path — the one the document will +have for the rest of its life — never the staging path. + +## Documentation upkeep + +Nothing gates `CLAUDE.md` or `README.md`, and both had drifted for nine phases before 2026-08-31: "two +published packages" against eleven, two API reports against nine, a gate list missing `verify:sse-37`, a +documentation table missing the largest file in the tree. The `housekeeping` skill is the check. + +```bash +node .claude/skills/housekeeping/probe.mjs # read-only. Eight checks. Always first. +node .claude/skills/housekeeping/apply.mjs # dry run; --write performs the git mv calls +bun run build && node .claude/skills/housekeeping/check-fences.mjs # typecheck every doc code fence +``` + +It derives each repository fact once — the package list, the `verify:*` gates, the named CI steps, the API +reports, the `docs/` tree — and checks every document that states it against that one derivation. It also +finds phase documents left in the inbox, Markdown stranded at the repository root, a publishable package +with no README, a broken relative link, an aggregate register left in a specification document, and a +`docs/open-items.md` citation that resolves to nothing. + +It is a **hand-run** tool, not a CI step. Run it after landing a phase, and before claiming the documentation +is current. Its apply stage moves files; the prose it reports on is edited by you. It refuses to write to +`docs/knowledge/`, `docs/product-spec/`, `docs/sdk-design-nodejs/` or the two sibling tables of contents, and +that refusal is a tested guard rather than a stated intention. diff --git a/README.md b/README.md index 638b778..7c071e4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,274 @@ -# nodejs-sdk -NodeJS SDK paltform by dexpace +

+ + + dexpace + +

+ +

Dexpace Node.js SDK

+ +[![CI](https://github.com/dexpace/nodejs-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/dexpace/nodejs-sdk/actions/workflows/ci.yml) +[![Node >=20.3](https://img.shields.io/badge/node-%3E%3D20.3-blue.svg)](https://nodejs.org/) +[![TypeScript strict](https://img.shields.io/badge/typescript-strict-blue.svg)](https://www.typescriptlang.org/tsconfig#strict) +[![Lint: gts](https://img.shields.io/badge/lint-gts-blue.svg)](https://github.com/google/gts) +![License: MIT](https://img.shields.io/badge/license-MIT-green.svg) + +A toolkit for building Node.js HTTP client libraries. It provides immutable request and response +models, a staged policy pipeline, pluggable transports, and an authentication pillar that speaks +OAuth bearer tokens and RFC 7616 Digest. Everything is typed end to end under `strict` plus +type-aware lint, ships ESM only, and targets Node 20.3 or later. + +The SDK is deliberately not an HTTP client. It defines the contracts — `Transport`, `Serde`, +`PaginationStrategy`, `Logger` — and supplies the models, policies and observability hooks that +surround them; the networking itself arrives through a transport package of your choosing. Pick the +adapter that fits your dependency budget, or write your own: the interface is two methods. + +## Packages + +A Bun workspace of eleven packages. Nine are published; `@dexpace/core` is a **peer** of every one of +the others, never a dependency, so a consumer can never end up with two copies of it. + +| Package | Provides | Third-party dependencies | +|---|---|---| +| `@dexpace/core` | Models, pipeline, seams, resilience pillars, SSE, pagination, configuration, observability | **none** | +| `@dexpace/transport-fetch` | `fetchTransport()` over the runtime's global `fetch` | none | +| `@dexpace/transport-undici` | `undiciTransport()` — connection pools, proxies, real `close()` | `undici` | +| `@dexpace/transport-shared` | Plumbing both transports need identically; not installed directly | none | +| `@dexpace/codec-json` | `jsonSerde()` — the reference wire codec, PATCH tri-state included | none | +| `@dexpace/body-file` | `fileBody()` — a file-backed request body over `node:fs` | none | +| `@dexpace/logging-pino` | `createPinoLogger()` | `pino` (optional peer) | +| `@dexpace/logging-debug` | `createDebugLogger()` | `debug` (optional peer) | +| `@dexpace/rx` | `Observable` views of SSE and pagination | `rxjs` (peer) | + +Two more are `private` and never published: `@dexpace/shrink-test`, which proves the published +bundles survive minify and tree-shake, and `@dexpace/transport-conformance`, the shared `TRANSPORT-N` +suite both transports run so they cannot drift apart. + +Install the core plus whichever transport you need: + +```sh +bun add @dexpace/core @dexpace/transport-fetch +``` + +## Quick start + +### A minimal request + +```typescript +import {Request} from '@dexpace/core'; +import {fetchTransport} from '@dexpace/transport-fetch'; + +const transport = fetchTransport(); + +const response = await transport.send( + Request.newBuilder().url('https://httpbin.org/get').build(), +); +try { + console.log(response.status.code, await response.text()); +} finally { + await response.close(); // the caller owns the body, always +} +``` + +### A POST with a JSON body + +```typescript +import {Request, serdeBody} from '@dexpace/core'; +import {jsonSerde} from '@dexpace/codec-json'; + +const request = Request.newBuilder() + .method('POST') + .url('https://httpbin.org/post') + .body(serdeBody({hello: 'world'}, jsonSerde())) // sets Content-Type: application/json + .build(); +``` + +### A configured pipeline + +`standardResilience()` returns a `Runtime` pre-wired with all four pillars in the order `AUTH-27` +requires — redirect wraps retry wraps auth — so a retry re-resolves credentials and a redirect hop +re-stamps them. Every slot is optional; an omitted one takes that pillar's own defaults. + +```typescript +import { + createAuthDescriptor, + createAuthRequirement, + createBearerToken, + standardResilience, +} from '@dexpace/core'; +import {undiciTransport} from '@dexpace/transport-undici'; + +declare function mintToken(): Promise; + +const client = standardResilience(undiciTransport({agentOptions: {connections: 32}}), { + retry: {settings: {maxAttempts: 5, totalTimeoutMs: 30_000}}, + redirect: {maxHops: 3}, + auth: { + credentials: { + bearer: {provider: async () => createBearerToken(await mintToken()), marginMs: 60_000}, + }, + tiers: {client: createAuthDescriptor([createAuthRequirement('OAUTH2')])}, + }, +}); +``` + +`PipelineBuilder` enforces stage ordering and the one-step-per-pillar rule, and supports surgical +edits anchored on a step's `type` symbol: `insertBefore`, `insertAfter`, `replace`, `remove`. +`PipelineBuilder.seedFrom(runtime, 'flatten' | 'nest')` layers your own steps onto the preset. + +### Streaming and replayable bodies + +```typescript +import {byteArrayBody, materialize, streamBody, stringBody} from '@dexpace/core'; +import {fileBody} from '@dexpace/body-file'; + +declare const stream: ReadableStream; + +byteArrayBody(new Uint8Array([1, 2, 3])); // replayable +stringBody('{"hello":"world"}', 'application/json'); // replayable +fileBody('upload.bin', {start: 0, count: 4096}); // replayable; fresh handle per send +const once = streamBody(stream); // single-use: a retry cannot re-send it + +const many = await materialize(once); // buffer it once, deliberately, to make it retryable +``` + +Buffering an arbitrarily large upload to make it retryable is a decision for the caller who knows how +large it is, not for the retry engine — so a retryable body arrives at the retry pillar already +retryable. + +## Architecture + +A request flows down through ordered `Step`s and back up through their post-processing. The terminal +stage hands it to a `Transport`. + +``` +caller → Runtime ──┬─ PRE_REDIRECT · REDIRECT · POST_REDIRECT + ├─ PRE_RETRY · RETRY · POST_RETRY + ├─ PRE_AUTH · AUTH · POST_AUTH + ├─ PRE_LOGGING · LOGGING · POST_LOGGING + ├─ PRE_SERDE · SERDE · POST_SERDE + └─ SEND → Transport → wire +``` + +Sixteen stages in `STAGE_ORDER`. Five of them — `REDIRECT`, `RETRY`, `AUTH`, `LOGGING`, `SERDE` — are +**pillars**: each admits exactly one step and raises on a second. The `PRE_`/`POST_` stages around +them stack, and are the user-extensible slots. + +`Runtime` implements `Transport`, so a pipeline is substitutable wherever a transport is — which is +what makes nesting, `seedFrom`, and driving a `Paginator` over a full pipeline work. + +Bottom-up, the layers are: + +1. **Bodies.** A request `Body` is a producer: `writeTo(sink)` emits on demand, `replayable` decides + whether a retry may re-send. A response body is a `ReadableStream` the **caller** owns and closes. +2. **Models.** `Request`, `Response`, `Headers`, `QueryParams`, `RequestOptions` and + `RequestConditions` are frozen at construction and reachable only through a builder, so validation + cannot be routed around and behaviour is identical under every transport. +3. **Context.** `DispatchContext` promotes to `RequestContext` then `ExchangeContext`, carrying one + `InstrumentationBundle` throughout; propagation is `AsyncLocalStorage`-based. +4. **Pipeline.** `Step`, `Next`, `StepContext`, `StepDescriptor`, `PipelineBuilder`, `Runtime`. +5. **Transport.** `send()` and `close()`. That is the whole contract. + +## Inside `@dexpace/core` + +| Module | Surface | +|---|---| +| `http/` | `Request`, `Response`, `Headers`, `HeaderName`, `Status`, `Protocol`, `MediaType`, `ETag`, `HttpRange`, `QueryParams`, `RequestOptions`, `RequestConditions` | +| `body/` | `byteArrayBody`, `stringBody`, `formUrlEncodedBody`, `multipartBody`, `streamBody`, `serdeBody`, `materialize`, `TypedResponse`, `HttpStatusError`, `toHttpError` | +| `pipeline/` | `Stage`, `STAGE_ORDER`, `PILLAR_STAGES`, `Step`, `Next`, `StepContext`, `StepDescriptor`, `PipelineBuilder`, `Runtime` | +| `retry/` | `retryStep`, `RetrySettings`, `BackoffSettings` — exponential backoff with jitter, `Retry-After` awareness, injectable `Clock`/`random` | +| `redirect/` | `redirectStep`, `RedirectSettings`, `RedirectPredicate` — loop detection, hop cap, downgrade guard, credential stripping | +| `auth/` | `authStep`, `standardResilience`, `createAuthDescriptor`, `createAuthRequirement`, `ApiKeyCredential`, `NameKeyCredential`, `BearerToken`, RFC 7235 challenges, RFC 7616 Digest | +| `serde/` | `Serde`, `Serializer`, `Deserializer`, `Schema`, `Tristate`, `decodeResponse`, `decodeSuccessResponse` | +| `sse/` | `sseStreamFrom`, `SseStream`, `SseEvent`, `typedSseStream` — WHATWG-compliant, bounded line buffer | +| `pagination/` | `Paginator`, `Page`, `PaginationStrategy`, `cursorStrategy`, `pageNumberStrategy`, `linkHeaderStrategy`, `paginateWithFetchers` | +| `config/` | `Configuration`, `ConfigurationBuilder`, `Clock`, `ProxyOptions`, `getBuildInfo`, HTTP-date parsing | +| `observability/` | `Logger`, `createLogger`, `LogEvent`, `Tracer`, `Span`, `Meter`, `loggingStep`, URL redaction, no-op singletons | +| `context/` | `DispatchContext` → `RequestContext` → `ExchangeContext`, `InstrumentationBundle` | +| `seams/` | `Transport`, `Serde`, `OperationDescriptor`, `buildRequest`, `composeSignal`, `isTimeoutSignal` | + +## Highlights + +- **Zero runtime dependencies, and it is a gate.** `@dexpace/core` takes none, and + `bun run verify:seam-1` asserts that for **every** package in the workspace plus the + `@dexpace/core`-as-peer rule that guards the dual-package hazard. +- **Immutable models, no public constructors.** Builders only; the emitted `.d.ts` declares each + constructor `private`, so a consumer cannot construct around `build()`'s validation. Deriving + deep-copies every collection rather than aliasing. +- **Pluggable everything, registered nothing.** `Transport`, `Serde`, `Schema`, + `PaginationStrategy`, `Logger`, `Tracer`, `Meter` and `Clock` are duck-typed — a conforming object + is a valid implementation, with no registry, no discovery and no install step. +- **Retry done right.** Exponential backoff with jitter, server pacing hints (`Retry-After`, + `X-RateLimit-Reset`) in a fixed precedence, an opt-in total-timeout budget, and deterministic tests + through an injectable `Clock`. +- **Redirects done right.** Loop detection, hop cap, `Authorization` stripped across origins, + HTTPS→HTTP downgrade refused by default, and the transport pinned to never follow a hop itself, so + the pipeline is the single redirect authority. +- **Real auth.** OAuth bearer with serialized concurrent refresh, an RFC 7235 `WWW-Authenticate` + parser, RFC 7616 Digest (MD5, MD5-sess, SHA-256, SHA-256-sess), Basic and key credential — with + credentials refused over plaintext and redacted in every `toString` and inspect path. +- **PATCH tri-state.** `Tristate` distinguishes absent, null and present, so `{}` and + `{"x": null}` stop being the same wire message. Wired into `@dexpace/codec-json` by default. +- **Server-Sent Events and pagination.** A WHATWG-compliant SSE parser with a bounded line buffer and + no reconnect path in core (gate-enforced), and a `Paginator` that walks item-by-item or page-by-page + over pluggable strategies. +- **Observability that costs nothing when off.** `NOOP_LOGGER`, `NOOP_TRACER` and `NOOP_METER` are + the defaults; a suppressed event never builds its field map. +- **Proven against Node, not just Bun.** A separate conformance suite runs the built artifact under + `node --test`, as a matrix over the declared floor and current LTS, because Bun's Web Streams and + `AbortSignal` are an independent implementation. + +## Development + +A [Bun](https://bun.sh) workspace, pinned by `.bun-version` (1.3.14). One install provisions every +package. + +```bash +git clone https://github.com/dexpace/nodejs-sdk.git +cd nodejs-sdk +bun install --frozen-lockfile +``` + +```bash +bun run build # every package's dist/ +bun run typecheck # tsc --noEmit, per package +bun run lint # gts — formatting AND type-aware rules, both fatal +bun run test # both Bun test trees, one coverage report, 80% line floor +bun run test:node # the built artifact under node --test +bun run api # every committed etc/*.api.md matches +``` + +Twenty named CI steps across two jobs, every one blocking +([`.github/workflows/ci.yml`](.github/workflows/ci.yml)). Run all of them locally before claiming +work is done: + +```bash +node .claude/skills/ci-preflight/run-ci.mjs --clean +``` + +`--clean` sweeps every `dist/` and `*.tsbuildinfo` first, so the run starts from the tree CI checks +out rather than a warm one, and pins every step to `.bun-version`'s Bun. Both matter: a transport +suite has passed on one Bun release and failed three ways on the pinned one. + +## Conventions + +The full contract is in [`CLAUDE.md`](CLAUDE.md); the documentation map is +[`docs/README.md`](docs/README.md). The short version: + +- **Spec-driven, not feature-driven.** [`docs/product-spec/`](docs/product-spec/) is normative and + numbered; the code exists to satisfy it. Before implementing anything, find the requirement IDs. +- **ESM only, `NodeNext`.** Relative imports carry `.js` even in `.ts` source; + `verbatimModuleSyntax` is on. No enums, no namespaces, no parameter properties. +- **`#private` fields, private constructors, `Object.freeze(this)`.** Every domain model follows one + construction pattern; deviating breaks invariants no tool catches. +- **Typed errors only.** Everything descends from `DexpaceError`; wrap-and-rethrow always passes + `{cause}`. +- **Lint is type-aware and strict.** 70-line function cap, `max-depth` 3, `max-params` 3, explicit + return types on exported functions. Formatting is an error, not a warning. Every + `eslint-disable` must carry a stated reason. +- **Every gap is recorded.** A deferral goes in [`docs/deferred-items.md`](docs/deferred-items.md), + a finding in [`docs/open-items.md`](docs/open-items.md), a deliberate divergence in the deviation + ledger. Silent gaps are the failure mode this project is structured to prevent. + +As-built documentation — how the packages compose, and worked examples across a package boundary — +is [`docs/sdk-documentation/`](docs/sdk-documentation/). diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..d638079 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,109 @@ +# `docs/` + +Eight trees and three registers, each with one owner and one job — counting `knowledge/`'s +`harvested/` and `notes/` as the two that `CLAUDE.md` treats them as, and `assets/` as one. This file +is the index; the rule is that nothing in `docs/` is unowned, and nothing is written by two things. + +| Entry | Owns | Written by | Housekeeping may write? | +|---|---|---|---| +| [`product-spec/`](./product-spec/) + [`product-spec.md`](./product-spec.md) | **Normative.** The numbered requirements — `HTTP-7`, `SEAM-1`, `RETRY-13`, `NFR-5`, … — that the code exists to satisfy | A human, deliberately | **No — frozen** | +| [`sdk-design-nodejs/`](./sdk-design-nodejs/) + [`sdk-design-nodejs.md`](./sdk-design-nodejs.md) | How each spec area maps to idiomatic TypeScript. Non-normative but binding by convention. §10 is the **normative deviation ledger** | A human, deliberately | **No — frozen** | +| [`knowledge/harvested/`](./knowledge/harvested/) | Harvested styleguide and spec knowledge, topic-indexed. Cited as "styleguide 6.7", "ch08" | The `knowledge-harvest` skill. **Never hand-edited** | **No — frozen** | +| [`knowledge/notes/`](./knowledge/notes/) | What the implementation found, overriding a harvested entry. Role `review`, manual `sha:` | A human | **No — frozen** | +| [`sdk-documentation/`](./sdk-documentation/) | **As-built.** How the packages compose, which one to install, worked cross-package examples | A human, or the skill on request | Yes | +| [`work/`](./work/) | Process records: per-phase design, plan and checklist, one directory per phase under a unit of delivery | The phase that produced them; **collected** here by the skill | Yes — `git mv` only | +| [`superpowers/`](./superpowers/) | Nothing, for long. The **inbox** the Superpowers skills write into | `brainstorming`, `writing-plans` | Yes — it drains it | +| [`open-items.md`](./open-items.md) | Everything unmet, unverified, misreported, or surprising. Sections A–U, letters permanent | Every review | Yes — appends | +| [`deferred-items.md`](./deferred-items.md) | Work a phase decided not to do yet, with the phase that owns it | Every phase's brainstorm | Yes — appends | +| [`deviations.md`](./deviations.md) | The as-built audit of §10, and the landing point for a deviation found outside a phase | An audit or review | Yes — appends | +| [`assets/`](./assets/) | Vendored wordmark SVGs the root `README.md` renders | Copied from `dexpace/morphic` | Yes | + +## Frozen means frozen + +`knowledge/`, `product-spec/`, `sdk-design-nodejs/` and the two sibling tables of contents are +**read-only** to routine maintenance. The `housekeeping` skill refuses to write to them, and that +refusal is a testable guard, not a paragraph of good intent +(`.claude/skills/housekeeping/guard.mjs`, `guard.test.mjs`). + +Each has its own reason: + +- **`product-spec/`** is what the code is measured against. A tool editing the yardstick is a + category error. +- **`sdk-design-nodejs/`** carries §10, the normative deviation ledger, whose numbering + `deviations.md` is keyed to. Amending it is a deliberate act; the audit beside it is where a + maintenance pass writes instead (`open-items.md` U4). +- **`knowledge/harvested/`** cannot absorb a hand edit. Its `` shas digest the whole source + file, not the entry, so an edit inside an entry changes no sha and the next harvest regenerates or + duplicates it silently. Record the finding in `knowledge/notes/` instead. +- **`knowledge/notes/`** is hand-written and could in principle be edited; it is grouped with + `harvested/` because the CLI reads the two as one corpus and a note's key citation couples them. + Whether that grouping is right is an open question (`open-items.md` U1). + +## The three registers, and which one a thing goes in + +The boundary is **when** the item was created, not what it is about. + +- A **deferral** is a decision made *before* the work: "not this phase, that one." → + `deferred-items.md` +- An **open item** is a discovery made *after*: "this is not what the checklist says it is." → + `open-items.md` +- A **deviation** is a place the port differs from the reference contract on purpose. → the owning + phase's `## Deviation Ledger` section, consolidated into §10; `deviations.md` audits §10 and + catches what has no owning phase. + +The same requirement ID can legitimately appear in two. `AUTH-37` is deferred to Phase 7b in +`deferred-items.md` and recorded as a live silent swallow at `open-items.md` G12. + +Register letters and item numbers in `open-items.md` are **permanent**: they are cited across the +repository from source comments, tests, changesets and this tree. A new review appends the next +letter; nothing is ever renumbered or reused. `node .claude/skills/housekeeping/probe.mjs +--only=citations` both counts them and checks that every one still resolves — the count lives in that +command, not in a sentence here, because three documents once carried three different wrong ones +(`open-items.md` U10). + +## `work/` and the inbox + +`docs/work//phaseN/` is the archive. `mvp/` is the only delivery so far and holds every +phase to date; a later effort becomes a sibling. + +``` +work/mvp/ + 2026-07-23-nodejs-sdk-v1-roadmap-design.md # belongs to no phase + 2026-07-25-checkpoint-scaffold-through-phase3a.md + scaffold/ + phase1/ … phase10/ + phase6/2026-07-28-phase6-segmentation-design.md # spans the phase + phase6/phase6a/ phase6b/ phase6c/ # one per sub-phase +``` + +A phase directory is `phaseN`, no hyphen. A phase with sub-phases nests one directory per sub-phase. +A document spanning a whole phase — a segmentation design, a shared checklist — sits at the `phaseN/` +level. Every file keeps its `YYYY-MM-DD-` prefix, which carries ordering the directory name does not. + +New documents do **not** land there directly. The `brainstorming` and `writing-plans` skills hard-code +`docs/superpowers/{specs,plans}/`, they are installed globally, and this repository cannot change +them — so that directory stays as an inbox and the `housekeeping` skill collects from it. See +[`superpowers/README.md`](./superpowers/README.md). + +## Querying the corpus + +`docs/knowledge/` is two trees and 39 topics. Never read a topic file whole when a filtered query +answers the question — a requirement-ID query returns ~170 tokens against a ~5700-token file read. + +```bash +bun run knowledge --req HTTP-13,HTTP-14,HTTP-15 # a whole task's IDs in one call +bun run knowledge --origin note --brief # everything the implementation found +bun run knowledge --topic documentation # the 21 rules the housekeeping skill obeys +bun run knowledge --chapter 6 interface class # a "styleguide 6.7" citation +``` + +`bun run verify:knowledge-structure` keeps the two trees apart and is a blocking CI step. +`bun run knowledge:drift` is the hand-run companion, deliberately not in CI: 16 of the 47 sources are +a sibling styleguide repository no CI checkout has. + +## Keeping this file true + +`.claude/skills/housekeeping/` probes every claim here against the repository — the tree itself, +`CLAUDE.md`'s package and gate counts, `README.md`'s, a README on every publishable package, broken +relative links, and register text that landed in a specification document. It is a hand-run tool, not +a CI step. Run it before claiming the documentation is current. diff --git a/docs/assets/dexpace-wordmark-dark.svg b/docs/assets/dexpace-wordmark-dark.svg new file mode 100644 index 0000000..e3a3c8a --- /dev/null +++ b/docs/assets/dexpace-wordmark-dark.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/docs/assets/dexpace-wordmark-light.svg b/docs/assets/dexpace-wordmark-light.svg new file mode 100644 index 0000000..727bee4 --- /dev/null +++ b/docs/assets/dexpace-wordmark-light.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/docs/deferred-items.md b/docs/deferred-items.md new file mode 100644 index 0000000..48e2027 --- /dev/null +++ b/docs/deferred-items.md @@ -0,0 +1,130 @@ +# Deferred Items + +Every item a phase's design or checklist explicitly pushed to a later phase, consolidated so it is not lost +between a phase's own design, plan and checklist files. One row that is *not* a deferral, included anyway +because it is easy to mistake for one: `SEAM-5`–`SEAM-10` will **never** be built in this port — that is a +permanent simplification, not a postponement. + +**Where this file came from.** It was the `## Deferred Items Log` section of +[`work/mvp/2026-07-23-nodejs-sdk-v1-roadmap-design.md`](./work/mvp/2026-07-23-nodejs-sdk-v1-roadmap-design.md) +until 2026-08-31. The roadmap's own rule (its "How Phases Get Executed" section) is that the document records +phase *status* and nothing else, and it named this log as its one **exception** — an exception that only +existed because there was nowhere else to put a cross-phase register. There is now: this file, beside +`open-items.md` and `deviations.md` at the `docs/` root. The roadmap keeps its dated status notes and links +here. + +## The three registers, and which one an item belongs in + +| Register | Holds | Item is | +|---|---|---| +| `deferred-items.md` (this file) | Work a phase decided **not to do yet**, with the phase that owns it | Scheduled, or explicitly unscheduled with a trigger | +| [`open-items.md`](./open-items.md) | Everything found to be unmet, unverified, misreported, or surprising | A gap between what is claimed and what is built | +| [`deviations.md`](./deviations.md) | The as-built audit of the deviation ledger | A place this port deliberately differs from the reference contract | + +The boundary is *when the item was created*, not what it is about. A deferral is a decision made **before** +the work: "not this phase, that one." An open item is a discovery made **after**: "this is not what the +checklist says it is." The same requirement ID can legitimately appear in both — `AUTH-37` is deferred to +Phase 7b here and recorded as a live silent-swallow at `open-items.md` G12. + +## Maintenance + +Every phase's brainstorming session checks this file for rows targeting it before starting, and appends any +new deferral it produces before the phase is considered done. That is how a decision made in Phase 0 ("we +will handle `NFR-2` properly once adapter packages exist") does not silently evaporate by Phase 8. + +A phase's own deferral section stays in its design or checklist. They are **upstream provenance**, the dated +record of what that phase decided, and this file is the aggregate they feed. Deleting them would delete the +provenance; the rule is that nothing may be *only* in a phase document. + +**Counted 2026-09-01, with the rule stated because three readings give three answers.** Under +`docs/work/mvp/`: **11** headings carry the exact title `## Deferred Items (add to the roadmap's Deferred +Items Log)`; **16** match `^## Deferred Items` at all, adding three bare `## Deferred Items` and one +`## Deferred Items Produced by This Phase` alongside the roadmap's own now-stubbed `## Deferred Items Log`; +and **22** match `^## Deferred`, adding five `## Deferred out of …` variants. The audit below walked the 16. + +```bash +grep -rlE '^## Deferred' docs/work | sort # the widest reading, 22 files +``` + +**The rule was being broken.** The 2026-08-31 audit walked those sections against this table and found +three items that never made it here. All three are appended below, marked as recovered, with the source +section named. Six more had reached `open-items.md` instead of this file, which is the right register for +what they became (`AUTH-37` → G12, `PIPE-40`/`REDIR-22` → G1, `OBS-19`/`OBS-28`/`OBS-29` → Section L) and are +left there. + +| Item | Originated in | Target phase | Note | +|---|---|---|---| +| `NFR-2` — each optional capability a separately installable unit (core + ≤1 external lib) | Phase 0 | **Codec half resolved in Phase 6a** (transport half stays **Phase 8a**) — retargeted 2026-07-28, codec half closed 2026-08-27 | **Closed for the codec half:** `packages/codec-json` ships with `dependencies: {}` hard-committed and zero external libraries, and `scripts/verify-seam-1.mjs` now asserts that for every package under `packages/` rather than for core alone. Originally "Phase 8, no adapter packages exist yet." The Phase 6 segmentation review found the premise false one phase early: `@dexpace/codec-json` is the workspace's first separately installable unit and takes **zero** external libraries — the cleanest instance of the requirement in the whole roadmap. 6a disposes of the codec half; `transport-fetch`/`transport-undici` close the rest in 8a — `transport-fetch` trivially (zero external libs), `transport-undici` with exactly one (`undici`). See the [Phase 8 segmentation design](./work/mvp/phase8/2026-07-28-phase8-segmentation-design.md) | +| `NFR-9` — automated shrink-survival regression guard | Phase 0 | **Resolved in Phase 9 (design)** | Explicitly out of scope per the scaffold design's own "Out of scope" list. Phase 9's design ships `@dexpace/shrink-test` (private, unpublished devDependency): an esbuild bundle/minify/tree-shake step, a dual-package-hazard fixture app, and a child-process round-trip guard wired into the default build as `bun run shrink-test`. Lands when Phase 9's plan executes | +| `NFR-11` — concurrency-model agnosticism, no async-framework type leak | Phase 0 | **Resolved in Phase 4c** | 4c's `Step`/`Next`/`Runtime` public surface is `Promise`-only — no RxJS, no generator, no framework-specific async type appears anywhere in the pipeline layer. Deferral closed | +| `NFR-12` — reproducible, byte-identical builds | Phase 0 | **Closed 2026-08-29** | **Verified, not asserted.** Two clean builds of an identical tree emit 644 byte-identical files, and all 9 publishable packages produce byte-identical `npm pack` tarballs across the same two builds. Now a blocking CI step, `bun run verify:reproducible-build` (`scripts/verify-reproducible-build.mjs`), negative-tested by injecting a `Date.now()` into `gen-version.mjs`. (Widened 2026-08-30: the tarball comparison was a by-hand check of `@dexpace/core` alone when this row was written; it is now a second leg inside the gate, over every publishable package, on both builds.) The "cannot execute without a real build artifact" premise expired once Phases 1–9 shipped code. See Phase 10's reconciled ledger, Item 14 | +| `NFR-13` — SPDX license header per source file | Phase 0 | Phase 1 onward — **written into Phase 1's plan (2026-07-28)** | Soft gap; the spec itself calls this "a review convention, not a mechanical gate". A 2026-07-28 plans review found no phase plan actually carried the convention, so Phase 1's plan now states it in its Global Constraints (`// SPDX-License-Identifier: MIT`, line 1 of every new file, all phases onward) — enforcement stays review-level | +| `NFR-14` — single source of truth for dependency/tool versions (Bun `catalog:`-equivalent) | Phase 0 | **Resolved in Phase 6a** — retargeted 2026-07-28, closed 2026-08-27 | **Closed.** The workspace root's `workspaces.catalog` block is the single source of version truth for `typescript`, `@microsoft/api-extractor`, `expect-type`, and `fast-check`; the root's own `devDependencies` and both member packages reference them as `"catalog:"`, so a bump is a one-line edit. Bun 1.4.0 local / 1.3.14 pinned both support catalogs, so the fallback Task 8 allowed for was not needed. Was: trivially true (one package, zero deps); the row's own text said it "becomes a real decision the moment a second package with its own dependencies exists." That moment is 6a scaffolding `@dexpace/codec-json`, not Phase 8. 6a picks the Bun equivalent of the pnpm `catalog:` protocol `sdk-design-nodejs/02` specifies, confirmed against `styleguide/typescript-bun/` | +| `NFR-15` — self-identifying version metadata (real `User-Agent`, never a placeholder) | Phase 0 | **Resolved in Phase 7a (design)** / **Phase 8a** | 7a's design ships `CFG-36`'s build/runtime descriptor (version via build-time codegen, never a runtime placeholder) and `RECOV-33`'s client-identity step that stamps it into `User-Agent`. Node-transport wiring (the header actually reaching the wire) still waits for 8a's concrete transports — a conformance test confirming `TRANSPORT-11`'s header-drop pass leaves it untouched, not new stamping logic. See the [Phase 8 segmentation design](./work/mvp/phase8/2026-07-28-phase8-segmentation-design.md) | +| `NFR-16` — publish provenance enforced on the release path | Phase 0 | Phase 10 / first real release | Still open — Phase 10's reconciled ledger (`docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md`, Item 14) records the intended verification (run `prepublishOnly` + `npm publish --provenance` for real) but cannot execute it without a real publish. Unblocks at first real release. **Correction 2026-08-29:** only `prepublishOnly` is wired — `--provenance` appears in no `package.json`, workflow, or `.npmrc`, and there is no release workflow at all. Authoring it is actionable now; only exercising it needs a registry | +| `NFR-8` — shrinker keep/retain configuration | Phase 0 | Phase 10 (Deviation Reconciliation) — closed 2026-07-28 | Re-confirmed as not applicable by design in Phase 10's reconciled ledger (`docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md`, Item 10) — this port has no reflection-driven discovery surface to keep-configure. Closed 2026-07-28 | +| Peer-dependency dedup for `@dexpace/core` (dual-package-hazard guard) | Phase 0 | **Resolved in Phase 6a** — retargeted 2026-07-28, closed 2026-08-27 | **Closed.** `@dexpace/codec-json` declares the `@dexpace/core` peer plus its `peerDependenciesMeta` entry; `scripts/verify-seam-1.mjs` asserts both for every non-core package, and `packages/codec-json/src/cross-package.test.ts` proves the consequence rather than the declaration — a `Tristate` constructed in core is recognized by the codec's replacer, because `TRISTATE_BRAND` is a registry-global `Symbol.for`. Mechanism specified in `sdk-design-nodejs/02` §2. `@dexpace/codec-json` is the first package to declare the `@dexpace/core` peer, so the guard installs in 6a. Not theoretical for this package specifically: `sdk-design-nodejs/02` names the `Tristate` discriminant and the `Outcome` sum type as exactly the branded-symbol checks two non-identical copies of core would break — and `Tristate` is 6a's own deliverable | +| `NFR-10`/`NFR-17` residual — CI running the built artifact against the *declared minimum* Node version (18.17), not just whatever the runner defaults to | Phase 0 | **Resolved in Phase 2 (plan)** (pulled forward from Phase 3) | Low-risk while the only export was a trivial `ping()`. Phase 2 is where it stops being trivial: `composeSignal()` calls `AbortSignal.any()`, which landed in **exactly** Node 18.17.0 — the declared floor to the patch version. Phase 2's plan Task 7 ships the `node-floor-conformance` CI job (`actions/setup-node` pinned to 18.17.0 running `scripts/verify-node-floor.mjs`, which forces the `AbortSignal.any()` branch); its checklist marks the row ✅. Lands when Phase 2's plan executes | +| `MultipartBody` model (one of HTTP-3's "each builder-based model" list) | Phase 1 | **Resolved in Phase 3b (design)** | Retargeted from "Phase 3" when Phase 3 split. 3b's design ships `MultipartBody` in full — composite replayability (`BODY-2`), one shared framing routine driving both declared length and written bytes, RFC-2046 boundary generation/validation (`MultipartBoundaryError`), part-header quoting/escaping (`HTTP-51`). Lands when 3b's plan executes | +| `Request`/`Response` real body type (currently `unknown` placeholder) | Phase 1 | **Resolved in Phase 3b (design)** | 3b's design replaces both placeholders — `Request` carries the §6 `Body` model (replayability, consume-once), `Response.body` is a single-use `ReadableStream \| null` (`BODY-14`). Lands when 3b's plan executes | +| `Logger`/`LogEvent` seam | Phase 2 | **Resolved in Phase 7b (design)** | `sdk-design-nodejs/03` §3.5 discusses it inside the seam-mapping doc, but it carries no `SEAM-N` ID — it's an `OBS-*` concern. 7b's design ships the facade, the process-wide global logger slot, and the two bridge packages (`@dexpace/logging-pino`, `@dexpace/logging-debug`). Lands when 7b's plan executes | +| `FakeTransport` test double | Phase 2 | **Resolved in Phase 5a** | Deliberately not built speculatively — 4a and 4b both used file-local stubs instead, and 4c's own brainstorm chose to keep doing so rather than build a shared double for PIPE-9's empty-pipeline case alone. 5a is the phase that finally needs one: scripted multi-response sequences (`503,503,200`), wire-send counting, and per-response close observation. Ships at `packages/core/src/testing/fake-transport.ts` (`@internal`) alongside `countingResponse()`, whose `ReadableStream` `cancel()` hook is the **only** sanctioned way to observe `Response.close()` — instances are `Object.freeze`d, so a spy assignment throws. 5b and 5c consume it unchanged. Deferral closed | +| Phase 4 split into 4a (Execution Context, `§7`) / 4b (recovery-chain primitives, `§8.2`) / 4c (stage-based pipeline, `§8.1`) | Phase 4 brainstorm | — | ~76 combined normative IDs, comparable to Phase 3's ~79 that forced its own 3a/3b split; each sub-phase gets its own brainstorm→spec→plan cycle. Dependency order: 4a first (contexts are the pipeline's own per-call correlation state), then 4b and 4c | +| Phase 5 split into 5a (Retry, `§9`) / 5b (Redirect, `§10`) / 5c (Auth, `§11`) | Phase 5 brainstorm | — | 111 combined normative IDs — the largest single phase in the roadmap, well past the ~76–79 that already forced the Phase 3 and Phase 4 splits. Build order is forced by coupling, not just size: retry is independent of the other two; redirect owns the cross-origin marker `REDIR-11` defines and `AUTH-29` reads, so it must precede auth; the standard-resilience preset needs all three steps installed, so it closes 5c. Each sub-phase gets its own brainstorm→spec→plan cycle | +| Phase 6 split into 6a (Serde, `§14`) / 6b (SSE, `§13`) / 6c (Pagination, `§12`) | Phase 6 brainstorm (2026-07-28) | — | 107 combined normative IDs (`PAGE` 36, `SSE` 41, `SERDE` 30), between the ~76–79 that forced the Phase 3 and Phase 4 splits and Phase 5's 111. Cut along the spec's own section boundaries because **the spec forbids the couplings that would cross them**: `SSE-37` (MUST) bars any serde dependency from core SSE, and `§12`'s preamble declares pagination serde-agnostic — so the cross-segment contract surface is empty by mandate, which is exactly the property whose absence caused the 5b/5c drift below. **No segment depends on another; the 6a→6b→6c order is convenience, not dependency**, and any sub-phase may execute out of order. 6a leads only because it scaffolds the workspace's second package and is the one segment that reshapes an already-published seam (`SEAM-21`); 6c trails because it is the most coupled to *earlier* phases (4c's `Runtime`, 5a's `StepContext.options`, 3b's `Response` body). Full rationale, per-segment ownership, and the collapsed-ID clusters in the [Phase 6 segmentation design](./work/mvp/phase6/2026-07-28-phase6-segmentation-design.md) | +| Collapsed-requirement disposition tables for Phase 6 — `PAGE-25`–`PAGE-33` (§12.9's async engine: this port has one async model, so the async generator *is* the engine), `SSE-18`/`SSE-31` (threading re-expressed against the event loop), `SERDE-8`/`SERDE-21`/`SERDE-22`/`SERDE-25`/`SERDE-26` (codec-engine configuration with no configurable engine to configure) | Phase 6 brainstorm | Each owning sub-phase's design (6c, **Resolved for 6b in Phase 6b design**, 6a) | Same service 5a's `RECOV-17`–`RECOV-34` table performs: without a row-by-row disposition, a naive appendix-B sweep reads ~18 collapsed requirements as uncovered. The segmentation design identifies the clusters and what does **not** collapse inside each — notably `PAGE-26`/`PAGE-27`/`PAGE-32`'s close-exactly-once obligations (re-expressed as `finally`-block obligations on the single generator), `SSE-18` re-expressed against the event loop, and `SSE-31`'s close-during-in-flight-read branch (re-expressed and tested, **not** collapsed), both documented in the [Phase 6b design](./work/mvp/phase6/phase6b/2026-07-28-phase6b-sse-design.md). **Note (Phase 9 design, 2026-07-28):** Phase 9's actual design scopes to `§19`/`§20` (`XCUT`/`NFR`) only — it does not re-verify `PAGE`/`SSE`/`SERDE` disposition, which stays each owning sub-phase's own responsibility as this row already states (6c, 6b, 6a respectively) | +| 3a's `readUtf8Line()` is unusable for SSE (`IO-14` keeps a lone `\r` as content, `SSE-2` requires it to terminate) | Phase 6b | **Resolved in Phase 6b** — closed 2026-08-27 | 6b owns `src/sse/line-reader.ts` instead of reshaping a frozen Phase 3a surface. Recorded so Phase 10's deviation review does not read the duplication as accidental | +| `sdk-design-nodejs/07` §7.1's item-view snippet closes the page *after* yielding its items; `PAGE-11` (MUST) requires closing *before* | Phase 6 brainstorm | **Resolved in Phase 6c** — closed 2026-08-27 | The 2026-07-28 plans review found the erratum was being written into `sdk-design-nodejs/07` only, while `docs/knowledge/pagination.md` carries the *same* wrong ordering in its Reference section directly beside the correct MUST in its Rules section. The knowledge corpus is the standing tie-breaker every later phase consults, so an erratum that skips it leaves the contradiction live; 6c amends both. Resolution per the standing tie-breaker (normative spec + knowledge corpus win over an illustrative snippet): `PAGE-11` governs — copy items, close, *then* yield. Costs nothing, since materialized items survive close per `PAGE-2`. The snippet remains correct about the thing §7.1 is actually arguing (JavaScript's automatic `.return()`-on-abandon), just not about close ordering. **Closed in Phase 6c.** | +| `PAGE-5`'s "strategy MUST read everything it needs from the response **synchronously** inside parse" | Phase 6 brainstorm | **Resolved in Phase 6c** — closed 2026-08-27 | Node has no synchronous body read, so the literal reading is unimplementable and `parse` returns a promise. Every part of the requirement's actual intent survives: single-use-body discipline, no retention of the response or its body past the call, no close, no mutation. Flagged so an async signature does not later read as an oversight or get "fixed" back toward a literal reading. **Closed in Phase 6c.** | +| `SSE-41` — reactive SSE adapter (backpressure-honoring `Observable` view, fatal/non-fatal split, source-ownership documentation) | Phase 6 brainstorm | **Phase 8b** (`@dexpace/rx`) | `MAY`. 6b ships the pull-based `AsyncGenerator` surface `SSE-39` mandates; the reactive view is a bridge package, and the roadmap scopes `§18`'s async-runtime adapters to 8b specifically (not 8a's transports) as of the 2026-07-28 [Phase 8 segmentation design](./work/mvp/phase8/2026-07-28-phase8-segmentation-design.md). `sdk-design-nodejs/02` identifies RxJS's push-based `Observable` as the one async shape in the Node ecosystem worth bridging at all. Is `ASYNC-21` restated — the segmentation design's §5.2 names it 8b's marquee deliverable | +| Appendix C `RECOV-17`–`RECOV-34` reconciliation (18 rows filed under "Recovery-chain pipeline primitives" that `§8.2`'s prose never defines — it stops at `RECOV-16`) | Phase 4 sizing review | **Resolved in Phase 5a** | They are retry-engine requirements stated a second time for the reference's second retry stack. Since this port collapses both stacks into one engine (`RETRY-28`, `sdk-design/06`), 16 of the 18 collapse onto the same implementation as their `§9` twin (e.g. `RECOV-21` restates `RETRY-9`/`10`/`11`'s backoff formula verbatim); `RECOV-34`'s settings-object validation is partially new; `RECOV-32` and `RECOV-33` have **no** `§9` twin and are genuinely new work. The full row-by-row mapping table lives in the [Phase 5a design](./work/mvp/phase5/phase5a/2026-07-26-phase5a-retry-design.md) — a naive appendix-B sweep should read it rather than re-deriving it, or it will read 18 requirements as uncovered. **Note (Phase 9 design, 2026-07-28):** `RECOV-*` is outside Phase 9's actual `XCUT`/`NFR`-scoped design; its disposition stays 5a's own responsibility per the table this row already points to | +| Real W3C Trace Context generation (trace-id/span-id byte generation, hex encoding, `traceparent`/`tracestate` parsing) — `InstrumentationBundle`'s actual tracing backend | Phase 4a | **Resolved in Phase 7b (design)** | 4a ships only `CTX-14`'s bundle shape and `CTX-15`'s no-op default. 7b's design generates real W3C/Datadog/no-op trace and span ids via `globalThis.crypto.getRandomValues` and lets a caller-supplied `tracerFactory` flow into `InstrumentationBundle` at pipeline-build time, without changing its already-frozen shape. Lands when 7b's plan executes | +| `contextsEqual()` value-equality utility for `ExecutionContext` | Phase 4a | Not scheduled — build only if 4b or 4c turns out to need one | `CTX-6` describes a consequence of key uniqueness, not a mandate for a new equality API; no consumer identified yet, so not built speculatively (same discipline as the original `FakeTransport` deferral) | +| `PIPE-35` — FLATTEN-vs-NEST seeding of a builder from an existing pipeline | Phase 4c | **Resolved in Phase 5c (design)** | Placed under `§8.1`'s "Bridges." heading but **not** bridge machinery — a builder capability independent of the sync/async collapse that disposes `PIPE-31`–`PIPE-34`. Deferred because 4c is the phase that first makes a pipeline constructible at all, so no caller yet holds one to seed from; the MUST clause ("make the choice explicit, never accidental") is vacuously satisfied while no seeding path exists. 5c's design ships `PipelineBuilder.seedFrom(runtime, 'flatten' \| 'nest')`, an explicit, non-defaulted mode argument. Deferral closed at design level; implementation lands when 5c's plan executes | +| `PIPE-2`'s redirect/retry conformance clause and `PIPE-40`'s 2-hop-redirect conformance clause | Phase 4c | `PIPE-40` → **Resolved in Phase 5b (design)**; `PIPE-2` → **Resolved in Phase 5c (design)** | 4c ships pipeline plumbing and zero pillar steps, so neither clause is testable there. `PIPE-40` is a contract on wrapping steps, closed by 5b's own two-hop `FakeTransport` test (wire-send count, per-hop close, final-response-open). `PIPE-2`'s stage-ordering half *is* covered in 4c; only the "auth step re-runs per redirect hop" half needed both a redirect step and an auth step — 5c's design specifies the per-hop re-run and adds the joint conformance test (auth step, "Closing `PIPE-2`'s remaining half and `AUTH-29`, jointly with 5b") | +| `PIPE-24`/`PIPE-39` — the standard-resilience preset (and `PIPE-24`'s "installs into empty slots only" clause) | Phase 4c | **Resolved in Phase 5c (design)** | 4c dispositioned both as "no preset shipped, revisit when one exists." A preset needs all three pillar steps installed, so it cannot land before auth. 5c's design ships `standardResilience()`, installing exactly the three pillars that exist by then (redirect, retry, auth) — `LOGGING` stays empty until Phase 7b ships a real logging step (**resolved in Phase 7b's design**, which amends `standardResilience()` to install it), a documented scope boundary, not a re-deferral | +| `PIPE-36` — a shipped pillar family locks its stage assignment | Phase 4c | **Resolved in Phase 5a** | 4c deferred it to "whichever future phase ships the first real pillar step family." That is 5a, and it is satisfied structurally: `retryStep()` is a factory returning a `StepDescriptor` with `stage: 'RETRY'` baked in — steps are functions carrying a descriptor, not classes with a subclassable stage assignment, so there is nothing to relocate. Deferral closed | +| Public-barrel promotion of the pillar-step authoring surface (`retryStep`, `StepDescriptor`, `Stage`, `PipelineBuilder`, `Runtime`) | Phase 4c, re-confirmed in Phase 5a | **Resolved in Phase 5c (design)** | 4c left "whether SDK callers ever author custom steps against a public surface" to "whichever phase first ships a pillar step." 5a answers: not yet. A caller cannot assemble a working pipeline until 5c's preset exists, and publishing `retryStep` alone would freeze shapes 5c may still reshape. 5c's design promotes `Stage`/`STAGE_ORDER`/`PILLAR_STAGES`/`StepDescriptor`/`StepContext`/`Next`/`PipelineBuilder`/`Runtime`/`retryStep`/`redirectStep`/`authStep`/`standardResilience`; everything else under `auth/` stays `@internal`. `packages/core/etc/core.api.md`'s diff at 5c's plan-writing time is the mechanical proof | +| `RETRY-29` — opt-in server-driven retry-classification override header | Phase 5a brainstorm | Not scheduled | `MAY`. Lets a response header force or suppress the retry classification. Widens the classifier's input surface to server-controlled values, which is a trust decision deserving its own deliberation rather than a default. No caller identified | +| `RECOV-33` — client-identity header step (Append/Replace token composition, blank-line suppression) | Phase 5a brainstorm | **Resolved in Phase 7a (design)** | One of only two appendix-C `RECOV-17`–`RECOV-34` rows with no `§9` `RETRY-*` twin (the other, `RECOV-32`'s idempotency key, shipped in 5a because retry preserves it per `RETRY-38`). Pure configuration-driven header composition with zero retry coupling, so it travels with `CFG-*` in 7a, ships as `clientIdentityStep()` consuming `CFG-36`'s build/runtime descriptor, and closes `NFR-15` alongside it. Lands when 7a's plan executes | +| `StepContext.signal` **and** `StepContext.options` — exposing the call's `AbortSignal` and per-call `RequestOptions` to steps | Phase 5a brainstorm (`signal`); 2026-07-28 plans review (`options`) | **Phase 5a, Task 1** | Found during 5a's spec self-review: 4c's `Cursor` accepts and threads a `signal` but `StepContext` never exposed it, so no step could observe cancellation — `RETRY-26`'s cancellable wait and `RETRY-32`'s "no attempts after cancellation" were both unimplementable. A 2026-07-28 review found the identical gap for `options`: `Cursor` threads them to terminal dispatch but `PIPE-17`'s "readable by any step" MUST was unsatisfied, and with it `RETRY-41`'s per-call override (`RequestOptions.maxRetries`, `HTTP-35`'s "0 disables retries for this call") had no wire — Phase 1 designed the knob, nothing read it. Both fields land as one additive amendment in 5a Task 1; 5a Task 9 wires the retry override, 5c Task 14 wires the per-call auth descriptor. **2026-07-29:** 4c's own design and plan now record the `PIPE-17` half as a deferral naming 5a Task 1, so the MUST is no longer deferred silently (4c validation review, F1); 4c's plan also forbids adding the two fields early, since their shape belongs to their first reader | +| `RequestOptionsBuilder.maxRetries` accepts `Infinity`, `NaN`, and fractional values | Phase 5a code review (2026-08-26) | **Resolved — closed in Phase 5's merge `cba4721` (2026-08-27)**, by the row's own second option (a Phase 1 fix with a changeset), *not* by Phase 10. Re-verified against source 2026-08-30 | `HTTP-35`'s stated intent is that an out-of-range retry count is a loud error, never silently reinterpreted, and the builder implements only the `< 0` half. `Number.isFinite`/integer are unchecked, so `maxRetries: Infinity` reaches a consumer as a budget that never terminates. Phase 5a found it because its per-call override feeds `maxAttempts` directly; 5a closed its own exposure at both ends (`retryStep`'s `effectiveSettings` and a precondition in `runWithRetry`), but the **builder** still accepts the value, so any future reader of the option inherits the trap. Tightening a public setter changes observable API behavior and needs a changeset, so it is recorded rather than folded into 5a. **Closed:** the setter is now `if (value !== undefined && !(Number.isInteger(value) && value >= 0))` throwing `RequestOptionsValidationError` (`packages/core/src/http/request-options.ts:178`), which covers `Infinity`, `NaN` and fractional values in one predicate; its TSDoc `@param`/`@throws` say so (`:170-175`). Shipped with `.changeset/2026-08-26-max-retries-range-check.md` (`@dexpace/core`, patch), authored 2026-08-26 and merged in `cba4721`. This row stayed open past its own resolution — **Phase 10 did not fix it and should never have been named as its owner**; the correction here is bookkeeping, not work | +| The two structured retry log events (`retry.attemptFailed`, `retry.exhausted`) and `RETRY-40`'s "log the failure" clause | Phase 5a execution (2026-08-26) | **Phase 7b, Task 9** | 5a's plan specifies all three emission points but its own 2026-07-29 correction forbids writing them: 5a executes before 7b, so an `observability/logger.js` import would not resolve, and 7b needs 5a's `FakeTransport`, so the dependency cannot run the other way. `engine.ts` carries a head comment marking the sites and naming 7b Task 9 as owner. `RETRY-40`'s non-fatal fall-back half **is** implemented in 5a; only the diagnostic half waits | +| Phase 7a Tasks 1-3 (`config/{clock,http-date,retryable}.ts`) executed early, as 5a's prerequisite | Phase 5a execution (2026-08-26) | **Executed — 7a's plan should mark Tasks 1-3 done, not rebuild them** | 5a's plan Prerequisite requires 7a's `config/` module to exist first (Task 8 consumes `Clock`, Task 4 imports `parseHttpDate`, Task 2 re-exports `isRetryableStatus`), and its Global Constraints ban shipping private copies. The three files were built verbatim from [7a's plan](./work/mvp/phase7/phase7a/2026-07-28-phase7a-configuration.md) Tasks 1-3 with their tests (22 tests, `CFG-15`-`CFG-17`, `CFG-29`-`CFG-31`, `CFG-35`). 7a's Tasks 4-10 are untouched, and none of the three is promoted to the public barrel — 7a Task 10 still owns that decision | +| `SEAM-30` cleanup (cancel an orphaned response on the completion race) | Phase 2 | **Phase 8a** | Documented as a TSDoc contract obligation on `Transport.send()` in Phase 2; only a real Transport implementation has a response to actually cancel. Collapses onto `TRANSPORT-9` (and `ASYNC-5`, which collapses onto the same thing) per the [Phase 8 segmentation design](./work/mvp/phase8/2026-07-28-phase8-segmentation-design.md) §5.1 — closes as part of 8a's conformance suite, not separate work | +| Byte-stream provider implementation (`ByteQueue`, `BufferedSource`/`Sink`, `TeeSink`) | Discussed in Phase 2 (`sdk-design/03` §3.1), built in | **Phase 3a** | `sdk-design-nodejs/03` covers this in the same document as Phase 2's other seams — the roadmap's phase split puts the *contract* in Phase 2 and the *implementation* in Phase 3a; don't conflate the two | +| Every buffering **cap** — `BODY-19`'s configurable tap cap, `BODY-30`/`HTTP-52`'s 1 MiB error-body cap, `BODY-34`'s shared preview-size configuration | Phase 3a | **Resolved in Phase 3b (design)** | Deliberate placement, not an omission — §5 bounds nothing; every spec-mandated cap sits in §6, and 3b's design wires all three: the `withRequestLogging` tee's `tapCapBytes` (`BODY-19`), `toHttpError()`'s fixed 1 MiB error-body cap (`BODY-30`/`HTTP-52`), and one shared preview-size parameter threaded through both logging tees and `toHttpError` (`BODY-34`). The rejected `maxRetainedBytes`-on-`BufferedSource` reasoning stands — don't re-litigate. Lands when 3b's plan executes | +| Promotion of any §5 type into the published `@dexpace/core` barrel | Phase 3a | **Resolved in Phase 3b (design)** — never promoted | 3b decided: `Body.writeTo` takes the platform's `WritableStream`, not `BufferedSink`, so no §5 type ever surfaces — all of `src/io/` stays `@internal` permanently. `api-extractor`'s report staying byte-identical across 3a was the mechanical proof the freeze held until the decision | +| `MAX_BYTE_ARRAY_LENGTH` constant value (`IO-9`) | Phase 3a | Phase 3a plan time | Core is runtime-agnostic, so `node:buffer`'s constant is off-limits; V8 and JavaScriptCore disagree and both have moved theirs; 12.6 forbids an import-time probe. Design fixes the *mechanism* (conservative constant + `RangeError` backstop); the number itself is confirmed when the plan is written | +| `Symbol.asyncDispose` on §5 resources (styleguide 13.1/13.2) | Phase 3a | **Re-scoped 2026-07-28 — the premise expired in Phase 6** | Declined in 3a for the same reason Phase 2 declined it on `Transport`: `Symbol.asyncDispose` postdates the `>=18.17` floor, and TypeScript does not polyfill it for a library *declaring* the method — the computed key silently becomes the string `"undefined"` at run time. The row's own escape clause was **"costs nothing today since no §5 type is public,"** and that stopped being true in Phase 6: 6b publishes `SseStream` and 6c publishes `Page`, both resource-owning classes whose primary teardown is a public `close()` — exactly the shape `styleguide/typescript/13` §13.1 forbids, with §13.2 prescribing `[Symbol.asyncDispose]` delegating to the legacy `close()`. It also has a second consumer now: `PAGE-12` (MUST) requires consumers of the page-level view to be *told* to wrap it in a scoped/auto-close construct, and `await using` is that construct. Both sub-phases therefore ship a **runtime-guarded, optionally-typed** `[Symbol.asyncDispose]`: installed via `Object.defineProperty` only when the well-known symbol exists (so the `"undefined"`-key hazard cannot occur on the declared floor), typed optional (so it never promises `await using` support the pinned 18.17.0 runtime cannot honor), and delegating to `close()`, which stays the supported teardown on every runtime. Requires `esnext.disposable` on the TypeScript `lib` list — a types-only change that does not move `engines.node`. **Closed 2026-08-30, and this row said otherwise until 2026-09-01.** It read "promotion to an unconditional `implements AsyncDisposable` is a one-line change still gated on the floor passing 18.18; **that** is the residue this row now tracks." The floor is `>=20.3` (`packages/core/package.json:9`) and has been since Phase 3b, so that gate passed long ago — and the answer is still no. `docs/open-items.md`'s Section D row [`await using` support](./open-items.md#d-nfr-10-await-using) records the decision as **rejected, not pending**, with four reasons: `NFR-10` makes the guarded install the repair rather than a workaround, its next clause forbids raising a general-purpose core's floor for one affordance, `scripts/verify-runtime-floor.mjs:22-29` derives the floor from the built-ins the SDK calls rather than the syntax it emits, and Phase 4's checklist already refused the same trade for `SuppressedError`. **There is no residue.** This row is history; the decision is over there. Every phase brainstorm reads this file first (see Maintenance above), which is exactly why a superseded answer here is worse than none. See 6b's and 6c's designs, "Disposal" | +| `SEAM-5`–`SEAM-10` (discovery/registration/conflict-resolution machinery) | Phase 2 | **Never** — not deferred | Node has no pluggable byte-stream factory or fragmented async ecosystem to discover across; a permanent, documented simplification vs. the JVM reference, recorded in Phase 10's reconciled deviation ledger (`docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md`, Item 2), not "TODO'd" anywhere. Closed 2026-07-28 | +| Concrete `Serde` implementation (`@dexpace/codec-json`) | Phase 2 | **Resolved in Phase 6a** — closed 2026-08-27 | **Closed.** `packages/codec-json` ships `jsonSerde()`, the Tristate replacer, and the `tristate()`/`tristateObject()` decode combinators, with its own api-extractor report at `packages/codec-json/etc/codec-json.api.md`. Phase 2 shipped the `Serde` interface only. Narrowed from "Phase 6" by the 2026-07-28 segmentation review | +| Concrete `Transport` implementations (`@dexpace/transport-fetch`, `-undici`) | Phase 2 | **Phase 8a** | Phase 2 ships the `Transport` interface only. Narrowed from "Phase 8" by the 2026-07-28 [Phase 8 segmentation design](./work/mvp/phase8/2026-07-28-phase8-segmentation-design.md) | +| `SEAM-21` — explicit runtime type token for deserialization (the type-witness mechanism) | Phase 2 | **Resolved in Phase 6a** — closed 2026-08-27 | **Closed.** Every decode entry point now takes a caller-supplied `Schema` witness; `Serde` dropped its type parameter, because a bundle is per wire format once the payload type is a parameter of the decode call. The reshaped seam **is** promoted to the public barrel — forced, not chosen: `@dexpace/codec-json` is a separate package and can reach core only through its public entry point. `sdk-design-nodejs/03` §3.3 defers to §7.3. Phase 2's `Serde.deserialize(data: unknown): T` is the erased/inferred generic SEAM-21 forbids, so the interface **will** change shape — which is why Phase 2 keeps `Serde` out of the package barrel and marks it `@internal`, so the rework is not a breaking change to a published API. Narrowed from "Phase 6" by the 2026-07-28 segmentation review, which also made this the reason 6a leads the phase: reshaping a seam belongs before, not after, other work built on the same barrel. 6a additionally decides whether the reshaped seam is finally promoted to the public barrel, and whether `Serde` stays generic in `T` at all once the schema carries `T` | +| `SEAM-14` — close *behavior* (idempotent, ownership-aware, releases only self-created resources) | Phase 2 | **Phase 8a** | The `close(): Promise` **signature is locked in Phase 2** — adding a required method to a published seam later is a breaking change. Only the behavior waits, until a transport owns a pool worth releasing. Asymmetric across 8a's two packages: `transport-fetch` owns no persistent resource (a sanctioned no-op close); `transport-undici` owns a real `Pool`/`Client`/`Agent` | +| `SEAM-12` — concurrent-call conformance test | Phase 2 | **Phase 8a** | Stated as a TSDoc contract obligation on `Transport.send()` in Phase 2; "fire many concurrent requests and assert no cross-talk" needs a real transport to fire through. Collapses onto `TRANSPORT-29` (and `ASYNC-22`, its twin) per the [Phase 8 segmentation design](./work/mvp/phase8/2026-07-28-phase8-segmentation-design.md) §5.1 | +| `SEAM-18` (sync↔async bridges) | Phase 2 | **Never** — not deferred | Same class as `SEAM-5`–`SEAM-10`: a bridge connects two transport seams and this port has one. Every obligation SEAM-18 names presupposes a blocking transport Node cannot idiomatically have. Its one non-bridge clause ("per-call options MUST be threaded through, not dropped") survives as a `Transport.send()` obligation. Recorded in Phase 10's reconciled deviation ledger (`docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md`, Item 2). Closed 2026-07-28 | +| `HTTP-18`/`HTTP-48`/`HTTP-50` — outbound header strictness vs. ETag obs-text permission, discovered replaying a server-issued ETag with obs-text bytes through a conditional request | Phase 1 | **Resolved in Phase 10** | `RequestConditions.applyTo`'s strict outbound path is kept; `HTTP-18`'s MUST-level splitting defense (reinforced by `XCUT-18`) outranks `HTTP-48`'s SHOULD-level obs-text permission. See Phase 10's reconciled ledger (`docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md`, Item 15). Closed 2026-07-28 | +| `FileBody` (`BODY-11`/`BODY-12`/`BODY-13`/`BODY-36`) — file-backed request body | Phase 3b brainstorm | **Resolved in Phase 8a (design)** | Needs `node:fs`, which conflicts with `@dexpace/core`'s zero-`node:`-import invariant. Resolved as a **structural, not nominal, recognition contract**: `@dexpace/core`'s `Body.kind` union gains a `'file'` member and a type-only `FileBodyDescriptor` interface (zero runtime cost — types erase), retrofitted into Phase 3b's plan; the concrete `fileBody()` factory needing real `node:fs` validation ships in a new fourth Phase 8a package, `@dexpace/body-file`, which both transports depend on and recognize via `body.kind === 'file'` structural narrowing, never a cross-package `instanceof`. Separately, 8a's design confirms (not merely flags) that true kernel-level zero-copy dispatch (`TRANSPORT-28`'s SHOULD) **has no Node analogue** — neither `fetch` nor `undici` expose a `sendfile`-shaped API for outbound bodies — recorded as a `PAGE-29`-shaped collapse in 8a's Deviation Ledger, not chased further. See [Phase 8a design](./work/mvp/phase8/phase8a/2026-07-28-phase8a-transport-design.md) §5 | +| `packages/core/src/redirect/cross-origin.ts` (the `REDIR-11`/`AUTH-29` shared signal — a real header, `CROSS_ORIGIN_MARKER_HEADER`, plus `hasCrossOriginMarker()`/`clearCrossOriginMarker()`) | Phase 5b brainstorm | **Resolved in Phase 5b (design)** | 5b ships and owns this module; 5c's own design (drafted concurrently, before either doc knew of the other) originally guessed an incompatible `WeakSet`-keyed shape against `REDIR-11`'s prose directly, then corrected itself against 5b's actual design once found mid-draft — see 5c's "How this doc was produced" / "Alignment with 5b's shipped design" sections. Recorded here as a caution: two solo brainstorms sharing a cross-phase contract, run without coordinating with each other, is exactly the scenario this kind of drift comes from — re-check for it explicitly if this ever happens again rather than assuming file-discovery mid-draft will always catch it. **The caution earned itself twice.** Catching the marker's *shape* mid-draft did not catch its *scope*: 5c's design consumed the marker on the outbound pass but still answered a `401`/`WWW-Authenticate` challenge on a marked hop, which would have stamped exactly the credential the marker exists to suppress — onto the server-chosen foreign host, over a URL whose HTTPS guard was deliberately skipped. Found in a plan review before any code existed and fixed in both 5c's plan and design (the marker now suppresses the whole hop, not just the outbound pass), but a cross-phase contract review needs to cover every place the consuming phase *acts on* the contract, not just where it reads it | +| `standardResilience()` gains a `LOGGING` pillar step | Phase 5c brainstorm | **Resolved in Phase 7b (design)** | 5c's preset installs only the three pillars that exist by then (redirect, retry, auth); a real logging step doesn't exist until Phase 7b. 7b's design amends `standardResilience()` to install `loggingStep()` (inert by default at `granularity: 'none'`) into the previously-empty slot. Lands when 7b's plan executes | +| `DigestChallengeUnsupportedError` — confirm a real caller-facing API needs to distinguish "unsatisfiable challenge" from "no replacement" before shipping it | Phase 5c brainstorm | **Resolved in Phase 10 — 2026-07-28** | `authStep()` itself never surfaces this distinction (both cases just leave the 401 unchanged); the leaf was sketched for a lower-level API 5c's design did not otherwise build. 5c's plan **kept** it rather than cutting — as an `@internal` leaf for a caller composing `composingHandler`/`digestHandler` directly, bypassing `authStep()`. **Resolved:** kept, permanently — no forced usage-sweep will ever run (Phase 9 is `XCUT`/`NFR`-scoped, no phase's code exists yet for one regardless), and an `@internal`-tier leaf costs nothing sitting unused; it can be removed later without a breaking change if it genuinely proves dead weight once real callers exist | +| Basic/Digest never stamp preemptively — an *interpretation*, not a stated requirement | Phase 5c brainstorm | **Resolved in Phase 10 — 2026-07-28** | `§11` phrases `AUTH-14` and `AUTH-15`–`AUTH-22` entirely as reactions to a parsed challenge, and never describes a preemptive-Basic path the way it separately describes Bearer's preemptive cached-token stamp; Digest structurally cannot stamp before seeing `realm`/`nonce`. 5c treats both uniformly as challenge-only. **Resolved:** confirmed correct as designed — the spec's asymmetry (describing Bearer's preemptive path, staying silent on Basic/Digest) reads as deliberate, and staying reactive matches this port's conservative-by-default posture elsewhere (credential-stripping by default, downgrade-deny by default). See Phase 10's reconciled ledger (`docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md`, Item 12) | +| True per-call / per-operation `AuthTiers`, resolved per call rather than fixed at step construction | Phase 5c plan | `perCall` tier: **Resolved in Phase 5c (design, 2026-07-28 revision)**. `operation` tier: unscoped | Originally fully unscoped because no phase shipped a per-call lookup source. The 2026-07-28 plans review closed the `perCall` half: the vehicle is `RequestOptions` (per-call operational overrides are exactly its Phase 1 charter), not `ExecutionContext` — `RequestOptions` gains `auth?: AuthDescriptor` (type-only, cycle-free import; amended in 5c Task 14 alongside `pipeline/builder.ts`'s existing amendment precedent), steps read it via `StepContext.options` (5a Task 1, `PIPE-17`), and `authStep` resolves `{...settings.tiers, perCall: ctx.options.auth}` when present. The `operation` tier still has no distinct source — nothing in this roadmap ships a per-operation layer (no codegen/client surface), so `operation` and `client` both remain construction-time configuration; that residue is a plumbing gap, not a conformance one (`AUTH-4`–`AUTH-7` are mechanically satisfied), and stays open here | +| Redirect predicate's scope over safety mechanics (credential stripping, downgrade, replayability, loop/cap) — 5b reads `REDIR-20`'s "MUST fully override" as scoped to code/method eligibility only, not these | Phase 5b brainstorm | **Resolved in Phase 10 — 2026-07-28** | A judgment call made without the user present; 5b's own design flagged it as narrow and mechanical to reverse if wrong. **Resolved:** confirmed correct as designed — `REDIR-20`'s snapshot (response, redirect count, visited URIs) carries nothing about credentials, and safety mechanics are separately governed by `XCUT-17`'s own universal, non-overridable framing; a predicate opting out of them would be a security regression, not a convenience. See Phase 10's reconciled ledger (`docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md`, Item 12) | +| Redirect structured logging (`SHOULD`-level hop/loop/downgrade events) | Phase 5b brainstorm | **Partially resolved in Phase 7b (plan, 2026-07-28)** | Same disposition as 5a's equivalent gap for retry. 7b's amendment to 5b's `redirect-step.ts` ships the hop event and a rejection event (distinguishing `SchemeDowngradeError`) via `getGlobalLogger()`, no change to `StepContext`'s shape. **Not fully closed:** `decide()`'s `Decision` type carries no reason discriminant on `'return-current'`, so a genuine loop-vs-hop-cap-vs-normal-termination distinction is out of scope for this retrofit — would need `Decision` reshaped, touching every assertion in `decide.test.ts`. 5a's equivalent (attempt-failed, retries-exhausted) closes cleanly with no such gap, since `Outcome.kind` already discriminates success/failure. Both land when their respective plans execute | +| 5a's `RetryConfig.clock`/`random` retyped against 7a's real `Clock` seam, replacing its ad hoc injection point | Phase 7a brainstorm | 7a (doc amendment to 5a's design/plan) | Single-sources the injectable-determinism seam 5a's own design already noted it was pre-empting ("the same injectable-determinism seam `CFG-15` wants for the clock") | +| 5a's private RFC 1123 parser in `pacing.ts` re-sourced from 7a's shared `config/http-date.ts` | Phase 7a brainstorm | 7a (doc amendment to 5a's design/plan) | 7a's module is a superset (adds the formatter 5a never needed); 5a's parser becomes an import, not a second implementation | +| 5a's private `RETRYABLE_STATUSES`/`isRetryableStatus` in `classify.ts` re-sourced from 7a's `config/retryable.ts` | Phase 7a brainstorm | 7a (doc amendment to 5a's design/plan) | `CFG-35` mandates one shared retryability definition; 7a Task 3 ships the identical set (408, 429, 5xx except 501/505) and 5a's `classify.ts` re-exports it unchanged, so `RETRY-1` and `CFG-35` cannot drift apart | +| `challengeHandler` slot on `ProxyOptions` has no protocol behind it | Phase 7a brainstorm | **Resolved in Phase 8a (design)** | The type carries the slot per `CFG-22`'s field list. Resolved as `transport-undici`-only: undici ships `ProxyAgent`/proxy-407 dispatch; `transport-fetch` ships no `proxy` option on `FetchTransportOptions` at all (an absent option, not a silently-ignored one) and documents no proxy support, since honoring `TRANSPORT-30` there would require depending on `undici` internally anyway, undercutting `transport-fetch`'s zero-added-dependency purpose. `§17`'s own preamble licenses this single-transport scoping. See [Phase 8a design](./work/mvp/phase8/phase8a/2026-07-28-phase8a-transport-design.md) §6 | +| Whether `clientIdentityStep` should be added to `standardResilience()`'s default install list | Phase 7a brainstorm | **Resolved in Phase 10 — 2026-07-28** | Not installed by default — no requirement mandates it (`RECOV-33` governs the step's own internal composition, not whether a preset installs it; `NFR-15` only requires that *when* a `User-Agent` is emitted it's real, not that every call carry one), and 5c's preset already closed its own scope for the pillars that exist. **Resolved:** stays out, permanently — adding it would be unrequested preset scope creep; a caller who wants it installs it explicitly, already possible via the public authoring surface | +| Retry/redirect structured-logging event names/fields | Phase 7b brainstorm | Phase 7b plan time | No spec-fixed vocabulary exists for these `SHOULD`-level events; naming is a plan-time detail, not a design-level decision | +| Whether `standardResilience()` should also accept a `tracerFactory`/`meter` pass-through convenience | Phase 7b brainstorm | **Resolved in Phase 9 (design)** — no friction found | No requirement mandates preset-level convenience wiring beyond installing the `LOGGING` step itself. Phase 9's `tests/conformance/xcut/fixtures/composed-pipeline.ts` configures logging/tracing/metrics the same way 7b's own tests do — a `LoggingStepSettings` object passed to `standardResilience()`'s existing `logging` option, plus `setGlobalLogger()` for a spy `Logger` — with no need for a separate `tracerFactory`/`meter` preset-level parameter. Closed, not just deferred again | +| A real `@opentelemetry/sdk-metrics`-backed `Meter` adapter package | Phase 7b brainstorm | Not scheduled | `OBS-31` only requires the no-op default and that core not depend on a metrics runtime; no package in the roadmap's phase table ships a concrete metrics backend, unlike tracing's duck-typed zero-adapter path | +| Phase 8 split into 8a (Transport Adapters, `§17`) / 8b (Async-Runtime Bridge, `§18`) | Phase 8 brainstorm (2026-07-28) | — | 52 nominal combined IDs (`TRANSPORT` 30, `ASYNC` 22) — well under the ~76–79 that forced the Phase 3/4 splits — but §17 is paid twice (two full `Transport` implementations, `transport-fetch` and `transport-undici`) and nine Deferred Items Log rows land here, pushing effective weight to Phase-7-before-its-split territory. Cut along the package boundary the roadmap table already implied, verified empty by the same test Phase 6 applied: `@dexpace/rx` depends only on Phase 6's `Page`/`SseStream`, never on `Transport`, and nothing in `Transport`'s collapsed `Promise`-returning contract (`sdk-design-nodejs/03` §3.2) references RxJS or any `ASYNC-*` id. **No segment depends on the other; the 8a→8b order is convenience, not dependency** (8a leads only because it is the larger, riskier half). A large share of `§18`'s `ASYNC-*` IDs collapse onto their `§17` `TRANSPORT-*` twin (the SEAM-11/SEAM-16 collapse restated at the async-adapter layer) or are inapplicable outright — Node has no blocking-transport/worker-thread-pool model for `ASYNC-3`/`4`/`7`/`14` to bite on, the same premise that already closed `SEAM-18` as "Never." Full rationale, per-segment ownership, the collapsed-ID disposition tables, and open items (notably `FileBody`'s package placement and whether Node's HTTP stack has any zero-copy dispatch path at all) in the [Phase 8 segmentation design](./work/mvp/phase8/2026-07-28-phase8-segmentation-design.md) | +| Assertion-density rule applied project-wide (`assertions.md:6-7`, styleguide Rule 8) | Phase 4b validation review F2 (2026-07-28) | **Not scheduled — re-deferred out of Phase 10, 2026-08-30** | Named Phase 10 by 4b's F2 resolution, but a project-wide convention sweep is not deviation reconciliation, and Phase 10 is the last row of the roadmap's phase table — so this becomes unscheduled with an explicit trigger rather than being handed to an invented phase. As-built the shape has changed: `invariant()` is now called from thirteen modules across `packages/core/src/` and `packages/body-file/src/`, and `recovery/` is the lone holdout at zero (`packages/core/src/recovery/outcome.ts:3` imports `assertNever` only). **Trigger:** the next defect traced to an unasserted precondition, or an assertion/naming convention sweep commissioned as its own phase. Full disposition in `docs/open-items.md` Section S | +| `#private`-vs-`private` field style settled project-wide, with the runtime-privacy justification stated | Phase 4b validation review F7 (2026-07-28) | **Not scheduled — re-deferred out of Phase 10, 2026-08-30; largely moot** | Also named Phase 10, same reasoning as the row above. Mostly discharged in the meantime by a different route than a sweep: `CLAUDE.md:172-173` now mandates "`#private` fields only. Not TS `private`." project-wide and cites styleguide 6.7's library carve-out as the justification F7 asked for. The residue is cosmetic (no per-class comment). **Trigger:** a lint rule mechanizing the convention, or a styleguide revision withdrawing the 6.7 carve-out. Full disposition in `docs/open-items.md` Section S | +| `CONSTANT_CASE`-vs-`lowerCamelCase` for module-level immutable collections (`STAGE_ORDER`, `PILLAR_STAGES`, Phase 1's `Protocol`/`Status` statics, 4b's constants) | Phase 4c validation review (2026-07-29) | **Not scheduled — re-deferred out of Phase 10, 2026-08-30** | Also named Phase 10; a naming-convention call is not a deviation from the reference contract. `naming-conventions.md:14`'s worked example keeps a module-level `new Set(...)` in `lowerCamelCase` because its contents can mutate, and a `ReadonlySet` type does not deep-freeze the underlying `Set`. Unchanged and self-consistent as-built — `STAGE_ORDER`/`PILLAR_STAGES` remain the pipeline's only such pair (`packages/core/src/pipeline/builder.ts:12`). **Trigger:** the next module-level immutable collection added outside `pipeline/`, which makes the fork visible in a third place. Full disposition in `docs/open-items.md` Section T | +| RFC 7616 §4 `username*` (RFC 5987) extended notation for a non-ASCII Digest username | Phase 5c checklist | Unscoped | **Recovered by the 2026-08-31 register audit; never reached this log.** `digestHandler()` rejects a non-header-safe username at construction today. Implementing `username*` would let it be sent correctly rather than refused, and is the standard's own answer. Source: `docs/work/mvp/phase5/phase5c/2026-07-26-phase5c-auth-checklist.md:220` | +| A caller-supplied `ChallengeHandler` list on `AuthStepSettings` | Phase 5c checklist | Unscoped | **Recovered by the 2026-08-31 register audit; never reached this log.** `handlers` was removed at review: it forced three types onto the public barrel and could not compose with the built-in handlers, which stay internal. If a caller ever needs to ADD a handler rather than replace the whole reaction, the shape to ship is an append-semantics field plus public `basicHandler`/`digestHandler` factories — not the replace-semantics field that was cut. Source: `docs/work/mvp/phase5/phase5c/2026-07-26-phase5c-auth-checklist.md:220` | +| Whether `@dexpace/body-file`'s `fileBody()` should also support a read-only memory-mapped view (`BODY-36`, MAY) | Phase 8a brainstorm | Not scheduled | **Recovered by the 2026-08-31 register audit; never reached this log.** `BODY-36` is a MAY for local hashing/signing without heap copying; no caller identified in this roadmap's scope, same "don't build speculatively" discipline as `FakeTransport`'s original deferral. Source: `docs/work/mvp/phase8/phase8a/2026-07-28-phase8a-transport-design.md:581` | diff --git a/docs/deviations.md b/docs/deviations.md index d2205e3..873bb27 100644 --- a/docs/deviations.md +++ b/docs/deviations.md @@ -1,25 +1,49 @@ -# Deviations That Cannot Be Corrected +# Deviations — the as-built audit, and the landing point for the rest Audit of `docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md` (the Phase 10 reconciled ledger, 17 items) performed against the **as-built code**, not against the phase specs that produced it. Every item was re-derived from source; each entry below records the file and line that proves the claim. -**Scope of this file:** the deviations that are *permanently uncorrectable* — where restoring the reference -contract's own mechanism is impossible on this platform, forbidden by a project constraint, or would be a -regression. Items found to be **correctable** are deliberately **not** listed here — they were fixed instead. +**Scope of this file, as of 2026-08-31:** two things, in this order. -**This file is the audit, not the ledger** (cross-reference added 2026-08-30; until then nothing in the repo -linked the two, and the numbering they share had no stated owner): +1. **The audit.** The deviations that are *permanently uncorrectable* — where restoring the reference + contract's own mechanism is impossible on this platform, forbidden by a project constraint, or would be a + regression. Items found to be **correctable** are deliberately **not** listed here; they were fixed + instead. That is everything below, and it is unchanged. +2. **The collection point.** A deviation found outside a phase, by a review or a maintenance pass, with no + phase ledger to write to and no permission to write to §10. It is appended under + "Deviations recorded outside a phase" at the end of this file, dated, and folded into §10 the next time §10 + is deliberately amended. That section is empty today: the 2026-08-31 restructure swept the non-frozen tree + and found no unrecorded deviation, only three unrecorded *deferrals* (`open-items.md` U2) and six + mis-numbered register citations (`open-items.md` U6). + +**This file is the audit and the mutable collection point; §10 is the ledger and it is frozen.** (The +cross-reference was added 2026-08-30, when nothing in the repo linked the two and the numbering they share had +no stated owner. The role below widened on 2026-08-31, when `docs/` gained a stated structure and §10 landed +inside a read-only tree.) - **`docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md` (§10) is the normative ledger** — the canonical list of deliberate deviations, and the **owner of the item numbers**. Every `## N` heading and every table row below is keyed to §10's numbering and has no independent identity. **If §10 - renumbers, this file must be renumbered in the same commit.** §10 now carries the matching pointer back here. + renumbers, this file must be renumbered in the same commit.** §10 carries the matching pointer back here. +- **§10 sits in a frozen tree.** `docs/sdk-design-nodejs/` is read-only to routine maintenance and to the + `housekeeping` skill, which refuses to write there — see [`README.md`](./README.md). Editing §10 is a + deliberate, hand-made act. **This file is not frozen**, and that asymmetry is the point: a deviation + discovered by a maintenance pass, a review, or an audit is recorded here on the day it is found, and §10 is + amended when someone deliberately amends it. What must never happen is the finding waiting for the ledger. + The coupling now spans a freeze boundary, and only one side of it can be repaired by the tool that notices + the drift — registered as `open-items.md` U4. - **This file is the as-built audit of that ledger** — it re-derives each item from source, carries the `file:line` evidence, and records which of §10's claims did not survive contact with the code. -- **A new deviation is recorded in neither.** It goes in the owning phase spec's own `## Deviation Ledger (for - Phase 10)` section; §10 is the consolidated **output** of those, not their intake. +- **A deviation *produced by a phase* is recorded in neither, at first.** It goes in the owning phase's own + `## Deviation Ledger (for Phase 10)` section — 24 such sections exist under `docs/work/mvp/` — and §10 is + the consolidated **output** of those, not their intake. Those sections are dated provenance and stay where + they are. +- **A deviation found *outside* a phase is recorded here.** A maintenance pass, a review of shipped code, or + an audit has no phase ledger to write to and cannot write to §10. It writes here, and the next deliberate + §10 amendment folds it in. This is the half of the intake rule that did not exist before 2026-08-31, and its + absence is why a finding with no owning phase had nowhere to go. - **The corpus no longer carries a copy.** `docs/knowledge/deliberate-deviations.md` was a harvested topic file derived from an older revision of §10 — a third of the register, mis-anchored, two entries false. It was dropped on 2026-08-31: a register accumulates rows and a harvest of it is one stale revision, so §10 is @@ -311,7 +335,8 @@ unsigned publication; a local build without keys still publishes unsigned." Sati produce that evidence; it unblocks at first release and not before. > **Corrected in the ledger 2026-08-29.** Item 14 claimed `prepublishOnly` *and* `npm publish --provenance` -> "are scripted (Phase 0 Task 3)". Only the first is. `docs/open-items.md:264` already recorded this +> "are scripted (Phase 0 Task 3)". Only the first is. `docs/open-items.md`'s Section D row +> "Publish + provenance CI job" ([`#d-nfr-16-provenance`](./open-items.md#d-nfr-16-provenance)) already recorded this > accurately ("`prepublishOnly` wired; nothing published yet"); §10 did not, and now does. > > **Still actionable, and not done here:** authoring the release workflow with `--provenance` and @@ -377,3 +402,16 @@ failure retryable with zero edits to the retry layer. A flat sibling would have the retry classifier, and again for every transport added later — trading one level of depth for an open-ended maintenance obligation that the styleguide's own rule exists to prevent. Held at exactly three; a fourth level is not sanctioned by this entry. + +## Deviations recorded outside a phase + +Empty as of 2026-08-31. + +A row here has no owning phase — it was found by a review, an audit, or a maintenance pass over shipped code. +It is recorded on the day it is found rather than waiting for `docs/sdk-design-nodejs/10-…`, which is in a +frozen tree and is amended only deliberately, by hand. When §10 is next amended, a row here becomes a numbered +§10 item and moves into the audit above under that number. + +| Deviation | Found by | Date | Evidence | §10 status | +|---|---|---|---|---| +| — | — | — | — | — | diff --git a/docs/open-items.md b/docs/open-items.md index 3949976..7898283 100644 --- a/docs/open-items.md +++ b/docs/open-items.md @@ -1,28 +1,59 @@ # Open Items -Running register of everything known to be unmet, unverified, misreported, or deliberately deferred across the -implemented portion of this project. Reviewed state: **scaffold milestone** (committed, `0ebdc79`), -**Phase 1 — Core HTTP Domain Model** (branch `2-phase-1-core-http-domain-model`, uncommitted at time of -review), **Phase 3a/3b**, **Phase 4a — Execution Context** (branch `7-phase-4a-execution-context`, three -review passes), **Phase 4b — Recovery-Chain Primitives** (branch -`8-phase-4b-recovery-chain-primitives`), and **Phase 5b — Redirect** (branch -`12-phase-5b-resilience-redirect`, three review passes). 4a and 4b are both merged into -`9-phase-4c-stage-based-pipeline`. Last reviewed **2026-08-27**. - -**Two phases are shipped but were never registered here: 4c (stage-based pipeline) and 5a (retry).** Both are -merged and both have executed checklists, but neither ran the scan this file's maintenance rule asks for, so -their absence below means "not reviewed", not "nothing found". Section G was written without reviewing either, -and says nothing about them beyond what 5b's own work touched — the one 5a file 5b modified -(`retry/engine.ts`) is recorded at G9. - -Sections A–E below were written against Phase 1 and are re-verified at each review; section F is Phase 4b's, -section G is Phase 5b's, section H is Phase 6a's, section I is Phase 6b's, -section J is Phase 6c's, and section K is Phase 7a's. +Running register of everything known to be unmet, unverified, misreported, or surprising across the +implemented portion of this project. **This is the only such register**; a second one at the repository root +was merged in as Section P on 2026-08-31 and deleted. + +It is one of three files at the `docs/` root, and the boundary between them is *when* an item was created: + +| Register | Holds | Item is | +|---|---|---| +| `open-items.md` (this file) | Everything unmet, unverified, misreported, or surprising | A gap found **after** the work, between what is claimed and what is built | +| [`deferred-items.md`](./deferred-items.md) | Work a phase decided not to do yet, with the phase that owns it | A decision made **before** the work: "not this phase, that one" | +| [`deviations.md`](./deviations.md) | The as-built audit of the deviation ledger | A place this port deliberately differs from the reference contract | + +The same requirement ID can legitimately sit in two of them. `AUTH-37` is deferred to Phase 7b in +`deferred-items.md` and recorded here at G12 as a live silent swallow. A requirement absent from this file is either satisfied or belongs to a phase that has not started. The point of the file is that nothing is unmet *silently* — every gap below is either scheduled against a named phase or awaiting a decision. +## Section index + +Each section is a review. Its letter is permanent: source comments cite items as `docs/open-items.md K11`, +and 24 such citations exist across the repository. **A letter is never reused and an item is never +renumbered.** A new review appends the next letter. + +| Section | Subject | Item IDs | +|---|---|---| +| A–C, E | Phase 1, re-verified at every review since | `A1`–`A6`, `B1`–`B4`, `C1`–`C3`, `E1` | +| D | Scheduled deferrals, Phase 1 onward | **none.** A bare table; its rows are cited by the anchors on them, not by an item ID | +| F | Phase 4b — recovery-chain primitives | `F1`–`F9` | +| G | Phase 5b — redirect | `G1`–`G13` | +| H | Phase 6a — serde | `H1`– | +| I | Phase 6b — Server-Sent Events | `I1`– | +| J | Phase 6c — pagination | `J1`– | +| K | Phase 7a — configuration and platform primitives | `K1`–`K20` | +| L | Phase 7b — instrumentation and observability | `L1`– | +| M | Phase 8b — async-runtime bridge | `M1`– | +| N | Phase 9 — cross-cutting invariants and conformance | `N1`– | +| O | Knowledge-corpus split | `O1`–`O3` | +| P | Phase 5a — retry (merged from the repository-root register, 2026-08-31) | `P1`–`P9` | +| Q–T | Four validation/execution reviews relocated from the roadmap, 2026-08-31 | **table rows, not `###` items:** Q `D1`–`D2`, R `E1`–`E8`, S `F1`–`F10`, T `F1`–`F9`. The reviews' own numbering — see the note on Section S | +| U | Documentation restructure | `U1`–`U11` | + +**Reviewed state.** Scaffold milestone (`0ebdc79`); Phase 1 (branch `2-phase-1-core-http-domain-model`, +uncommitted at time of review); Phases 3a/3b; Phase 4a (`7-phase-4a-execution-context`, three passes); +Phase 4b (`8-phase-4b-recovery-chain-primitives`); Phase 5b (`12-phase-5b-resilience-redirect`, three +passes); Phase 5a (three passes, now Section P, re-verified against source 2026-08-31); Phases 6a/6b/6c, +7a/7b, 8b, 9. Last reviewed **2026-08-31**. + +**Phase 4c is still not registered here.** It is merged and has an executed checklist, but never ran the scan +this file's maintenance rule asks for, so its absence means "not reviewed", not "nothing found". Section S is +a *document* validation review of 4b and Section T of 4c; neither is a review of the shipped code. Phase 5a's +gap closed on 2026-08-31 with Section P. + **Status vocabulary** | Status | Meaning | @@ -34,7 +65,7 @@ awaiting a decision. --- -## A. Requirements unmet or misreported +## Section A — Requirements unmet or misreported ### A1 — HTTP-24: `charset` does not return null for an unknown encoding — **DECIDE** @@ -155,7 +186,7 @@ observable and owes a real test. --- -## B. Gates and tooling +## Section B — Gates and tooling ### B1 — NFR-10 / NFR-17: CI never runs on the declared minimum runtime — **RESOLVED** (2026-08-26) @@ -225,11 +256,11 @@ NFR-14 decision at Phase 8. --- -## C. Documentation defects +## Section C — Documentation defects ### C1 — Phase 1's scope statement contradicts its own plan — **ACT** -`docs/superpowers/specs/2026-07-23-phase1-core-http-domain-model-design.md` says the scope is "Full +`docs/work/mvp/phase1/2026-07-23-phase1-core-http-domain-model-design.md` says the scope is "Full `product-spec/04-core-http-domain-model.md` (HTTP-3 through HTTP-53, both MUST and SHOULD level) in one phase." The plan's own Self-Review then amends that: *"The Phase 1 spec's scope statement should be read — and amended @@ -246,13 +277,13 @@ here so the promise survives until then. ### C3 — The Phase 4 checklist under-reports Phase 4a — **ACT** -`docs/superpowers/plans/2026-07-26-phase4-execution-context-and-pipelines-checklist.md` still carries its +`docs/work/mvp/phase4/2026-07-26-phase4-execution-context-and-pipelines-checklist.md` still carries its banner: "the plans are reviewed and corrected as of 2026-07-26 but **not yet executed**. Every ✅ means 'the plan builds and tests it,' not 'it is on `main`.'" Phase 4a's rows are now built, tested, and committed on `7-phase-4a-execution-context`, so the banner understates them while 4b and 4c remain unbuilt. The same checklist maps only `CTX-*`. It has no `XCUT-14` row, even though -`docs/superpowers/specs/2026-07-28-phase9-cross-cutting-conformance-design.md:66` names "4a's context registry" +`docs/work/mvp/phase9/2026-07-28-phase9-cross-cutting-conformance-design.md:66` names "4a's context registry" as an XCUT-14 site and appendix B's only conformance row that `ContextStore` satisfies is B.8's "Caller/server-keyed maps bounded with drain-to-cap loop (XCUT-14)" — appendix B has no CTX section at all. The ID is now cited in `store.ts` and `store.test.ts`; the checklist is the remaining gap. @@ -261,7 +292,7 @@ Split the banner per sub-phase, and add an `XCUT-14` row pointing at 4a Task 4 ( --- -## D. Scheduled deferrals +## Section D — Scheduled deferrals No action now. Each is already owned by a named phase; this table exists so none can quietly lapse. @@ -282,8 +313,8 @@ No action now. Each is already owned by a named phase; this table exists so none | `contextsEqual()`, value equality over `ExecutionContext` | CTX-5 (equality framing) | none | Built only if 4b or 4c needs one. `CTX-5`'s operative half — pinning an explicit shared key — ships via `ContextInit.key` | | `FakeTransport` test double | — | 4c | 4a never touches `Transport`; `PIPE-9`'s empty-pipeline dispatch is the likely first real consumer | | Self-identifying version metadata (real `User-Agent`) | NFR-15 | 7/8 | | -| Publish + provenance CI job | NFR-16 | release | `prepublishOnly` wired; nothing published yet. **Sharpened 2026-08-29:** there is no release workflow at all and `--provenance` appears in no manifest, workflow, or `.npmrc`. §10's ledger claimed the flag "is scripted"; it never was. Authoring the workflow is actionable **now** — only running it against a real registry is blocked | -| `await using` support on `Page`, `fetchTransport()`, `undiciTransport()` | NFR-10 | **none — decided against 2026-08-30** | These three declared `[Symbol.asyncDispose]` as a plain class member; on the `>=20.3` floor the computed key is `undefined`, so the method bound to the string key `"undefined"` — junk on the prototype, no disposal, and a `.d.ts` promising `AsyncDisposable` regardless. Fixed 2026-08-29 to `SseStream`'s guarded install, which costs the type-level `await using` affordance (`close()` is unaffected). **This row previously read "raising the floor to `>=20.4` restores the declaration honestly and lets all four sites drop the guard." That is now a rejected option, not a pending one — the floor stays `>=20.3` and all four guarded installs stay.** Four reasons, in the order that decides it. (1) `NFR-10` is **MUST**-level and requires that "the emitted-artifact target and the visible-API level must agree" (`docs/product-spec/20-non-functional-requirements-and-quality-bar.md:29`); the unguarded class member violated it directly, and the guarded install *is* the repair — not a workaround waiting to be undone. (2) The same requirement's next clause: "A capability that genuinely requires a newer runtime MUST be isolated into its own unit that declares the higher floor explicitly; that unit MUST NOT be a hard dependency of the general-purpose core." Raising core's floor to recover `await using` is the exact inverse — it drags every consumer onto a higher runtime for one syntactic affordance. (3) **The floor is derived, not chosen.** `scripts/verify-runtime-floor.mjs:33` pairs language level `es2023` with `>=20.3`, and its own banner comment (`:22-29`) says the floor is "set by the runtime built-ins the SDK calls rather than by the syntax it emits" and that "adding or moving a row here is a reviewed choice about what runtimes the SDK supports, never a mechanical bump." `>=20.3` is the *minimum* Node that runs what this project emits — `globalThis.crypto` is absent from ESM on every Node 18, and `AbortSignal.any()` landed in 20.3.0. Moving it to satisfy a type-level convenience inverts what the gate is for. (4) **There is a decided precedent.** `docs/superpowers/plans/2026-07-26-phase4-execution-context-and-pipelines-checklist.md:208` rejected raising the floor for `SuppressedError` on the same reasoning and shipped a guarded shim instead — `packages/core/src/suppress.ts`. `close()` remains the supported teardown on every runtime; a consumer who has raised *their own* floor to 20.4+ can still reach the installed member through a cast. See §10 ledger item 11 and I3/J3 below | +| Publish + provenance CI job | NFR-16 | release | `prepublishOnly` wired; nothing published yet. **Sharpened 2026-08-29:** there is no release workflow at all and `--provenance` appears in no manifest, workflow, or `.npmrc`. §10's ledger claimed the flag "is scripted"; it never was. Authoring the workflow is actionable **now** — only running it against a real registry is blocked | +| `await using` support on `Page`, `fetchTransport()`, `undiciTransport()` | NFR-10 | **none — decided against 2026-08-30** | These three declared `[Symbol.asyncDispose]` as a plain class member; on the `>=20.3` floor the computed key is `undefined`, so the method bound to the string key `"undefined"` — junk on the prototype, no disposal, and a `.d.ts` promising `AsyncDisposable` regardless. Fixed 2026-08-29 to `SseStream`'s guarded install, which costs the type-level `await using` affordance (`close()` is unaffected). **This row previously read "raising the floor to `>=20.4` restores the declaration honestly and lets all four sites drop the guard." That is now a rejected option, not a pending one — the floor stays `>=20.3` and all four guarded installs stay.** Four reasons, in the order that decides it. (1) `NFR-10` is **MUST**-level and requires that "the emitted-artifact target and the visible-API level must agree" (`docs/product-spec/20-non-functional-requirements-and-quality-bar.md:29`); the unguarded class member violated it directly, and the guarded install *is* the repair — not a workaround waiting to be undone. (2) The same requirement's next clause: "A capability that genuinely requires a newer runtime MUST be isolated into its own unit that declares the higher floor explicitly; that unit MUST NOT be a hard dependency of the general-purpose core." Raising core's floor to recover `await using` is the exact inverse — it drags every consumer onto a higher runtime for one syntactic affordance. (3) **The floor is derived, not chosen.** `scripts/verify-runtime-floor.mjs:33` pairs language level `es2023` with `>=20.3`, and its own banner comment (`:22-29`) says the floor is "set by the runtime built-ins the SDK calls rather than by the syntax it emits" and that "adding or moving a row here is a reviewed choice about what runtimes the SDK supports, never a mechanical bump." `>=20.3` is the *minimum* Node that runs what this project emits — `globalThis.crypto` is absent from ESM on every Node 18, and `AbortSignal.any()` landed in 20.3.0. Moving it to satisfy a type-level convenience inverts what the gate is for. (4) **There is a decided precedent.** `docs/work/mvp/phase4/2026-07-26-phase4-execution-context-and-pipelines-checklist.md:208` rejected raising the floor for `SuppressedError` on the same reasoning and shipped a guarded shim instead — `packages/core/src/suppress.ts`. `close()` remains the supported teardown on every runtime; a consumer who has raised *their own* floor to 20.4+ can still reach the installed member through a cast. See §10 ledger item 11 and I3/J3 below | | NFR-8 re-confirmed as a documented non-applicability | NFR-8 | 10 | No reflection-driven discovery surface exists by design | | Redirect structured logging — hop, rejection, and permitted-downgrade events | REDIR-28, REDIR-15 (surfacing clause), XCUT-17(d) | 7b | Task 9. 5b executes before 7b and 7b needs 5b's step, so the import cannot run either way until then. See G2 | | Redirect's loop-detected and malformed-Location events | REDIR-28 | none | Blocked behind a reason discriminant on `decide()`'s `'return-current'` variant, which no phase owns. See G3 | @@ -295,7 +326,7 @@ No action now. Each is already owned by a named phase; this table exists so none --- -## E. Process +## Section E — Process ### E1 — Phase 1 has no commits — **DECIDE** @@ -310,7 +341,7 @@ departure. --- -## F. Phase 4b — Recovery-Chain Primitives +## Section F — Phase 4b (Recovery-Chain Primitives) Three review passes ran over this phase; everything they found is either fixed in the branch or listed here. Nothing below blocks the phase — the `RECOV-1`–`RECOV-16` mapping is satisfied and every CI step is green. @@ -399,7 +430,7 @@ phases execute, per this file's own maintenance rule. --- -## G. Phase 5b — Redirect +## Section G — Phase 5b (Redirect) Three review passes ran over this phase. Everything they found is either fixed in the branch or listed here. Nothing below blocks the phase — `REDIR-1`–`REDIR-27` are satisfied, `PIPE-40` is closed, and every CI step is @@ -506,7 +537,7 @@ are the same deferral. Appendix B reaches redirect only through the `XCUT-17` li ### G8 — The 5b design doc's process note claims it is uncommitted — **ACT** -`docs/superpowers/specs/2026-07-26-phase5b-redirect-design.md:23` ends: "Not committed — left for the user to +`docs/work/mvp/phase5/phase5b/2026-07-26-phase5b-redirect-design.md:23` ends: "Not committed — left for the user to review and commit if it holds up." It was committed in `c6603aa` ("Planning (#26)") and has since been amended twice. The sentence is stale and should be dropped or rewritten; the rest of the process note (that the design was authored autonomously and every judgment call is re-listed in the Deviation Ledger for challenge) is still @@ -1222,7 +1253,7 @@ publishes `StepDescriptor` lands, add `clientIdentityStep`/`ClientIdentitySettin ### K2 — Proxy resolution implements the property tier the design doc ledgered as collapsed — **RESOLVED** (2026-08-27) -`docs/superpowers/specs/2026-07-28-phase7a-configuration-design.md` ledgers "system-property layer collapses +`docs/work/mvp/phase7/phase7a/2026-07-28-phase7a-configuration-design.md` ledgers "system-property layer collapses into environment-only (proxy and general config alike)" and its plan's Task 7 sketch dropped `CFG-24`'s property tier and all of `CFG-26` accordingly. The shipped `resolveProxyOptions` implements both, against the substitutable property seam `Configuration` already carries for `CFG-3`/`CFG-4`. Rationale: the collapse is a @@ -1231,7 +1262,7 @@ property seam is empty and real behavior is environment-only, exactly as ledgere without the tier, `CFG-24`'s same-layer-port and https-only-credentials clauses and every clause of `CFG-26` would have been silent gaps, with `getRawProperty` left without a single consumer in the repository. The deviation ledger's wording is now narrowed to say the *production sources* collapse, not the resolution logic: -`docs/superpowers/specs/2026-07-28-phase7a-configuration-design.md`'s ledger row and its §"Proxy model" prose +`docs/work/mvp/phase7/phase7a/2026-07-28-phase7a-configuration-design.md`'s ledger row and its §"Proxy model" prose were both corrected on 2026-08-27. The correction was made here rather than deferred to Phase 10 because that ledger is Phase 10's *input* — handing the reconciliation sweep a row that misstates the as-built code inverts the dependency — and because the file is this phase's own design doc, which this phase may edit. @@ -1261,7 +1292,7 @@ not whole. ### K6 — Six defects in the phase plan's own implementation sketches — **WATCH** -The plan doc at `docs/superpowers/plans/2026-07-28-phase7a-configuration.md` still contains the sketches +The plan doc at `docs/work/mvp/phase7/phase7a/2026-07-28-phase7a-configuration.md` still contains the sketches below. They were corrected in the shipped code; the plan was not rewritten, so a future reader following it verbatim would reintroduce them. The plan now opens with a banner saying so and pointing here and at the checklist as the as-built record, which is the mitigation — the six sketches are deliberately left in place @@ -1334,7 +1365,7 @@ surface-widening the phase declined to do for K1: return edge, and nothing in CI would catch the loop (see K12). 2. **Its `RECOV-32` sibling would land elsewhere.** The idempotency-key step — the adjacent requirement, the same kind of object — is planned for `packages/core/src/recovery/idempotency-key.ts` - (`docs/superpowers/plans/2026-07-26-phase5a-retry.md:157,2529`). Two adjacent `RECOV-3x` steps in two + (`docs/work/mvp/phase5/phase5a/2026-07-26-phase5a-retry.md:157,2529`). Two adjacent `RECOV-3x` steps in two unrelated folders. Also noted here, since it is the same class of question: the barrel comment at `packages/core/src/index.ts` @@ -1701,17 +1732,852 @@ added unilaterally. Two candidate mechanisms were reviewed: **Trigger:** the next re-harvest, whichever lands first. Until then `bun run knowledge:drift` is the check, and it is named in the phase-start section of the `knowledge-lookup` skill. -### O3 — `docs/superpowers/` still carries pre-split corpus paths — **WON'T FIX** +### O3 — the phase records still carry pre-split corpus paths — **WON'T FIX** The move to `harvested/` invalidated every `docs/knowledge/.md:` citation in the repository. The 107 in `packages/`, `test/`, `tests/` and `.changeset/` were repointed in the same commit, per the rule that a comment that no longer matches is corrected with the change that staled it. -The ~200 in `docs/superpowers/plans/` and `docs/superpowers/specs/` were deliberately left. Those files are -dated records of what a phase planned and found at the time; they are not retro-edited, and a phase plan -citing the path that existed when it was written is accurate about its own moment. `CLAUDE.md` states the -split so a reader does not read it as an oversight. +The 207 under what was then `docs/superpowers/plans/` and `docs/superpowers/specs/`, and is now +`docs/work/mvp/`, were deliberately left — 28 distinct topic paths across 33 files. Those files are dated +records of what a phase planned and found at the time; they are not retro-edited, and a phase plan citing the +path that existed when it was written is accurate about its own moment. `CLAUDE.md` states the split so a +reader does not read it as an oversight. + +**The tree moved; the verdict did not.** The 2026-08-31 restructure (`docs/work/mvp/phaseN/`, this file's +Section P onward) repointed every `docs/superpowers/...` *path* citation, because those name files this +project owns and moved. The corpus citations above are a different case: the file they name was deleted by a +harvest, not relocated by us, and nothing this repository does can make the old line number mean anything +again. See U1 for the citations the restructure could not repoint at all. + + +## Section P — Phase 5a (Retry) + +> **Merged 2026-08-31 from the repository-root `open-items.md`.** Phase 5a's code review (passes 1–3, +> 2026-08-26) wrote its findings to a second register at the repository root, created in `cba4721` and never +> folded in — which is exactly the gap this file's own preamble named ("Two phases are shipped but were never +> registered here: 4c and 5a"). The root file is deleted; its nine findings are below, numbered `P1`–`P9`, +> text unchanged apart from the heading form and the status word. Its own status legend was +> 🔴 defect, owner named — 🟡 accepted limitation — 🟢 correct, documented to stop a future "fix" — +> 📄 documentation drift; each is restated in this register's vocabulary in the heading, with the original +> `**Owner:**` line kept intact. +> +> **Every item was re-verified against as-built source on 2026-08-31** before being merged, per this file's +> maintenance rule. Eight still hold. One (`P9`) had been closed by Phase 7b and is marked so. +> +> Findings that *were* fixed in 5a are not listed — they are in the code and its tests. + +### P1 — `toHttpError`'s `finally` can let a teardown failure mask the drain failure — **WATCH** + +**Where:** `packages/core/src/body/http-status-error.ts:106-109` (Phase 3b) + +```ts +} finally { + reader?.releaseLock(); + await response.close(); +} +``` + +`Response.close()` documents `@throws Whatever cancelling the body stream raises, other than the +TypeError a locked stream reports`. Awaiting it in a bare `finally` means a teardown failure replaces +whatever the `try` was propagating — the inversion `RECOV-12` forbids and `suppress()` exists to +prevent (`packages/core/src/suppress.ts` says so in its own doc comment, about native `using`). + +**Why it is not urgent:** cancelling an *errored* `ReadableStream` rejects with the stream's stored +error rather than invoking the source's `cancel` hook, so on the common path `close()` rethrows the +very error already propagating and the masking is unobservable. It becomes observable only for a +stream whose `cancel` hook fails independently of the read that failed. + +**Why it is not fixed here:** shipped Phase 3b code with its own tests, outside 5a's scope. Phase 5a +fixed the same shape at its own call site (`retry/engine.ts`'s `releaseQuietly` / +`withReleaseFailure`), which is what made the upstream instance visible. + +**Owner:** Phase 10 (Deviation Reconciliation), or a Phase 3b follow-up. + +**Re-verified 2026-08-31:** unchanged. `packages/core/src/body/http-status-error.ts:106-109` still ends the +drain in a bare `finally` that awaits `response.close()` after `reader?.releaseLock()`. The +release-before-close ordering now carries its own comment (a different defect, fixed); the masking window is +the same one described above. + + +### P2 — `RequestOptionsBuilder.maxRetries` — fixed here, but the pattern deserves a sweep — **WATCH** + +**Where:** `packages/core/src/http/request-options.ts` (Phase 1) + +Fixed in this phase (see `.changeset/2026-08-26-max-retries-range-check.md`): the setter rejected only +`value < 0`, so `Infinity`, `NaN`, and fractions reached a consumer as a retry budget that never +terminates. + +**What is still open:** the *class* of bug, not this instance. `timeoutMs` next door has the same +shape — it rejects `<= 0` and accepts `Infinity`/`NaN`. A non-finite timeout is less dangerous than a +non-finite retry ceiling (it degrades to "no deadline" rather than "never stop"), but it is the same +gap in the same requirement (`HTTP-35`), and no other numeric public setter has been audited. + +**Owner:** Phase 10, as a sweep over every public numeric setter — is the range check the full range, +or only its lower bound? + +**Re-verified 2026-08-31:** half unchanged. `maxRetries` is fixed — +`packages/core/src/http/request-options.ts:178` now rejects anything that is not `Number.isInteger(value) && +value >= 0`. `timeoutMs` at `:151` still tests `value <= 0` only, so `Infinity` and `NaN` both pass. No other +public numeric setter has been audited since. + + +### P3 — `RetrySettings.retryableStatuses` is immutable by type, not at runtime — **WATCH** + +**Where:** `packages/core/src/retry/settings.ts` + +`retrySettings()` returns `Object.freeze({...})`, but freeze is shallow and does not seal a `Set`'s +internal slots: anyone holding the settings object can still call `.add()` on the status set and +change policy for every later call. + +`RECOV-34`'s actual requirement — a *defensive copy* so a caller mutating **their own** source +collection cannot alter policy — is satisfied and tested. What is not achievable is `RETRY-42`'s +"immutable after construction" as a runtime guarantee. + +This is a deliberate house position, not an oversight: `config/retryable.ts` records it — *"`Object.freeze` +does not seal a `Set`'s internal slots, so a frozen `Set` would be a misleading no-op — typed +`ReadonlySet` instead, same treatment as Phase 1's `IDEMPOTENT_METHODS`."* A genuine runtime guarantee +would need a wrapper object with no mutators, which changes the shape every consumer reads. + +**Owner:** none. Recorded so the gap between the type-level and runtime guarantee is not rediscovered +as a bug. + +**Re-verified 2026-08-31:** unchanged. `packages/core/src/retry/settings.ts:104-106` still returns +`Object.freeze({… retryableStatuses: new Set(merged.retryableStatuses)})`, and freeze does not seal a `Set`'s +internal slots. + + +### P4 — `RETRY-18`'s 365-day pacing ceiling is spec-mandated and operationally hazardous — **ACT** + +**Where:** `packages/core/src/retry/pacing.ts` + +A server that sends `X-RateLimit-Reset` in **milliseconds** instead of epoch seconds — a common +server-side mistake — produces a delta of roughly 56,000 years. `RETRY-18`/`RECOV-26` require +clamping to a 365-day ceiling, so the parser returns exactly that: a retry parked for a year, which +is indistinguishable from a hang. + +Nothing shortens it by default. `totalTimeoutMs` would, but `RETRY-28` makes it explicitly opt-in and +it is `undefined` by default. The caller's own `AbortSignal` is the only other exit. + +Implementing a tighter ceiling would be a deviation from a MUST, so the port complies. Recorded +because "spec-compliant" and "safe by default" diverge here, and the mitigation (set +`totalTimeoutMs`) is a caller decision that needs documenting when the retry surface is finally +published in Phase 5c. + +**Owner:** Phase 5c, as a documentation obligation on the public retry surface. + +**Re-verified 2026-08-31: the Phase 5c documentation obligation was not discharged.** `MAX_PACING_MS` is +`packages/core/src/retry/pacing.ts:13-14`, commented as the `RETRY-18`/`RECOV-26` ceiling. +`RetrySettings.totalTimeoutMs`'s TSDoc (`settings.ts:22-27`) documents the opt-in and `RETRY-28`'s reasoning +but says nothing about the year-long park it mitigates, and neither does `retryStep`. Owner is now unassigned +— Phase 5c is closed. **The work is a TSDoc paragraph on `totalTimeoutMs` naming the failure mode.** + + +### P5 — `parsePacingHint` reads only the first value of a repeated header — **WATCH** + +**Where:** `packages/core/src/retry/pacing.ts` + +`Headers.get()` returns the first value. Given `Retry-After: garbage` followed by `Retry-After: 5`, +the parser tries `garbage`, fails, falls through the remaining header names, and returns `null` — no +hint, fall back to backoff — rather than trying the second value. + +Safe (`RETRY-16`'s fallback is the conservative answer) and arguably correct, since a repeated +`Retry-After` is malformed to begin with. `RETRY-21`'s precedence is defined across header *names*, +not across duplicate values of one name, so nothing requires the second value to be tried. + +**Owner:** none. Recorded because "first usable value wins" reads, on a fast skim of `RETRY-21`, like +it should scan duplicates too. + +**Re-verified 2026-08-31:** unchanged. + + +### P6 — A fixed delay is deliberately not clamped to `maxDelayMs` — **WATCH** + +**Where:** `packages/core/src/retry/backoff.ts` + +`computeDelay` returns `fixedDelayMs` before the cap is applied, so `fixedDelayMs: 3_600_000` with +`maxDelayMs: 8000` waits an hour. This looks like a missed clamp and is not: `RETRY-43` describes the +mode as *"zeroing the base and cap so only the fixed delay applies"* — the cap is part of the schedule +this mode replaces, not a bound that outlives it. + +Documented in the field's own TSDoc. Listed here so a future reviewer reaches the reasoning before +"fixing" it. + +**Re-verified 2026-08-31:** unchanged. `packages/core/src/retry/backoff.ts:73` still returns +`settings.fixedDelayMs` before the cap is applied. + + +### P7 — A response that ends the retry loop is handed over live, not closed — **WATCH** + +**Where:** `packages/core/src/retry/engine.ts` + +`RETRY-32` says *"any response that arrives from an already-in-flight attempt MUST be closed rather +than leaked."* The engine closes every response it **discards**. A response that survives the gates — +attempt cap reached, budget spent, status not retryable — is returned **live and unread**, even when +the caller has already aborted. + +That is not a leak: ownership transfers to the caller, which is the only reader that could close it, +and a `Promise` always resolves to its awaiter, so this port has no "value that can never be +delivered" case for the reference's orphan rule to bite on. Both halves are asserted. +The narrowing is inseparable from `RETRY-36`'s disposition (`toHttpError` drains the body and drops +the headers irreversibly, and 4c's pillar signature must return a `Response`), which the phase design +already ledgers. + +**Re-verified 2026-08-31:** unchanged. + + +### P8 — The Phase 5a design doc overstates the `RETRY-32` guarantee — **ACT** + +**Where:** `docs/work/mvp/phase5/phase5a/2026-07-26-phase5a-retry-design.md`, "The wait" + +> `RETRY-32`: once the caller's signal is aborted the driver launches no further attempts, and any +> response arriving from an in-flight attempt is closed rather than leaked. + +The second clause describes only responses the engine discards — see the item above. The +implementation checklist carries the corrected wording; the design doc still carries the blanket +claim, and was left alone because it is a phase design of record, not a working document. + +**Owner:** Phase 9 (cross-cutting conformance), which reads these documents as its source. + +**Re-verified 2026-08-31:** unchanged. The blanket claim is still at +`docs/work/mvp/phase5/phase5a/2026-07-26-phase5a-retry-design.md:357`. The design is a dated record and is not +retro-edited, which is why this item exists instead of an edit; what is owed is a correction *note*, not a +rewrite. Phase 9 is closed, so the owner is now unassigned. + + +### P9 — Phase 7b still owes `engine.ts` two log events — **CLOSED** + +**Where:** `packages/core/src/retry/engine.ts` (head comment) + +`RETRY-40`'s "log the failure" clause and the two `SHOULD`-level structured events +(`retry.attemptFailed`, `retry.exhausted`) are specified in 5a's plan but written by Phase 7b Task 9 — +5a executes before 7b, and 7b depends on 5a's `FakeTransport`, so the cycle can only be broken in this +direction. The non-fatal half of `RETRY-40` **is** implemented here. + +Already recorded in the roadmap's Deferred Items Log; repeated here so this file is a complete picture +of what Phase 5a knowingly left undone. + +**Owner:** Phase 7b, Task 9. + +**Closed 2026-08-31, by Phase 7b.** Both events ship: `http.retry.exhausted` at +`packages/core/src/retry/engine.ts:323` and `http.retry.attemptFailed` at `:396`, with a third — +`http.retry.delayOverrideFailed` — that the original finding did not anticipate (`engine.ts:18`). Note the +names gained an `http.` prefix relative to the `retry.attemptFailed`/`retry.exhausted` spelled in 5a's plan. + + +## Section Q — Phase 3b validation review (2026-07-28) + +> **Relocated.** A validation pass over Phase 3b's design and plan **before** either was executed. Relocated verbatim on +2026-08-31 from the roadmap's `## Open Findings — Phase 3b Validation Review (2026-07-28)` section. Its rows +are labelled `D1`, `D2` — the review's own numbering, not this register's item IDs. + +A validation pass over `specs/2026-07-25-phase3b-body-lifecycle-design.md` and +`plans/2026-07-25-phase3b-body-lifecycle.md` (`docs/validation-prompts/phase3b-body-lifecycle-validation-prompt.md`) +returned **BLOCKED** on two runtime defects and a cluster of overclaimed disposition rows. **All findings except +D1 and D2 below are applied** to both documents. Recorded here rather than in `docs/deferred-items.md` because +these are review findings against an unexecuted phase, not deferrals of work. + +The two blockers, both now fixed, are worth naming since they generalize: (1) `ReadableStream.cancel()` rejects +with `TypeError` on a locked stream and reading to `{done: true}` does **not** release the reader's lock, so +`Response.bytes()`, `toHttpError()` and the response-logging wrapper each had a `finally`-scoped close that +replaced a successful read with a `TypeError` — a `reader.releaseLock()`-before-cancel constraint now sits in the +plan's Global Constraints, and **every later phase that takes a reader and later closes the stream inherits it**; +(2) `HTTP-39`/`BODY-10`'s exact-length copy was dispositioned as "reuses Phase 3a's `writeAll`" while the plan's +own global constraint forbids importing `BufferedSink`, leaving a declared `contentLength` unverified and a short +stream sending a truncated body silently. + +**Cross-phase note for 4b.** 4b's preamble relies on `Response.close()` latching `#closed` before awaiting +`body.cancel()` so a close rejection propagates exactly once. That still holds: the latch is unchanged and the +only rejection now swallowed is the `TypeError` a still-locked external reader produces, which `BODY-15` requires +close to tolerate. Every other close failure propagates as before. + +| # | Sev | Finding | Where | Resolution | +|---|---|---|---|---| +| D1 | major — **CLOSED (3b execution)** | Task 13 Step 6 specifies a **minor** changeset on the reasoning that `Request.body`'s move from `unknown` to `Body \| undefined` is "not breaking for any real caller, since `unknown` accepted nothing usable before." That premise is false — `unknown` accepted *everything*, which is exactly why Task 7 Step 1 has to rewrite every `.body('x')` call site in the existing suite. `api-design.md:72` classes a narrowed parameter type as breaking, requiring MAJOR. `ResponseBuilder.body` narrows the same way | PLAN Task 13 Step 6; `api-design.md:72` | **Resolved: branch (b), minor.** `@dexpace/core` is `0.0.0`, and semver's own initial-development carve-out () puts a 0.x breaking change out as minor; the pointer is recorded in the changeset itself, not only here. Revisit at 1.0, when the carve-out stops applying and Phases 4a/4b/5's identical narrowings become real majors. The alternatives were (a) ship it as **major**, which is what the corpus rule says and what the plan now instructs by default, or (b) if `@dexpace/core` is still pre-1.0 and the repo's release policy treats 0.x breaks as minor, keep minor and record the policy pointer. The plan carries both branches with the false justification deleted; pick one before Task 13 runs. Settle once — Phases 4a/4b/5 narrow Phase-1 placeholder types the same way | +| D2 | major — **CLOSED (3b execution)** | Three Phase-1/3a symbols the 3b plan now calls could not be verified: `MAX_ARRAY_BYTES` (assumed exported from `io/byte-queue.ts`, backing `AllocationLimitError`'s `limit` argument — used by both logging tees' `BODY-32` cap clamp), `Status.isError` (used by `toHttpError`'s `BODY-31` gate, replacing a `code < 400` that wrongly swept non-standard 6xx into the error path), and `Protocol.token` (used by `TypedResponse`). `packages/` does not exist on the planning branch, so none could be checked | PLAN Task 10, 11 (`MAX_ARRAY_BYTES`), Task 12 (`Status.isError`), Task 9 (`Protocol.token`) | **Verified against the real code.** All three exist and are used: the constant is `MAX_BYTE_ARRAY_LENGTH` in `io/limits.ts` (not `MAX_ARRAY_BYTES` in `io/byte-queue.ts` — the real name was used, no duplicate added), and `Status.isError` and `Protocol.token` are both present as assumed, so `HTTP-11`'s classification is not a Phase-1 gap. Original guidance, kept for the record: Task 11's Interfaces block carries a "Verify before writing" note. If a name differs, use the real one; do **not** add a second constant or a local `isError` helper. If `Status` genuinely has no `isError`, `HTTP-11`'s classification is itself a Phase-1 gap and the gate becomes `code >= 400 && code <= 599` pending that fix | + +**Applied without needing a decision** (recorded so the reasoning survives): `BODY-34`'s "one shared cap" +contradiction resolved in the plan's favour — the shared preview cap covers the two logging tees, and +`toHttpError`'s 1 MiB cap is separate because `HTTP-52` *fixes* its value and a spec-fixed value cannot be the +configurable one; `BODY-26`/`BODY-29` built (`LoggedResponseBody` gained a non-draining `error()` and a +regime-dependent `contentLength`); `BODY-25` ledgered as structurally inapplicable — `ReadableStreamDefaultReader` +takes no requested count, so "zero bytes for a positive count" has no analog; `BODY-32`'s negative-cap rejection +added to both tees, which previously accepted a negative cap and silently mirrored nothing; `HTTP-3`'s +`MultipartBodyBuilder` added (`HTTP-3` names "the multipart body" explicitly and Phase 1 could not satisfy it); +`HTTP-2` honored by exporting the concrete body classes from the public barrel as **types only**; the `@internal` +tags removed from the three errors Task 13 promotes, which would have made `api-extractor` either fail or +silently omit them; `withResponseLogging` decomposed under the 70-line cap and made pull-driven, since its +`start()`-loop tail stream eagerly materialized the whole remainder of exactly the oversized bodies the cap +exists to keep off the heap. + +**Correction to 4b's F2 below.** That row states "Phases 1/2/3b/4a ship zero" assertions. **3b no longer does** — +`invariant` pre/postconditions now sit on both tees' caps, `materialize`'s byte accounting, `MultipartBody`'s +framing length, `StreamBody`'s `contentLength`, `drainOnce`'s cap, and `toHttpError`'s buffer loop. Phases 1, 2 +and 4a still ship zero, so 4b's F2 remains open as a project-level question for Phase 10 — 3b is now a second +data point alongside 4c that the rule is applicable, not just aspirational. + + +## Section R — Phase 3b execution (2026-08-25, expanded 2026-08-26) + +> **Relocated.** What Phase 3b's execution found once the code existed. Relocated verbatim on 2026-08-31 from the roadmap's +`## Open Findings — Phase 3b Execution` section. Its rows are labelled `E1`–`E7` — the review's own +numbering, not this register's item IDs. + +Findings that surfaced only once Phase 3b's plan was actually executed, across three review passes. Nearly all +are **checkpoint-owned**, not 3b-owned: the 3b design took the checkpoint +(`plans/2026-07-25-checkpoint-scaffold-through-phase3a.md`) as a signed-off prerequisite, and it has not run. +Every box in that document is unchecked and no commit implements it. + +### Why nobody noticed: the checkpoint was cherry-picked, not skipped + +The more useful framing than "the checkpoint did not run" is that **parts of it did**, which is exactly what made +the 3b plan's prerequisite claim plausible to whoever wrote it. Measured status of every `§5` item as of +2026-08-26: + +| § | Item | Status | +|---|---|---| +| 5.1 | Coverage floor as a *blocking* gate | **Done** — `bunfig.toml` carries `coverage = true`, `coverageThreshold = 0.8` | +| 5.2 | Flatten the `DomainModelError` tier | **Open** — E2 below | +| 5.3 | Error leaves carry identifying `readonly` fields | **Partial** — 2 of 10; E3 below | +| 5.4 | `Symbol.asyncDispose` + floor bump + `lib` entry | **Open** — E1 below | +| 5.5 | Bounded collections vs `RetentionWindow`/tap | **No action needed** — confirmatory in the checkpoint itself | +| 5.6 | `AbortSignal.any` composition | **No action needed** — confirmatory | +| 5.7 | Flat hoisting lets a package resolve an undeclared dependency | **Open** — E4 below | +| 5.8 | `NFR-14`'s stale "no direct Bun equivalent" reason | **Resolved in Phase 6a (2026-08-27)** — E7 below | +| 5.9 | `bun test` proves nothing about the Node runtime | **Done 2026-08-26** — E5 below | +| 5.10 | Per-class `#private` justification comments | **Open** — E6 below | +| 5.11 | Phase 4 pre-commitment: `Stage` must not be an `enum` | Not yet due (Phase 4) | +| 5.12 | Tooling conflicts already resolved by the plans | Recorded only | + +Partial application is worse here than none at all. `§5.1` is visible in `bunfig.toml` and half of `§5.3` is +visible in `errors.ts`, so a reader checking whether the checkpoint had landed would have found evidence that it +had. **Verify a prerequisite against the artifact it was supposed to produce, not against a spot check.** + +| # | Sev | Finding | Where | Resolution | +|---|---|---|---|---| +| E1 | **blocker — CLOSED in 3b, reopened against checkpoint §5.4** | 3b shipped `[Symbol.asyncDispose]` on `Response` and `LoggedResponseBody` on the strength of the design's claim that "the floor is bumped and `lib` extended before 3b starts". Neither happened: `engines.node` is still `">=18.17"` and `lib` is `["ES2022", "DOM", "DOM.AsyncIterable"]`. Two consequences, both real: below Node 18.18 the computed key evaluates to `undefined` and binds the method to the string `"undefined"`; and the symbol's *type* reaches the package only through a dev-only global, so a consumer compiling against the published `.d.ts` on this repo's own declared `lib` fails with `TS2550: Property 'asyncDispose' does not exist on type 'SymbolConstructor'`. No gate covered it — `verify:dual-consumption` runs `node`, not `tsc` | `packages/core/package.json`; `tsconfig.base.json`; 3b design §"Response Body" | **3b reverted to `close()`-only**, matching the decision Phase 3a shipped and every other resource owner still carries, with both classes now asserting the symbol's *absence* so it cannot be reintroduced ahead of the floor. Re-adding it is checkpoint §5.4's job and must land on all seven owners at once — `Transport`, `ByteQueue`, `BufferedSource`, `BufferedSink`, `RetentionWindow`, `Response`, `LoggedResponseBody`. **Version numbers now verified**, discharging §5.4's own "verify against the actual Node release notes before writing the number" instruction: `Symbol.dispose`/`Symbol.asyncDispose` first shipped in **Node 18.18.0**, backported to **20.4.0** — symbols only, not the `using` syntax. So §5.4's "believed 18.18.0" was right and the bump really is patch-level. **Renumbered 2026-08-26 by E8:** the floor is now `>=20.3`, and on the 20.x line the symbols arrive in 20.4.0, so §5.4's bump reads `>=20.3` → `>=20.4`. **Note for 4b's F1:** that finding assumed the floor had already been "raised at most to `18.18.0` at the 2026-07-25 checkpoint" and that `esnext.disposable` was in `lib`. Neither premise holds — see F1's own amended row | +| E2 | major — **OPEN, checkpoint §5.2** | 3b's Task 1 flattened `io/`'s four error leaves off `IoError` on the stated basis that checkpoint §5.2 had already flattened Phase 1's `DomainModelError` tier. It had not, so the taxonomy is now *mixed*: `DexpaceError → EndOfStreamError` is two levels while `DexpaceError → DomainModelError → RequiredFieldError` is still three | `packages/core/src/http/errors.ts`; 3b design §"Error Tree" | **Deliberately not fixed in 3b.** Removing `DomainModelError` deletes a class exported from the public barrel that consumers can `instanceof` — a breaking API change belonging to the checkpoint. The residual is strictly smaller than what preceded it (`io/` no longer adds a second independent violation) and is recorded in 3b's ledger and checklist. **Blast radius, measured:** ten leaves extend it — `RequiredFieldError`, `HeaderValidationError`, `MediaTypeParseError`, `ProtocolParseError`, `UrlConstructionError`, `RequestOptionsValidationError`, `EtagParseError`, `HttpRangeValidationError`, `RequestConditionsValidationError`, `RequestBodyNotAllowedError` — all in one file, and `DomainModelError` itself is a runtime value export, so `instanceof` narrowing on it is live public API. §5.2 pre-specifies the replacement (an exported `isDomainModelError` type-guard union, never a re-subclass), and 3b already proved that pattern twice in-tree with `isIoError` and `isBodyError`. **Sequencing:** §5.2's own note — "Phase 4's error families then land as leaves on `DexpaceError` too, which is what keeps the flattening from being undone one phase later". **Ten queued phases introduce new SDK error types** — 4a (`DuplicateContextKeyError`), 4c (five, including `PillarCollisionError`, `CrossStageEditError`, `ReservedStageError`), 5b (`NonReplayableBodyError`, `SchemeDowngradeError`), 5c (`AuthResolutionError`, `PlaintextCredentialError`, `DigestChallengeUnsupportedError`), 6a (`SerdeError`, `SerializationError`, `DeserializationError`), 6b (`SseStreamError`, `SseLineTooLongError`), 6c (`PaginationError`), 8a (`TransportFailureError`), and 5a/8b, which reuse rather than define. Counted from the phase design docs 2026-08-26; 4b and 7a/7b define none. Every one of those that ships before the flatten is another tier decision taken against the wrong parent. Owned by checkpoint §5.2 | +| E3 | major — **OPEN, checkpoint §5.3** | §5.3 requires every error subclass to carry its identifying inputs as sanitized `readonly` fields, because `JSON.stringify(error)` and structured-log field enumeration bypass `.message` entirely. It was applied to **two** leaves and stopped: `RequiredFieldError` carries `fieldName`, `HeaderValidationError` carries `kind` + `escapedName`. The other **eight** carry nothing — their identifying data exists only interpolated into the message string, which is precisely the shape the rule forbids. Not raised by any of Phase 3b's three review passes either; found only when the checkpoint was audited item by item | `packages/core/src/http/errors.ts` | **Open.** Same file and same ten classes as E2, so doing §5.2 and §5.3 in one pass is strictly cheaper than two. §5.3 also specifies the sanitization shape per leaf: the offending *name* control-character-escaped, the offending *value* never stored raw (a `valueLength`, a masked minimum fragment, or no field at all), and for `MediaTypeParseError` the failing token/offset rather than the full input. It further asks for a file comment on `errors.ts` recording *why* fields are sanitized at construction — that comment is what stops a later contributor "restoring" the raw value | +| E4 | major — **OPEN, checkpoint §5.7** | No isolated linker is configured. `bunfig.toml` carries only a `[test]` block and there is no `.npmrc` at all, so the install is flat-hoisted by default. Under flat hoisting `@dexpace/core` can import a package it never declared and still pass every gate — including `verify:seam-1`, which reads the `dependencies` map rather than what the code actually resolves. That is the one phantom-dependency failure mode `SEAM-1`'s gate structurally cannot see | `bunfig.toml` (no linker key); no `.npmrc`; `scripts/verify-seam-1.mjs` | **Open.** §5.7 requires confirming the exact linker option against the pinned Bun version before writing it. Low effort, and it strengthens a `SEAM-1` guarantee the project treats as foundational | +| E5 | **blocker — CLOSED 2026-08-26, checkpoint §5.9** | Was: no `test:node` script existed, yet the 3b plan's Task 13 Step 3 gate sequence called `bun run test:node`, so that plan could not be executed as written; `node-floor-conformance` pinned `18.17.0` alone, leaving current LTS unexercised against the "in addition to current LTS" half of the rule; and all 516 unit tests ran only on Bun. Audited 2026-08-26: **319 of those 516 tests, across 21 of 43 files, exercise a runtime-divergent surface** — Web Streams, `AbortSignal`, async iteration, or `ByteQueue`'s `Uint8Array` handling — against **two** assertions of Node coverage, neither of which touched `io/`. The `ci` job additionally pinned no Node at all, so `verify:dual-consumption`/`verify:consumer-types`/`verify:runtime-floor` ran on an undeclared runner default | `.github/workflows/ci.yml`; root `package.json` scripts; 3b plan Task 13 Step 3 | **Closed by implementing §5.9's own prescription, not a substitute.** `bun test` is unchanged as the unit runner and is now scoped to `packages/` so the two layers cannot blur. Added `test/node-conformance/` — 30 `node --test` cases over the **built** artifact, seeded with `composeSignal` plus Phase 3a's byte-stream surface and Phase 3b's public body surface — wired as `test:node`. `scripts/verify-node-floor.mjs` is **retired**, 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/*']` (floor pin moved to `20.3.0` by E8); `lts/*` resolves at run time so the LTS half cannot go stale. The membership rule §5.9:378 states — a phase touching a runtime-divergent surface adds a case here — is recorded in `test/node-conformance/README.md` and `CLAUDE.md`. **Note:** the CI job name changed, so any branch protection requiring `node-floor-conformance` needs updating to `node-conformance` | +| E6 | minor — **OPEN, checkpoint §5.10** | §5.10 ratifies the `#private` *choice* for wire-model classes but calls the missing per-declaration justification "a real, uncorrected gap" — the corpus wants the reason where a reader meets the field, not in a plan document they will never open. **None** of the eleven `packages/core/src/http/` model files carries one. Measured 2026-08-26 by grepping for a comment naming runtime privacy or citing `HTTP-1`/`SEAM-29` near a `#private` declaration: four files matched and all four were false positives — unrelated `HTTP-10`/`HTTP-11`/`HTTP-13`/`HTTP-18` requirement citations in ordinary TSDoc | `packages/core/src/http/*.ts` | **Open.** One short comment per declaring class (not per field), naming the runtime-privacy requirement and citing `HTTP-1`/`SEAM-29`. §5.10 also asks that the `http-domain-model.md` conflict entry then be resolved as a carve-out **scoped to wire-model classes only**, so it cannot read as blanket permission for `#private` elsewhere | +| E7 | minor — **RESOLVED, Phase 6a (2026-08-27)** | The scaffold checklist deferred `NFR-14` on the reasoning that pnpm's `catalog:` protocol "has no direct Bun equivalent". Bun has since added workspace catalogs, and Phase 6a adopted them: the root `workspaces.catalog` block now single-sources the four tool versions, referenced as `"catalog:"` from the root's own `devDependencies` and from both member packages. The stale reason is therefore moot rather than corrected in place — the decision it would have misled a later reader into re-litigating has been made. Confirmed against the pinned Bun version (`.bun-version` 1.3.14; catalogs landed in 1.2.0), as §5.8 required. | `plans/2026-07-23-scaffold-milestone-checklist.md:45`; two `docs/knowledge` lines | **Closed.** | +| E8 | **blocker — CLOSED 2026-08-26** | `MultipartBody` generates its boundary from `crypto.getRandomValues`, a bare global reference, 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 a CommonJS probe would have reported the floor as satisfied. Every `multipartBody(...)` call therefore threw `ReferenceError: crypto is not defined` on the declared floor. Uncaught until E5's conformance suite ran the built artifact on the pinned floor for the first time; `bun test` cannot see it, because Bun supplies the global. The same run exposed a second, unrelated defect: `seams.test.mjs` awaited an `AbortSignal.timeout()` abort with nothing else scheduled, and that timer is unref'd on every Node version, so on 18.17.0's test runner the loop drained first and the 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 why it passed on `lts/*` | `packages/core/src/body/multipart-body.ts:36`; `packages/core/package.json`; `tsconfig.base.json`; `.github/workflows/ci.yml`; `sdk-design-nodejs/02:10` | **Floor raised to `>=20.3`**, the option taken in preference to a `node:crypto` fallback (which would put a Node-only specifier in a package documented as running on browsers, Deno, Bun and Workers, and cannot be reached synchronously from a constructor) or a non-crypto RNG (which silently downgrades the unguessable-boundary mitigation that `HTTP-51` leans on against multipart injection). **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 on a pinned 20.0.0. `lib`/`target` move to `ES2023` with it, keeping `verify:runtime-floor`'s pairing table honest; its `es2023` row is amended to `>=20.3` with the built-ins, not the syntax, named as the reason. The CI matrix floor pin moves `18.17.0` → `20.3.0`, and `seams.test.mjs` gains a case asserting `globalThis.crypto.getRandomValues` is a function *in ESM*, so the floor cannot regress silently. Node 18 went EOL in April 2025, so no supported runtime is dropped. **Note for E1:** this discharges E1's floor half in the sense that only `Symbol.dispose`/`Symbol.asyncDispose` now stand between the declared floor and §5.4 — but not the number: the symbols reached the 20.x line in **20.4.0**, so §5.4's bump is now `>=20.3` → `>=20.4`, still patch-level, and still required before any owner declares the method | + +### Suggested order + +**Before Phase 4 starts:** + +1. **E2 + E3 together**, in one pass over `packages/core/src/http/errors.ts`. Same ten classes, same file, and + E2's sequencing argument means every phase that ships first adds leaves to a tier that is about to be removed. +2. **E1** (§5.4's three parts, which do not work separately). Cheaper now than when the checkpoint was written: + the new `verify:consumer-types` gate mechanically proves a `lib` entry that is declared but whose floor was + not raised, and proves the reverse too. +3. **F1 is closed** — resolved to branch (b) and implemented 2026-08-26 as `packages/core/src/suppress.ts`. + Read it before designing against `SuppressedError` anywhere. See "F1 resolution — the verified version facts" under + "Open Findings — Phase 4b Validation Review" further down this document. That amendment changes 4b's design + input, not just its wording, and F1 already notes the resolution has to land in 5a, 6b and 6c at the same + time. + +**Not blocking Phase 4, ordered by how fast they decay:** E4, E6, E7. E5 is closed — it was the one that grew +with every phase, which is why it went first. + +### Phase-3-owned residuals + +Distinct from the checkpoint items above: these belong to Phase 3 itself and are recorded in its ledger and +checklist rather than being anyone else's to close. + +| Item | Level | Disposition | +|---|---|---| +| Multipart boundary **non-appearance** in part content | `HTTP-51`, ⚠️ partial | RFC 2046 puts two duties on the sender; only the `bchars` grammar half is checkable here, because a `StreamBody` part's bytes do not exist until the write and a partial scan would read as a complete guarantee. Mitigated by generating a 32-character Web Crypto boundary by default and documenting the obligation on both caller-supplied entry points. Revisit only if demand for caller-chosen boundaries appears | +| `StreamBody` always single-use, no mark/reset | `BODY-9` (SHOULD), bounded | Node's `ReadableStream` has no generic mark/reset. Closes only if the platform gains one | +| `BODY-34`'s shared preview-cap **value** | ⏳ Phase 7 | Both tees take the parameter today; Phase 7 owns the `Logger`/config surface that threads one value through them | +| `BODY-4`/`BODY-5` replayability **consultation** | ⏳ Phase 5 | Phase 3 guarantees the property is correct; retry/redirect/auth consult it | +| `FileBody` (`HTTP-40`/`BODY-11`/`12`/`13`/`36`) | ⏳ Phase 8a | Already resolved in 8a's design as `@dexpace/body-file` plus a structural `Body.kind === 'file'` contract | +| Both logging tees unwired to any `Logger` | ⏳ Phase 7 | Mechanism ships now because the IDs are `§6`; nothing constructs one yet. Matches Phase 2 shipping `Serde` with no implementation | + +Also worth carrying forward, since three separate defects in 3b traced to the same root: **a `Body`/sink decorator +must forward BOTH teardown paths.** A `WritableStream` adapter that declares `write` and `close` but no `abort` +silently swallows the delegate's abort — the default abort algorithm is a no-op — leaving the real sink open and +locked and letting a truncated body be committed downstream as a complete one. Likewise `pipeTo`'s default +`preventCancel: false` cancels the *source* when the destination fails, which takes cancellation ownership away +from the caller (`BODY-8`). Phase 4c's stage pipeline and Phase 8a's transports both wrap sinks; both inherit this. + + +## Section S — Phase 4b validation review (2026-07-28) + +> **Relocated.** A validation pass over Phase 4b's design and plan before execution. Relocated verbatim on 2026-08-31 from +the roadmap's `## Open Findings — Phase 4b Validation Review (2026-07-28)` section. + +**Its rows are `F1`–`F10`, the review's own numbering.** Section F above numbers *its* items `F1`–`F9`, and +Section T below numbers a different review's rows `F1`–`F9` again. Three `F` namespaces, no overlap in +meaning. A citation must name the section — "Section S's F2", never a bare "F2". Renumbering was rejected: +the roadmap's own status notes cite "4b's F2/F7" by these numbers, and a dated record that changes its row +IDs stops matching the documents that quote it. + +A validation pass over `specs/2026-07-25-phase4b-recovery-chain-design.md` and +`plans/2026-07-25-phase4b-recovery-chain.md` (`docs/validation-prompts/phase4b-recovery-chain-validation-prompt.md`) +returned **BLOCKED**. The `RECOV-1`–`RECOV-16` mapping itself is sound and every cross-phase reference 4b consumes +checks out against the earlier phase plans — `toHttpError(): Promise` (3b), `RequestOptions.EMPTY` +(Phase 1), `Transport.send(request, options?, signal?)` + `CancellationError` (Phase 2), and `Response.close()` latching +`#closed` *before* awaiting `body.cancel()` so it propagates a close rejection exactly once (3b). Nothing below is a +defect in that mapping. Recorded here rather than in `docs/deferred-items.md` because these are review findings against +an unexecuted phase, not deferrals of work. + +**Status (2026-08-26): F1 and F2 are closed and Phase 4b is implemented.** F1 landed as branch (b) — the +runtime-guarded `suppress()` helper in `packages/core/src/suppress.ts`, shipped with both branches of the guard +forced in `bun test` and re-forced from real Node in `test/node-conformance/recovery-chain.test.mjs`. F2 landed +as a Deviation Ledger row in 4b's design, deferring the density rule to Phase 10's project-wide pass rather than +making 4b the one module that differs. Phases 5a, 6a, 6b and 6c now have a helper to call and no longer carry an +open decision — only the mechanical substitution of `suppress(...)` for `new SuppressedError(...)` when each +executes. + +**Status (2026-07-28): F3–F10 are applied** to `specs/2026-07-25-phase4b-recovery-chain-design.md` and +`plans/2026-07-25-phase4b-recovery-chain.md`. **F1 and F2 remain open — they need decisions**, and both documents now +carry a blocking notice pointing here. The rows below keep the full finding text so the reasoning survives; the +Resolution column records what was done. + +**F1 was cross-phase and blocked four phases, not one.** Phases 5a, 6b and 6c all reach for native +`SuppressedError` on the same false premise. The resolution landed as a shared helper rather than as four +parallel edits, so the cross-phase obligation is discharged by 4b: each of the other three substitutes +`suppress(...)` for `new SuppressedError(...)` when it executes, with no decision left to make. + +| # | Sev | Finding | Where | Resolution | +|---|---|---|---|---| +| F1 | **blocker** — ✅ closed | `SuppressedError` does not exist on the declared runtime floor. `engines.node` is `">=18.17"`, raised at most to `18.18.0` at the 2026-07-25 checkpoint (which exposes `Symbol.dispose`/`Symbol.asyncDispose` only — Node backported those two symbols; `SuppressedError` is a V8 global from the full Explicit Resource Management proposal). `esnext.disposable` in `lib` supplies its *type*, so `new SuppressedError(...)` type-checks and then throws `ReferenceError` at call time — the exact `NFR-10` trap `tooling-and-quality-gates.md:60-61` describes. `bun test` passes locally; the `node-floor-conformance` job pinned to `18.17.0`, `verify:node-floor` and `test:node` all fail | PLAN:19-20 (Tech Stack, claims it is "already available since Phase 3b's checkpoint lib bump" — false), PLAN:804, SPEC:124; also 5a plan:36, 6b design:163, 6c design:192 | **Resolved 2026-08-26: take branch (b)** — the runtime-guarded `suppress()` helper. The "confirm the first supporting Node release" condition this row left open is now discharged, and it settles the choice rather than merely informing it; two of this row's own premises also turn out to be false. See "F1 resolution — the verified version facts" below the table. **Partially applied 2026-07-28:** the false Tech Stack claim is deleted and replaced with a blocking notice at the top of the plan stating the real constraint; **Applied 2026-08-26:** `packages/core/src/suppress.ts` ships the guarded helper, `response-chain.ts` calls it, and assertions are written against its shape rather than `instanceof SuppressedError` — the `instanceof` form would silently assert nothing on the floor runtime | +| F2 | major — ✅ closed | Zero assertions across the whole `recovery/` module — a dozen functions, no `invariant()` call, against `assertions.md:6-7`'s 2-per-function module average (and `styleguide-overview.md:22-23` Rule 8). Neither document acknowledges the rule or argues an exemption. Concretely: no `apply()` checks that a step returned a value at all, so a step returning `undefined` poisons the fold silently. Project-wide inconsistency, not 4b's alone — Phases 1/2/3b/4a ship zero, 4c ships fifteen | PLAN:463-479, 818-859, 964-966, 1352-1370 | **Resolved 2026-08-26: Deviation Ledger row.** Recorded in 4b's design with the concrete cost named (a step returning `undefined` poisons the fold silently). Assertions added to 4b alone would deepen the 0-vs-15 split with 4c rather than close it, so the density rule is settled once at Phase 10 and applied project-wide. **Re-deferred 2026-08-30 (Phase 10): NOT SCHEDULED.** Phase 10's scope is deviation reconciliation; a project-wide assertion sweep is neither a deviation nor a reconciliation, and Phase 10 is the last row of the phase table, so there is no later phase to hand it to and none is invented here. The picture has changed since 4b: `invariant()` is now called from thirteen modules across `packages/core/src/` and `packages/body-file/src/` (`body`, `auth`, `observability`, `io`, `retry`, `pagination`, `config`, `sse`, `redirect`, `pipeline`, `serde`, `context`, `testing`), so the 0-vs-15 split is no longer the shape of the problem — `recovery/` is now the outlier, still with zero (`packages/core/src/recovery/` imports only `assertNever`, `outcome.ts:3`). **Trigger:** the next defect traced to an unasserted precondition, or a naming/assertion convention sweep commissioned as its own phase — whichever comes first. Logged in `docs/deferred-items.md` so it is tracked rather than silent | +| F3 | major — ✅ applied | SPEC:270 still says "the only new failure surface is `wrapCancellation()`'s `invariant()` crash" — stale text from a superseded draft. SPEC:194-204, SPEC:279 and PLAN:63-74 all state the opposite. An agent executing from the File Layout section would restore the `invariant()`, and because the helper runs inside `dispatchWithRecovery`'s own `catch`, that throw bypasses the response and recovery chains — the one failure mode `RECOV-2` exists to prevent | SPEC:270-271 | Replace with `assertNever`'s `InvariantViolation` crash, matching the already-correct PLAN:89-90 | +| F4 | minor — ✅ applied | Spec never designs the `assertNever` addition Task 1 builds. PLAN modifies `packages/core/src/invariant.ts` (new exported symbol, two tests, its own commit); SPEC's File Layout lists only `recovery/` | SPEC:258-268 vs PLAN:102-103, 124-197 | Add the `invariant.ts` line to the spec's File Layout with a one-line note that `fold()` is the codebase's first discriminated-union `switch` | +| F5 | minor — ✅ applied | `RECOV-14`'s second normative sentence (steps safe for concurrent invocation; per-request state never on the step instance) is claimed but neither designed nor tested — both documents cite `RECOV-14` for the defensive copy only. The design does satisfy it (all per-call state is local), but nothing records or guards that | SPEC:141-144, PLAN:49-51 | One sentence in the design + one plan test interleaving two `apply()` calls on one chain | +| F6 | minor — ✅ applied | `RECOV-32`/`RECOV-33` read as silent drops. 4b's deferral sentence covers "backoff, budget, pacing headers → Phase 5"; neither an idempotency-key header injector nor `User-Agent` composition is any of those. Both *are* built — `RECOV-32` in Phase 5a Task 11, `RECOV-33` in Phase 7a Task 9 — but 4b names neither, and 7a is not "Phase 5" | SPEC:18-20 | Extend the Scope sentence to name `RECOV-17`–`RECOV-31`/`RECOV-34` → 5a, `RECOV-32` → 5a, `RECOV-33` → 7a | +| F7 | minor — ✅ applied | `#private` fields with no justifying comment, against `data-modeling.md:20-23` (`private` is the default; `#private` needs a stated runtime-privacy requirement). Neither chain class needs it — unlike 3b's `Response`, whose `#closed` genuinely must survive `Object.freeze(this)`. Inherited pattern: 4a's `ContextStore` does the same | SPEC:64, 78-79; PLAN:464, 819-820, 833, 847 | Ledger row recording `#private` as the package-wide field style with no runtime-privacy claim; project-wide reconciliation is Phase 10's. **Re-deferred 2026-08-30 (Phase 10): NOT SCHEDULED, and mostly moot.** The finding's actual ask was a *stated* runtime-privacy requirement, and the project has since stated one: `CLAUDE.md:172-173` makes "`#private` fields only. Not TS `private`." a mandated construction rule and cites styleguide 6.7's carve-out for libraries whose internals must stay unreachable reflectively. That is the justification F7 asked for, adopted project-wide rather than argued per class — `packages/core/src/http/status.ts:22-23` and `packages/core/src/recovery/request-chain.ts:26` are the same shape. What is left is cosmetic (no per-class comment) and has no owner: Phase 10 is the last phase, and a convention already written into `CLAUDE.md` does not need a sweep to enforce it. **Trigger:** a lint rule that mechanizes the convention, or a styleguide revision that withdraws the 6.7 carve-out. Logged in `docs/deferred-items.md` | +| F8 | minor — ✅ applied | Plan's `ResponseRecoveryChain` property test drops half of what the spec specifies. SPEC promises the property also proves the response-step phase never runs on a `Failure` input (`RECOV-4`); the plan's generator emits recovery steps only and never seeds a `Failure`, asserting only that `apply()` settles | SPEC:293-295 vs PLAN:754-773 | **Applied 2026-07-28 — generator extended**, not spec narrowed: the property now generates response *and* recovery steps over a seed that is arbitrarily `Success` or `Failure`, and asserts `responseStepRuns === 0` on every `Failure` seed. Task 3's expected test count moves 12 → 13 | +| F9 | minor — ✅ applied | `fold(outcome, onSuccess, onFailure)` takes three positional parameters, tripping `function-design.md:22-23` ("options object at 3 or more"), which is one stricter than the lint gate (`max-params: ['error', 3]` errors at four). Passes CI while violating the corpus. Phase 2's shipped `Transport.send(request, options?, signal?)` is the same shape | SPEC:36, PLAN:320 | Ledger row recording it as deliberate (matching `Transport.send`), or `fold(outcome, {onSuccess, onFailure})`. See the corpus conflict below | +| F10 | minor — ✅ applied | `statusMappingStep` is a module-level `const` arrow, against `function-design.md:18-21` ("top-level named `function` declarations… arrows are reserved for inline callbacks"). `func-style`'s `allowArrowFunctions: true` will not catch it, and named declarations survive in stack traces — which matters for a function whose whole job is to `throw` | SPEC:227, PLAN:1081 | `export async function statusMappingStep(...)` plus `statusMappingStep satisfies ResponseStep` to keep the conformance check | + +#### Section S, row F1 — resolution: the verified version facts + +Two of F1's premises are false, and the second changes which branch is affordable. + +**1. The floor was never raised.** F1 assumed `engines.node` had been "raised at most to `18.18.0` at the +2026-07-25 checkpoint" and that `esnext.disposable` was in `lib`. The checkpoint has not run at all — see +"Open Findings — Phase 3b Execution", finding E1. `engines.node` is still `">=18.17"` and `lib` is +`["ES2022", "DOM", "DOM.AsyncIterable"]`. + +**2. `SuppressedError` needs a far higher floor than `Symbol.asyncDispose`.** These are not the same bump, and +F1 treats them as comparable. Node backported the `Symbol.dispose`/`Symbol.asyncDispose` *symbols alone* in +**18.18.0** and **20.4.0**. `SuppressedError` belongs to the full Explicit Resource Management proposal, which +shipped in **V8 13.8 / Chromium 134** and reached Node only in **24.0.0**. So F1's branch (a) — "raise +`engines.node` past the first release shipping Explicit Resource Management" — is not a patch bump from 18.18. +It means `>=24.0.0`, **dropping Node 18, 20 and 22 outright**, which is disproportionate to the need and is +exactly the kind of unsanctioned floor move the checkpoint at plan:57 forbids. + +Branch **(b)** therefore wins on cost rather than as a compromise: a runtime-guarded +`suppress(primary, secondary)` helper in `packages/core/src/`, using native `SuppressedError` when +`globalThis.SuppressedError` exists and attaching a `suppressed` property otherwise. + +**A third point that must not be lost when E1 lands.** `esnext.disposable` in `lib` supplies +`Symbol.asyncDispose`'s *type*; it does **not** supply `SuppressedError`'s *runtime*. The +type-checks-then-throws-`ReferenceError` trap F1 describes therefore survives E1's floor bump intact. Adding the +`lib` entry is not a fix for F1 and must not be read as one — including by Phases 5a, 6b and 6c, which reach for +native `SuppressedError` on the same false premise and which F1 already notes must be resolved together. + +**Corpus conflict surfaced, not a finding.** `function-design.md:22-23` requires an options object at 3+ parameters +while `function-design.md:40-41` sets `max-params: ['error', 3]`, which errors only at four — the prose is one +parameter stricter than its own stated enforcement. F9 is filed against the prose; if the lint threshold is the +authority, F9 dissolves. Worth settling in the corpus rather than per-phase. + +A second conflict the 4b documents met and resolved correctly, recorded so a later reader does not re-litigate it: +`resource-management.md:4-5,72` mandates `using`/`await using` and documents that native disposal builds a +`SuppressedError` with the *disposal* failure primary, while `RECOV-12` requires the opposite priority. 4b picks +`RECOV-12` and argues it at SPEC:107-113 / PLAN:55-59. Correct call, already justified in-document. + + +## Section T — Phase 4c validation review (2026-07-29) + +> **Relocated.** A validation pass over Phase 4c's design and plan before execution. Relocated verbatim on 2026-08-31 from +the roadmap's `## Open Findings — Phase 4c Validation Review (2026-07-29)` section. **Its rows are `F1`–`F9`, +the review's own numbering** — see the namespace note on Section S. + +A validation pass over `specs/2026-07-25-phase4c-stage-pipeline-design.md` and +`plans/2026-07-25-phase4c-stage-pipeline.md` +(`docs/validation-prompts/phase4c-stage-pipeline-validation-prompt.md`) returned **NEEDS WORK — no blockers.** +The `PIPE-1`–`PIPE-40` mapping is sound and every cross-phase reference 4c consumes checks out against the earlier +phase plans: `Transport.send(request, options?, signal?)` + `close()` (Phase 2), `DexpaceError` as the taxonomy +root under `http/errors.ts` (Phase 2's retrofit), `RequestOptions.EMPTY` (Phase 1), `Status.of`/`Protocol.HTTP_1_1` +(Phase 1), and 4a's `createRequestContext(request, init?)`, `promoteToRequest`/`promoteToExchange`, +`ContextStore.install/get/close/clear/size` with the `kind`/`key`/`request`/`instrumentation`/`operationName` +context shape. Nothing below is a defect in that mapping. + +**Status: F1–F8 are applied** to both 4c documents. **F9 remains open — it needs a decision.** + +| # | Sev | Finding | Where | Resolution | +|---|---|---|---|---| +| F9 | major — **OPEN, needs a decision** | `Cursor` accepts the caller's `AbortSignal`, threads it to the terminal transport, and never checks it between steps. `concurrency-and-async.md:46` requires `signal.throwIfAborted()` "at the top of each loop iteration or before each expensive step"; the step walk (and, worse, a pillar step's fork-driven re-drives) is exactly that. An aborted call keeps walking steps and keeps re-driving until the transport hop finally rejects | PLAN `cursor.ts` `#dispatch`; SPEC "Cursor and fork" | **Undecided**, because the fix is not one line: a raw `signal.throwIfAborted()` surfaces a `DOMException` the SDK taxonomy does not own, against Phase 2's `CancellationError` and `XCUT-1`'s "cancellation is terminal, non-retryable, flag preserved" — and `RECOV-11`/4b's `wrapCancellation` already has a shape for this. Either (a) check in `#dispatch` and map to `CancellationError`, or (b) leave the cursor signal-blind and let 5a's `ctx.signal` + `RETRY-32` carry cancellation, recording (b) as a Deviation Ledger row. Settle before 5a Task 1 lands, since 5a is what makes the signal reachable from a step | +| F1 | major — ✅ applied | `PIPE-17`'s "options MUST be readable by any step" was claimed satisfied while `StepContext` exposes only `next`/`fork`/`context`. A MUST silently unmet is a blocker; it is a legitimate deferral only if the document names the phase that takes it — neither did. (The work itself is already scheduled: 5a Task 1, per the row in `docs/deferred-items.md`) | SPEC "Steps", PLAN Self-Review `PIPE-17` row | Both documents now record the partial deferral by name — `StepContext.options`/`.signal` land in **Phase 5a Task 1**; the plan's Global Constraints forbid adding them early, since their shape belongs to their first reader | +| F2 | major — ✅ applied | Spec listed `replace` among the operations that raise `PillarCollisionError` on an occupied pillar; the plan's `replace()` deliberately runs no pillar check. `PIPE-5` exempts replace by name ("it swaps a single occupant within its own stage 1:1") and the collision error points the caller *at* replace — an agent following the spec would have made replacing a pillar step impossible, since the incoming type is distinct by definition | SPEC:285 vs PLAN `replace()` | `replace` removed from the collision bullet, `prependAll` added to it, and the exemption spelled out with `PIPE-5`'s own wording | +| F3 | major — ✅ applied | `afterEach(() => contextStore.clear())` in `runtime.test.ts` and `builder.test.ts`. 4a's plan forbids this by name — it wipes entries a sibling test file installed in the same `bun test` process (`testing.md:50,52`), and 4a's own store tests avoid the singleton for exactly this reason. Not needed either: `Runtime.send()` evicts its own entry in a `finally` on both paths | PLAN runtime.test.ts, builder.test.ts | Both hooks deleted (and the now-unused `afterEach`/`contextStore` imports), replaced by a comment recording why. The one surviving `contextStore.size` read is a before/after **delta** inside a single test, which the 2026-07-26 review already sanctioned | +| F4 | major — ✅ applied | `NFR-13`'s SPDX header was absent from all eleven code listings and from Global Constraints, against "written into Phase 1's plan… line 1 of every new file, all phases onward" (Deferred Items Log) and 4a's precedent | PLAN, every code block | Global Constraints bullet added, `// SPDX-License-Identifier: MIT` prepended to every listing, and Task 6 gains Step 3b's grep — 4a's gate, copied. **Project-wide drift, not 4c's alone:** the 4b, 5a, 5b, 5c, 6b and 6c plans carry no SPDX header either; Phase 9's `NFR-13` sweep is where that gets closed | +| F5 | major — ✅ applied | The design's "**Property tests:**" heading and the Phase 4 checklist's "Property tests where invariants exist ✅ … 4c (edit-order independence, batch ordering)" row both claimed properties the plan never shipped — `builder.test.ts` had no `fast-check` import and two hand-picked examples. `testing.md:29` puts an invariant-bearing assembler like `build()` squarely in property-test territory | SPEC "Testing" vs PLAN builder.test.ts | Three real `fc.assert` properties added (edit-sequence-equals-from-scratch for `PIPE-22`; batch order preserved / reversed for `PIPE-38`), generated over the non-pillar stages so cases exercise ordering rather than `PIPE-5`'s collision. Task 5's expected count 19 → 22; Tech Stack names `fast-check`. The spec's "arbitrary sequence" now says `append`/`prepend`, matching what the generator emits — the anchored edits need a generated anchor that exists, which makes the model larger than the property it proves, so they stay example-tested | +| F6 | minor — ✅ applied | `PillarCollisionError` and `AnchorNotFoundError` carried their symbols as fields but never rendered them into the message, while `PIPE-5` asks the error to "name both step types", `PIPE-21` to identify "the missing type", both 4c documents claimed exactly that, and `error-handling.md:40` requires identifying inputs in the message — a bare `symbol` field is invisible in a stack trace or log line | PLAN errors.ts | Both messages interpolate `String(type)` (`Symbol(retry)`), matching 4a's `DuplicateContextKeyError`; the fields stay for `error-handling.md:44`, and `errors.test.ts` now asserts the message names them | +| F7 | minor — ✅ applied | `StepContext.fork?: () => Next` spelled bare, against the plan's own `exactOptionalPropertyTypes` constraint ("optional properties are spelled `?: T \| undefined`, never bare `?: T`") — the same shape 5a Task 1's added fields will use | SPEC:135, PLAN step.ts | `fork?: (() => Next) \| undefined` in both documents | +| F8 | minor — ✅ applied | Spec's `PipelineBuilder` listing tagged `insertBefore` with `PIPE-19` and `replace` with "PIPE-18/19"; `PIPE-18` covers both inserts and `PIPE-19` covers replace. Also `#exchangeSource` in prose for what is a module-level exported function, not a private field | SPEC:274-275, SPEC:386 | IDs corrected; the prose names `exchangeSource` and says it is the module-level helper | + +**Not findings, recorded so they are not re-raised.** Assertion density (`assertions.md:6-7`) is already open +project-wide as 4b's F2 — 4c is the phase that *satisfies* it, not one that violates it. `STAGE_ORDER` and +`PILLAR_STAGES` in `CONSTANT_CASE` sit against `naming-conventions.md:14`, whose worked example is literally a +module-level `new Set(...)` staying `lowerCamelCase` because its contents can mutate; a `ReadonlySet` type does +not make the underlying `Set` deeply immutable and `Object.freeze` cannot fix a `Set`. Left alone because the +casing question is project-wide (Phase 1's `Protocol`/`Status` statics, 4b's constants) and renaming one phase's +two constants would fork the convention rather than settle it — Phase 10's reconciliation owns it. + +**Re-deferred 2026-08-30 (Phase 10): NOT SCHEDULED.** Phase 10 does not own it and did not settle it. The +`CONSTANT_CASE`-vs-`lowerCamelCase` question for module-level immutable collections is a naming-convention +call, not a deviation from the reference contract, so it is outside a reconciliation phase's scope; Phase 10 is +also the last row of the phase table, so there is no later phase to hand it to and none is invented here. The +state is unchanged and still consistent within itself — `STAGE_ORDER` and `PILLAR_STAGES` remain `CONSTANT_CASE` +and remain the pipeline's only such pair (`packages/core/src/pipeline/builder.ts:12`, `:179`, `:248`, `:269`). +**Trigger:** the next module-level immutable collection added outside `pipeline/`, which would make the fork +visible in a third place and force the choice — or a naming-convention sweep commissioned as its own phase. +Logged in `docs/deferred-items.md` so it is tracked rather than silent. + + +## Section U — Documentation restructure (2026-08-31) + +Found while giving `docs/` a stated structure: three frozen trees, a `work/` tree of process records, an +as-built `sdk-documentation/` tree, and three registers at the root. Everything below is a consequence of +that pass, not of a phase. + +### U1 — Five citations point at paths the restructure moved — **DECIDE** + +The move of `docs/superpowers/{specs,plans}/` to `docs/work/mvp/phaseN/` repointed 143 path citations. Five +were left. Four are in trees the restructure treats as read-only: + +| File | Cites | +|---|---| +| `docs/knowledge/notes/pagination.md:11` | `docs/superpowers/specs/2026-07-28-phase6c-pagination-design.md` | +| `docs/knowledge/notes/tooling-and-quality-gates.md:9` | `docs/superpowers/plans/2026-07-23-scaffold-milestone-checklist.md:54` | +| `docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md:8` | `docs/superpowers/specs/2026-07-28-phase10-deviation-reconciliation-design.md` | +| `docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md:155` | `docs/superpowers/specs/2026-07-28-phase9-cross-cutting-conformance-design.md` | + +A fifth sits in `.changeset/2026-08-25-body-lifecycle.md:7`, which cites +`docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md`. **`.changeset/` is frozen release +history** — the rule, stated here once: a changeset records what a release said when it was written and is +never retro-edited. `probe.mjs`'s citation check skips the directory for that reason; `guard.mjs`'s `FROZEN` +list does not include it, because the guard governs what a *tool* may write and nothing writes there, while +the register rule governs what a *person* may edit. Two mechanisms, two different questions, and the answer +to both is "leave it". + +No gate catches these. `verify:knowledge-structure` applies its source-root check to `harvested/` entries +only, and notes are exempt by design; nothing checks a `` path for existence, in either tree. +`bun run knowledge:drift` reads `SOURCES.md` rows and note *keys*, not note source paths. + +Two of the four are `` provenance lines under `docs/knowledge/notes/`, which is hand-written — editing +them is mechanically safe and would not disturb a harvest, since a note carries a manual `sha:` marker. The +other two are prose inside the normative design tree. **The decision is which of "frozen" and "correct" +wins for a hand-written note.** If `notes/` is editable in principle, these two are a one-line fix and this +item shrinks to the two `sdk-design-nodejs/10` lines. + +### U2 — Three phase deferrals never reached the aggregate log — **CLOSED (recovered 2026-08-31)** + +Twenty `## Deferred Items` sections exist under `docs/work/mvp/`, fourteen of them titled "add to the +roadmap's Deferred Items Log". Walking all twenty against the aggregate found three items that were never +added: + +- RFC 7616 §4 `username*` (RFC 5987) extended notation for a non-ASCII Digest username + (`docs/work/mvp/phase5/phase5c/2026-07-26-phase5c-auth-checklist.md:220`) +- A caller-supplied `ChallengeHandler` list on `AuthStepSettings` (same section) +- A read-only memory-mapped view for `fileBody()` (`BODY-36`, MAY) + (`docs/work/mvp/phase8/phase8a/2026-07-28-phase8a-transport-design.md:581`) + +All three are appended to `docs/deferred-items.md`, marked as recovered. Six further items had reached this +file instead of the log, which is the correct register for what they became, and were left: `AUTH-37` (G12), +the `PIPE-40`/`REDIR-22` contradiction (G1), and `OBS-19`/`OBS-28`/`OBS-29` (Section L). + +The failure mode is worth naming because it is silent by construction: a phase writes its deferral into its +own checklist, marks the checklist done, and nothing reads the checklist again. "Add to the roadmap's +Deferred Items Log" is an instruction to a human in a document nobody re-opens. + +### U3 — Three `F` namespaces coexist, and one bare citation is already ambiguous — **DECIDE** + +Section F numbers its items `F1`–`F9`. Section S (Phase 4b validation review) numbers its rows `F1`–`F10` +and Section T (Phase 4c validation review) numbers its rows `F1`–`F9`, both the reviews' own numbering, +carried over unchanged when they moved out of the roadmap. + +Renumbering S and T was rejected: `docs/work/mvp/2026-07-23-nodejs-sdk-v1-roadmap-design.md`'s Phase 10 +status note cites "4b's F2/F7" by those numbers, and a dated record whose row IDs change stops matching every +document that quotes it. The cost is that a bare "F2" is ambiguous across three sections. + +**The `###` level is reserved for item IDs, and Section S had one collision.** Its narrative sub-heading +"F1 resolution — the verified version facts" was an `###`, which put a second `### F1` in the register and +made the ID set genuinely ambiguous rather than merely context-dependent. It is now an `####`, spelled +"Section S, row F1 — …". Every `### ` in this file is exactly one item; a narrative sub-heading +inside a section goes one level deeper. The citation check +(`node .claude/skills/housekeeping/probe.mjs --only=citations`) reads that level, so the convention is load +bearing. + +**The trigger has already fired.** `docs/work/mvp/phase4/phase4b/2026-07-25-phase4b-recovery-chain-design.md:15` +reads "see `docs/open-items.md` F8", and `F8` exists three times: Section F's item at `### F8`, a Section S +row, and a Section T row. Sections F and S are **both about Phase 4b**, so a reader cannot even disambiguate +by subject. `probe.mjs`'s citation check passes it, because `F8` resolves — the "dangerous kind" U6 names. + +Three ways out, and one must be chosen: + +1. **Qualify the citation, leave the register alone.** One-line edit: "Section F's F8". Cheapest, and + does nothing about the next one. +2. **Give S and T section-prefixed row IDs** (`S1`–`S10`, `T1`–`T9`), keeping a "was F" column so the + roadmap's "4b's F2/F7" still resolves. Removes the ambiguity permanently; edits a dated record's + presentation, though not its content. +3. **Teach the citation check to require a section qualifier** for any ID that appears in more than one + namespace. Mechanical, and turns the next occurrence into a finding rather than a reader's problem. + +Not taken here: the choice is the owner's, and this pass is scoped to correcting what was recorded +falsely. The count in the paragraph above was also wrong — see U6 for the one derivation. + +### U4 — `docs/deviations.md` is keyed to a file inside a frozen tree — **WATCH** + +`docs/deviations.md` states its own coupling: "§10 is the owner of the item numbers… **If §10 renumbers, +this file must be renumbered in the same commit.**" §10 is +`docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md`, which now sits in a tree +the housekeeping skill refuses to write to. + +That is the right arrangement — the normative ledger should not be edited by a maintenance tool — but it +means the two halves of one numbering scheme are now on opposite sides of a freeze boundary, and only one of +them can be repaired by the tool that notices the drift. Nothing checks that the two agree. + +**Trigger:** the next deviation added to §10, which is a hand edit by definition, and must carry the matching +`docs/deviations.md` edit in the same commit. + +### U5 — `CLAUDE.md` and `README.md` have no gate, and had drifted for nine phases — **WATCH** + +Measured on `f93ccd9`, before this pass: `CLAUDE.md` claimed "two published packages today" against 9 +publishable and 2 private; its API section named 2 committed reports against 9; its gate list omitted +`verify:sse-37`, a blocking CI step; and its documentation-hierarchy table omitted `docs/open-items.md`, the +largest file in the tree. `README.md` was two lines and misspelled "platform". + +Every one of those is checkable against the repository in a few lines of script, and the `housekeeping` +skill's probe stage now does check them (`.claude/skills/housekeeping/probe.mjs`). It is deliberately **not** +a CI step — it is a hand-run tool, like `bun run test:scripts` was before Phase 10 promoted it. + +**Trigger:** the same drift recurring after a phase lands. The probe existing is not the same as the probe +being run; if it recurs, the answer is a blocking CI step, and the precedent for promoting one is `test:scripts` +(open-items H13). + +**The skill's own 77 tests are not run by any CI step either.** `package.json`'s `test:scripts` globs +`scripts/*.test.mjs`, and these live in `.claude/skills/housekeeping/`. Promoting them is a one-line glob +change; the argument for it is H13's, exactly — a gate whose own logic degrades still exits 0, so nothing +else in the run notices. Not done here because wiring this skill into CI was explicitly out of scope for the +change that added it. Run them by hand with `node --test .claude/skills/housekeeping/*.test.mjs`. + +### U6 — Six citations named the wrong section, and four of them resolved to nothing — **CLOSED (fixed 2026-08-31)** + +Auditing every `open-items.md ` citation against the register's actual `### ` headings — +the check the acceptance criteria asked for, mechanised — found six citations written `G` that meant +`K`. Section G is Phase 5b (Redirect); Section K is Phase 7a (Configuration), and every one of the six sits +in `packages/core/src/config/`: + +| Site | Cited | Means | +|---|---|---| +| `packages/core/src/config/build-info.ts:37` | `G11` (`DigestChallengeUnsupportedError` — closed) | `K11` — `client-identity-step.ts`'s folder placement, the live outbound-header concern in `config/` | +| `packages/core/src/config/build-info.ts:38` | `G18` (does not exist) | `K18` — `isHeaderSafe` duplicates `http/ascii-validation.ts` | +| `packages/core/src/config/configuration.ts:35` | `G14` (does not exist) | `K14` — a configuration seam that fails is silently invisible | +| `packages/core/src/config/equality.test.ts:40` | `G16` (does not exist) | `K16` — `deepEqual`/`deepHash` require acyclic input | +| `packages/core/src/config/configuration.test.ts:11` | `G3` (redirect `Decision` reasons) | `K3` — `CFG-12` is documented, not enforced | +| `.changeset/2026-08-27-configuration-review-pass-3.md:30` | `G16` (does not exist) | `K16` | + +All but the changeset are fixed. `.changeset/` is frozen release history and is left as it is. + +**Two of the six were the dangerous kind.** `G11` and `G3` both *resolve* — to items about redirects and +Digest errors that have nothing to do with the comment quoting them. A reader who follows the citation lands +on a real entry and gets a wrong answer, which no "does this ID exist" check catches. The four that dangled +were the safe failures. + +The mis-citations are pre-existing: reproduced on `f93ccd9` before any change in this pass. `probe.mjs` now +runs this check, and it is the reason the check exists. + +### U7 — `redirectStep()` is public; the guard that makes it safe is not — **DECIDE** + +`redirectStep` is exported from the barrel (`packages/core/src/index.ts`). Its companion, +`stripCrossOriginMarkerStep()` — the `POST_AUTH` step that removes the internal cross-origin marker +header before dispatch — is `@internal`, and so is `withRedirect()`, the helper that installs the two +together (`packages/core/src/redirect/strip-marker-step.ts:34,55`). + +A caller who builds a pipeline by hand and appends `redirectStep()` therefore gets redirects **and** +forwards `CROSS_ORIGIN_MARKER_HEADER` to the wire on every cross-origin hop. `withRedirect`'s own +TSDoc names the hazard — "a caller who installs `redirectStep()` directly against the builder's +lower-level API is responsible for installing the guard too" — but the guard it names is not reachable +from outside the package, so that responsibility cannot be discharged. `standardResilience()` is +currently the only safe way to get a redirect pillar, and `PipelineBuilder.seedFrom()` the only safe +way to extend one. + +`REDIR-11(c)` calls the strip a MUST on the credential-attaching layer and recommends a robust port +strip it independently. This port does, inside the preset. + +Three ways out, and one must be chosen: + +1. **Promote `withRedirect()`** to the public barrel and document `redirectStep()` as the + lower-level primitive. Smallest change; makes the safe path the obvious one. +2. **Fold the guard into `redirectStep()`'s own descriptor** so the pillar cannot be installed + without it. Impossible as written — a `StepDescriptor` occupies one stage, and the guard is a + second step at `POST_AUTH`. +3. **Unexport `redirectStep()`.** Consistent with `loggingStep`/`authStep`/`retryStep` all being + public, so it would be the odd one out; rejected on symmetry unless the whole authoring surface + retracts. + +Found on 2026-08-31 while writing `packages/core/README.md`'s hand-built-pipeline example, which is +why that example uses `seedFrom` and says so. + +### U8 — Two published READMEs shipped a code sample that does not compile — **CLOSED (fixed 2026-08-31)** + +`packages/transport-fetch/README.md:14` and `packages/transport-undici/README.md:14` both opened with +`await using transport = fetchTransport(...)`. Phase 10 dropped `& AsyncDisposable` from both +factories' return types on 2026-08-30 — the decision recorded at line 316 of this file, four reasons +deep — and neither README was updated. `tsc` on the extracted snippet: + +``` +error TS2851: The initializer of an 'await using' declaration must be either an object with a +'[Symbol.asyncDispose]()' or '[Symbol.dispose]()' method, or be 'null' or 'undefined'. +``` + +These are the READMEs npm renders on the package page: the first thing a new consumer copies, and it +would not have compiled for them. Both now show `close()` in a `finally`, and both say why in one +paragraph. + +**What let it through.** Nothing typechecks a Markdown code fence. `verify:consumer-types` compiles +the built `.d.ts`, `api:ci` diffs the report, and neither reads a README. The harvested styleguide +asks for exactly this check — "the documentation build typechecks the code fences inside `@example` +tags so worked examples cannot silently drift" +(`docs/knowledge/harvested/documentation.md:50`) — for TSDoc `@example` blocks, which this project +also does not do. The `housekeeping` probe now extracts every ` ```typescript ` fence from every +package README and typechecks it against the built packages, which is how this was found. + +**Trigger:** promote the fence check to a blocking CI step the next time a README sample breaks +between probe runs. It needs `dist/` and so must sit after Build, which is why it is not there today. + +### U9 — A `@throws` tag named an error class that does not exist, and ten more name classes nobody can catch — **PARTLY FIXED (2026-08-31; recounted 2026-09-01)** + +Writing [`docs/sdk-documentation/errors.md`](./sdk-documentation/errors.md) against source turned up +two problems in the same place. + +**`MaxHopsExceededError` was never written.** It is named in two `@throws` tags — +`packages/core/src/redirect/redirect-step.ts:134` and `packages/core/src/auth/preset.ts:80` — and +`grep -r MaxHopsExceededError packages/` finds nothing else. Both tags shipped into the emitted +`.d.ts` (`dist/redirect/redirect-step.d.ts:46`, `dist/auth/preset.d.ts:58`), so a consumer's editor +offered a class to catch that no build ever produced. + +Worse, the behaviour it documents is wrong in the other direction: exceeding `maxHops` does not throw +at all. `packages/core/src/redirect/decide.ts:205` returns `RETURN_CURRENT`, handing the caller the +unfollowed 3xx — which is also exactly what `maxHops: 0` reduces to, and is why "disable redirects" +needs no separate branch. Both tags are now replaced with a sentence stating that. + +**Ten error classes are documented as catchable and are not exported.** Every one is named as the +subject of a `@throws` tag on a symbol whose TSDoc ships in the emitted `.d.ts`, and none appears in +any of the nine committed `*.api.md` reports — so a consumer reads the tag, reaches for `instanceof`, +and has nothing to reach for. + +**Counted 2026-09-01, and the counting rule matters:** a `@throws` tag whose *subject* is the class. +`packages/core/src/body/materialize.ts:12` mentions `EndOfStreamError` inside a +`@throws Whatever the delegate's writeTo raises …` tag and is not counted; a grep for lines merely +containing one of the ten returns 58 rather than 57. + +| Class | `@throws` tags | Exported? | +|---|---|---| +| `InvariantViolation` | 24 | no — and `invariant.ts:10` tags it `@internal` | +| `PillarCollisionError` | 9 | no | +| `ReservedStageError` | 8 | no | +| `EndOfStreamError` | 4 | no — `index.ts:34` exports only `IoError`, `TransportFailureError` from `io/errors.js` | +| `AnchorNotFoundError` | 3 | no | +| `CrossStageEditError` | 3 | no | +| `SchemeDowngradeError` | 2 | no | +| `CursorAlreadyAdvancedError` | 2 | no | +| `DuplicateContextKeyError` | 1 | no — behind the internal `ContextStore` | +| `NonReplayableBodyError` | 1 | no | + +**57 tags, ten classes, none reachable.** An earlier revision of this item said "the seven" and +tabulated seven; it missed `InvariantViolation`, `EndOfStreamError` and `DuplicateContextKeyError`, +which are the first, fourth and ninth by weight — the largest of them by a factor of two and a half. + +A caller can catch them as `DexpaceError` or test `error.name`. The harvested styleguide's rule for +`@throws` is that it names the type **and what the caller should do about it** +(`docs/knowledge/harvested/documentation.md:24`), which is not actionable without the class. + +**DECIDE:** promote them, or stop documenting them as catchable. + +1. **Export the ten.** Consistent with `AuthResolutionError`, `PlaintextCredentialError`, + `PaginationError` and `SseStreamError`, all of which are public for exactly this reason. Costs an + API-report regeneration and a `minor` changeset. +2. **Downgrade the tags to prose** — "raises a `DexpaceError` named `SchemeDowngradeError`" — and + keep the surface closed. Honest, but it makes a documented failure unhandleable by class, which is + what the styleguide rule is against. +3. **Split the difference:** export the eight a caller could act on, and drop the tags on + `InvariantViolation` and `DuplicateContextKeyError`, which signal bugs rather than conditions. + +`InvariantViolation` is the awkward one either way: it extends `Error` rather than `DexpaceError`, so +even the broad catch misses it, and 24 tags promise it to a consumer who cannot name it. + +**Not taken here.** It is a public-surface change, not a documentation fix, and this pass is scoped +to the latter — but the *count* is corrected now rather than deferred with the decision. A silent +omission is the failure mode this register exists to prevent; a recorded DECIDE is not. + +**Nothing catches this class of defect.** `api-extractor` diffs signatures, not TSDoc bodies; no +gate reads a `@throws` tag. Both were found by reading source to write a document, which is the +argument for writing the document. + +### U10 — Three documents stated three different, all-wrong citation counts — **CLOSED (2026-09-01)** + +`docs/README.md`, `CLAUDE.md` and U3 said "24 citations … nine in `packages/core/src/`" (twice) and +"Twenty-five … 13". No reading of the repository reaches any of them. + +**There is now one derivation and it is a command, not a sentence:** + +```bash +node .claude/skills/housekeeping/probe.mjs --only=citations +``` + +It prints four totals — sites, sites outside this register, sites in `packages/core/src/`, and +distinct IDs against the number of items — from the same regex and the same file set the check itself +uses. + +**No number is written down here on purpose.** The two prose statements were replaced by a pointer to +that command rather than by a corrected count, because a corrected count is the same defect one +commit later. This item is its own proof: the figures the audit measured moved within the same +session that recorded them, as the citations to `open-items.md:316` became anchors and this section +grew two items. + +That is the general lesson, and it is why U11 exists: a count nobody can recompute is a count nobody +recounts. + +### U11 — The count checker could not read the counts it was written for — **CLOSED (fixed 2026-09-01)** + +`checkClaims`'s three count regexes all required `(\d+)`. **Every count claim in `CLAUDE.md` and +`README.md` is spelled as an English word** — "eleven packages", "nine committed reports", "Twenty +named CI steps" — so the check protected exactly one sentence in the repository, and nothing asserted +a claim was even present. + +Four reproductions, each printing `no drift found` before the fix: + +| Mutation | Why it passed | +|---|---| +| Append `Two published packages today, and that is the whole workspace.` to `CLAUDE.md` | "Two" is a word | +| Reword `**20 named steps**` past its pattern | no match, no check | +| Delete that sentence outright | presence was never asserted | +| The live tree's own wrong citation counts (U10) | "24"/"nine" never matched a pattern | + +The first is verbatim the drift `.claude/skills/housekeeping/SKILL.md:10-12` names as this tool's +reason for existing, against a workspace of eleven. + +Fixed: `parseNumeral` reads digits, words and hyphenated compounds; the claim table is +subject-anchored and every row is **required** in each document that must state it; fenced code and +double-quoted spans are excluded, because `CLAUDE.md`'s own upkeep section quotes the drift it fixed +and reported speech is not a claim. + +**The suite did not catch it, and could not have.** Seven of the eight checks had bodies that could be +replaced with `return;` with the tests still green, and so did `apply.mjs`'s only `assertAllWritable` +call. `fixture.mjs` now builds throwaway repositories and every check has a pair — a tree it reports +clean over and a mutation it must fire on — which is the shape +`scripts/verify-seam-1.test.mjs:6` and `verify-test-partition.test.mjs:4` already use here. 29 tests → +75. ## Maintaining this file @@ -1720,3 +2586,15 @@ checklist row marked ✅ against code that does not implement it (A1, A2 are bot only when the underlying requirement is genuinely satisfied *and* its checklist row agrees. When a phase closes, re-scan its checklist against the code rather than trusting the marks. +**A new review is a new section, with the next letter.** Never renumber an existing item and never reuse a +letter: item IDs are cited from source comments, which no gate updates. `node scripts/knowledge.mjs` has +nothing to do with this file; the check that every citation resolves lives in the `housekeeping` skill's probe +(`.claude/skills/housekeeping/probe.mjs`), and U6 records what it found the first time it ran. + +**Heading form.** `## Section ` for a section, `### — **STATUS**` +for an item. Sections A–G used `## A.` until 2026-08-31; the letters did not change, only the form. + +**Do not open a second register.** One was opened at the repository root in `cba4721` and sat unmerged for +five days across four phases (now Section P). A finding that is not in this file is not registered, wherever +else it is written down. + diff --git a/docs/sdk-documentation/architecture.md b/docs/sdk-documentation/architecture.md new file mode 100644 index 0000000..6d37af2 --- /dev/null +++ b/docs/sdk-documentation/architecture.md @@ -0,0 +1,192 @@ +# Architecture + +**Start here.** This tree documents the code that exists, package by package and seam by seam. It is +the front door for the other ten files: + +| File | Covers | +|---|---| +| [`http.md`](./http.md) | The domain model: `Request`, `Response`, `Headers`, `Status`, `QueryParams`, and their kin | +| [`bodies.md`](./bodies.md) | Request bodies as producers, response bodies as owned resources | +| [`pipelines.md`](./pipelines.md) | Stages, steps, the four pillars, `standardResilience()` | +| [`auth.md`](./auth.md) | Tiers, credentials, schemes, challenges, and the HTTPS guard | +| [`errors.md`](./errors.md) | The error tree, and which failure means what | +| [`quality-gates.md`](./quality-gates.md) | Every blocking gate, what it protects, how to run it | +| [`write-a-transport.md`](./write-a-transport.md) | Implementing `Transport`, and proving it | +| [`write-a-serde.md`](./write-a-serde.md) | Implementing `Serde` | +| [`write-a-paging-strategy.md`](./write-a-paging-strategy.md) | Implementing `PaginationStrategy` | +| [`write-a-response-handler.md`](./write-a-response-handler.md) | Turning a `Response` into your model | + +## What this tree is not + +It is not the API reference. Every exported symbol's signature is in +`packages/*/etc/*.api.md`, regenerated by `api:local` and diffed in CI by `bun run api`; what each +symbol *means*, including every `@throws`, is in the TSDoc, which ships in the emitted `.d.ts` and +appears on hover. Both are generated and gate-verified. A third hand-written copy would drift, and +the harvested styleguide's rule is one authoritative place per fact +(`docs/knowledge/harvested/documentation.md:32`). + +What those two cannot express is what this tree holds: **how the packages compose, which one to +install for which job, and worked examples that cross a package boundary.** + +It is also not the specification. `docs/product-spec/` is normative and numbered; every `HTTP-N`, +`SEAM-N`, `RETRY-N`, `PAGE-N` identifier here is an entry there. Where the port deliberately differs, +`docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md` is the ledger and +`docs/deviations.md` the as-built audit of it. + +## The shape of the thing + +This is a toolkit for building HTTP client libraries, not an HTTP client. `@dexpace/core` defines the +models, the pipeline, and the seams; it never opens a socket. The networking arrives through a +transport package you choose. + +``` + your library + │ + ▼ + Runtime ─── a Pipeline of Steps, itself a Transport + │ + │ PRE_REDIRECT · REDIRECT · POST_REDIRECT + │ PRE_RETRY · RETRY · POST_RETRY + │ PRE_AUTH · AUTH · POST_AUTH + │ PRE_LOGGING · LOGGING · POST_LOGGING + │ PRE_SERDE · SERDE · POST_SERDE + ▼ SEND + Transport ── @dexpace/transport-fetch │ @dexpace/transport-undici │ yours + │ + ▼ + wire +``` + +A `Runtime` implements `Transport`, so a pipeline is substitutable wherever a transport is — which is +what makes `PipelineBuilder.seedFrom()` and nested pipelines work at all. + +## The eleven packages + +Nine are published; two are private and exist only to serve the build. + +| Package | Provides | Third-party dependencies | +|---|---|---| +| `@dexpace/core` | Models, pipeline, seams, retry/redirect/auth/logging pillars, SSE, pagination, configuration, observability | **none** | +| `@dexpace/transport-fetch` | `fetchTransport()` over the runtime's global `fetch` | none | +| `@dexpace/transport-undici` | `undiciTransport()` — pools, proxies, real `close()` | `undici` | +| `@dexpace/transport-shared` | `@internal` plumbing both transports need identically | none | +| `@dexpace/codec-json` | `jsonSerde()` — the reference wire codec, with PATCH tri-state | none | +| `@dexpace/body-file` | `fileBody()` — a file-backed request body over `node:fs` | none | +| `@dexpace/logging-pino` | `createPinoLogger()` | `pino` (optional peer) | +| `@dexpace/logging-debug` | `createDebugLogger()` | `debug` (optional peer) | +| `@dexpace/rx` | `Observable` views of SSE and pagination | `rxjs` (peer) | +| `@dexpace/shrink-test` | *private.* Proves the published bundles survive minify + tree-shake | — | +| `@dexpace/transport-conformance` | *private.* The shared `TRANSPORT-N` suite both transports run | — | + +### Two rules the layout enforces mechanically + +**Zero runtime dependencies, everywhere.** `SEAM-1` says core takes none. `bun run verify:seam-1` +asserts it for **every** package under `packages/`, not core alone, and `NFR-2` is the reason: each +optional capability is a separately installable unit taking core plus at most one external library. +`transport-undici` spends its one on `undici`; `transport-fetch` spends none. Reaching for a small +date or URL utility is exactly the reflex that gate exists to catch. + +**`@dexpace/core` is always a peer, never a dependency.** Two copies of core in one install would +defeat the branded symbols and identity checks the seams rely on — the dual-package hazard. The same +gate checks this, and `verify:dual-consumption` then imports each built package from plain `node` and +exercises it end to end. + +## The seams + +A seam is an interface core defines and does not implement. There are four that matter, and each is +small enough to quote in full. + +```typescript +interface Transport { + send(request: Request, options?: RequestOptions, signal?: AbortSignal): Promise<Response>; + close(): Promise<void>; +} + +interface Serde { + readonly serializer: Serializer; + readonly deserializer: Deserializer; + readonly mediaType: string; +} + +interface PaginationStrategy<T> { + parse(response: Response, template: Request): Promise<PageInfo<T>>; +} + +interface Logger { + atLevel(level: LogLevel): LogEvent; + withContext(fields: Readonly<Record<string, unknown>>): Logger; +} +``` + +There is **no registration step and no discovery mechanism**. A conforming object is a valid +implementation; you pass it in. `SEAM-5`–`SEAM-10` describe a classpath-style plugin registry with +conflict resolution, and this port will never build it — a permanent simplification, recorded in +`docs/deferred-items.md` as a row that is explicitly *not* a deferral. `Tracer`, `Span`, `Meter`, +`Counter`, `Histogram` and `Clock` are the same shape: duck-typed, so an OpenTelemetry object +satisfies them with no adapter. + +## A request, end to end + +```typescript +import { + ApiKeyCredential, + Request, + createAuthDescriptor, + createAuthRequirement, + standardResilience, + toHttpError, +} from '@dexpace/core'; +import {undiciTransport} from '@dexpace/transport-undici'; + +const transport = undiciTransport({agentOptions: {connections: 32}}); + +const client = standardResilience(transport, { + retry: {settings: {maxAttempts: 4}}, + redirect: {maxHops: 3}, + auth: { + credentials: {apiKey: {credential: new ApiKeyCredential('k'), headerName: 'X-Api-Key'}}, + tiers: {client: createAuthDescriptor([createAuthRequirement('API_KEY')])}, + }, +}); + +const response = await client.send( + Request.newBuilder().url('https://api.example.com/v1/things').build(), +); + +const failure = await toHttpError(response); // drains and closes on a 4xx/5xx +if (failure !== null) throw failure; + +try { + console.log(await response.text()); +} finally { + await response.close(); + await transport.close(); // the pipeline never owns the transport (PIPE-27) +} +``` + +Reading that stack outward from the wire: + +1. **Bodies.** A request `Body` is a *producer*: `writeTo(sink)` emits bytes on demand, and + `replayable` decides whether a retry may re-send it. A response body is a + `ReadableStream<Uint8Array>` the **caller** owns and must `close()`. See [`bodies.md`](./bodies.md). +2. **Models.** `Request`, `Response`, `Headers`, `QueryParams`, `RequestOptions` and + `RequestConditions` are frozen at construction and reachable only through a builder, so + case-insensitivity, multi-value ordering, header-injection defenses and method/body legality are + fixed once and behave identically under every transport. See [`http.md`](./http.md). +3. **Context.** `DispatchContext` promotes to `RequestContext` and then `ExchangeContext`, carrying an + `InstrumentationBundle` throughout. A step reads `ctx.context.kind` to know which promotion it is + in. +4. **Pipeline.** Sixteen ordered stages; five of them pillars admitting one step each. See + [`pipelines.md`](./pipelines.md). +5. **Transport.** Two methods. See [`write-a-transport.md`](./write-a-transport.md). + +## Runtime floor and module format + +ESM only, `NodeNext` resolution, `engines.node >= 20.3`. The floor is derived rather than chosen: +`scripts/verify-runtime-floor.mjs` pairs the TypeScript language level with the Node version whose +built-ins the SDK actually calls — `globalThis.crypto` is absent from ESM on every Node 18, and +`AbortSignal.any()` landed in 20.3.0. Moving it is a reviewed decision about supported runtimes, never +a mechanical bump, and one such request has already been refused: `Symbol.asyncDispose` arrived in +20.4, so `await using` is **not** offered on `Page`, `fetchTransport()` or `undiciTransport()`, and +`close()` is the teardown on every runtime — see `open-items.md`'s Section D row +[`await using` support](../open-items.md#d-nfr-10-await-using). diff --git a/docs/sdk-documentation/auth.md b/docs/sdk-documentation/auth.md new file mode 100644 index 0000000..dc7bba5 --- /dev/null +++ b/docs/sdk-documentation/auth.md @@ -0,0 +1,146 @@ +# Authentication + +The auth pillar resolves *which* credential a call needs, then *stamps* it — once per attempt, per +redirect hop, per retry. It runs inside redirect and inside retry (`AUTH-27`), which is why a retried +request never replays a stale token and a redirected one is re-stamped against the new hop. + +## Tiers + +```typescript +interface AuthTiers { + readonly perCall?: AuthDescriptor; // highest precedence + readonly operation?: AuthDescriptor; + readonly client?: AuthDescriptor; // lowest +} +``` + +The most specific tier that is set wins (`AUTH-4`). `perCall` comes from +`RequestOptions.newBuilder().auth(descriptor)`, which is how one call opts out of, or into, something +different from the client default. Only the *selection* is tiered; whether a selected requirement can +be satisfied at all is `AUTH-6`, and failing it is an `AuthResolutionError`. + +An `AuthDescriptor` is a list of `AuthRequirement`s, each a scheme plus optional scopes and +parameters. Both are built by factory, never as an object literal: + +```typescript +import {createAuthDescriptor, createAuthRequirement} from '@dexpace/core'; + +const clientTier = createAuthDescriptor([ + createAuthRequirement('OAUTH2', ['read:things']), + createAuthRequirement('NO_AUTH'), // allowsAnonymous becomes true +]); +``` + +The factories validate and freeze (`AUTH-3`). A descriptor containing a `NO_AUTH` requirement reports +`allowsAnonymous`, which is how "authentication is optional here" is expressed. + +## Credentials + +```typescript +interface AuthCredentialSet { + readonly bearer?: {provider: TokenProvider; marginMs?: number}; + readonly basic?: {username: string; password: string}; + readonly digest?: {username: string; password: string; algorithmPreference?: DigestAlgorithm[]}; + readonly apiKey?: {credential: ApiKeyCredential | NameKeyCredential; headerName?: string; prefix?: string}; +} +``` + +The five schemes are `OAUTH2`, `API_KEY`, `BASIC`, `DIGEST` and `NO_AUTH`. A requirement names a +scheme; the credential set supplies the material for it. A requirement with no matching credential is +an `AuthResolutionError` at send time, not a silent unauthenticated request. + +**`ApiKeyCredential`, `NameKeyCredential` and `BearerToken` are nominal, not structural.** Each carries +a `#private` field, so no caller-side object literal is assignable to them and the validation in each +factory cannot be routed around. They also override `toString()` and Node's inspect symbol, so a +credential cannot leak into a log line or a stack trace by accident. + +```typescript +import {ApiKeyCredential, createBearerToken} from '@dexpace/core'; + +String(new ApiKeyCredential('super-secret')); // 'ApiKeyCredential{key=***}' +String(createBearerToken('t', 1)); // 'BearerToken{token=***, expiresAt=1}' — the expiry survives +``` + +The inspect symbol matters as much as `toString`: `console.log(credential)` and `util.inspect` do not +route an object argument through `toString`, so both are overridden (`AUTH-8`). + +## A worked client + +```typescript +import { + createAuthDescriptor, + createAuthRequirement, + createBearerToken, + standardResilience, +} from '@dexpace/core'; +import {undiciTransport} from '@dexpace/transport-undici'; + +declare function mintToken(): Promise<string>; + +const client = standardResilience(undiciTransport(), { + auth: { + credentials: { + bearer: { + provider: async () => createBearerToken(await mintToken(), Date.now() + 3_600_000), + marginMs: 60_000, + }, + }, + tiers: {client: createAuthDescriptor([createAuthRequirement('OAUTH2')])}, + }, +}); +``` + +`TokenProvider` is `() => Promise<BearerToken>`. The cache refreshes a token `marginMs` before its +`expiresAt`, and **concurrent refreshes are serialized** — a burst of calls arriving at expiry mints +one token, not one per call (`XCUT-12`). A provider returning a null or already-expired token is an +`AuthResolutionError` (`AUTH-35`), not a request sent with a dead token. + +## The HTTPS guard + +A credentialed scheme meeting a non-HTTPS URL is a `PlaintextCredentialError`, raised before the +request is dispatched (`AUTH-28`). There is no option to disable it. `NO_AUTH` never trips it, which +is why an auth-less `standardResilience()` installs a `NO_AUTH`-only step rather than no step at all — +the pillar slot stays filled and the behaviour stays uniform. + +## Challenges + +```typescript +type ChallengeHook = ( + response: Response, + request: Request, + options?: {signal?: AbortSignal}, +) => Promise<Request | undefined>; +``` + +A `challengeHook` sees a `401` and may return a replacement request; returning `undefined` means "I +cannot satisfy this", and the `401` surfaces to the caller unchanged. The built-in Basic and Digest +handlers — including the RFC 7235 `WWW-Authenticate` parser and RFC 7616 Digest with MD5, MD5-sess, +SHA-256 and SHA-256-sess — are internal and drive themselves; the hook is for schemes this SDK does +not implement. + +There is deliberately **no** way to append a handler to the built-in list. A `handlers` field existed +and was cut at review: it forced three types onto the public barrel and could not compose with the +internal handlers, so it was replace-semantics masquerading as extension. The shape to ship, if a +caller ever needs it, is an append field plus public `basicHandler`/`digestHandler` factories +(`docs/deferred-items.md`). + +**Basic and Digest never stamp preemptively.** They react to a challenge. That is an interpretation of +`§11` rather than a stated requirement, and it is ledgered as one. + +## Redirects and credentials + +Credentials are attached at an origin and must not follow a request to a different one. The redirect +pillar marks a cross-origin hop with an internal header; the auth step is that marker's consumer and +first stripper, and a `POST_AUTH` guard strips it again as an idempotent backstop, so the marker can +never reach the wire (`REDIR-11`, `AUTH-29`). + +That guard is installed by `standardResilience()` and is not publicly reachable, which is why a +hand-built pipeline should start from `PipelineBuilder.seedFrom()` rather than `redirectStep()` — see +[`pipelines.md`](./pipelines.md) and `docs/open-items.md` U7. + +## Proxy credentials are a separate axis + +`ProxyOptions.credentials` answers a proxy's `407`; the auth pillar answers an origin's `401`. They +never cross: proxy credentials are never sent in answer to a `401`, and a per-request +`Proxy-Authorization` header is dropped from the outbound pass whenever a proxy is configured. See +`@dexpace/transport-undici`'s README — it is the only transport that can route a proxy at all. diff --git a/docs/sdk-documentation/bodies.md b/docs/sdk-documentation/bodies.md new file mode 100644 index 0000000..1602bbc --- /dev/null +++ b/docs/sdk-documentation/bodies.md @@ -0,0 +1,202 @@ +# Bodies + +There are two body concepts and they are not symmetric. A **request** body is a producer you hand to +the SDK. A **response** body is a resource the SDK hands to you, and you own it. + +## Request bodies are producers, not buffers + +```typescript +interface Body { + readonly kind: 'byte-array' | 'string' | 'stream' | 'form-urlencoded' | 'multipart' | 'file'; + readonly mediaType: string | undefined; + readonly contentLength: number; + readonly replayable: boolean; + writeTo(sink: WritableStream<Uint8Array>): Promise<void>; +} +``` + +`writeTo` emits bytes on demand into a sink the transport supplies. Nothing is buffered until +something asks for it, which is what lets a file body of any size cost a constant amount of heap. + +**The concrete classes are exported as types only.** `ByteArrayBody`, `StringBody`, +`FormUrlEncodedBody`, `MultipartBody` and `StreamBody` are `export type`, never values, because +exporting the class would publish `new ByteArrayBody(...)` as a field-wise constructor — which +`HTTP-2` forbids and which duplicates the factory for no stated need. Construct through the factory +and annotate with the type. + +| Factory | Signature | `replayable` | +|---|---|---| +| `byteArrayBody` | `(bytes, mediaType?)` | `true` | +| `stringBody` | `(text, mediaType?)` | `true` | +| `formUrlEncodedBody` | `(input)` — a `QueryParams`, `Map`, plain object, or entry list | `true` | +| `multipartBody` | `(parts, boundary?)` | as its least-replayable part | +| `streamBody` | `(stream, mediaType?, contentLength?)` | `false` | +| `serdeBody` | `(value, serde, mediaType?)` | `true` | +| `fileBody` (`@dexpace/body-file`) | `(path, {start?, count?})` | `true` | + +```typescript +import {Request, multipartBody, stringBody} from '@dexpace/core'; +import {fileBody} from '@dexpace/body-file'; + +const upload = multipartBody([ + {name: 'metadata', body: stringBody('{"kind":"photo"}', 'application/json')}, + {name: 'file', filename: 'cat.jpg', body: fileBody('./cat.jpg')}, +]); + +const request = Request.newBuilder() + .method('POST') + .url('https://api.example.com/v1/uploads') + .body(upload) + .build(); +``` + +`multipartBody` generates a boundary when you do not supply one, and validates a supplied one against +the boundary grammar — a bad boundary is a `MultipartBoundaryError` at construction, not a corrupt +request on the wire. + +## Replayability, and what retry does about it + +`replayable` answers one question: can this body be sent a second time? A `ReadableStream` is +single-use by construction, so `streamBody(...).replayable` is `false` and a retry of a request +carrying one cannot re-send it. + +`materialize(body)` is the escape hatch — it drains the body once, into memory, and returns an +equivalent replayable one: + +```typescript +import {materialize, streamBody} from '@dexpace/core'; + +declare const someStream: ReadableStream<Uint8Array>; + +const once = streamBody(someStream, 'application/octet-stream'); +const many = await materialize(once); // now replayable; the original is consumed + +console.log(once.replayable, many.replayable); // false true +``` + +That is a deliberate cost, taken deliberately: buffering an arbitrarily large upload to make it +retryable is a decision for the caller who knows how large it is, not for the retry engine. A +retryable body arrives at the retry pillar already retryable. + +**A body is single-use even when `replayable` is `true`** in one sense that matters: `writeTo` may be +called again, but the *sink* may not be reused. Each call needs its own sink, and the transport +supplies one per attempt. + +## Response bodies belong to the caller + +`Response.body` is a `ReadableStream<Uint8Array> | null`. `Response` also offers `bytes()` and +`text()`, which drain it. + +**You close it. Always. On every path.** `BODY-15` puts ownership with the caller, and nothing in the +pipeline closes a response it hands you — not the retry pillar, not the redirect pillar, not +`Runtime.send()`. + +```typescript +import type {Response} from '@dexpace/core'; + +declare const response: Response; + +async function read(): Promise<string> { + try { + return await response.text(); + } finally { + await response.close(); + } +} +``` + +`close()` is idempotent, and idempotent in the strict sense: the promise is **memoized**, not +flag-guarded, so a release that *fails* propagates that failure to every caller rather than the +second call reporting success over a connection that was never released. + +`bytes()` and `text()` close the response themselves, whether the read succeeds or not (`BODY-16`) — +including when an external consumer already holds the reader lock and `getReader()` throws. Calling +`close()` afterwards is still correct and costs nothing. + +**On the request side, a single-use body written twice raises `ConsumedBodyError`** (`BODY-3`). That +is a different error from anything on the response side; `isBodyError(e)` narrows to it and its two +siblings. + +### The one place the SDK closes a response for you + +`toHttpError(response)` does, because it must: + +```typescript +import {toHttpError, type Response} from '@dexpace/core'; + +declare const response: Response; + +const failure = await toHttpError(response); +if (failure !== null) throw failure; // response is already drained and closed +console.log(await response.text()); // only reachable on 2xx/3xx +``` + +On an error status it drains the body up to a 1 MiB cap (`BODY-30`/`HTTP-52`), keeps the preview on +the returned `HttpStatusError`, and closes the response — the connection is released even for a +20 GB error body, because the drain keeps reading past the cap and discards. On a non-error status it +returns `null` and leaves the response untouched and unread. + +`HttpStatusError` therefore carries the status, the headers, and a bounded body preview. The full +body is irrecoverably gone; that is the trade, and it is deliberate. The cap itself is required — +`BODY-30`/`HTTP-52` — and the decision to size it once for every consumer rather than make it +configurable is recorded as a **closed deferral** in +[`docs/deferred-items.md`](../deferred-items.md) ("Every buffering **cap**"), not as a deviation: +nothing here departs from the reference contract. `errors.md` states the same fact the same way. + +### Reading a response as a model + +`TypedResponse<T>` pairs a `Response` with a parse function and exposes `value()`: + +```typescript +import {TypedResponse, type Response} from '@dexpace/core'; + +declare const response: Response; + +const typed = new TypedResponse(response, async r => JSON.parse(await r.text()) as {id: number}); +const {id} = await typed.value(); +``` + +Status, headers, protocol and request stay reachable without consuming anything. For the +schema-driven form, see [`write-a-response-handler.md`](./write-a-response-handler.md). + +## File bodies, and why they are a separate package + +```typescript +import {fileBody} from '@dexpace/body-file'; + +const body = fileBody('./upload.bin', {start: 1024, count: 4096}); +``` + +`@dexpace/core` cannot import `node:fs`; its zero-`node:`-import invariant is hard, and that is the +whole reason `@dexpace/body-file` exists as its own unit. + +The file is stat'd at **construction**, not at send time (`HTTP-40`, `BODY-11`), and all four ways +the range can be wrong are rejected there: the path must exist and be a regular file, `start >= 0`, +`start <= size`, `count >= 0`, and `start + count <= size`. The `start <= size` check earns its place +independently — `count` defaults to `size - start`, which goes negative past end-of-file and then +*satisfies* the sum check, silently producing a zero-byte upload. + +`writeTo()` opens a **fresh** handle per call, so a retry re-sends the same bytes. It does not close +the sink it was handed (`BODY-8` — closing belongs to whoever created it) but aborts it on failure, +so a consumer sees the error rather than a silently truncated stream. A short read raises rather than +reporting success (`BODY-13`). + +**Transports recognize a file body structurally, on `body.kind === 'file'`, never by `instanceof`.** +That is what lets `@dexpace/transport-undici` dispatch straight off the file — honoring `start`/`count`, +one fewer userspace copy — while depending on neither `@dexpace/body-file` nor anything it exports. +`FileBodyDescriptor` in core is the structural contract both sides agree on. + +## Serde bodies + +`serdeBody(value, serde, mediaType?)` serializes through a `Serde` and takes the serde's own media +type unless you override it: + +```typescript +import {serdeBody} from '@dexpace/core'; +import {jsonSerde} from '@dexpace/codec-json'; + +const body = serdeBody({name: 'ada'}, jsonSerde()); // Content-Type: application/json +``` + +See [`write-a-serde.md`](./write-a-serde.md) for the seam itself, including the PATCH tri-state +problem that makes `{}` and `{"x": null}` different messages. diff --git a/docs/sdk-documentation/errors.md b/docs/sdk-documentation/errors.md new file mode 100644 index 0000000..f09ed2a --- /dev/null +++ b/docs/sdk-documentation/errors.md @@ -0,0 +1,179 @@ +# Errors + +Every error this SDK raises **as a condition a caller might handle** descends from `DexpaceError`, +which descends from `Error`. There are no bare `throw new Error(...)` sites, and wrap-and-rethrow +always passes `{cause}`, so the original is always reachable. + +The one deliberate exception is `InvariantViolation`, which extends `Error` directly because it +signals a bug rather than a condition — see the end of this file. + +```typescript +import {DexpaceError, type Request, type Transport} from '@dexpace/core'; + +declare const client: Transport; +declare const request: Request; + +try { + await client.send(request); +} catch (error) { + if (error instanceof DexpaceError) { + // ours: name, message, and a cause chain + } + throw error; +} +``` + +Each class sets `this.name = new.target.name` in its constructor, so `error.name` is the class name +even after minification changes the function's own `name`. + +## The tree + +Two levels by rule, with exactly one sanctioned third. + +``` +Error +└── DexpaceError + ├── DomainModelError — a model rejected its input + │ ├── RequiredFieldError a builder was missing a required field (HTTP-4) + │ ├── HeaderValidationError a header name or value broke the grammar + │ ├── UrlConstructionError the URL could not be built + │ ├── MediaTypeParseError malformed media type + │ ├── EtagParseError malformed ETag + │ ├── ProtocolParseError unrecognized protocol token + │ ├── HttpRangeValidationError malformed or impossible Range + │ ├── RequestOptionsValidationError timeoutMs / maxRetries out of range + │ ├── RequestConditionsValidationError + │ └── RequestBodyNotAllowedError a body on a method that forbids one + ├── IoError — a byte-level failure + │ └── TransportFailureError the one third level, see below + ├── HttpStatusError — the server answered 4xx/5xx (see `toHttpError`) + ├── CancellationError — the caller aborted; terminal, never retried + ├── ConsumedBodyError — a single-use request body was written twice (BODY-3) + ├── MultipartBoundaryError — a supplied multipart boundary broke the grammar + ├── FormBodyValidationError — form-encoded input was not encodable + ├── AuthResolutionError — no configured credential satisfies the resolved tier + ├── PlaintextCredentialError — a credentialed scheme met a non-HTTPS URL (AUTH-28) + ├── SerializationError — a value could not be serialized + ├── DeserializationError — wire bytes did not satisfy the schema + ├── SseStreamError — the SSE stream failed + ├── SseLineTooLongError — a line exceeded the bounded buffer + ├── PaginationError — engine misuse or a precondition violation + └── OperationAssemblyError — an OperationDescriptor could not build a Request +``` + +**`TransportFailureError extends IoError` is the deliberate third level.** It is ledgered +(`docs/deviations.md` item 17): flattening it would force every consumer to discriminate on a string +tag, and `catch (e) { if (e instanceof IoError) }` still catches a transport failure. Held at exactly +three; a fourth is not sanctioned. + +## The distinction that matters most: cancel versus timeout + +```typescript +import { + CancellationError, + TransportFailureError, + type Request, + type RequestOptions, + type Transport, +} from '@dexpace/core'; + +declare const client: Transport; +declare const request: Request; +declare const options: RequestOptions; +declare const signal: AbortSignal; + +export async function call(): Promise<void> { + try { + await client.send(request, options, signal); + } catch (error) { + if (error instanceof CancellationError) return; // the caller asked to stop + if (error instanceof TransportFailureError) throw error; // retryable: the network failed + throw error; + } +} +``` + +- **`CancellationError` is terminal.** The caller aborted. The retry engine will not retry it, and + nothing further will be attempted. A raw `DOMException` from `AbortSignal` is never surfaced; both + shipped transports map it (`TRANSPORT-3`/`TRANSPORT-4`). +- **`TransportFailureError` is retryable.** A timeout, a connection reset, a DNS failure. + +`composeSignal(userSignal, timeoutMs)` builds the combined signal, and `isTimeoutSignal(signal)` tells +the two apart at the point of abort — which is exactly how a transport decides which of the two +errors to raise. + +## Narrowing helpers + +Three predicates exist for the cases where `instanceof` on a union is tedious: + +```typescript +import {isBodyError, isSerdeError} from '@dexpace/core'; + +declare const e: unknown; + +isBodyError(e); // ConsumedBodyError | MultipartBoundaryError | FormBodyValidationError +isSerdeError(e); // SerializationError | DeserializationError +``` + +## HTTP status failures + +A 4xx or 5xx is **not** an exception. `send()` resolves with the response, because the response is +often the useful part. `toHttpError` is the opt-in conversion: + +```typescript +import {toHttpError, type Request, type Transport} from '@dexpace/core'; + +declare const client: Transport; +declare const request: Request; + +const response = await client.send(request); +const failure = await toHttpError(response); +if (failure !== null) throw failure; +``` + +On an error status it drains the body to a 1 MiB cap (`BODY-30`/`HTTP-52`), keeps that preview on the +error, and **closes the response**. On any other status it returns `null` and leaves the response +untouched. The full error body is irrecoverable after the call; that is the documented trade. + +## Errors you cannot catch by class + +Some throwables are not exported from the barrel, so `instanceof` is unavailable and the only handle +is `error.name` or the message: + +| Error | Raised by | Reachable as | +|---|---|---| +| `SchemeDowngradeError` | the redirect pillar, on a rejected HTTPS→HTTP hop | `error.name === 'SchemeDowngradeError'` | +| `NonReplayableBodyError` | the redirect pillar, when a hop needs a body resend it cannot do | `error.name === 'NonReplayableBodyError'` | +| `PillarCollisionError` | `PipelineBuilder`, on a second step in one pillar stage | `error.name === 'PillarCollisionError'` | +| `AnchorNotFoundError` | `insertBefore`/`insertAfter`/`replace`, on an unknown `type` symbol | `error.name === 'AnchorNotFoundError'` | +| `CrossStageEditError`, `ReservedStageError`, `CursorAlreadyAdvancedError` | `PipelineBuilder` and the cursor | likewise | + +All of them descend from `DexpaceError`, so the broad catch works; only the narrow one does not. + +**`InvariantViolation` is the exception to the exception.** It extends `Error` directly, not +`DexpaceError`, and it is not exported. It signals a broken internal precondition — a bug in this SDK +or in a seam implementation you supplied, never a condition to handle. `standardResilience()` raises +it synchronously for invalid pillar settings, such as a non-finite bearer refresh margin or a +non-header-safe Digest username, which is the one case a consumer will see it: at wiring time, loudly, +before any request is sent. + +**Whether to promote the classes in the table above is an open decision** (`docs/open-items.md` U9, +where the full count is ten classes across 57 `@throws` tags). Every one of them is documented in a +`@public` function's `@throws` tag, and the styleguide's rule for `@throws` is that it names the type +*and what the caller should do about it* (`docs/knowledge/harvested/documentation.md:24`), which a +caller cannot act on without the class. `InvariantViolation` is the awkward member and may well be +resolved the other way — by dropping its tags rather than exporting it, since a bug is not a +condition. + +## What does not throw + +Worth stating, because each looks like it should: + +- **Exceeding the redirect hop cap.** `maxHops` returns the current 3xx response, unfollowed + (`REDIR-17`, `decide.ts:205`). `maxHops: 0` is how "do not follow redirects" is spelled, and it is + the same code path. +- **A retry budget running out.** The last response is returned live and unread; ownership transfers + to you (`docs/open-items.md` P7). +- **An unrecognized status code.** `Status.of(599)` is a valid `Status`; see [`http.md`](./http.md). +- **A malformed ETag.** `ETag.parse` returns `undefined`. `EtagParseError` is for the construction + paths that cannot degrade. diff --git a/docs/sdk-documentation/http.md b/docs/sdk-documentation/http.md new file mode 100644 index 0000000..e33e507 --- /dev/null +++ b/docs/sdk-documentation/http.md @@ -0,0 +1,170 @@ +# The HTTP domain model + +Everything in `packages/core/src/http/` follows one shape, and the shape is the point: a model is +frozen at construction and reachable only through a builder or a static factory, so +case-insensitivity, multi-value semantics, ordering, header-injection defenses, method/body legality +and total status handling are decided **once** and behave identically under every transport. + +## Building and deriving + +```typescript +import {Request} from '@dexpace/core'; + +const request = Request.newBuilder() + .method('POST') + .url('https://api.example.com/v1/things') + .headers( + Request.newBuilder().build().headers.newBuilder() + .set('Accept', 'application/json') + .add('X-Tag', 'a') + .add('X-Tag', 'b') + .build(), + ) + .build(); +``` + +`Request.newBuilder()` (static) starts empty. `request.newBuilder()` (instance) returns a builder +**pre-filled from that instance, deep-copying every collection** — so deriving never aliases the +source (`HTTP-3`), and mutating the derived request's headers cannot reach back into the original. + +There is no public constructor on any of them (`HTTP-2`). The emitted `.d.ts` declares the +constructor `private`, so a consumer cannot construct around `build()`'s validation. A missing +required field is a `` `${name} is required` `` error from one shared helper (`HTTP-4`), never a +bespoke message per field. + +**One place still leaks mutability, deliberately.** `request.url` returns a *clone* of the native +`URL` on every access, because `URL` is mutable and freezing the model cannot cascade into it +(`HTTP-5`). Reading it in a loop allocates; hoist it. + +## Headers + +```typescript +headers.get('content-type'); // first value, case-insensitive +headers.getAll('set-cookie'); // every value, in insertion order +headers.names(); // the names as first written +headers.entries(); // [name, value] per value, not per name +``` + +`HeadersBuilder` has four mutators, and the split is not cosmetic: + +| Method | For | +|---|---| +| `set(name, value)` | Outbound. Replaces every existing value. `null` removes the name | +| `add(name, value)` | Outbound. Appends, preserving order | +| `setInbound` / `addInbound` | The **lenient** pair, for values a server sent | + +Outbound values are validated against the strict field-value grammar: a CR, LF or NUL in a header +value is a `HeaderValidationError`, because that is header injection. Inbound values are accepted +leniently — obs-text bytes and all — because rejecting what a server actually sent would make the +client unable to read real responses. `HTTP-18`/`HTTP-48`/`HTTP-50`'s tension is exactly this, and +`docs/deviations.md` item 15 records the one case it cannot resolve: a server-issued `ETag` +containing obs-text does not round-trip, because replaying it outbound would have to pass the strict +grammar. + +`HeaderName.of(raw)` is the validated name type; every accessor takes `string | HeaderName`. + +## Status + +`Status` is **total**. Any integer is a `Status`: + +```typescript +Status.of(200).name // 'OK' +Status.of(200).isSuccess // true +Status.of(599).name // undefined +Status.of(599).isRecognized // false +Status.of(599).isServerError // true +Status.recognized(599) // undefined +``` + +An unrecognized code is never an error — a server is free to invent one — but `recognized()` lets a +caller tell a vendor code from a registered one when that matters. The class predicates +(`isInformational`, `isSuccess`, `isRedirect`, `isClientError`, `isServerError`, `isError`) are +range checks and work on unrecognized codes too. + +## The value types + +These have no builder; a static factory is the whole surface. + +| Type | Factories | Notes | +|---|---|---| +| `Status` | `of`, `recognized` | above | +| `Protocol` | `HTTP_1_1`, `HTTP_2`, `parse` | Both shipped transports always report `HTTP_1_1`: neither `fetch`'s `Response` nor undici's `ResponseData` exposes the negotiated version. A ledgered deviation, not a silent gap | +| `MediaType` | `of`, `parse` | `parse('text/plain;charset=utf-8').charset` → `'utf-8'`. `matches(pattern)` does wildcard subtype matching. **`charset=bogus` returns `'bogus'`, not `undefined`** — there is no encoding registry to fail a lookup against (`docs/open-items.md` A1) | +| `ETag` | `parse`, `ANY` | `parse` returns `undefined` on a malformed tag rather than throwing. `isWeak`, `opaque`, `raw` | +| `HttpRange` | `bounded`, `open`, `suffix`, `parse` | `kind` discriminates the three | + +## Per-call options + +`RequestOptions` carries what belongs to *this* call rather than to the request: + +```typescript +import {RequestOptions} from '@dexpace/core'; + +const options = RequestOptions.newBuilder() + .timeoutMs(5_000) + .maxRetries(3) + .tags(new Map([['operation', 'listThings']])) + .build(); +``` + +`RequestOptions.EMPTY` is the shared no-op instance. A step reads it as `ctx.options`, and a +transport receives it as `send()`'s second argument. `maxRetries` rejects anything that is not a +non-negative integer; `timeoutMs` rejects zero and negatives but — a known gap — still accepts +`Infinity` and `NaN` (`docs/open-items.md` P2). + +`auth` on the builder is the **per-call** auth tier, the highest-precedence one; see +[`auth.md`](./auth.md). + +## Conditional requests + +`RequestConditions` is a builder over the four conditional headers, and it applies itself: + +```typescript +import {ETag, RequestConditions, type Request} from '@dexpace/core'; + +declare const request: Request; + +const conditions = RequestConditions.newBuilder() + .ifNoneMatch(ETag.parse('"abc"') ?? ETag.ANY) + .ifModifiedSince(new Date(0)) + .build(); + +const conditioned = request.newBuilder().headers(conditions.applyTo(request.headers)).build(); +``` + +`applyTo` returns a **new** `Headers` — it never mutates the one it is given. + +## Query parameters + +`QueryParams` is the one URL-manipulation surface. It is not `URLSearchParams`, and the difference is +deliberate: `URLSearchParams` re-serializes a whole query string, reorders parameters, and re-encodes +what was already encoded. `QueryParams` preserves insertion order and encodes exactly once +(`docs/open-items.md` J-section), with RFC 3986 component encoding rather than +`application/x-www-form-urlencoded`'s — so a space becomes `%20`, not `+`, and a literal `+` becomes +`%2B`. + +```typescript +import {QueryParams} from '@dexpace/core'; + +const params = QueryParams.newBuilder() + .add('q', 'a b') + .add('plus', 'c+d') + .add('flag', null) // HTTP-28: a value-less parameter, stored as the empty string + .build(); + +params.encode(); // 'q=a%20b&plus=c%2Bd&flag=' +``` + +`add(name, null)` records a value-less parameter as a single empty string, never the text `"null"`. +A name whose value list ends up empty is dropped at `build()` so it cannot leave a phantom entry that +`has()` reports and `encode()` never emits (`HTTP-30`). + +The pagination engine's query splice and the `Link`-header tokenizer are deliberately **not** +exported: publishing them would put a second URL-manipulation surface next to this one. + +## Operations + +`buildRequest(baseUrl, operation)` assembles a `Request` from an `OperationDescriptor` — a declarative +path template with its parameters — for callers generating clients from a service description rather +than writing builders by hand. It raises `OperationAssemblyError` on a template a parameter set +cannot satisfy. diff --git a/docs/sdk-documentation/pipelines.md b/docs/sdk-documentation/pipelines.md new file mode 100644 index 0000000..94cd767 --- /dev/null +++ b/docs/sdk-documentation/pipelines.md @@ -0,0 +1,198 @@ +# Pipelines + +A pipeline is an ordered list of steps ending at a transport. Building one is the main thing a client +library does with this SDK. + +## The three types + +```typescript +type Next = (request?: Request) => Promise<Response>; +type Step = (request: Request, ctx: StepContext) => Promise<Response>; + +interface StepDescriptor { + readonly type: symbol; // stable identity, for anchoring and removal + readonly stage: Stage; // where in the order it sits + readonly fn: Step; // the behaviour +} +``` + +A step receives the request, may rewrite it, calls `ctx.next(maybeRewritten)` to invoke everything +below, and may post-process the response on the way back up. `ctx.next()` with no argument passes the +request through unchanged. + +```typescript +interface StepContext { + readonly next: Next; + readonly context: ExecutionContext; // DispatchContext | RequestContext | ExchangeContext + readonly options?: RequestOptions; // the per-call options + readonly signal?: AbortSignal; // the caller's signal + readonly fork?: () => Next; // a fresh chain, for steps that re-drive +} +``` + +`fork` is what separates a re-driving step from an ordinary one. `next` may be called once; a step +that retries or follows a redirect calls `ctx.fork()` to obtain a fresh downstream chain per attempt. +Retry, redirect and auth all use it. An ordinary step does not need it and should not take it. + +## The sixteen stages + +``` +PRE_REDIRECT REDIRECT POST_REDIRECT +PRE_RETRY RETRY POST_RETRY +PRE_AUTH AUTH POST_AUTH +PRE_LOGGING LOGGING POST_LOGGING +PRE_SERDE SERDE POST_SERDE +SEND +``` + +`STAGE_ORDER` is that array. `PILLAR_STAGES` is the set `{REDIRECT, RETRY, AUTH, LOGGING, SERDE}` — +each admits **exactly one** step and raises on a second. The `PRE_`/`POST_` stages around them stack +with append/prepend semantics, and are the user-extensible slots. + +Order is not arbitrary. Redirect wraps retry wraps auth (`AUTH-27`), so a retry attempt re-resolves +credentials and a redirect hop re-stamps them. Getting that backwards means replaying a stale token +or leaking a credential across an origin. + +`SERDE` is reserved and ships no behaviour anywhere in this roadmap's scope. It is a pillar so that a +future serde step cannot be installed twice. + +## The preset + +```typescript +import {standardResilience} from '@dexpace/core'; +import {fetchTransport} from '@dexpace/transport-fetch'; + +const client = standardResilience(fetchTransport(), { + retry: {settings: {maxAttempts: 4, totalTimeoutMs: 30_000}}, + redirect: {maxHops: 3, allowSchemeDowngrade: false}, + logging: {granularity: 'headers'}, + // auth: omitted -> a NO_AUTH-only step that stamps nothing +}); +``` + +Every slot is optional and every omitted one takes that pillar's own defaults. +`PIPE-24`'s "installs into empty pillar slots only" holds **by construction**: the function always +starts from a fresh `PipelineBuilder`, so no slot can be occupied and no runtime check is needed. + +`standardResilience()` also installs the redirect pillar through an internal helper that seats a +second, `POST_AUTH` step alongside it — the guard that strips the SDK's internal cross-origin marker +header before dispatch (`REDIR-11(c)`). **That guard is not publicly reachable**, so a pipeline that +installs `redirectStep()` by hand forwards the marker to the wire; use `seedFrom` instead of building +a redirect pipeline from scratch (`docs/open-items.md` U7). + +## Extending the preset + +```typescript +import {PipelineBuilder, standardResilience, type Step} from '@dexpace/core'; +import {fetchTransport} from '@dexpace/transport-fetch'; + +const requestId: Step = async (request, ctx) => + ctx.next( + request + .newBuilder() + .headers(request.headers.newBuilder().set('X-Request-Id', crypto.randomUUID()).build()) + .build(), + ); + +const runtime = PipelineBuilder.seedFrom(standardResilience(fetchTransport()), 'flatten') + .append({type: Symbol('x-request-id'), stage: 'PRE_SERDE', fn: requestId}) + .build(); +``` + +`seedFrom(runtime, mode)` takes a **built** runtime and returns a builder seeded from it: + +- **`'flatten'`** unpacks the runtime's steps into the new builder, so the result is one flat chain + and the new step sits in true stage order among the old ones. +- **`'nest'`** installs the whole runtime as a single terminal unit, so the old pipeline runs as an + opaque inner chain. Use this when the inner pipeline's ordering must be preserved exactly. + +The rest of the builder API operates by descriptor `type` symbol: `insertBefore`, `insertAfter`, +`replace`, `remove`, `reload`. Anchoring on a symbol rather than a position is what keeps an edit +correct when the surrounding pipeline changes. + +## Runtime + +```typescript +class Runtime implements Transport { + send(request: Request, options?: RequestOptions, signal?: AbortSignal): Promise<Response>; + close(): Promise<void>; + get steps(): readonly StepDescriptor[]; + get transport(): Transport; +} +``` + +Because `Runtime` **is** a `Transport`, a pipeline is substitutable for the transport it wraps — which +is what makes `'nest'` mode and `Paginator`'s `transport` field work on a full pipeline. + +`Runtime.close()` is a documented no-op. The pipeline never owns the transport it was given +(`PIPE-27`); closing it is the caller's job, in the `finally` that also closes the response. + +## Execution context + +A call promotes through three context shapes, and a step reads `ctx.context.kind` to know which it is +in: + +| `kind` | Shape adds | Meaning | +|---|---|---| +| `'dispatch'` | `key`, `instrumentation` | Before a request exists | +| `'request'` | `request`, `operationName` | A request has been assembled | +| `'exchange'` | the response side | A response has arrived | + +All three carry the same `InstrumentationBundle`, so trace and span identity survive the promotions. +`activateSpan(span)` returns a `Scope`; `getActiveSpan()` reads the current one. Propagation is +`AsyncLocalStorage`-based, which is why `@dexpace/rx` installs no RxJS scheduler — adding +`observeOn`/`subscribeOn` downstream makes reinstating the context the caller's job. + +## The four shipped pillars + +| Pillar | Factory | Key settings | +|---|---|---| +| Retry | `retryStep(options?)` | `maxAttempts`, `retryableStatuses`, `totalTimeoutMs`, `attemptHeaderName`, backoff (`initialDelayMs`, `multiplier`, `maxDelayMs`, `jitter`, `fixedDelayMs`), injectable `clock`/`random` | +| Redirect | `redirectStep(overrides?)` | `maxHops`, `allowedMethods`, `allow303`, `allowSchemeDowngrade`, `locationHeader`, `predicate` | +| Auth | `authStep(settings)` | `credentials`, `tiers`, `challengeHook`, `bearerMarginMs` — see [`auth.md`](./auth.md) | +| Logging | `loggingStep(settings?)` | `granularity`, `severity`, `previewSizeBytes`, `droppedHeaderPolicy`, `logger`, `meter`, `tracerFactory` | + +Retry and redirect are worth two notes each, because both surprise people: + +- **Retry pacing honours the server.** `Retry-After`, `X-RateLimit-Reset` and friends are parsed in a + fixed precedence and win over computed backoff. Every computed delta is clamped to a 365-day + ceiling that `RETRY-18` mandates — so a server that sends `X-RateLimit-Reset` in milliseconds + instead of epoch seconds parks the retry for a year, which is indistinguishable from a hang. Set + `totalTimeoutMs` if that matters to you; it is opt-in and `undefined` by default + (`docs/open-items.md` P4). +- **Retry hands back a live response.** Any response the engine *discards* is closed. The response + that ends the loop — attempt cap reached, budget spent, status not retryable — is returned live and + unread. Ownership transfers to you (`docs/open-items.md` P7). +- **Redirects are never followed by the transport.** Both shipped transports pin redirects off, so + the pipeline is the single redirect authority (`TRANSPORT-1`/`TRANSPORT-2`). +- **A non-replayable body ends a redirect.** `PIPE-40` and `REDIR-22` disagree about what should + happen; this port closes the response and throws (`docs/open-items.md` G1). + +## Testing a pipeline + +Nothing here needs a socket. A `Transport` is two methods, so the test double is a literal: + +```typescript +import {Protocol, Response, Status, type Transport} from '@dexpace/core'; + +const alwaysOk: Transport = { + send: async request => + Response.newBuilder() + .request(request) + .status(Status.of(200)) + .protocol(Protocol.HTTP_1_1) + .body(null) + .build(), + close: async () => undefined, +}; +``` + +Timing is testable without waiting: `retryStep({clock, random})` takes both seams, so a test drives +the backoff schedule deterministically. `loggingStep({clock, logger, meter})` takes the same shape. + +Core does carry a scripted `FakeTransport` under `src/testing/`, but it is `@internal` and **not** +exported from the barrel — it exists for core's own multi-attempt tests. A consumer writes the +five-line literal above. + +`@dexpace/transport-conformance` is the other half of this, for transport authors rather than +pipeline authors: see [`write-a-transport.md`](./write-a-transport.md). diff --git a/docs/sdk-documentation/quality-gates.md b/docs/sdk-documentation/quality-gates.md new file mode 100644 index 0000000..c178ad9 --- /dev/null +++ b/docs/sdk-documentation/quality-gates.md @@ -0,0 +1,108 @@ +# Quality gates + +Twenty named steps across two CI jobs, every one of them blocking +([`.github/workflows/ci.yml`](../../.github/workflows/ci.yml)). Seventeen in the `ci` job, three in a +`node-conformance` matrix that runs after it. `bun run test` passing is not evidence that the work is +done; the whole set is. + +## Running them + +```bash +node .claude/skills/ci-preflight/run-ci.mjs --clean +``` + +That is the command. `--clean` sweeps every `dist/` and `*.tsbuildinfo` first, so the run starts from +the tree CI checks out rather than a warm one, and it pins every step to `.bun-version`'s Bun via +mise. Both matter: Phase 8a's transport rows passed on Bun 1.4.0 and failed three ways on the pinned +1.3.14, and a missing `build:deps` entry is invisible against a warm `dist/`. + +## The `ci` job, in order + +| Step | Command | What it protects | +|---|---|---| +| Install | `bun install --frozen-lockfile` | The lockfile is authoritative | +| Knowledge-corpus structure | `verify:knowledge-structure` | `docs/knowledge/`'s two trees stay separate. First, because it is pure Node over Markdown — a corpus mistake reports in seconds | +| Typecheck | `typecheck` | `tsc --noEmit` per package, over twelve projects | +| Lint | `lint` | `gts lint` — formatting **and** type-aware rules, both fatal | +| Build | `build` | Every package's `dist/` | +| Test | `test --coverage` | Both Bun test trees, one coverage report, 80% line floor | +| Gate self-tests | `test:scripts` | The gates' own logic, on `node --test` | +| API surface | `api` | All 9 committed `etc/*.api.md` reports match | +| Package health | `lint:publish` | `publint` + `attw` over every built package | +| Dual consumption | `verify:dual-consumption` | Plain `node` imports each built package and exercises it | +| Consumer types | `verify:consumer-types` | The built `.d.ts` compiles on the declared `lib` with `types: []` | +| SEAM-1 | `verify:seam-1` | Zero runtime dependencies in **every** package, plus the `@dexpace/core` peer rule | +| SSE-37/38 | `verify:sse-37` | No serde dependency and no reconnect path in core SSE | +| Runtime floor | `verify:runtime-floor` | `tsconfig` target and `engines.node` agree | +| Test partition | `verify:test-partition` | The five files that keep the two `tests/` suites apart | +| Reproducible build | `verify:reproducible-build` | Two clean builds of one tree agree, `dist/` and tarball (`NFR-12`) | +| Dependency audit | `audit` | `bun audit --audit-level=high --prod` | + +Three of those deserve their reasons stated, because each exists because something silently broke. + +**`test:scripts` tests the gates themselves.** A gate whose own logic degrades — a bad glob, a +swallowed assertion — still exits 0, so nothing else in the run would notice. It became blocking in +Phase 10, and the proof it should have been is that `knowledge.test.mjs` had been failing on `main` +since `36c3f96` with nobody noticing. + +**`verify:reproducible-build` runs after every step that needs `dist/`, deliberately.** It sweeps +every `dist/` and rebuilds the workspace twice, so it would otherwise pull the rug from under any +step above that resolves a workspace package by name. It is not the last step — `Dependency audit` +follows it (`.github/workflows/ci.yml:93` then `:96`), and can, because `bun audit` reads manifests +rather than build output. + +**`verify:seam-1` covers every package, not core alone.** `NFR-2` is the reason: each optional +capability is core plus at most one external library, and the gate is what makes reaching for a small +utility a red build rather than a code-review argument. + +## The `node-conformance` job + +Install, build, then `bun run test:node` under real Node — as a matrix over `engines.node`'s declared +floor (`20.3.0`) and `lts/*`, with `fail-fast: false`, because "broken on the floor" and "broken on +LTS" are different diagnoses. + +It exists because Bun's Web Streams, `AbortSignal` and `Uint8Array` are an independent implementation +of Node's, and `packages/core/src/io/` is where they diverge. **A change to a runtime-divergent +surface adds a case there, not only to `bun run test`.** + +## Two test trees, and the rule between them + +``` +packages/*/src/*.test.ts colocated unit tests bun +tests/conformance/xcut/ cross-cutting conformance bun +tests/node-conformance/ runtime conformance node --test, against dist/ +``` + +`bun run test` is the **only** command that runs both Bun trees; it passes `./packages ./tests` +explicitly. A bare `bun test` silently runs only the first, because `bunfig.toml`'s +`[test] root = "packages"` governs discovery — and reports green over a suite it never opened, with no +"0 files matched" to notice. + +`tests/node-conformance/` must never run on Bun; that is the only reason the tree exists. One key +holds the line — `bunfig.toml`'s `[test] pathIgnorePatterns` — and Bun accepts an unknown `[test]` key +without complaint, so a typo gives no warning and no failure. Measured on pinned Bun 1.3.14: with the +key, 164 files; without it, 178, of which thirteen pass silently and the fourteenth trips an unrelated +timer assertion. Treat that exit code as an accident, not a control. `verify:test-partition` checks +the key's name and the four other files that must agree with it. + +## Gates that are not in CI, on purpose + +| Command | Why not | +|---|---| +| `bun run knowledge:drift` | 16 of the 47 corpus sources are a sibling styleguide repository no CI checkout has. Drift is normal and the fix is a re-harvest, not a red build | +| `bun run shrink-test` | Runs inside the default build via `@dexpace/shrink-test`, not as its own step | +| The `housekeeping` skill's probe | A hand-run maintenance tool, like `test:scripts` was before Phase 10 promoted it | + +## Per-package obligations + +- **A changeset** for any consumer-facing change: `bun run changeset`, never `bunx changeset`. The + wrapper renames the generated file to `YYYY-MM-DD-<slug>.md`. +- **A regenerated API report** after changing a package's exports: `cd packages/<pkg> && bun run + api:local`, then commit it. `bun run api` is what CI diffs. +- **A TSDoc block with `@public`** on anything the barrel exports, plus `@throws` naming each + catchable error class. `api-extractor` records an undocumented export as `(undocumented)` in the + committed report, which makes the omission a reviewable diff. +- **`// SPDX-License-Identifier: MIT` on line 1** of every source file (`NFR-13`). +- **A requirement-ID citation** in every test file's header comment. +- **A reason on every `eslint-disable`** (`eslint-comments/require-description`, wired for `NFR-7`). + Suppressing a rule without a stated reason fails lint. diff --git a/docs/sdk-documentation/write-a-paging-strategy.md b/docs/sdk-documentation/write-a-paging-strategy.md new file mode 100644 index 0000000..7242662 --- /dev/null +++ b/docs/sdk-documentation/write-a-paging-strategy.md @@ -0,0 +1,166 @@ +# Write a paging strategy + +A strategy is one method: + +```typescript +interface PaginationStrategy<T> { + parse(response: Response, template: Request): Promise<PageInfo<T>>; +} + +interface PageInfo<T> { + readonly items: readonly T[]; + readonly nextRequest: Request | undefined; // undefined ends the walk +} +``` + +Given the page that just arrived and the request template the walk started from, produce this page's +items and the request that fetches the next one. `undefined` for `nextRequest` is how a walk ends — +there is no separate "done" flag to keep consistent with it. + +## Three ship already + +Reach for a custom strategy only when none of these fits. + +```typescript +import {cursorStrategy, linkHeaderStrategy, pageNumberStrategy} from '@dexpace/core'; + +interface Thing { + readonly id: string; +} +declare function parseItems(payload: string): readonly Thing[]; + +// ?cursor=<opaque>, taken from the payload +cursorStrategy<Thing>({ + extract: async r => { + const {items, next} = JSON.parse(await r.text()) as { + items: readonly Thing[]; + next: string | null; + }; + return {items, cursor: next}; + }, + parameterName: 'cursor', // default +}); + +// RFC 8288 Link: <...>; rel="next" +linkHeaderStrategy<Thing>({extract: async r => parseItems(await r.text()), headerName: 'Link'}); + +// ?page=1,2,3… +pageNumberStrategy<Thing>({extract: async r => parseItems(await r.text()), startPage: 1}); +``` + +`extract` is handed the live response. `Response` has `text()` and `bytes()`, not `json()` — this is +the SDK's own model, not the WHATWG one. + +Each takes an `extract` that reads the payload and lets the strategy own the URL manipulation. + +## Driving one + +```typescript +import { + Paginator, + Request, + type PaginationStrategy, + type Transport, +} from '@dexpace/core'; + +interface Thing { + readonly id: string; +} +declare const client: Transport; +declare const strategy: PaginationStrategy<Thing>; +declare const signal: AbortSignal; + +const paginator = new Paginator<Thing>({ + transport: client, // a Runtime is a Transport, so a full pipeline works here + initialRequest: Request.newBuilder().url('https://api.example.com/v1/things').build(), + strategy, + maxPages: 50, + signal, +}); + +for await (const thing of paginator.items()) { /* item by item */ } +for await (const page of paginator.pages()) { /* page by page */ } +``` + +`items()` and `pages()` each build a **fresh** generator per call (`PAGE-8`), so two iterations are +independent walks, not two views of one. That is also why `@dexpace/rx`'s `pageItems$`/`pages$` are +cold and repeatable while its SSE observables are not. + +## The four rules + +**1. Take everything you need from the response before your promise settles** (`PAGE-5`). The +response you are handed is live and single-use; the engine may close it the moment `parse` resolves. +Read the items and the cursor first, retain nothing past the call, and never hand the `Response` +itself to a caller. + +**`parse` returns a `Promise`, and that is deliberate — do not "fix" it toward the literal +requirement.** `PAGE-5` says the strategy reads what it needs *synchronously* inside `parse`. Node has +no synchronous body read, so the literal form is unimplementable here and the discipline the clause +protects — single use, nothing retained — is what the async signature preserves. Recorded in +[`docs/deferred-items.md`](../deferred-items.md) precisely so an async signature does not later read +as an oversight. Every shipped strategy's `extract` above is `async` for the same reason. + +**2. Build `nextRequest` from the template, not from the response.** The template carries the headers, +auth tier and options the walk was started with. A next request built from scratch loses all of them. + +```typescript +const next = template + .newBuilder() + .url(withQueryParam(template.url, 'cursor', cursor)) + .build(); +return pageInfo(items, next); +``` + +`pageInfo(items, nextRequest?)` is the `PageInfo` factory. Use `QueryParams` for the URL work — see +[`http.md`](./http.md) for why not `URLSearchParams`. + +**3. A page is closed before its items are yielded** (`PAGE-11`). The engine does this for you. It is +worth knowing because it means your `extract` is the **only** place the response body is readable, and +because `sdk-design-nodejs/07` §7.1's illustrative snippet has it backwards — closing after yielding — +which is an erratum recorded in `docs/knowledge/notes/pagination.md` and `docs/open-items.md` J1. + +**4. Terminate.** Returning a `nextRequest` equal to the one just fetched is an infinite walk. +`maxPages` on `PaginatorInit` is the backstop, not the design. Loop detection is not the paginator's +job. + +`PaginationError` is reserved for engine misuse and precondition violations — not for "the server +returned a page I did not understand", which is your `extract`'s error to raise. + +## The fetcher form + +When the API is already wrapped in functions rather than reachable as requests, skip +`PaginationStrategy` entirely: + +```typescript +import {paginateWithFetchers, type FetcherPage, type PagingOptions} from '@dexpace/core'; + +interface Thing { + readonly id: string; +} +declare function firstPage(options: PagingOptions): Promise<FetcherPage<Thing> | undefined>; +declare function nextPage(key: string, options: PagingOptions): Promise<FetcherPage<Thing> | undefined>; + +for await (const page of paginateWithFetchers<Thing>({ + first: async options => firstPage(options), + next: async (key, options) => nextPage(key, options), + maxPages: 20, +})) { + console.log(page.items); +} +``` + +`first` and `next` return a `FetcherPage<T>` — a `Page<T>` plus either a `continuationToken` or a +`nextLink` — or `undefined` to end the walk. This is the adapter for a generated client whose +pagination is already a pair of methods. + +## Disposal + +`Page` has a `close()`. It does **not** support `await using`: `Symbol.asyncDispose` arrived in Node +20.4 and this project's floor is 20.3, so the disposal member is installed only when the symbol +exists and is never declared in the `.d.ts`. Declaring it anyway would be a type that lies on the +supported runtime, which `NFR-10` forbids. `close()` is the teardown on every runtime, and +`open-items.md`'s Section D row [`await using` support](../open-items.md#d-nfr-10-await-using) +records the decision with the four reasons the floor does not move instead. + +Within a `Paginator` walk the engine closes each page for you; `close()` matters when you hold a +`Page` yourself. diff --git a/docs/sdk-documentation/write-a-response-handler.md b/docs/sdk-documentation/write-a-response-handler.md new file mode 100644 index 0000000..ed46630 --- /dev/null +++ b/docs/sdk-documentation/write-a-response-handler.md @@ -0,0 +1,143 @@ +# Write a response handler + +A response handler turns a `Response` into your model. Core ships three shapes; write your own when +none fits. + +## The three shipped shapes + +All three come from `@dexpace/core`. + +| Shape | Use when | +|---|---| +| `decodeSuccessResponse(response, deserializer, target)` | The common case: decode 2xx, raise on 4xx/5xx | +| `decodeResponse(response, deserializer, target)` | You want the error body decoded too — an RFC 7807 problem document, say | +| `new TypedResponse(response, parse)` | You need the status, headers and request *alongside* the value, decoded lazily | + +`target` is `{schema, typeName?}`: the runtime type witness plus an optional label that names it in +an error message. It travels as one object because a schema and its label describe one thing, and +because `(response, deserializer, schema, typeName)` is four parameters. + +```typescript +import {decodeSuccessResponse, type Response, type Schema} from '@dexpace/core'; +import {jsonSerde} from '@dexpace/codec-json'; + +interface Thing { + readonly id: string; +} +declare const ThingSchema: Schema<Thing>; // any object with parse(input: unknown): Thing + +const serde = jsonSerde(); + +export async function readThing(response: Response): Promise<Thing> { + return decodeSuccessResponse(response, serde.deserializer, { + schema: ThingSchema, + typeName: 'Thing', + }); +} +``` + +**Import `Response` from `@dexpace/core`, always.** A bare `Response` in a signature resolves to the +DOM global under a `lib` that includes `"DOM"`, and the two are unrelated types — this SDK's +`Response` has `status: Status`, `close()` and `request`, and no `json()`. The mistake typechecks +until you try to pass one. + +`decodeSuccessResponse` delegates to `decodeResponse` on a 2xx and to `toHttpError` otherwise, so a +`4xx` becomes an `HttpStatusError` carrying the status, the headers and a bounded body preview +(`SERDE-28`). + +## What the shipped handlers guarantee, and what you must reproduce + +**1. The response is closed on every path.** Success, missing body, codec failure, stream failure. A +handler that returns early on one branch strands a connection. + +**2. A close failure never displaces the real failure.** This is the part that looks like a +one-liner and is not: + +```typescript +// WRONG: when close() rejects while an error is already in flight, the finally's +// rejection REPLACES it — the caller is told their connection dropped when in fact +// their payload was malformed. +try { + return await work(); +} finally { + await response.close(); +} +``` + +The rule the shipped handlers follow (`RECOV-12`): + +| Work | Close | Result | +|---|---|---| +| threw | ok | the work error propagates | +| threw | threw | the work error stays **primary**; the close error attaches as `suppressed` | +| ok | threw | the close error propagates — it is the only failure there is | + +The suppression wrapper's `name` is `'SuppressedError'`, `.error` is the primary and `.suppressed` the +release failure. **`instanceof SuppressedError` is not a valid test**: the class is absent on this +project's declared Node floor and a structurally identical stand-in is built there instead. Test the +shape, or read `.error` unconditionally. + +**3. Only payload failures are re-typed.** `SERDE-12`: a malformed body or a shape mismatch becomes a +`DeserializationError` with the original chained; a genuine stream failure propagates untouched, +because re-wrapping it would tell a caller their payload was malformed when their socket dropped. + +**4. `isSerdeError(e)` is the supported discriminator**, not `instanceof` against a stream-error class +— that class is not public surface. + +### The limit of that discriminator, stated plainly + +Every error already in this SDK's typed tree passes through untouched, so a stream failure raised by +core's own I/O layer is always recognizable. A **foreign** one is not. `decodeResponse` hands the live +stream to the codec and never reads it, so at the catch a transport's raw error is indistinguishable +from a non-conforming codec leaking one — and since `SERDE-27` requires a codec failure to surface as +a serde exception, the untyped case is wrapped. + +In practice that means a `fetch`/undici `TypeError('terminated')`, a hand-built `ReadableStream` +errored with a bare `Error`, or an aborted body (`DOMException` named `'AbortError'`) is reported as a +`DeserializationError`. Read `isSerdeError(e) === true` as "payload **or** foreign stream", not as +proof of a payload failure. Fixing it needs the transport to tag its stream errors. + +## Writing one + +```typescript +import {DeserializationError, type Response} from '@dexpace/core'; + +export async function readNdjson<T>( + response: Response, + parseLine: (line: string) => T, +): Promise<T[]> { + try { + const text = await response.text(); // text() closes the response itself (BODY-16) + return text + .split('\n') + .filter(line => line.length > 0) + .map(parseLine); + } catch (cause) { + throw new DeserializationError('could not decode the NDJSON payload', {cause}); + } +} +``` + +`response.text()` and `.bytes()` close the response whether the read succeeds or not, which is why +this handler needs no `finally`. A handler that reads `response.body` directly does, and then owes +rule 2's ordering. + +**`decodeResponse` never buffers.** It hands the live body stream to `Deserializer.deserializeFrom`, +which reads it to EOF. Whether the codec buffers is the codec's business — `@dexpace/codec-json` +must, because `JSON.parse` has no incremental form, and that is ledgered. A handler that needs to act +on the payload *before* it ends reads `response.body` itself, as above. + +## Two more things a response can be + +**Server-Sent Events.** `sseStreamFrom(response)` yields `SseEvent`s; `typedSseStream(stream, mapper)` +decodes them into your models. The mapper returns `mapperValue(v)`, `MAPPER_SKIP` or `MAPPER_DONE`. +Core's SSE parser has **no** serde dependency and no reconnect path, and +`bun run verify:sse-37` is a blocking CI step that proves both. + +**A page.** See [`write-a-paging-strategy.md`](./write-a-paging-strategy.md). + +## Ownership, once more + +The pipeline never closes a response it hands you. `toHttpError` and the two `decode*` handlers do, +because they read it. A handler you write must decide which it is and say so in its own TSDoc — that +is the single fact a caller cannot recover from the signature. diff --git a/docs/sdk-documentation/write-a-serde.md b/docs/sdk-documentation/write-a-serde.md new file mode 100644 index 0000000..3834c6a --- /dev/null +++ b/docs/sdk-documentation/write-a-serde.md @@ -0,0 +1,222 @@ +# Write a serde + +A serde is a wire codec behind three interfaces. It is bigger than it first looks: the encode half +has **four allocation profiles** and the decode half **two**, and an implementor owes all six +(`SEAM-20`, `SERDE-3`/`SERDE-4`/`SERDE-5`/`SERDE-6`). + +```typescript +interface Serde { + readonly serializer: Serializer; + readonly deserializer: Deserializer; + readonly mediaType: string; +} + +interface Serializer { + serialize(value: unknown): Uint8Array; // fresh buffer + serializeToString(value: unknown): string; // fresh string + serializeInto(value: unknown, target: Uint8Array, offset?: number): number; // caller's buffer + serializeTo(value: unknown, sink: WritableStream<Uint8Array>): Promise<void>; // caller's sink +} + +interface Deserializer { + deserialize<T>(data: Uint8Array, schema: Schema<T>, typeName?: string): T; + deserializeFrom<T>(source: ReadableStream<Uint8Array>, schema: Schema<T>, typeName?: string): Promise<T>; +} + +interface Schema<T> { + parse(input: unknown): T; +} +``` + +No encode method takes a `Schema` — encoding has the value in hand and needs no witness. + +`@dexpace/core` ships **no** codec. `@dexpace/codec-json` is the reference implementation and a peer +of core, never a dependency of it — which is what forced the seam to be public in the first place: a +separate package can only reach core through its published entry point. + +## Schema is the type witness + +`Schema<T>` is one method, `parse(input: unknown): T`. Zod, Valibot, ArkType, a hand-written +predicate — anything with a `parse` satisfies it, and nothing registers. This is `SEAM-21`'s type +witness: TypeScript erases generics, so a deserializer cannot reflect on `T`; the schema **is** the +runtime carrier of the type, and it is also the source of the static one, so there is no separate type +argument to keep in sync. + +`typeName` is diagnostics only. It never selects behaviour; it makes a `DeserializationError` +message name the thing that failed to parse. + +## The minimum + +All six methods, no shortcuts. This is the shape, not a sketch: + +```typescript +import { + DeserializationError, + SerializationError, + type Schema, + type Serde, +} from '@dexpace/core'; + +const TEXT = new TextEncoder(); + +export function csvSerde(): Serde { + const encode = (value: unknown): string => { + if (!Array.isArray(value)) { + throw new SerializationError('the csv serializer takes an array of rows'); + } + return value.map(row => String(row)).join('\n'); + }; + + const decode = <T>(text: string, schema: Schema<T>, typeName?: string): T => { + try { + return schema.parse(text.split('\n')); + } catch (cause) { + throw new DeserializationError( + `could not decode ${typeName ?? 'the target type'}`, + {cause}, + ); + } + }; + + return { + mediaType: 'text/csv', + serializer: { + serialize: value => TEXT.encode(encode(value)), + serializeToString: encode, + serializeInto(value, target, offset = 0) { + const bytes = TEXT.encode(encode(value)); + // A plain RangeError, deliberately: SERDE-4 says a buffer that does not fit is the + // caller's arithmetic error, not an encoding failure, so it is NOT a SerializationError. + if (offset < 0 || offset + bytes.length > target.length) { + throw new RangeError('the encoded payload does not fit at that offset'); + } + target.set(bytes, offset); + return bytes.length; + }, + async serializeTo(value, sink) { + const writer = sink.getWriter(); // TypeError if contended — a programmer error, not re-typed + try { + await writer.write(TEXT.encode(encode(value))); + } finally { + writer.releaseLock(); // never close or abort: the caller owns the sink (SERDE-3) + } + }, + }, + deserializer: { + deserialize: (data, schema, typeName) => + decode(new TextDecoder().decode(data), schema, typeName), + async deserializeFrom(source, schema, typeName) { + const reader = source.getReader(); + const chunks: Uint8Array[] = []; + try { + for (;;) { + const {done, value} = await reader.read(); + if (done) break; + chunks.push(value); + } + } finally { + reader.releaseLock(); // never cancel: the caller owns the source (SERDE-3) + } + const joined = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0)); + let at = 0; + for (const chunk of chunks) { + joined.set(chunk, at); + at += chunk.length; + } + return decode(new TextDecoder().decode(joined), schema, typeName); + }, + }, + }; +} +``` + +Six rules, all visible above: + +1. **Raise `SerializationError` / `DeserializationError`, never a raw error** — with one stated + exception: `serializeInto`'s out-of-range or does-not-fit case is a plain `RangeError` with no + chained cause (`SERDE-4`). Both serde errors descend from `DexpaceError`, and `isSerdeError(e)` + narrows the union. +2. **Always pass `{cause}`.** The underlying parser's message is what a caller actually debugs with. +3. **Never take ownership of a caller's stream** (`SERDE-3`). `serializeTo` does not close or abort + the sink; `deserializeFrom` does not cancel the source. Release your lock and leave the resource + to whoever opened it — including on the failure path. +4. **A contended stream is a plain `TypeError`**, not re-typed. Two writers on one sink is a + programmer error, not an encoding failure. +5. **A wire failure propagates unwrapped** (`SERDE-12`). Re-wrapping a write or read failure as a + serde exception tells a caller their payload was malformed when their socket dropped. The rule is + direction-agnostic: it applies to `serializeTo` as much as to `deserializeFrom`. +6. **A wire `null` decoded into a non-null target MUST throw** `DeserializationError` naming that + target, on **every** entry point (`SERDE-13`), never return a `null` that detonates at a later + field access. The fallback label is the literal `'the target type'`; each codec repeats it, + because `SEAM-1` leaves core with no exported constant to share. + +`mediaType` is the default `Content-Type` — `serdeBody(value, serde)` reads it, and a caller may +override per body. + +## Using one + +```typescript +import {serdeBody} from '@dexpace/core'; +import {jsonSerde} from '@dexpace/codec-json'; + +const serde = jsonSerde(); +const body = serdeBody({name: 'ada'}, serde); // Content-Type: application/json +``` + +See [`write-a-response-handler.md`](./write-a-response-handler.md) for the decode side. + +## The tri-state problem + +This is the part a JSON codec gets wrong by default, and the reason `@dexpace/codec-json` exists as a +reference rather than as a five-line `JSON.parse` wrapper. + +A PATCH request has **three** meanings for a field, and JavaScript gives you two: + +| Intent | Wire | JavaScript | +|---|---|---| +| Leave it alone | key absent | `undefined` | +| Clear it | `"x": null` | `null` | +| Set it | `"x": 1` | `1` | + +`JSON.stringify` drops `undefined` keys, so the first two collapse the moment anything round-trips +through an optional property. `Tristate<T>` is core's answer — a branded discriminated union of +`'absent' | 'null' | 'present'`: + +```typescript +import {absent, foldTristate, nullValue, present} from '@dexpace/core'; + +const patch = { + name: present('ada'), + nickname: nullValue(), // emits "nickname": null + bio: absent(), // omits the key entirely +}; + +foldTristate(patch.name, { + onAbsent: () => 'unchanged', + onNull: () => 'cleared', + onPresent: value => `set to ${value}`, +}); +``` + +`isPresent`, `isNull`, `isAbsent` and `valueOrNull` are the narrowing helpers; `ofNullable` lifts a +`T | null | undefined`. The `TRISTATE_BRAND` symbol is what makes `isTristate` reliable against a +caller-shaped object literal. + +`jsonSerde()` wires the encoding side **on by default** — `jsonSerde({tristate: false})` is the only +way out — and exports `tristate(schema)` and `tristateObject(shape)` to lift your schemas, plus +`tristateReplacer` for use with a bare `JSON.stringify`. + +If your format has its own three-state encoding, map `Tristate` onto it. If it genuinely has only +two, say so in the README rather than silently collapsing absent into null. + +## What core's serde seam does not do + +- **No default codec, and no fallback to JSON.** A pipeline with no serde configured serializes + nothing. +- **No content negotiation.** `mediaType` is a default, not a negotiation. +- **No incremental decode.** `deserializeFrom` reads its source to EOF before returning; it is + streaming *input*, not streaming *output*. `@dexpace/codec-json` must buffer, because `JSON.parse` + has no incremental form, and that limitation is ledgered. +- **No SSE coupling.** Core's SSE parser has no serde dependency at all, and + `bun run verify:sse-37` is a blocking CI step that proves it. `typedSseStream(stream, mapper)` is + where a caller plugs decoding in. diff --git a/docs/sdk-documentation/write-a-transport.md b/docs/sdk-documentation/write-a-transport.md new file mode 100644 index 0000000..dbf2648 --- /dev/null +++ b/docs/sdk-documentation/write-a-transport.md @@ -0,0 +1,138 @@ +# Write a transport + +A transport is two methods: + +```typescript +interface Transport { + send(request: Request, options?: RequestOptions, signal?: AbortSignal): Promise<Response>; + close(): Promise<void>; +} +``` + +There is no registration step. A conforming object is a valid transport; you pass it to +`standardResilience()` or `new PipelineBuilder(...)`. Write one when the two shipped adapters do not +fit — a different HTTP client, a mock service, an in-process loopback, an instrumented wrapper. + +## The smallest useful one + +```typescript +import { + Headers, + Protocol, + Response, + Status, + type Request, + type Transport, +} from '@dexpace/core'; + +export function echoTransport(): Transport { + return { + async send(request: Request): Promise<Response> { + return Response.newBuilder() + .request(request) + .status(Status.of(200)) + .protocol(Protocol.HTTP_1_1) + .headers(Headers.newBuilder().setInbound('content-type', 'text/plain').build()) + .body(new Blob([request.url.href]).stream()) + .build(); + }, + async close(): Promise<void> {}, + }; +} +``` + +Note `setInbound`, not `set`: values a server sent are accepted leniently. Using the strict setter on +a real server's headers means a response with an obs-text byte in it becomes unreadable. + +## Nine rules a real transport must follow + +The full contract is `docs/product-spec/17-transport-adapter-conformance-contract.md`, thirty +`TRANSPORT-N` clauses. These are the ones that are easy to get wrong. + +**1. Never follow redirects** (`TRANSPORT-1`/`TRANSPORT-2`). Pin them off at the client — `fetch`'s +`redirect: 'manual'`, undici's `maxRedirections: 0` — and pin them off even behind a caller-supplied +dispatcher that may carry a redirect interceptor. The pipeline is the single redirect authority, and +a transport that follows a hop silently defeats loop detection, the hop cap, credential stripping and +the downgrade guard all at once. + +**2. Drop the framing headers, and log every drop by name** +(`TRANSPORT-10`–`TRANSPORT-13`). `Content-Length`, `Host` and `Transfer-Encoding` are computed by the +client, so forwarding a caller's copy corrupts framing. `Connection` is in the drop set for a +`fetch`-class transport and not for an undici-class one — §17 says so explicitly. Log the **name**, +never the value, and dedupe per name by default. + +**3. Map aborts to exactly two errors** (`TRANSPORT-3`/`TRANSPORT-4`/`TRANSPORT-8`). A timeout is the +retryable `TransportFailureError`; a caller abort is the terminal `CancellationError`. A raw +`DOMException` must never surface. `isTimeoutSignal(signal)` is how you tell them apart. + +**4. An abort after delivery must not close the delivered body** (`SEAM-16`). Both native clients tie +a response body's lifetime to the signal they were given, so dispatch over a **fork** of the signal +and detach it at delivery. Get this wrong and a caller who aborts a moment after `send()` resolves +finds the body they already own torn out from under them. + +**5. The caller owns the response body** (`BODY-15`). Return it live and unread. Do not buffer it, do +not close it. + +**6. Ownership decides who closes what** (`SEAM-14`). A dispatcher or client the caller supplied is +never touched by your `close()`. One you constructed is yours to close. Make that decision once, at +construction, and make supplying both a caller-owned client *and* an option that would build one a +construction-time `TypeError` rather than a silent win for one of them. + +**7. `close()` must be idempotent, concurrent-safe, and non-blocking** (`TRANSPORT-15`/`TRANSPORT-16`). +No unbounded await — a graceful drain would stall teardown for as long as one in-flight send against +a slow peer takes. Destroying is the sanctioned choice; in-flight sends then reject with +`CancellationError`, and so does a `send()` issued after `close()`, because it cannot succeed over a +dispatcher that no longer exists and so is not a retryable failure. Declare your post-close mode +(`SEAM-15`) either way: `@dexpace/transport-fetch`'s `close()` is a documented no-op over a runtime +global it does not own, and `send()` keeps working after it. + +**8. Recognize a file body structurally** (`TRANSPORT-28`). `body.kind === 'file'` widens the body to +`FileBodyDescriptor` — `path`, `start`, `count` — and lets you dispatch straight off the file. Never +`instanceof` against `@dexpace/body-file`: a transport must not depend on it. + +**9. Send a real `User-Agent`** (`NFR-15`), never a placeholder. `getBuildInfo()` supplies the tokens. + +## Prove it + +Do not hand-roll the assertions. `@dexpace/transport-conformance` is the suite both shipped adapters +run, which is what keeps them from drifting apart: + +```typescript +import {runTransportConformanceSuite} from '@dexpace/transport-conformance'; +import {myTransport} from '../src/index.js'; + +runTransportConformanceSuite('my-transport', () => myTransport(), { + supportsInternalCancel: false, // TRANSPORT-8: a cancel path distinct from a caller abort + supportsProxy: false, // TRANSPORT-30 + dropsConnectionHeader: true, // TRANSPORT-11: is `Connection` in your drop set? +}); +``` + +The three capability flags are the only clauses §17 scopes to a subset of transports; everything else +runs unconditionally. The suite starts its own fixture server, and a second one on a separate origin +for the rows that deliberately leave a connection unusable — a client that reuses a poisoned +connection otherwise fails thirty rows downstream, which is a debugging problem of a different order. + +The package is `private` and its `exports` name `./src/index.ts`, so it resolves unbuilt and is a +`devDependency`. + +## Reuse the plumbing + +`@dexpace/transport-shared` exists so the algorithm both adapters need exists once. Its exports are +`@internal` and it is not a package to install directly, but reading it is the fastest way to see +what a correct implementation of rules 2, 3, 4 and 6 looks like: + +| Module | Concern | +|---|---| +| `header-mapping.ts` | The outbound drop-and-degrade pass and the lenient inbound copy | +| `drop-log.ts` | Bounded, case-insensitive, drain-to-cap dedup of already-logged drop names | +| `abort-mapping.ts` | The single mapping from an aborted signal to `TransportFailureError` or `CancellationError` | +| `body-pump.ts` | Turning a `Body` into a request stream the transport owns, plus idempotent teardown for an abandoned producer | +| `signal-fork.ts` | Rule 4's fork-and-detach | + +## Package it + +`@dexpace/core` goes in `peerDependencies`, never `dependencies` — two copies of core defeat the +identity checks the seams rely on, and `verify:seam-1` enforces it. Take at most one external HTTP +library (`NFR-2`). Declare `engines.node` honestly; `verify:runtime-floor` checks it against your +`tsconfig` target. diff --git a/docs/superpowers/README.md b/docs/superpowers/README.md new file mode 100644 index 0000000..c4ed020 --- /dev/null +++ b/docs/superpowers/README.md @@ -0,0 +1,35 @@ +# `docs/superpowers/` — the inbox, not the archive + +New phase documents land here. They do not stay here. + +The Superpowers `brainstorming` and `writing-plans` skills write to hard-coded paths — +`docs/superpowers/specs/YYYY-MM-DD-<topic>-design.md` (`brainstorming/SKILL.md:100`, restated at `:206`) +and `docs/superpowers/plans/YYYY-MM-DD-<feature-name>.md` (`writing-plans/SKILL.md:18`, restated at +`:157`). Those skills are installed globally, shared across projects, and this repository cannot change +them. So the path stays, and this directory is the drop point it writes into. + +The archive is [`docs/work/`](../work/). Every finished phase's design, plan and checklist lives under +`docs/work/mvp/phaseN/` — one directory per phase, sub-phases nested inside it, each file keeping its +`YYYY-MM-DD-` prefix. The 62 documents that were here on 2026-08-31 moved there in a single `git mv` +commit, so `git log --follow` still resolves each one across the move. + +## What to do with a file that appears here + +Run the `housekeeping` skill (`.claude/skills/housekeeping/`). Its probe stage lists every file sitting +in `specs/` or `plans/` — staged or not — and its apply stage works out which `docs/work/mvp/phaseN/` +directory each belongs in and moves it with `git mv`. + +**It does not repoint the references.** That is deliberate and the tool says so when it finishes: a +maintenance tool that rewrites prose to make its own check pass produces documentation that is true and +useless at the same time. After `--write`, run the probe's `links` and `citations` checks and fix what +they report, in the same commit. Doing the whole thing by hand is fine too; the rules are in +[`docs/README.md`](../README.md) and the layout is visible in `docs/work/mvp/`. + +A file left here is not lost — it is just not filed. The probe reports it every run until it is. + +## What must not happen here + +Do not point a citation at `docs/superpowers/`. It is a staging path, and anything written here is +scheduled to move. Cite `docs/work/mvp/phaseN/<file>` — the path the document will have for the rest of +its life. The one deliberate exception is a document describing the *skills'* write behavior, such as +this file and the roadmap's "How Phases Get Executed" section. diff --git a/docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md b/docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md deleted file mode 100644 index 333b96d..0000000 --- a/docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md +++ /dev/null @@ -1,584 +0,0 @@ -# Node.js SDK — v1 Roadmap - -**Status:** Draft, approved for planning. - -**Purpose:** High-level, ordered phase list from empty repo to a spec-conformant v1 of the `nodejs-sdk`. This is -an index, not an implementation plan — each phase gets its own brainstorm → spec → plan cycle when its turn -comes. Do not add implementation detail to this document as phases complete; instead link to the phase's own -spec file. - -**Governing documents:** - -- `docs/product-spec.md` (+ `docs/product-spec/*`) — the language-agnostic, normative contract. Requirement IDs - (`SEAM-*`, `HTTP-*`, `IO-*`, `BODY-*`, `CTX-*`, `PIPE-*`, `RECOV-*`, `RETRY-*`, `REDIR-*`, `AUTH-*`, `PAGE-*`, - `SSE-*`, `SERDE-*`, `OBS-*`, `CFG-*`, `TRANSPORT-*`, `ASYNC-*`, `XCUT-*`, `NFR-*`) are the vocabulary every - phase below cites against. -- `docs/sdk-design-nodejs.md` (+ `docs/sdk-design-nodejs/*`) — the Node/TS port design, already broken into the - seams this roadmap sequences. -- `/home/mohammad/Projects/dexpace/styleguide/typescript/` (core rules) and - `/home/mohammad/Projects/dexpace/styleguide/typescript-bun/` (toolchain/runtime rules) — binding, in force from - Phase 0 onward, for every phase without exception. - -## Cross-Cutting Constraints (apply to every phase, not their own phase) - -- **Styleguide enforcement is continuous**, not a one-time gate. Every phase's code is written and reviewed - against `styleguide/typescript`'s 15 chapters (Tiger Style overlay on Google's TS guide) from the moment the - toolchain exists (Phase 0). -- **Package manager and test runner: Bun, not pnpm.** `sdk-design-nodejs/02` specifies a pnpm workspace; the - styleguide mandates Bun (`bun install`, `bun.lock`, `.bun-version`, `bun test`) as binding for all dexpace - projects. Resolved 2026-07-23 in favor of the styleguide — see the - [scaffold milestone design](./2026-07-23-scaffold-milestone-design.md) for the reconciled shape. The - multi-package workspace *layout* from `sdk-design-nodejs/02` (package map, project references, peer-dependency - discipline) still holds; only the pnpm-specific mechanics are replaced. Library packages still build with - plain `tsc` (never `Bun.build`, which is reserved for services), per `typescript-bun/08-build-and-distribution.md`. -- **Dual JS/TS consumption.** TypeScript is the source of truth; the SDK must serve both TS and plain-JS - consumers. `tsc` compiles to ESM JS + `.d.ts`; no TS-only runtime syntax leaks into shipped output (the - styleguide's erasable-syntax stance already helps here — no enums, no decorators, no constructor parameter - properties). Verified per-package as each package is built, not only once at the end. -- **Requirement-ID traceability.** Each phase's deliverable should be traceable back to the product-spec - requirement IDs it satisfies, feeding `docs/product-spec/appendix-c-consolidated-normative-requirement-index.md` - and the Phase 9 conformance pass. - -## Phase List - -| Phase | Name | Package(s) | Product-spec refs | sdk-design refs | -|---|---|---|---|---| -| 0 | Toolchain & Style Gate | workspace root, `@dexpace/core` (stub) | — | §2, §9 (see [scaffold milestone design](./2026-07-23-scaffold-milestone-design.md)) | -| 1 | Core HTTP Domain Model | `@dexpace/core` | §4 | §4 | -| 2 | Seam Foundations | `@dexpace/core` | §3 | §3 | -| 3a | I/O Contracts | `@dexpace/core` | §5 | §3.1 (Web Streams direct, no pluggable provider) — see [Phase 3a design](./2026-07-24-phase3a-io-contracts-design.md) | -| 3b | Body Lifecycle | `@dexpace/core` | §6 | §3.1 — see [Phase 3b design](./2026-07-25-phase3b-body-lifecycle-design.md) | -| 4a | Execution Context | `@dexpace/core` | §7 | §5 — see [Phase 4a design](./2026-07-25-phase4a-execution-context-design.md) | -| 4b | Recovery-Chain Primitives | `@dexpace/core` | §8.2 | §5 — see [Phase 4b design](./2026-07-25-phase4b-recovery-chain-design.md) | -| 4c | Stage-Based Pipeline | `@dexpace/core` | §8.1 | §5 — see [Phase 4c design](./2026-07-25-phase4c-stage-pipeline-design.md) | -| 5a | Resilience — Retry | `@dexpace/core` | §9, appendix C `RECOV-17`–`RECOV-34` | §6 — see [Phase 5a design](./2026-07-26-phase5a-retry-design.md) | -| 5b | Resilience — Redirect | `@dexpace/core` | §10 | §6 — see [Phase 5b design](./2026-07-26-phase5b-redirect-design.md) | -| 5c | Resilience — Auth | `@dexpace/core` | §11 | §6 — see [Phase 5c design](./2026-07-26-phase5c-auth-design.md). Both 5b and 5c were drafted solo/concurrently (user away from keyboard); 5c's own doc records reconciling with 5b's cross-origin-marker design after finding it mid-draft — see its "Alignment with 5b's shipped design" sections | -| 6a | Serde | `@dexpace/core`, `@dexpace/codec-json` | §14 | §7.3 — see [Phase 6 segmentation design](./2026-07-28-phase6-segmentation-design.md) | -| 6b | SSE | `@dexpace/core` | §13 | §7.2 — see [Phase 6 segmentation design](./2026-07-28-phase6-segmentation-design.md) | -| 6c | Pagination | `@dexpace/core` | §12 | §7.1 — see [Phase 6 segmentation design](./2026-07-28-phase6-segmentation-design.md) | -| 7a | Configuration & Platform Primitives | `@dexpace/core` | §16, appendix C `RECOV-33` | §8 — see [Phase 7 segmentation design](./2026-07-28-phase7-segmentation-design.md) and [Phase 7a design](./2026-07-28-phase7a-configuration-design.md) | -| 7b | Instrumentation & Observability | `@dexpace/core`, `@dexpace/logging-pino`, `@dexpace/logging-debug` | §15 | §8 — see [Phase 7 segmentation design](./2026-07-28-phase7-segmentation-design.md) and [Phase 7b design](./2026-07-28-phase7b-observability-design.md) | -| 8a | Transport Adapters | `@dexpace/transport-fetch`, `@dexpace/transport-undici`, `@dexpace/body-file`, `@dexpace/transport-shared` | §17 | §3.2 (single `Promise` primitive collapses JVM's SEAM-11/SEAM-16 fragmentation) — see [Phase 8 segmentation design](./2026-07-28-phase8-segmentation-design.md) and [Phase 8a design](./2026-07-28-phase8a-transport-design.md) | -| 8b | Async-Runtime Bridge | `@dexpace/rx` | §18 | §3.2 (RxJS `Observable` is the only Node-worthwhile async adapter) — see [Phase 8 segmentation design](./2026-07-28-phase8-segmentation-design.md) and [Phase 8b design](./2026-07-28-phase8b-async-runtime-design.md) | -| 9 | Cross-Cutting Invariants & Conformance | all packages, `@dexpace/shrink-test` | §19, §20, appendix B | — see [Phase 9 design](./2026-07-28-phase9-cross-cutting-conformance-design.md) and [Phase 9 plan](../plans/2026-07-28-phase9-cross-cutting-conformance.md) | -| 10 | Deviation Reconciliation | `@dexpace/core`, `@dexpace/transport-fetch`, `@dexpace/transport-undici`, `@dexpace/shrink-test` — **corrected 2026-08-30**; this cell read `— (review only)` and the phase shipped code. See the Phase 10 status note below | — | §10 | - -**Status note (2026-07-27).** Phases 5a/5b/5c have a design **and** a written implementation plan; none of the -three has been executed — no `src/retry/`, `src/redirect/`, or `src/auth/` exists yet. 5b's and 5c's plans were -reviewed against the knowledge corpus and against each other's declared APIs before execution; the corrections -that outlive their own phase are logged below (see the `cross-origin.ts`, `AuthTiers`, preemptive-stamp, and -`DigestChallengeUnsupportedError` rows). Everything else stayed inside the two plans' own Deviation Ledgers. - -**Status note (2026-07-28).** A cross-phase deferral review swept this log against every written design/plan. -Two real gaps were found and folded into the unexecuted plans: `StepContext` never exposed the caller's per-call -`RequestOptions` (`PIPE-17`'s "readable by any step" MUST — extended 5a Task 1's amendment to two fields), which -in turn left `RETRY-41`'s per-call retry-count override (`RequestOptions.maxRetries`, `HTTP-35`) wired to -nothing (now read by 5a Task 9) and left `AUTH-4`'s `perCall` tier with no per-call source (now -`RequestOptions.auth?: AuthDescriptor`, amended in 5c Task 14). Bookkeeping: the rows targeting Phase 2 and -Phase 3b below were marked resolved-at-design/plan level, and `NFR-13`'s SPDX convention was written into -Phase 1's plan. No executed code exists yet, so every change was a document edit, not a retrofit. - -**Status note (2026-07-28, later same day).** Phase 6 was brainstormed and split into 6a (Serde) / 6b (SSE) / -6c (Pagination) — see the [Phase 6 segmentation design](./2026-07-28-phase6-segmentation-design.md). The split -review produced three findings recorded in the log below that outlive the sizing question: three Phase-0 deferrals -(`NFR-2`, `NFR-14`, peer-dependency dedup) become live in 6a rather than Phase 8, because `@dexpace/codec-json` — -not a transport adapter — is the workspace's first second package; `sdk-design-nodejs/07`'s item-view snippet -contradicts `PAGE-11`'s close-before-yield MUST in a way appendix B's own conformance test does not catch; and -`PAGE-5`'s "synchronously inside parse" needs an explicit re-expression for a runtime with no synchronous body -read. - -**Status note (2026-07-28, end of day).** All three sub-phases now have **both** a design and a written -implementation plan (`specs/2026-07-28-phase6{a,b,c}-*-design.md`, `plans/2026-07-28-phase6{a,b,c}-*.md`); none -has been executed — no `src/serde/`, `src/sse/`, `src/pagination/`, or `packages/codec-json/` exists yet. The -three plans were then reviewed against each other and against the knowledge corpus, the same pass 5b/5c got. The -corrections that outlive their own sub-phase are logged below (the `Symbol.asyncDispose` row, whose stated -premise 6b/6c invalidate, and the `PAGE-11` erratum row, which needed carrying into `docs/knowledge/` and not -only into `sdk-design-nodejs/07`). Everything else stayed inside the three plans' own task lists and Deviation -Ledgers. One process note worth keeping: the segmentation design declares the three sub-phases order-free, but -each plan's **Prerequisite** section had been written as a linear chain (6b "Phases 0 through 6a", 6c "0 through -6b"), which would have silently re-imposed the dependency the split exists to avoid. All three now state -"Phases 0 through 5c" plus an explicit note naming what — if anything — a sibling sub-phase adds. - -**Ordering rationale:** toolchain first (Phase 0) so every subsequent phase is written under the style/quality -gates from line one. From there, bottom-up by dependency: domain model before the seams that operate on it, -seams before the pipelines built on top of them, pipelines before the resilience layer wrapping them, and -pagination/SSE/serde/instrumentation as the outer layers consuming everything underneath. Transport and -async-runtime adapters (Phase 8) come late because they are the most Node-specific judgment calls (per -sdk-design's §3 framing) and benefit from every other seam already being stable. Conformance (Phase 9) and -deviation reconciliation (Phase 10) close the roadmap by construction — they audit what phases 0-8 built rather -than building anything new. - -## How Phases Get Executed - -Each phase, when its turn comes: - -1. Its own brainstorming session — scoped to that phase alone, referencing this roadmap for context. -2. A spec file at `docs/superpowers/specs/YYYY-MM-DD-<phase-name>-design.md`. -3. Its own implementation plan (via the writing-plans skill), executed independently. - -This document is updated only to mark a phase's status (not-started / in-progress / done) and link to its spec -once written — it does not absorb implementation detail from completed phases. **Exception:** the Deferred Items -Log below. Every phase's brainstorming session should check this log for entries targeting it before starting, -and append any new deferral it produces before that phase is considered done — this is how a decision made in -Phase 0 ("we'll handle NFR-2 properly once adapter packages exist") doesn't silently evaporate by Phase 8. - -## Deferred Items Log - -Every item a phase's design or checklist explicitly pushed to a later phase, consolidated here so it isn't lost -between a phase's own spec/checklist files and this index. One row that is *not* a deferral, included anyway -because it's easy to mistake for one: `SEAM-5`–`SEAM-10` will **never** be built in this port — that's a -permanent simplification, not a postponement. - -| Item | Originated in | Target phase | Note | -|---|---|---|---| -| `NFR-2` — each optional capability a separately installable unit (core + ≤1 external lib) | Phase 0 | **Codec half resolved in Phase 6a** (transport half stays **Phase 8a**) — retargeted 2026-07-28, codec half closed 2026-08-27 | **Closed for the codec half:** `packages/codec-json` ships with `dependencies: {}` hard-committed and zero external libraries, and `scripts/verify-seam-1.mjs` now asserts that for every package under `packages/` rather than for core alone. Originally "Phase 8, no adapter packages exist yet." The Phase 6 segmentation review found the premise false one phase early: `@dexpace/codec-json` is the workspace's first separately installable unit and takes **zero** external libraries — the cleanest instance of the requirement in the whole roadmap. 6a disposes of the codec half; `transport-fetch`/`transport-undici` close the rest in 8a — `transport-fetch` trivially (zero external libs), `transport-undici` with exactly one (`undici`). See the [Phase 8 segmentation design](./2026-07-28-phase8-segmentation-design.md) | -| `NFR-9` — automated shrink-survival regression guard | Phase 0 | **Resolved in Phase 9 (design)** | Explicitly out of scope per the scaffold design's own "Out of scope" list. Phase 9's design ships `@dexpace/shrink-test` (private, unpublished devDependency): an esbuild bundle/minify/tree-shake step, a dual-package-hazard fixture app, and a child-process round-trip guard wired into the default build as `bun run shrink-test`. Lands when Phase 9's plan executes | -| `NFR-11` — concurrency-model agnosticism, no async-framework type leak | Phase 0 | **Resolved in Phase 4c** | 4c's `Step`/`Next`/`Runtime` public surface is `Promise`-only — no RxJS, no generator, no framework-specific async type appears anywhere in the pipeline layer. Deferral closed | -| `NFR-12` — reproducible, byte-identical builds | Phase 0 | **Closed 2026-08-29** | **Verified, not asserted.** Two clean builds of an identical tree emit 644 byte-identical files, and all 9 publishable packages produce byte-identical `npm pack` tarballs across the same two builds. Now a blocking CI step, `bun run verify:reproducible-build` (`scripts/verify-reproducible-build.mjs`), negative-tested by injecting a `Date.now()` into `gen-version.mjs`. (Widened 2026-08-30: the tarball comparison was a by-hand check of `@dexpace/core` alone when this row was written; it is now a second leg inside the gate, over every publishable package, on both builds.) The "cannot execute without a real build artifact" premise expired once Phases 1–9 shipped code. See Phase 10's reconciled ledger, Item 14 | -| `NFR-13` — SPDX license header per source file | Phase 0 | Phase 1 onward — **written into Phase 1's plan (2026-07-28)** | Soft gap; the spec itself calls this "a review convention, not a mechanical gate". A 2026-07-28 plans review found no phase plan actually carried the convention, so Phase 1's plan now states it in its Global Constraints (`// SPDX-License-Identifier: MIT`, line 1 of every new file, all phases onward) — enforcement stays review-level | -| `NFR-14` — single source of truth for dependency/tool versions (Bun `catalog:`-equivalent) | Phase 0 | **Resolved in Phase 6a** — retargeted 2026-07-28, closed 2026-08-27 | **Closed.** The workspace root's `workspaces.catalog` block is the single source of version truth for `typescript`, `@microsoft/api-extractor`, `expect-type`, and `fast-check`; the root's own `devDependencies` and both member packages reference them as `"catalog:"`, so a bump is a one-line edit. Bun 1.4.0 local / 1.3.14 pinned both support catalogs, so the fallback Task 8 allowed for was not needed. Was: trivially true (one package, zero deps); the row's own text said it "becomes a real decision the moment a second package with its own dependencies exists." That moment is 6a scaffolding `@dexpace/codec-json`, not Phase 8. 6a picks the Bun equivalent of the pnpm `catalog:` protocol `sdk-design-nodejs/02` specifies, confirmed against `styleguide/typescript-bun/` | -| `NFR-15` — self-identifying version metadata (real `User-Agent`, never a placeholder) | Phase 0 | **Resolved in Phase 7a (design)** / **Phase 8a** | 7a's design ships `CFG-36`'s build/runtime descriptor (version via build-time codegen, never a runtime placeholder) and `RECOV-33`'s client-identity step that stamps it into `User-Agent`. Node-transport wiring (the header actually reaching the wire) still waits for 8a's concrete transports — a conformance test confirming `TRANSPORT-11`'s header-drop pass leaves it untouched, not new stamping logic. See the [Phase 8 segmentation design](./2026-07-28-phase8-segmentation-design.md) | -| `NFR-16` — publish provenance enforced on the release path | Phase 0 | Phase 10 / first real release | Still open — Phase 10's reconciled ledger (`docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md`, Item 14) records the intended verification (run `prepublishOnly` + `npm publish --provenance` for real) but cannot execute it without a real publish. Unblocks at first real release. **Correction 2026-08-29:** only `prepublishOnly` is wired — `--provenance` appears in no `package.json`, workflow, or `.npmrc`, and there is no release workflow at all. Authoring it is actionable now; only exercising it needs a registry | -| `NFR-8` — shrinker keep/retain configuration | Phase 0 | Phase 10 (Deviation Reconciliation) — closed 2026-07-28 | Re-confirmed as not applicable by design in Phase 10's reconciled ledger (`docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md`, Item 10) — this port has no reflection-driven discovery surface to keep-configure. Closed 2026-07-28 | -| Peer-dependency dedup for `@dexpace/core` (dual-package-hazard guard) | Phase 0 | **Resolved in Phase 6a** — retargeted 2026-07-28, closed 2026-08-27 | **Closed.** `@dexpace/codec-json` declares the `@dexpace/core` peer plus its `peerDependenciesMeta` entry; `scripts/verify-seam-1.mjs` asserts both for every non-core package, and `packages/codec-json/src/cross-package.test.ts` proves the consequence rather than the declaration — a `Tristate` constructed in core is recognized by the codec's replacer, because `TRISTATE_BRAND` is a registry-global `Symbol.for`. Mechanism specified in `sdk-design-nodejs/02` §2. `@dexpace/codec-json` is the first package to declare the `@dexpace/core` peer, so the guard installs in 6a. Not theoretical for this package specifically: `sdk-design-nodejs/02` names the `Tristate` discriminant and the `Outcome` sum type as exactly the branded-symbol checks two non-identical copies of core would break — and `Tristate` is 6a's own deliverable | -| `NFR-10`/`NFR-17` residual — CI running the built artifact against the *declared minimum* Node version (18.17), not just whatever the runner defaults to | Phase 0 | **Resolved in Phase 2 (plan)** (pulled forward from Phase 3) | Low-risk while the only export was a trivial `ping()`. Phase 2 is where it stops being trivial: `composeSignal()` calls `AbortSignal.any()`, which landed in **exactly** Node 18.17.0 — the declared floor to the patch version. Phase 2's plan Task 7 ships the `node-floor-conformance` CI job (`actions/setup-node` pinned to 18.17.0 running `scripts/verify-node-floor.mjs`, which forces the `AbortSignal.any()` branch); its checklist marks the row ✅. Lands when Phase 2's plan executes | -| `MultipartBody` model (one of HTTP-3's "each builder-based model" list) | Phase 1 | **Resolved in Phase 3b (design)** | Retargeted from "Phase 3" when Phase 3 split. 3b's design ships `MultipartBody` in full — composite replayability (`BODY-2`), one shared framing routine driving both declared length and written bytes, RFC-2046 boundary generation/validation (`MultipartBoundaryError`), part-header quoting/escaping (`HTTP-51`). Lands when 3b's plan executes | -| `Request`/`Response` real body type (currently `unknown` placeholder) | Phase 1 | **Resolved in Phase 3b (design)** | 3b's design replaces both placeholders — `Request` carries the §6 `Body` model (replayability, consume-once), `Response.body` is a single-use `ReadableStream<Uint8Array> \| null` (`BODY-14`). Lands when 3b's plan executes | -| `Logger`/`LogEvent` seam | Phase 2 | **Resolved in Phase 7b (design)** | `sdk-design-nodejs/03` §3.5 discusses it inside the seam-mapping doc, but it carries no `SEAM-N` ID — it's an `OBS-*` concern. 7b's design ships the facade, the process-wide global logger slot, and the two bridge packages (`@dexpace/logging-pino`, `@dexpace/logging-debug`). Lands when 7b's plan executes | -| `FakeTransport` test double | Phase 2 | **Resolved in Phase 5a** | Deliberately not built speculatively — 4a and 4b both used file-local stubs instead, and 4c's own brainstorm chose to keep doing so rather than build a shared double for PIPE-9's empty-pipeline case alone. 5a is the phase that finally needs one: scripted multi-response sequences (`503,503,200`), wire-send counting, and per-response close observation. Ships at `packages/core/src/testing/fake-transport.ts` (`@internal`) alongside `countingResponse()`, whose `ReadableStream` `cancel()` hook is the **only** sanctioned way to observe `Response.close()` — instances are `Object.freeze`d, so a spy assignment throws. 5b and 5c consume it unchanged. Deferral closed | -| Phase 4 split into 4a (Execution Context, `§7`) / 4b (recovery-chain primitives, `§8.2`) / 4c (stage-based pipeline, `§8.1`) | Phase 4 brainstorm | — | ~76 combined normative IDs, comparable to Phase 3's ~79 that forced its own 3a/3b split; each sub-phase gets its own brainstorm→spec→plan cycle. Dependency order: 4a first (contexts are the pipeline's own per-call correlation state), then 4b and 4c | -| Phase 5 split into 5a (Retry, `§9`) / 5b (Redirect, `§10`) / 5c (Auth, `§11`) | Phase 5 brainstorm | — | 111 combined normative IDs — the largest single phase in the roadmap, well past the ~76–79 that already forced the Phase 3 and Phase 4 splits. Build order is forced by coupling, not just size: retry is independent of the other two; redirect owns the cross-origin marker `REDIR-11` defines and `AUTH-29` reads, so it must precede auth; the standard-resilience preset needs all three steps installed, so it closes 5c. Each sub-phase gets its own brainstorm→spec→plan cycle | -| Phase 6 split into 6a (Serde, `§14`) / 6b (SSE, `§13`) / 6c (Pagination, `§12`) | Phase 6 brainstorm (2026-07-28) | — | 107 combined normative IDs (`PAGE` 36, `SSE` 41, `SERDE` 30), between the ~76–79 that forced the Phase 3 and Phase 4 splits and Phase 5's 111. Cut along the spec's own section boundaries because **the spec forbids the couplings that would cross them**: `SSE-37` (MUST) bars any serde dependency from core SSE, and `§12`'s preamble declares pagination serde-agnostic — so the cross-segment contract surface is empty by mandate, which is exactly the property whose absence caused the 5b/5c drift below. **No segment depends on another; the 6a→6b→6c order is convenience, not dependency**, and any sub-phase may execute out of order. 6a leads only because it scaffolds the workspace's second package and is the one segment that reshapes an already-published seam (`SEAM-21`); 6c trails because it is the most coupled to *earlier* phases (4c's `Runtime`, 5a's `StepContext.options`, 3b's `Response` body). Full rationale, per-segment ownership, and the collapsed-ID clusters in the [Phase 6 segmentation design](./2026-07-28-phase6-segmentation-design.md) | -| Collapsed-requirement disposition tables for Phase 6 — `PAGE-25`–`PAGE-33` (§12.9's async engine: this port has one async model, so the async generator *is* the engine), `SSE-18`/`SSE-31` (threading re-expressed against the event loop), `SERDE-8`/`SERDE-21`/`SERDE-22`/`SERDE-25`/`SERDE-26` (codec-engine configuration with no configurable engine to configure) | Phase 6 brainstorm | Each owning sub-phase's design (6c, **Resolved for 6b in Phase 6b design**, 6a) | Same service 5a's `RECOV-17`–`RECOV-34` table performs: without a row-by-row disposition, a naive appendix-B sweep reads ~18 collapsed requirements as uncovered. The segmentation design identifies the clusters and what does **not** collapse inside each — notably `PAGE-26`/`PAGE-27`/`PAGE-32`'s close-exactly-once obligations (re-expressed as `finally`-block obligations on the single generator), `SSE-18` re-expressed against the event loop, and `SSE-31`'s close-during-in-flight-read branch (re-expressed and tested, **not** collapsed), both documented in the [Phase 6b design](./2026-07-28-phase6b-sse-design.md). **Note (Phase 9 design, 2026-07-28):** Phase 9's actual design scopes to `§19`/`§20` (`XCUT`/`NFR`) only — it does not re-verify `PAGE`/`SSE`/`SERDE` disposition, which stays each owning sub-phase's own responsibility as this row already states (6c, 6b, 6a respectively) | -| 3a's `readUtf8Line()` is unusable for SSE (`IO-14` keeps a lone `\r` as content, `SSE-2` requires it to terminate) | Phase 6b | **Resolved in Phase 6b** — closed 2026-08-27 | 6b owns `src/sse/line-reader.ts` instead of reshaping a frozen Phase 3a surface. Recorded so Phase 10's deviation review does not read the duplication as accidental | -| `sdk-design-nodejs/07` §7.1's item-view snippet closes the page *after* yielding its items; `PAGE-11` (MUST) requires closing *before* | Phase 6 brainstorm | **Resolved in Phase 6c** — closed 2026-08-27 | The 2026-07-28 plans review found the erratum was being written into `sdk-design-nodejs/07` only, while `docs/knowledge/pagination.md` carries the *same* wrong ordering in its Reference section directly beside the correct MUST in its Rules section. The knowledge corpus is the standing tie-breaker every later phase consults, so an erratum that skips it leaves the contradiction live; 6c amends both. Resolution per the standing tie-breaker (normative spec + knowledge corpus win over an illustrative snippet): `PAGE-11` governs — copy items, close, *then* yield. Costs nothing, since materialized items survive close per `PAGE-2`. The snippet remains correct about the thing §7.1 is actually arguing (JavaScript's automatic `.return()`-on-abandon), just not about close ordering. **Closed in Phase 6c.** | -| `PAGE-5`'s "strategy MUST read everything it needs from the response **synchronously** inside parse" | Phase 6 brainstorm | **Resolved in Phase 6c** — closed 2026-08-27 | Node has no synchronous body read, so the literal reading is unimplementable and `parse` returns a promise. Every part of the requirement's actual intent survives: single-use-body discipline, no retention of the response or its body past the call, no close, no mutation. Flagged so an async signature does not later read as an oversight or get "fixed" back toward a literal reading. **Closed in Phase 6c.** | -| `SSE-41` — reactive SSE adapter (backpressure-honoring `Observable` view, fatal/non-fatal split, source-ownership documentation) | Phase 6 brainstorm | **Phase 8b** (`@dexpace/rx`) | `MAY`. 6b ships the pull-based `AsyncGenerator` surface `SSE-39` mandates; the reactive view is a bridge package, and the roadmap scopes `§18`'s async-runtime adapters to 8b specifically (not 8a's transports) as of the 2026-07-28 [Phase 8 segmentation design](./2026-07-28-phase8-segmentation-design.md). `sdk-design-nodejs/02` identifies RxJS's push-based `Observable` as the one async shape in the Node ecosystem worth bridging at all. Is `ASYNC-21` restated — the segmentation design's §5.2 names it 8b's marquee deliverable | -| Appendix C `RECOV-17`–`RECOV-34` reconciliation (18 rows filed under "Recovery-chain pipeline primitives" that `§8.2`'s prose never defines — it stops at `RECOV-16`) | Phase 4 sizing review | **Resolved in Phase 5a** | They are retry-engine requirements stated a second time for the reference's second retry stack. Since this port collapses both stacks into one engine (`RETRY-28`, `sdk-design/06`), 16 of the 18 collapse onto the same implementation as their `§9` twin (e.g. `RECOV-21` restates `RETRY-9`/`10`/`11`'s backoff formula verbatim); `RECOV-34`'s settings-object validation is partially new; `RECOV-32` and `RECOV-33` have **no** `§9` twin and are genuinely new work. The full row-by-row mapping table lives in the [Phase 5a design](./2026-07-26-phase5a-retry-design.md) — a naive appendix-B sweep should read it rather than re-deriving it, or it will read 18 requirements as uncovered. **Note (Phase 9 design, 2026-07-28):** `RECOV-*` is outside Phase 9's actual `XCUT`/`NFR`-scoped design; its disposition stays 5a's own responsibility per the table this row already points to | -| Real W3C Trace Context generation (trace-id/span-id byte generation, hex encoding, `traceparent`/`tracestate` parsing) — `InstrumentationBundle`'s actual tracing backend | Phase 4a | **Resolved in Phase 7b (design)** | 4a ships only `CTX-14`'s bundle shape and `CTX-15`'s no-op default. 7b's design generates real W3C/Datadog/no-op trace and span ids via `globalThis.crypto.getRandomValues` and lets a caller-supplied `tracerFactory` flow into `InstrumentationBundle` at pipeline-build time, without changing its already-frozen shape. Lands when 7b's plan executes | -| `contextsEqual()` value-equality utility for `ExecutionContext` | Phase 4a | Not scheduled — build only if 4b or 4c turns out to need one | `CTX-6` describes a consequence of key uniqueness, not a mandate for a new equality API; no consumer identified yet, so not built speculatively (same discipline as the original `FakeTransport` deferral) | -| `PIPE-35` — FLATTEN-vs-NEST seeding of a builder from an existing pipeline | Phase 4c | **Resolved in Phase 5c (design)** | Placed under `§8.1`'s "Bridges." heading but **not** bridge machinery — a builder capability independent of the sync/async collapse that disposes `PIPE-31`–`PIPE-34`. Deferred because 4c is the phase that first makes a pipeline constructible at all, so no caller yet holds one to seed from; the MUST clause ("make the choice explicit, never accidental") is vacuously satisfied while no seeding path exists. 5c's design ships `PipelineBuilder.seedFrom(runtime, 'flatten' \| 'nest')`, an explicit, non-defaulted mode argument. Deferral closed at design level; implementation lands when 5c's plan executes | -| `PIPE-2`'s redirect/retry conformance clause and `PIPE-40`'s 2-hop-redirect conformance clause | Phase 4c | `PIPE-40` → **Resolved in Phase 5b (design)**; `PIPE-2` → **Resolved in Phase 5c (design)** | 4c ships pipeline plumbing and zero pillar steps, so neither clause is testable there. `PIPE-40` is a contract on wrapping steps, closed by 5b's own two-hop `FakeTransport` test (wire-send count, per-hop close, final-response-open). `PIPE-2`'s stage-ordering half *is* covered in 4c; only the "auth step re-runs per redirect hop" half needed both a redirect step and an auth step — 5c's design specifies the per-hop re-run and adds the joint conformance test (auth step, "Closing `PIPE-2`'s remaining half and `AUTH-29`, jointly with 5b") | -| `PIPE-24`/`PIPE-39` — the standard-resilience preset (and `PIPE-24`'s "installs into empty slots only" clause) | Phase 4c | **Resolved in Phase 5c (design)** | 4c dispositioned both as "no preset shipped, revisit when one exists." A preset needs all three pillar steps installed, so it cannot land before auth. 5c's design ships `standardResilience()`, installing exactly the three pillars that exist by then (redirect, retry, auth) — `LOGGING` stays empty until Phase 7b ships a real logging step (**resolved in Phase 7b's design**, which amends `standardResilience()` to install it), a documented scope boundary, not a re-deferral | -| `PIPE-36` — a shipped pillar family locks its stage assignment | Phase 4c | **Resolved in Phase 5a** | 4c deferred it to "whichever future phase ships the first real pillar step family." That is 5a, and it is satisfied structurally: `retryStep()` is a factory returning a `StepDescriptor` with `stage: 'RETRY'` baked in — steps are functions carrying a descriptor, not classes with a subclassable stage assignment, so there is nothing to relocate. Deferral closed | -| Public-barrel promotion of the pillar-step authoring surface (`retryStep`, `StepDescriptor`, `Stage`, `PipelineBuilder`, `Runtime`) | Phase 4c, re-confirmed in Phase 5a | **Resolved in Phase 5c (design)** | 4c left "whether SDK callers ever author custom steps against a public surface" to "whichever phase first ships a pillar step." 5a answers: not yet. A caller cannot assemble a working pipeline until 5c's preset exists, and publishing `retryStep` alone would freeze shapes 5c may still reshape. 5c's design promotes `Stage`/`STAGE_ORDER`/`PILLAR_STAGES`/`StepDescriptor`/`StepContext`/`Next`/`PipelineBuilder`/`Runtime`/`retryStep`/`redirectStep`/`authStep`/`standardResilience`; everything else under `auth/` stays `@internal`. `packages/core/etc/core.api.md`'s diff at 5c's plan-writing time is the mechanical proof | -| `RETRY-29` — opt-in server-driven retry-classification override header | Phase 5a brainstorm | Not scheduled | `MAY`. Lets a response header force or suppress the retry classification. Widens the classifier's input surface to server-controlled values, which is a trust decision deserving its own deliberation rather than a default. No caller identified | -| `RECOV-33` — client-identity header step (Append/Replace token composition, blank-line suppression) | Phase 5a brainstorm | **Resolved in Phase 7a (design)** | One of only two appendix-C `RECOV-17`–`RECOV-34` rows with no `§9` `RETRY-*` twin (the other, `RECOV-32`'s idempotency key, shipped in 5a because retry preserves it per `RETRY-38`). Pure configuration-driven header composition with zero retry coupling, so it travels with `CFG-*` in 7a, ships as `clientIdentityStep()` consuming `CFG-36`'s build/runtime descriptor, and closes `NFR-15` alongside it. Lands when 7a's plan executes | -| `StepContext.signal` **and** `StepContext.options` — exposing the call's `AbortSignal` and per-call `RequestOptions` to steps | Phase 5a brainstorm (`signal`); 2026-07-28 plans review (`options`) | **Phase 5a, Task 1** | Found during 5a's spec self-review: 4c's `Cursor` accepts and threads a `signal` but `StepContext` never exposed it, so no step could observe cancellation — `RETRY-26`'s cancellable wait and `RETRY-32`'s "no attempts after cancellation" were both unimplementable. A 2026-07-28 review found the identical gap for `options`: `Cursor` threads them to terminal dispatch but `PIPE-17`'s "readable by any step" MUST was unsatisfied, and with it `RETRY-41`'s per-call override (`RequestOptions.maxRetries`, `HTTP-35`'s "0 disables retries for this call") had no wire — Phase 1 designed the knob, nothing read it. Both fields land as one additive amendment in 5a Task 1; 5a Task 9 wires the retry override, 5c Task 14 wires the per-call auth descriptor. **2026-07-29:** 4c's own design and plan now record the `PIPE-17` half as a deferral naming 5a Task 1, so the MUST is no longer deferred silently (4c validation review, F1); 4c's plan also forbids adding the two fields early, since their shape belongs to their first reader | -| `RequestOptionsBuilder.maxRetries` accepts `Infinity`, `NaN`, and fractional values | Phase 5a code review (2026-08-26) | **Resolved — closed in Phase 5's merge `cba4721` (2026-08-27)**, by the row's own second option (a Phase 1 fix with a changeset), *not* by Phase 10. Re-verified against source 2026-08-30 | `HTTP-35`'s stated intent is that an out-of-range retry count is a loud error, never silently reinterpreted, and the builder implements only the `< 0` half. `Number.isFinite`/integer are unchecked, so `maxRetries: Infinity` reaches a consumer as a budget that never terminates. Phase 5a found it because its per-call override feeds `maxAttempts` directly; 5a closed its own exposure at both ends (`retryStep`'s `effectiveSettings` and a precondition in `runWithRetry`), but the **builder** still accepts the value, so any future reader of the option inherits the trap. Tightening a public setter changes observable API behavior and needs a changeset, so it is recorded rather than folded into 5a. **Closed:** the setter is now `if (value !== undefined && !(Number.isInteger(value) && value >= 0))` throwing `RequestOptionsValidationError` (`packages/core/src/http/request-options.ts:178`), which covers `Infinity`, `NaN` and fractional values in one predicate; its TSDoc `@param`/`@throws` say so (`:170-175`). Shipped with `.changeset/2026-08-26-max-retries-range-check.md` (`@dexpace/core`, patch), authored 2026-08-26 and merged in `cba4721`. This row stayed open past its own resolution — **Phase 10 did not fix it and should never have been named as its owner**; the correction here is bookkeeping, not work | -| The two structured retry log events (`retry.attemptFailed`, `retry.exhausted`) and `RETRY-40`'s "log the failure" clause | Phase 5a execution (2026-08-26) | **Phase 7b, Task 9** | 5a's plan specifies all three emission points but its own 2026-07-29 correction forbids writing them: 5a executes before 7b, so an `observability/logger.js` import would not resolve, and 7b needs 5a's `FakeTransport`, so the dependency cannot run the other way. `engine.ts` carries a head comment marking the sites and naming 7b Task 9 as owner. `RETRY-40`'s non-fatal fall-back half **is** implemented in 5a; only the diagnostic half waits | -| Phase 7a Tasks 1-3 (`config/{clock,http-date,retryable}.ts`) executed early, as 5a's prerequisite | Phase 5a execution (2026-08-26) | **Executed — 7a's plan should mark Tasks 1-3 done, not rebuild them** | 5a's plan Prerequisite requires 7a's `config/` module to exist first (Task 8 consumes `Clock`, Task 4 imports `parseHttpDate`, Task 2 re-exports `isRetryableStatus`), and its Global Constraints ban shipping private copies. The three files were built verbatim from [7a's plan](../plans/2026-07-28-phase7a-configuration.md) Tasks 1-3 with their tests (22 tests, `CFG-15`-`CFG-17`, `CFG-29`-`CFG-31`, `CFG-35`). 7a's Tasks 4-10 are untouched, and none of the three is promoted to the public barrel — 7a Task 10 still owns that decision | -| `SEAM-30` cleanup (cancel an orphaned response on the completion race) | Phase 2 | **Phase 8a** | Documented as a TSDoc contract obligation on `Transport.send()` in Phase 2; only a real Transport implementation has a response to actually cancel. Collapses onto `TRANSPORT-9` (and `ASYNC-5`, which collapses onto the same thing) per the [Phase 8 segmentation design](./2026-07-28-phase8-segmentation-design.md) §5.1 — closes as part of 8a's conformance suite, not separate work | -| Byte-stream provider implementation (`ByteQueue`, `BufferedSource`/`Sink`, `TeeSink`) | Discussed in Phase 2 (`sdk-design/03` §3.1), built in | **Phase 3a** | `sdk-design-nodejs/03` covers this in the same document as Phase 2's other seams — the roadmap's phase split puts the *contract* in Phase 2 and the *implementation* in Phase 3a; don't conflate the two | -| Every buffering **cap** — `BODY-19`'s configurable tap cap, `BODY-30`/`HTTP-52`'s 1 MiB error-body cap, `BODY-34`'s shared preview-size configuration | Phase 3a | **Resolved in Phase 3b (design)** | Deliberate placement, not an omission — §5 bounds nothing; every spec-mandated cap sits in §6, and 3b's design wires all three: the `withRequestLogging` tee's `tapCapBytes` (`BODY-19`), `toHttpError()`'s fixed 1 MiB error-body cap (`BODY-30`/`HTTP-52`), and one shared preview-size parameter threaded through both logging tees and `toHttpError` (`BODY-34`). The rejected `maxRetainedBytes`-on-`BufferedSource` reasoning stands — don't re-litigate. Lands when 3b's plan executes | -| Promotion of any §5 type into the published `@dexpace/core` barrel | Phase 3a | **Resolved in Phase 3b (design)** — never promoted | 3b decided: `Body.writeTo` takes the platform's `WritableStream<Uint8Array>`, not `BufferedSink`, so no §5 type ever surfaces — all of `src/io/` stays `@internal` permanently. `api-extractor`'s report staying byte-identical across 3a was the mechanical proof the freeze held until the decision | -| `MAX_BYTE_ARRAY_LENGTH` constant value (`IO-9`) | Phase 3a | Phase 3a plan time | Core is runtime-agnostic, so `node:buffer`'s constant is off-limits; V8 and JavaScriptCore disagree and both have moved theirs; 12.6 forbids an import-time probe. Design fixes the *mechanism* (conservative constant + `RangeError` backstop); the number itself is confirmed when the plan is written | -| `Symbol.asyncDispose` on §5 resources (styleguide 13.1/13.2) | Phase 3a | **Re-scoped 2026-07-28 — the premise expired in Phase 6** | Declined in 3a for the same reason Phase 2 declined it on `Transport`: `Symbol.asyncDispose` postdates the `>=18.17` floor, and TypeScript does not polyfill it for a library *declaring* the method — the computed key silently becomes the string `"undefined"` at run time. The row's own escape clause was **"costs nothing today since no §5 type is public,"** and that stopped being true in Phase 6: 6b publishes `SseStream` and 6c publishes `Page`, both resource-owning classes whose primary teardown is a public `close()` — exactly the shape `styleguide/typescript/13` §13.1 forbids, with §13.2 prescribing `[Symbol.asyncDispose]` delegating to the legacy `close()`. It also has a second consumer now: `PAGE-12` (MUST) requires consumers of the page-level view to be *told* to wrap it in a scoped/auto-close construct, and `await using` is that construct. Both sub-phases therefore ship a **runtime-guarded, optionally-typed** `[Symbol.asyncDispose]`: installed via `Object.defineProperty` only when the well-known symbol exists (so the `"undefined"`-key hazard cannot occur on the declared floor), typed optional (so it never promises `await using` support the pinned 18.17.0 runtime cannot honor), and delegating to `close()`, which stays the supported teardown on every runtime. Requires `esnext.disposable` on the TypeScript `lib` list — a types-only change that does not move `engines.node`. Promotion to an unconditional `implements AsyncDisposable` is a one-line change still gated on the floor passing 18.18; **that** is the residue this row now tracks, not the expired "no public resource type" premise. See 6b's and 6c's designs, "Disposal" | -| `SEAM-5`–`SEAM-10` (discovery/registration/conflict-resolution machinery) | Phase 2 | **Never** — not deferred | Node has no pluggable byte-stream factory or fragmented async ecosystem to discover across; a permanent, documented simplification vs. the JVM reference, recorded in Phase 10's reconciled deviation ledger (`docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md`, Item 2), not "TODO'd" anywhere. Closed 2026-07-28 | -| Concrete `Serde` implementation (`@dexpace/codec-json`) | Phase 2 | **Resolved in Phase 6a** — closed 2026-08-27 | **Closed.** `packages/codec-json` ships `jsonSerde()`, the Tristate replacer, and the `tristate()`/`tristateObject()` decode combinators, with its own api-extractor report at `packages/codec-json/etc/codec-json.api.md`. Phase 2 shipped the `Serde<T>` interface only. Narrowed from "Phase 6" by the 2026-07-28 segmentation review | -| Concrete `Transport` implementations (`@dexpace/transport-fetch`, `-undici`) | Phase 2 | **Phase 8a** | Phase 2 ships the `Transport` interface only. Narrowed from "Phase 8" by the 2026-07-28 [Phase 8 segmentation design](./2026-07-28-phase8-segmentation-design.md) | -| `SEAM-21` — explicit runtime type token for deserialization (the type-witness mechanism) | Phase 2 | **Resolved in Phase 6a** — closed 2026-08-27 | **Closed.** Every decode entry point now takes a caller-supplied `Schema<T>` witness; `Serde` dropped its type parameter, because a bundle is per wire format once the payload type is a parameter of the decode call. The reshaped seam **is** promoted to the public barrel — forced, not chosen: `@dexpace/codec-json` is a separate package and can reach core only through its public entry point. `sdk-design-nodejs/03` §3.3 defers to §7.3. Phase 2's `Serde<T>.deserialize(data: unknown): T` is the erased/inferred generic SEAM-21 forbids, so the interface **will** change shape — which is why Phase 2 keeps `Serde<T>` out of the package barrel and marks it `@internal`, so the rework is not a breaking change to a published API. Narrowed from "Phase 6" by the 2026-07-28 segmentation review, which also made this the reason 6a leads the phase: reshaping a seam belongs before, not after, other work built on the same barrel. 6a additionally decides whether the reshaped seam is finally promoted to the public barrel, and whether `Serde` stays generic in `T` at all once the schema carries `T` | -| `SEAM-14` — close *behavior* (idempotent, ownership-aware, releases only self-created resources) | Phase 2 | **Phase 8a** | The `close(): Promise<void>` **signature is locked in Phase 2** — adding a required method to a published seam later is a breaking change. Only the behavior waits, until a transport owns a pool worth releasing. Asymmetric across 8a's two packages: `transport-fetch` owns no persistent resource (a sanctioned no-op close); `transport-undici` owns a real `Pool`/`Client`/`Agent` | -| `SEAM-12` — concurrent-call conformance test | Phase 2 | **Phase 8a** | Stated as a TSDoc contract obligation on `Transport.send()` in Phase 2; "fire many concurrent requests and assert no cross-talk" needs a real transport to fire through. Collapses onto `TRANSPORT-29` (and `ASYNC-22`, its twin) per the [Phase 8 segmentation design](./2026-07-28-phase8-segmentation-design.md) §5.1 | -| `SEAM-18` (sync↔async bridges) | Phase 2 | **Never** — not deferred | Same class as `SEAM-5`–`SEAM-10`: a bridge connects two transport seams and this port has one. Every obligation SEAM-18 names presupposes a blocking transport Node cannot idiomatically have. Its one non-bridge clause ("per-call options MUST be threaded through, not dropped") survives as a `Transport.send()` obligation. Recorded in Phase 10's reconciled deviation ledger (`docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md`, Item 2). Closed 2026-07-28 | -| `HTTP-18`/`HTTP-48`/`HTTP-50` — outbound header strictness vs. ETag obs-text permission, discovered replaying a server-issued ETag with obs-text bytes through a conditional request | Phase 1 | **Resolved in Phase 10** | `RequestConditions.applyTo`'s strict outbound path is kept; `HTTP-18`'s MUST-level splitting defense (reinforced by `XCUT-18`) outranks `HTTP-48`'s SHOULD-level obs-text permission. See Phase 10's reconciled ledger (`docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md`, Item 15). Closed 2026-07-28 | -| `FileBody` (`BODY-11`/`BODY-12`/`BODY-13`/`BODY-36`) — file-backed request body | Phase 3b brainstorm | **Resolved in Phase 8a (design)** | Needs `node:fs`, which conflicts with `@dexpace/core`'s zero-`node:`-import invariant. Resolved as a **structural, not nominal, recognition contract**: `@dexpace/core`'s `Body.kind` union gains a `'file'` member and a type-only `FileBodyDescriptor` interface (zero runtime cost — types erase), retrofitted into Phase 3b's plan; the concrete `fileBody()` factory needing real `node:fs` validation ships in a new fourth Phase 8a package, `@dexpace/body-file`, which both transports depend on and recognize via `body.kind === 'file'` structural narrowing, never a cross-package `instanceof`. Separately, 8a's design confirms (not merely flags) that true kernel-level zero-copy dispatch (`TRANSPORT-28`'s SHOULD) **has no Node analogue** — neither `fetch` nor `undici` expose a `sendfile`-shaped API for outbound bodies — recorded as a `PAGE-29`-shaped collapse in 8a's Deviation Ledger, not chased further. See [Phase 8a design](./2026-07-28-phase8a-transport-design.md) §5 | -| `packages/core/src/redirect/cross-origin.ts` (the `REDIR-11`/`AUTH-29` shared signal — a real header, `CROSS_ORIGIN_MARKER_HEADER`, plus `hasCrossOriginMarker()`/`clearCrossOriginMarker()`) | Phase 5b brainstorm | **Resolved in Phase 5b (design)** | 5b ships and owns this module; 5c's own design (drafted concurrently, before either doc knew of the other) originally guessed an incompatible `WeakSet<Request>`-keyed shape against `REDIR-11`'s prose directly, then corrected itself against 5b's actual design once found mid-draft — see 5c's "How this doc was produced" / "Alignment with 5b's shipped design" sections. Recorded here as a caution: two solo brainstorms sharing a cross-phase contract, run without coordinating with each other, is exactly the scenario this kind of drift comes from — re-check for it explicitly if this ever happens again rather than assuming file-discovery mid-draft will always catch it. **The caution earned itself twice.** Catching the marker's *shape* mid-draft did not catch its *scope*: 5c's design consumed the marker on the outbound pass but still answered a `401`/`WWW-Authenticate` challenge on a marked hop, which would have stamped exactly the credential the marker exists to suppress — onto the server-chosen foreign host, over a URL whose HTTPS guard was deliberately skipped. Found in a plan review before any code existed and fixed in both 5c's plan and design (the marker now suppresses the whole hop, not just the outbound pass), but a cross-phase contract review needs to cover every place the consuming phase *acts on* the contract, not just where it reads it | -| `standardResilience()` gains a `LOGGING` pillar step | Phase 5c brainstorm | **Resolved in Phase 7b (design)** | 5c's preset installs only the three pillars that exist by then (redirect, retry, auth); a real logging step doesn't exist until Phase 7b. 7b's design amends `standardResilience()` to install `loggingStep()` (inert by default at `granularity: 'none'`) into the previously-empty slot. Lands when 7b's plan executes | -| `DigestChallengeUnsupportedError` — confirm a real caller-facing API needs to distinguish "unsatisfiable challenge" from "no replacement" before shipping it | Phase 5c brainstorm | **Resolved in Phase 10 — 2026-07-28** | `authStep()` itself never surfaces this distinction (both cases just leave the 401 unchanged); the leaf was sketched for a lower-level API 5c's design did not otherwise build. 5c's plan **kept** it rather than cutting — as an `@internal` leaf for a caller composing `composingHandler`/`digestHandler` directly, bypassing `authStep()`. **Resolved:** kept, permanently — no forced usage-sweep will ever run (Phase 9 is `XCUT`/`NFR`-scoped, no phase's code exists yet for one regardless), and an `@internal`-tier leaf costs nothing sitting unused; it can be removed later without a breaking change if it genuinely proves dead weight once real callers exist | -| Basic/Digest never stamp preemptively — an *interpretation*, not a stated requirement | Phase 5c brainstorm | **Resolved in Phase 10 — 2026-07-28** | `§11` phrases `AUTH-14` and `AUTH-15`–`AUTH-22` entirely as reactions to a parsed challenge, and never describes a preemptive-Basic path the way it separately describes Bearer's preemptive cached-token stamp; Digest structurally cannot stamp before seeing `realm`/`nonce`. 5c treats both uniformly as challenge-only. **Resolved:** confirmed correct as designed — the spec's asymmetry (describing Bearer's preemptive path, staying silent on Basic/Digest) reads as deliberate, and staying reactive matches this port's conservative-by-default posture elsewhere (credential-stripping by default, downgrade-deny by default). See Phase 10's reconciled ledger (`docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md`, Item 12) | -| True per-call / per-operation `AuthTiers`, resolved per call rather than fixed at step construction | Phase 5c plan | `perCall` tier: **Resolved in Phase 5c (design, 2026-07-28 revision)**. `operation` tier: unscoped | Originally fully unscoped because no phase shipped a per-call lookup source. The 2026-07-28 plans review closed the `perCall` half: the vehicle is `RequestOptions` (per-call operational overrides are exactly its Phase 1 charter), not `ExecutionContext` — `RequestOptions` gains `auth?: AuthDescriptor` (type-only, cycle-free import; amended in 5c Task 14 alongside `pipeline/builder.ts`'s existing amendment precedent), steps read it via `StepContext.options` (5a Task 1, `PIPE-17`), and `authStep` resolves `{...settings.tiers, perCall: ctx.options.auth}` when present. The `operation` tier still has no distinct source — nothing in this roadmap ships a per-operation layer (no codegen/client surface), so `operation` and `client` both remain construction-time configuration; that residue is a plumbing gap, not a conformance one (`AUTH-4`–`AUTH-7` are mechanically satisfied), and stays open here | -| Redirect predicate's scope over safety mechanics (credential stripping, downgrade, replayability, loop/cap) — 5b reads `REDIR-20`'s "MUST fully override" as scoped to code/method eligibility only, not these | Phase 5b brainstorm | **Resolved in Phase 10 — 2026-07-28** | A judgment call made without the user present; 5b's own design flagged it as narrow and mechanical to reverse if wrong. **Resolved:** confirmed correct as designed — `REDIR-20`'s snapshot (response, redirect count, visited URIs) carries nothing about credentials, and safety mechanics are separately governed by `XCUT-17`'s own universal, non-overridable framing; a predicate opting out of them would be a security regression, not a convenience. See Phase 10's reconciled ledger (`docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md`, Item 12) | -| Redirect structured logging (`SHOULD`-level hop/loop/downgrade events) | Phase 5b brainstorm | **Partially resolved in Phase 7b (plan, 2026-07-28)** | Same disposition as 5a's equivalent gap for retry. 7b's amendment to 5b's `redirect-step.ts` ships the hop event and a rejection event (distinguishing `SchemeDowngradeError`) via `getGlobalLogger()`, no change to `StepContext`'s shape. **Not fully closed:** `decide()`'s `Decision` type carries no reason discriminant on `'return-current'`, so a genuine loop-vs-hop-cap-vs-normal-termination distinction is out of scope for this retrofit — would need `Decision` reshaped, touching every assertion in `decide.test.ts`. 5a's equivalent (attempt-failed, retries-exhausted) closes cleanly with no such gap, since `Outcome.kind` already discriminates success/failure. Both land when their respective plans execute | -| 5a's `RetryConfig.clock`/`random` retyped against 7a's real `Clock` seam, replacing its ad hoc injection point | Phase 7a brainstorm | 7a (doc amendment to 5a's design/plan) | Single-sources the injectable-determinism seam 5a's own design already noted it was pre-empting ("the same injectable-determinism seam `CFG-15` wants for the clock") | -| 5a's private RFC 1123 parser in `pacing.ts` re-sourced from 7a's shared `config/http-date.ts` | Phase 7a brainstorm | 7a (doc amendment to 5a's design/plan) | 7a's module is a superset (adds the formatter 5a never needed); 5a's parser becomes an import, not a second implementation | -| 5a's private `RETRYABLE_STATUSES`/`isRetryableStatus` in `classify.ts` re-sourced from 7a's `config/retryable.ts` | Phase 7a brainstorm | 7a (doc amendment to 5a's design/plan) | `CFG-35` mandates one shared retryability definition; 7a Task 3 ships the identical set (408, 429, 5xx except 501/505) and 5a's `classify.ts` re-exports it unchanged, so `RETRY-1` and `CFG-35` cannot drift apart | -| `challengeHandler` slot on `ProxyOptions` has no protocol behind it | Phase 7a brainstorm | **Resolved in Phase 8a (design)** | The type carries the slot per `CFG-22`'s field list. Resolved as `transport-undici`-only: undici ships `ProxyAgent`/proxy-407 dispatch; `transport-fetch` ships no `proxy` option on `FetchTransportOptions` at all (an absent option, not a silently-ignored one) and documents no proxy support, since honoring `TRANSPORT-30` there would require depending on `undici` internally anyway, undercutting `transport-fetch`'s zero-added-dependency purpose. `§17`'s own preamble licenses this single-transport scoping. See [Phase 8a design](./2026-07-28-phase8a-transport-design.md) §6 | -| Whether `clientIdentityStep` should be added to `standardResilience()`'s default install list | Phase 7a brainstorm | **Resolved in Phase 10 — 2026-07-28** | Not installed by default — no requirement mandates it (`RECOV-33` governs the step's own internal composition, not whether a preset installs it; `NFR-15` only requires that *when* a `User-Agent` is emitted it's real, not that every call carry one), and 5c's preset already closed its own scope for the pillars that exist. **Resolved:** stays out, permanently — adding it would be unrequested preset scope creep; a caller who wants it installs it explicitly, already possible via the public authoring surface | -| Retry/redirect structured-logging event names/fields | Phase 7b brainstorm | Phase 7b plan time | No spec-fixed vocabulary exists for these `SHOULD`-level events; naming is a plan-time detail, not a design-level decision | -| Whether `standardResilience()` should also accept a `tracerFactory`/`meter` pass-through convenience | Phase 7b brainstorm | **Resolved in Phase 9 (design)** — no friction found | No requirement mandates preset-level convenience wiring beyond installing the `LOGGING` step itself. Phase 9's `tests/conformance/xcut/fixtures/composed-pipeline.ts` configures logging/tracing/metrics the same way 7b's own tests do — a `LoggingStepSettings` object passed to `standardResilience()`'s existing `logging` option, plus `setGlobalLogger()` for a spy `Logger` — with no need for a separate `tracerFactory`/`meter` preset-level parameter. Closed, not just deferred again | -| A real `@opentelemetry/sdk-metrics`-backed `Meter` adapter package | Phase 7b brainstorm | Not scheduled | `OBS-31` only requires the no-op default and that core not depend on a metrics runtime; no package in the roadmap's phase table ships a concrete metrics backend, unlike tracing's duck-typed zero-adapter path | -| Phase 8 split into 8a (Transport Adapters, `§17`) / 8b (Async-Runtime Bridge, `§18`) | Phase 8 brainstorm (2026-07-28) | — | 52 nominal combined IDs (`TRANSPORT` 30, `ASYNC` 22) — well under the ~76–79 that forced the Phase 3/4 splits — but §17 is paid twice (two full `Transport` implementations, `transport-fetch` and `transport-undici`) and nine Deferred Items Log rows land here, pushing effective weight to Phase-7-before-its-split territory. Cut along the package boundary the roadmap table already implied, verified empty by the same test Phase 6 applied: `@dexpace/rx` depends only on Phase 6's `Page`/`SseStream`, never on `Transport`, and nothing in `Transport`'s collapsed `Promise`-returning contract (`sdk-design-nodejs/03` §3.2) references RxJS or any `ASYNC-*` id. **No segment depends on the other; the 8a→8b order is convenience, not dependency** (8a leads only because it is the larger, riskier half). A large share of `§18`'s `ASYNC-*` IDs collapse onto their `§17` `TRANSPORT-*` twin (the SEAM-11/SEAM-16 collapse restated at the async-adapter layer) or are inapplicable outright — Node has no blocking-transport/worker-thread-pool model for `ASYNC-3`/`4`/`7`/`14` to bite on, the same premise that already closed `SEAM-18` as "Never." Full rationale, per-segment ownership, the collapsed-ID disposition tables, and open items (notably `FileBody`'s package placement and whether Node's HTTP stack has any zero-copy dispatch path at all) in the [Phase 8 segmentation design](./2026-07-28-phase8-segmentation-design.md) | -| Assertion-density rule applied project-wide (`assertions.md:6-7`, styleguide Rule 8) | Phase 4b validation review F2 (2026-07-28) | **Not scheduled — re-deferred out of Phase 10, 2026-08-30** | Named Phase 10 by 4b's F2 resolution, but a project-wide convention sweep is not deviation reconciliation, and Phase 10 is the last row of the table above — so this becomes unscheduled with an explicit trigger rather than being handed to an invented phase. As-built the shape has changed: `invariant()` is now called from thirteen modules across `packages/core/src/` and `packages/body-file/src/`, and `recovery/` is the lone holdout at zero (`packages/core/src/recovery/outcome.ts:3` imports `assertNever` only). **Trigger:** the next defect traced to an unasserted precondition, or an assertion/naming convention sweep commissioned as its own phase. Full disposition in the Phase 4b review section below | -| `#private`-vs-`private` field style settled project-wide, with the runtime-privacy justification stated | Phase 4b validation review F7 (2026-07-28) | **Not scheduled — re-deferred out of Phase 10, 2026-08-30; largely moot** | Also named Phase 10, same reasoning as the row above. Mostly discharged in the meantime by a different route than a sweep: `CLAUDE.md:172-173` now mandates "`#private` fields only. Not TS `private`." project-wide and cites styleguide 6.7's library carve-out as the justification F7 asked for. The residue is cosmetic (no per-class comment). **Trigger:** a lint rule mechanizing the convention, or a styleguide revision withdrawing the 6.7 carve-out. Full disposition in the Phase 4b review section below | -| `CONSTANT_CASE`-vs-`lowerCamelCase` for module-level immutable collections (`STAGE_ORDER`, `PILLAR_STAGES`, Phase 1's `Protocol`/`Status` statics, 4b's constants) | Phase 4c validation review (2026-07-29) | **Not scheduled — re-deferred out of Phase 10, 2026-08-30** | Also named Phase 10; a naming-convention call is not a deviation from the reference contract. `naming-conventions.md:14`'s worked example keeps a module-level `new Set(...)` in `lowerCamelCase` because its contents can mutate, and a `ReadonlySet` type does not deep-freeze the underlying `Set`. Unchanged and self-consistent as-built — `STAGE_ORDER`/`PILLAR_STAGES` remain the pipeline's only such pair (`packages/core/src/pipeline/builder.ts:12`). **Trigger:** the next module-level immutable collection added outside `pipeline/`, which makes the fork visible in a third place. Full disposition in the Phase 4c review section below | - -**Status note (2026-07-28, Phase 7).** Phase 7 was brainstormed and split into 7a (Configuration & Platform -Primitives, `§16`) / 7b (Instrumentation & Observability, `§15`) — see the -[Phase 7 segmentation design](./2026-07-28-phase7-segmentation-design.md). Unlike Phase 6's three segments, this -split has one real (if soft) cross-segment dependency — `OBS-35`'s log-level resolution wants 7a's `Configuration` -— so 7a leads and 7b trails deliberately, rather than "order is convenience only." Both sub-phases got full -designs in this same session (not just a segmentation note): [7a](./2026-07-28-phase7a-configuration-design.md) -and [7b](./2026-07-28-phase7b-observability-design.md). All six Deferred Items Log rows that previously targeted -bare "Phase 7" are updated above to point at 7a or 7b specifically, each marked resolved-at-design-level. Three -new retrofits to 5a's already-written (still unexecuted) design/plan came out of 7a's brainstorm (`Clock`, RFC -1123 parser, and `RETRY-1`/`CFG-35` retryable-status single-sourcing); two more amendments — to 5a's and 5b's -steps for structured logging, and to 5c's preset for the `LOGGING` slot — came out of 7b's. No executed code -exists yet for any phase, so every change listed here is a document edit, not a retrofit to shipped code. - -**Execution order is no longer the numeric order for Phase 5.** These five retrofits do not merely annotate 5a/5b/5c -— they make Phase 7 a *prerequisite* of Phase 5's execution, in both directions the amendment banners record: -7a's `config/{clock,http-date,retryable}.ts` must exist before 5a's plan runs (its Task 8 consumes `Clock`), and -7b's `observability/{logger,redaction,logging-step}.ts` must exist before 5b's Task 6 and 5c's Task 16 run. The -**Ordering rationale** above ("resilience layer... instrumentation as the outer layers consuming everything -underneath") describes the dependency direction as originally designed; it holds for everything except these -named modules, which invert it. Anyone executing plans in roadmap order must run 7a (and, for 5b/5c, 7b) first, -or execute 5a/5b/5c against the pre-amendment text and accept a duplicate-implementation deviation. Each affected -plan's own **Prerequisite** section states this; this note exists so the roadmap does not read as contradicting -them. - -**Status note (2026-07-28, Phase 8).** Phase 8 was brainstormed solo (user away from keyboard, `docs/knowledge/` -as standing tie-breaker per standing instruction) and split into 8a (Transport Adapters, `§17`) / 8b -(Async-Runtime Bridge, `§18`) — see the [Phase 8 segmentation design](./2026-07-28-phase8-segmentation-design.md). -Only a segmentation document was produced this session, not full per-sub-phase designs (unlike Phase 7, which got -both in one sitting) — 8a and 8b each still need their own brainstorm → spec → plan cycle. Nine Deferred Items -Log rows that previously targeted bare "Phase 8" or "first concrete Transport" are updated above to point at 8a -or 8b specifically; none is resolved-at-design-level yet, only re-targeted and, where the segmentation review's -own analysis showed it, pre-dispositioned as collapsed/not-applicable (recorded in the segmentation design's §5, -carried forward into 8a's/8b's own row-by-row tables when those designs are written, not re-derived). Two package -column changes: Phase 8's roadmap-table row splits into 8a/8b, and the segmentation design flags a **possible -fourth package** (`FileBody`'s home, e.g. `@dexpace/body-node`) that 8a's own design must confirm or reject -before the roadmap table can be updated further — not decided by this pass. No executed code exists yet for any -phase, so every change here is a document edit. - -**Status note (2026-07-28, Phase 8, continued).** Both sub-phases got full designs and written implementation -plans in a follow-up pass this same day: [8a design](./2026-07-28-phase8a-transport-design.md) / -[8a plan](../plans/2026-07-28-phase8a-transport.md) and [8b design](./2026-07-28-phase8b-async-runtime-design.md) / -[8b plan](../plans/2026-07-28-phase8b-async-runtime.md). Neither plan has been executed — no `packages/` -directory exists in this repository as of this pass. The "possible fourth package" question above is settled: -8a's design confirms `@dexpace/body-file` (a fourth Phase 8a package, `FileBody`'s concrete factory) plus a fifth, -`@dexpace/transport-shared` (header-mapping helpers both transports need identically, found necessary only once -the plan reached implementation-level detail — the segmentation design and 8a's own design doc did not anticipate -this fifth package; it surfaced from "don't duplicate the same algorithm in two sibling packages" rather than -from any `TRANSPORT-N` requirement directly). The roadmap table's 8a row above is updated to list all four -published packages. `challengeHandler`'s protocol and the zero-copy-dispatch question are both resolved (not -merely flagged) in 8a's design — see the updated Deferred Items Log rows above. 8b's design resolved `ASYNC-18` -as inapplicable to the whole port, not merely out of 8b's scope — a correction to the segmentation design's -framing, recorded in 8b's design §3 and not requiring a Deferred Items Log row of its own since nothing was ever -targeted at a phase to begin with. - -**Status note (2026-08-26, Phase 5a EXECUTED).** Phase 5a is implemented and green across the full gate -sequence — the first phase to run out of numeric order, per the execution-order note above. Closed by this -execution: `PIPE-36`, `PIPE-17`'s "readable by any step" MUST (via `StepContext.options`), -`StepContext.signal`, the `FakeTransport` double, `RECOV-32`, and the `RECOV-17`-`RECOV-34` reconciliation — -each row above already anticipated 5a and is now satisfied in code rather than only at design level. Two new -rows were added: the Phase 7b log-event deferral, and the record that 7a's Tasks 1-3 were executed early as -5a's prerequisite. Still deferred out of 5a: `RETRY-29` (not scheduled), `RECOV-33` (7a Task 9), and -public-barrel promotion of the step-authoring surface (5c) — `packages/core/etc/core.api.md` is byte-identical -across the phase, which is that decision's mechanical proof. Per-requirement disposition: -[2026-07-26-phase5a-retry-checklist.md](../plans/2026-07-26-phase5a-retry-checklist.md). - -**Status note (2026-07-28, Phase 9).** Phase 9 was brainstormed solo (user away from keyboard, `docs/knowledge/` -as standing tie-breaker per standing precedent) and got a full design **and** a written implementation plan in -one session: [design](./2026-07-28-phase9-cross-cutting-conformance-design.md) / -[plan](../plans/2026-07-28-phase9-cross-cutting-conformance.md). Neither has been executed — no `packages/` -directory exists in this repository as of this pass. Per the roadmap's own framing ("audits what phases 0-8 built -rather than building anything new"), Phase 9's scope is deliberately narrow: a per-ID disposition table for all -24 `XCUT` IDs and all 17 `NFR` IDs (the grep across every prior spec/plan turned up exactly two incidental -`XCUT-N` citations before this pass, confirming this is the first systematic tabulation of that family), one new -package (`@dexpace/shrink-test`, closing `NFR-9`), and one new top-level `tests/conformance/xcut/` integration -suite driving 5c/7b's `standardResilience()` composed pipeline — not a general re-litigation of every open -judgment call that happened to say "Phase 9" in this log. Three consequences of that narrower scope: - -- `NFR-9` closes here (design-level) — see the updated row above. -- One deferred item closes here too: whether `standardResilience()` needs a `tracerFactory`/`meter` pass-through - convenience — resolved no, the composed-pipeline fixture needed no such convenience (see the updated row above). -- Four deferred items that targeted "Phase 9 conformance sweep" turned out to be `AUTH-*`/`REDIR-*` interpretive - judgment calls or preset-shape questions, not `XCUT`/`NFR` conformance checks, and are retargeted above to - Phase 10 (Deviation Reconciliation) — the roadmap's other audit-only phase and the one that already carries - this class of write-up. This retargeting is a document edit only; it does not touch Phase 10's own design or - plan files. - -Also closed as part of this pass: three `unresolved 2026-07-25` markers in `docs/knowledge/tooling-and-quality-gates.md` -(package manager/lockfile, test-runner/coverage-gating, `gts` baseline) that a 2026-07-25 cross-phase checkpoint -had already decided but never back-ported into the corpus itself — directly relevant here since `NFR-5`/`NFR-6`/ -`NFR-7` are exactly the rows those stale markers left unconfirmed. - -**Status note (2026-08-30, Phase 10 EXECUTED — scope corrected).** Phase 10 is executed, and it **shipped code**. -The phase-table row above and this phase's own design (`2026-07-28-phase10-deviation-reconciliation-design.md:15`, -"Phase 10 ships no package") both said the opposite; both are corrected in place rather than overwritten, because -an unrecorded scope change is the exact failure mode this phase spent its audit correcting elsewhere. What -actually landed, on `25-phase-10-deviation-reconciliation`: - -- **A live defect, found by auditing the ledger against source rather than against the specs that produced it.** - `Page`, `FetchTransport` and `UndiciTransport` each declared `[Symbol.asyncDispose]` as a plain computed class - member. The symbol arrived in Node 20.4 and every package declares `engines.node ">=20.3"`, so on the declared - floor the computed key evaluated to `undefined` and the method bound to the string key `"undefined"` — junk on - the prototype, no disposal, and a `.d.ts` promising `AsyncDisposable` regardless (`NFR-10`). All three now - install it through a guarded module-scope `Object.defineProperty`, matching `SseStream` - (`packages/core/src/pagination/page.ts:114`, `packages/transport-fetch/src/fetch-transport.ts:314`, - `packages/transport-undici/src/undici-transport.ts:566`, `packages/core/src/sse/stream.ts:209`). -- **A breaking type change across three packages,** with two changesets: `Page` no longer declares `implements - AsyncDisposable` and the two transport factories no longer return `Transport & AsyncDisposable`, so `await - using` stops type-checking. Pre-1.0, so `minor` per the same initial-development carve-out the earlier `Body` - narrowing used. -- **A new blocking CI step** closing `NFR-12` on evidence — `bun run verify:reproducible-build` - (`scripts/verify-reproducible-build.mjs`), see the `NFR-12` row above. -- **Three further defects, from three subsequent review passes:** a `verify-dual-consumption` assertion that - passed on the floor only *because* of the junk prototype key, a dispatcher leak in `UndiciTransport.close()` - where the first rejecting `destroy()` aborted the reverse walk and stranded the `ProxyAgent` holding the pooled - connections, and a stranded body producer in `send()` from evaluating `prepareBody()` before header mapping. -- **An extended shrink guard** — `packages/shrink-test/` now asserts the disposal installs survive a real esbuild - `bundle + minify + treeShaking` pass, which is the standing evidence for keeping `"sideEffects": false` on the - three packages carrying one. - -**Why the "review only" scope was right to break, and where that judgment is recorded.** The audit's method — -re-derive every ledger claim from as-built source — is what surfaced the defect; a documents-only phase would -have copied the wrong claim forward. Fixing a live correctness defect found *by* the audit is inside the phase's -purpose, and leaving it recorded-but-unfixed would have shipped a `.d.ts` that lies on the declared floor. The -project-wide **convention sweeps** that also named Phase 10 were held to the original scope and re-deferred -instead — see the three rows added to the Deferred Items Log above and the dated dispositions on 4b's F2/F7 and -4c's `CONSTANT_CASE` note below. Per-item evidence: `docs/deviations.md` (the as-built audit). - -## Open Findings — Phase 3b Validation Review (2026-07-28) - -A validation pass over `specs/2026-07-25-phase3b-body-lifecycle-design.md` and -`plans/2026-07-25-phase3b-body-lifecycle.md` (`docs/validation-prompts/phase3b-body-lifecycle-validation-prompt.md`) -returned **BLOCKED** on two runtime defects and a cluster of overclaimed disposition rows. **All findings except -D1 and D2 below are applied** to both documents. Recorded here rather than in the Deferred Items Log because -these are review findings against an unexecuted phase, not deferrals of work. - -The two blockers, both now fixed, are worth naming since they generalize: (1) `ReadableStream.cancel()` rejects -with `TypeError` on a locked stream and reading to `{done: true}` does **not** release the reader's lock, so -`Response.bytes()`, `toHttpError()` and the response-logging wrapper each had a `finally`-scoped close that -replaced a successful read with a `TypeError` — a `reader.releaseLock()`-before-cancel constraint now sits in the -plan's Global Constraints, and **every later phase that takes a reader and later closes the stream inherits it**; -(2) `HTTP-39`/`BODY-10`'s exact-length copy was dispositioned as "reuses Phase 3a's `writeAll`" while the plan's -own global constraint forbids importing `BufferedSink`, leaving a declared `contentLength` unverified and a short -stream sending a truncated body silently. - -**Cross-phase note for 4b.** 4b's preamble relies on `Response.close()` latching `#closed` before awaiting -`body.cancel()` so a close rejection propagates exactly once. That still holds: the latch is unchanged and the -only rejection now swallowed is the `TypeError` a still-locked external reader produces, which `BODY-15` requires -close to tolerate. Every other close failure propagates as before. - -| # | Sev | Finding | Where | Resolution | -|---|---|---|---|---| -| D1 | major — **CLOSED (3b execution)** | Task 13 Step 6 specifies a **minor** changeset on the reasoning that `Request.body`'s move from `unknown` to `Body \| undefined` is "not breaking for any real caller, since `unknown` accepted nothing usable before." That premise is false — `unknown` accepted *everything*, which is exactly why Task 7 Step 1 has to rewrite every `.body('x')` call site in the existing suite. `api-design.md:72` classes a narrowed parameter type as breaking, requiring MAJOR. `ResponseBuilder.body` narrows the same way | PLAN Task 13 Step 6; `api-design.md:72` | **Resolved: branch (b), minor.** `@dexpace/core` is `0.0.0`, and semver's own initial-development carve-out (<https://semver.org/#spec-item-4>) puts a 0.x breaking change out as minor; the pointer is recorded in the changeset itself, not only here. Revisit at 1.0, when the carve-out stops applying and Phases 4a/4b/5's identical narrowings become real majors. The alternatives were (a) ship it as **major**, which is what the corpus rule says and what the plan now instructs by default, or (b) if `@dexpace/core` is still pre-1.0 and the repo's release policy treats 0.x breaks as minor, keep minor and record the policy pointer. The plan carries both branches with the false justification deleted; pick one before Task 13 runs. Settle once — Phases 4a/4b/5 narrow Phase-1 placeholder types the same way | -| D2 | major — **CLOSED (3b execution)** | Three Phase-1/3a symbols the 3b plan now calls could not be verified: `MAX_ARRAY_BYTES` (assumed exported from `io/byte-queue.ts`, backing `AllocationLimitError`'s `limit` argument — used by both logging tees' `BODY-32` cap clamp), `Status.isError` (used by `toHttpError`'s `BODY-31` gate, replacing a `code < 400` that wrongly swept non-standard 6xx into the error path), and `Protocol.token` (used by `TypedResponse`). `packages/` does not exist on the planning branch, so none could be checked | PLAN Task 10, 11 (`MAX_ARRAY_BYTES`), Task 12 (`Status.isError`), Task 9 (`Protocol.token`) | **Verified against the real code.** All three exist and are used: the constant is `MAX_BYTE_ARRAY_LENGTH` in `io/limits.ts` (not `MAX_ARRAY_BYTES` in `io/byte-queue.ts` — the real name was used, no duplicate added), and `Status.isError` and `Protocol.token` are both present as assumed, so `HTTP-11`'s classification is not a Phase-1 gap. Original guidance, kept for the record: Task 11's Interfaces block carries a "Verify before writing" note. If a name differs, use the real one; do **not** add a second constant or a local `isError` helper. If `Status` genuinely has no `isError`, `HTTP-11`'s classification is itself a Phase-1 gap and the gate becomes `code >= 400 && code <= 599` pending that fix | - -**Applied without needing a decision** (recorded so the reasoning survives): `BODY-34`'s "one shared cap" -contradiction resolved in the plan's favour — the shared preview cap covers the two logging tees, and -`toHttpError`'s 1 MiB cap is separate because `HTTP-52` *fixes* its value and a spec-fixed value cannot be the -configurable one; `BODY-26`/`BODY-29` built (`LoggedResponseBody` gained a non-draining `error()` and a -regime-dependent `contentLength`); `BODY-25` ledgered as structurally inapplicable — `ReadableStreamDefaultReader` -takes no requested count, so "zero bytes for a positive count" has no analog; `BODY-32`'s negative-cap rejection -added to both tees, which previously accepted a negative cap and silently mirrored nothing; `HTTP-3`'s -`MultipartBodyBuilder` added (`HTTP-3` names "the multipart body" explicitly and Phase 1 could not satisfy it); -`HTTP-2` honored by exporting the concrete body classes from the public barrel as **types only**; the `@internal` -tags removed from the three errors Task 13 promotes, which would have made `api-extractor` either fail or -silently omit them; `withResponseLogging` decomposed under the 70-line cap and made pull-driven, since its -`start()`-loop tail stream eagerly materialized the whole remainder of exactly the oversized bodies the cap -exists to keep off the heap. - -**Correction to 4b's F2 below.** That row states "Phases 1/2/3b/4a ship zero" assertions. **3b no longer does** — -`invariant` pre/postconditions now sit on both tees' caps, `materialize`'s byte accounting, `MultipartBody`'s -framing length, `StreamBody`'s `contentLength`, `drainOnce`'s cap, and `toHttpError`'s buffer loop. Phases 1, 2 -and 4a still ship zero, so 4b's F2 remains open as a project-level question for Phase 10 — 3b is now a second -data point alongside 4c that the rule is applicable, not just aspirational. - -## Open Findings — Phase 3b Execution (2026-08-25, expanded 2026-08-26) - -Findings that surfaced only once Phase 3b's plan was actually executed, across three review passes. Nearly all -are **checkpoint-owned**, not 3b-owned: the 3b design took the checkpoint -(`plans/2026-07-25-checkpoint-scaffold-through-phase3a.md`) as a signed-off prerequisite, and it has not run. -Every box in that document is unchecked and no commit implements it. - -### Why nobody noticed: the checkpoint was cherry-picked, not skipped - -The more useful framing than "the checkpoint did not run" is that **parts of it did**, which is exactly what made -the 3b plan's prerequisite claim plausible to whoever wrote it. Measured status of every `§5` item as of -2026-08-26: - -| § | Item | Status | -|---|---|---| -| 5.1 | Coverage floor as a *blocking* gate | **Done** — `bunfig.toml` carries `coverage = true`, `coverageThreshold = 0.8` | -| 5.2 | Flatten the `DomainModelError` tier | **Open** — E2 below | -| 5.3 | Error leaves carry identifying `readonly` fields | **Partial** — 2 of 10; E3 below | -| 5.4 | `Symbol.asyncDispose` + floor bump + `lib` entry | **Open** — E1 below | -| 5.5 | Bounded collections vs `RetentionWindow`/tap | **No action needed** — confirmatory in the checkpoint itself | -| 5.6 | `AbortSignal.any` composition | **No action needed** — confirmatory | -| 5.7 | Flat hoisting lets a package resolve an undeclared dependency | **Open** — E4 below | -| 5.8 | `NFR-14`'s stale "no direct Bun equivalent" reason | **Resolved in Phase 6a (2026-08-27)** — E7 below | -| 5.9 | `bun test` proves nothing about the Node runtime | **Done 2026-08-26** — E5 below | -| 5.10 | Per-class `#private` justification comments | **Open** — E6 below | -| 5.11 | Phase 4 pre-commitment: `Stage` must not be an `enum` | Not yet due (Phase 4) | -| 5.12 | Tooling conflicts already resolved by the plans | Recorded only | - -Partial application is worse here than none at all. `§5.1` is visible in `bunfig.toml` and half of `§5.3` is -visible in `errors.ts`, so a reader checking whether the checkpoint had landed would have found evidence that it -had. **Verify a prerequisite against the artifact it was supposed to produce, not against a spot check.** - -| # | Sev | Finding | Where | Resolution | -|---|---|---|---|---| -| E1 | **blocker — CLOSED in 3b, reopened against checkpoint §5.4** | 3b shipped `[Symbol.asyncDispose]` on `Response` and `LoggedResponseBody` on the strength of the design's claim that "the floor is bumped and `lib` extended before 3b starts". Neither happened: `engines.node` is still `">=18.17"` and `lib` is `["ES2022", "DOM", "DOM.AsyncIterable"]`. Two consequences, both real: below Node 18.18 the computed key evaluates to `undefined` and binds the method to the string `"undefined"`; and the symbol's *type* reaches the package only through a dev-only global, so a consumer compiling against the published `.d.ts` on this repo's own declared `lib` fails with `TS2550: Property 'asyncDispose' does not exist on type 'SymbolConstructor'`. No gate covered it — `verify:dual-consumption` runs `node`, not `tsc` | `packages/core/package.json`; `tsconfig.base.json`; 3b design §"Response Body" | **3b reverted to `close()`-only**, matching the decision Phase 3a shipped and every other resource owner still carries, with both classes now asserting the symbol's *absence* so it cannot be reintroduced ahead of the floor. Re-adding it is checkpoint §5.4's job and must land on all seven owners at once — `Transport`, `ByteQueue`, `BufferedSource`, `BufferedSink`, `RetentionWindow`, `Response`, `LoggedResponseBody`. **Version numbers now verified**, discharging §5.4's own "verify against the actual Node release notes before writing the number" instruction: `Symbol.dispose`/`Symbol.asyncDispose` first shipped in **Node 18.18.0**, backported to **20.4.0** — symbols only, not the `using` syntax. So §5.4's "believed 18.18.0" was right and the bump really is patch-level. **Renumbered 2026-08-26 by E8:** the floor is now `>=20.3`, and on the 20.x line the symbols arrive in 20.4.0, so §5.4's bump reads `>=20.3` → `>=20.4`. **Note for 4b's F1:** that finding assumed the floor had already been "raised at most to `18.18.0` at the 2026-07-25 checkpoint" and that `esnext.disposable` was in `lib`. Neither premise holds — see F1's own amended row | -| E2 | major — **OPEN, checkpoint §5.2** | 3b's Task 1 flattened `io/`'s four error leaves off `IoError` on the stated basis that checkpoint §5.2 had already flattened Phase 1's `DomainModelError` tier. It had not, so the taxonomy is now *mixed*: `DexpaceError → EndOfStreamError` is two levels while `DexpaceError → DomainModelError → RequiredFieldError` is still three | `packages/core/src/http/errors.ts`; 3b design §"Error Tree" | **Deliberately not fixed in 3b.** Removing `DomainModelError` deletes a class exported from the public barrel that consumers can `instanceof` — a breaking API change belonging to the checkpoint. The residual is strictly smaller than what preceded it (`io/` no longer adds a second independent violation) and is recorded in 3b's ledger and checklist. **Blast radius, measured:** ten leaves extend it — `RequiredFieldError`, `HeaderValidationError`, `MediaTypeParseError`, `ProtocolParseError`, `UrlConstructionError`, `RequestOptionsValidationError`, `EtagParseError`, `HttpRangeValidationError`, `RequestConditionsValidationError`, `RequestBodyNotAllowedError` — all in one file, and `DomainModelError` itself is a runtime value export, so `instanceof` narrowing on it is live public API. §5.2 pre-specifies the replacement (an exported `isDomainModelError` type-guard union, never a re-subclass), and 3b already proved that pattern twice in-tree with `isIoError` and `isBodyError`. **Sequencing:** §5.2's own note — "Phase 4's error families then land as leaves on `DexpaceError` too, which is what keeps the flattening from being undone one phase later". **Ten queued phases introduce new SDK error types** — 4a (`DuplicateContextKeyError`), 4c (five, including `PillarCollisionError`, `CrossStageEditError`, `ReservedStageError`), 5b (`NonReplayableBodyError`, `SchemeDowngradeError`), 5c (`AuthResolutionError`, `PlaintextCredentialError`, `DigestChallengeUnsupportedError`), 6a (`SerdeError`, `SerializationError`, `DeserializationError`), 6b (`SseStreamError`, `SseLineTooLongError`), 6c (`PaginationError`), 8a (`TransportFailureError`), and 5a/8b, which reuse rather than define. Counted from the phase design docs 2026-08-26; 4b and 7a/7b define none. Every one of those that ships before the flatten is another tier decision taken against the wrong parent. Owned by checkpoint §5.2 | -| E3 | major — **OPEN, checkpoint §5.3** | §5.3 requires every error subclass to carry its identifying inputs as sanitized `readonly` fields, because `JSON.stringify(error)` and structured-log field enumeration bypass `.message` entirely. It was applied to **two** leaves and stopped: `RequiredFieldError` carries `fieldName`, `HeaderValidationError` carries `kind` + `escapedName`. The other **eight** carry nothing — their identifying data exists only interpolated into the message string, which is precisely the shape the rule forbids. Not raised by any of Phase 3b's three review passes either; found only when the checkpoint was audited item by item | `packages/core/src/http/errors.ts` | **Open.** Same file and same ten classes as E2, so doing §5.2 and §5.3 in one pass is strictly cheaper than two. §5.3 also specifies the sanitization shape per leaf: the offending *name* control-character-escaped, the offending *value* never stored raw (a `valueLength`, a masked minimum fragment, or no field at all), and for `MediaTypeParseError` the failing token/offset rather than the full input. It further asks for a file comment on `errors.ts` recording *why* fields are sanitized at construction — that comment is what stops a later contributor "restoring" the raw value | -| E4 | major — **OPEN, checkpoint §5.7** | No isolated linker is configured. `bunfig.toml` carries only a `[test]` block and there is no `.npmrc` at all, so the install is flat-hoisted by default. Under flat hoisting `@dexpace/core` can import a package it never declared and still pass every gate — including `verify:seam-1`, which reads the `dependencies` map rather than what the code actually resolves. That is the one phantom-dependency failure mode `SEAM-1`'s gate structurally cannot see | `bunfig.toml` (no linker key); no `.npmrc`; `scripts/verify-seam-1.mjs` | **Open.** §5.7 requires confirming the exact linker option against the pinned Bun version before writing it. Low effort, and it strengthens a `SEAM-1` guarantee the project treats as foundational | -| E5 | **blocker — CLOSED 2026-08-26, checkpoint §5.9** | Was: no `test:node` script existed, yet the 3b plan's Task 13 Step 3 gate sequence called `bun run test:node`, so that plan could not be executed as written; `node-floor-conformance` pinned `18.17.0` alone, leaving current LTS unexercised against the "in addition to current LTS" half of the rule; and all 516 unit tests ran only on Bun. Audited 2026-08-26: **319 of those 516 tests, across 21 of 43 files, exercise a runtime-divergent surface** — Web Streams, `AbortSignal`, async iteration, or `ByteQueue`'s `Uint8Array` handling — against **two** assertions of Node coverage, neither of which touched `io/`. The `ci` job additionally pinned no Node at all, so `verify:dual-consumption`/`verify:consumer-types`/`verify:runtime-floor` ran on an undeclared runner default | `.github/workflows/ci.yml`; root `package.json` scripts; 3b plan Task 13 Step 3 | **Closed by implementing §5.9's own prescription, not a substitute.** `bun test` is unchanged as the unit runner and is now scoped to `packages/` so the two layers cannot blur. Added `test/node-conformance/` — 30 `node --test` cases over the **built** artifact, seeded with `composeSignal` plus Phase 3a's byte-stream surface and Phase 3b's public body surface — wired as `test:node`. `scripts/verify-node-floor.mjs` is **retired**, 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/*']` (floor pin moved to `20.3.0` by E8); `lts/*` resolves at run time so the LTS half cannot go stale. The membership rule §5.9:378 states — a phase touching a runtime-divergent surface adds a case here — is recorded in `test/node-conformance/README.md` and `CLAUDE.md`. **Note:** the CI job name changed, so any branch protection requiring `node-floor-conformance` needs updating to `node-conformance` | -| E6 | minor — **OPEN, checkpoint §5.10** | §5.10 ratifies the `#private` *choice* for wire-model classes but calls the missing per-declaration justification "a real, uncorrected gap" — the corpus wants the reason where a reader meets the field, not in a plan document they will never open. **None** of the eleven `packages/core/src/http/` model files carries one. Measured 2026-08-26 by grepping for a comment naming runtime privacy or citing `HTTP-1`/`SEAM-29` near a `#private` declaration: four files matched and all four were false positives — unrelated `HTTP-10`/`HTTP-11`/`HTTP-13`/`HTTP-18` requirement citations in ordinary TSDoc | `packages/core/src/http/*.ts` | **Open.** One short comment per declaring class (not per field), naming the runtime-privacy requirement and citing `HTTP-1`/`SEAM-29`. §5.10 also asks that the `http-domain-model.md` conflict entry then be resolved as a carve-out **scoped to wire-model classes only**, so it cannot read as blanket permission for `#private` elsewhere | -| E7 | minor — **RESOLVED, Phase 6a (2026-08-27)** | The scaffold checklist deferred `NFR-14` on the reasoning that pnpm's `catalog:` protocol "has no direct Bun equivalent". Bun has since added workspace catalogs, and Phase 6a adopted them: the root `workspaces.catalog` block now single-sources the four tool versions, referenced as `"catalog:"` from the root's own `devDependencies` and from both member packages. The stale reason is therefore moot rather than corrected in place — the decision it would have misled a later reader into re-litigating has been made. Confirmed against the pinned Bun version (`.bun-version` 1.3.14; catalogs landed in 1.2.0), as §5.8 required. | `plans/2026-07-23-scaffold-milestone-checklist.md:45`; two `docs/knowledge` lines | **Closed.** | -| E8 | **blocker — CLOSED 2026-08-26** | `MultipartBody` generates its boundary from `crypto.getRandomValues`, a bare global reference, 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 a CommonJS probe would have reported the floor as satisfied. Every `multipartBody(...)` call therefore threw `ReferenceError: crypto is not defined` on the declared floor. Uncaught until E5's conformance suite ran the built artifact on the pinned floor for the first time; `bun test` cannot see it, because Bun supplies the global. The same run exposed a second, unrelated defect: `seams.test.mjs` awaited an `AbortSignal.timeout()` abort with nothing else scheduled, and that timer is unref'd on every Node version, so on 18.17.0's test runner the loop drained first and the 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 why it passed on `lts/*` | `packages/core/src/body/multipart-body.ts:36`; `packages/core/package.json`; `tsconfig.base.json`; `.github/workflows/ci.yml`; `sdk-design-nodejs/02:10` | **Floor raised to `>=20.3`**, the option taken in preference to a `node:crypto` fallback (which would put a Node-only specifier in a package documented as running on browsers, Deno, Bun and Workers, and cannot be reached synchronously from a constructor) or a non-crypto RNG (which silently downgrades the unguessable-boundary mitigation that `HTTP-51` leans on against multipart injection). **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 on a pinned 20.0.0. `lib`/`target` move to `ES2023` with it, keeping `verify:runtime-floor`'s pairing table honest; its `es2023` row is amended to `>=20.3` with the built-ins, not the syntax, named as the reason. The CI matrix floor pin moves `18.17.0` → `20.3.0`, and `seams.test.mjs` gains a case asserting `globalThis.crypto.getRandomValues` is a function *in ESM*, so the floor cannot regress silently. Node 18 went EOL in April 2025, so no supported runtime is dropped. **Note for E1:** this discharges E1's floor half in the sense that only `Symbol.dispose`/`Symbol.asyncDispose` now stand between the declared floor and §5.4 — but not the number: the symbols reached the 20.x line in **20.4.0**, so §5.4's bump is now `>=20.3` → `>=20.4`, still patch-level, and still required before any owner declares the method | - -### Suggested order - -**Before Phase 4 starts:** - -1. **E2 + E3 together**, in one pass over `packages/core/src/http/errors.ts`. Same ten classes, same file, and - E2's sequencing argument means every phase that ships first adds leaves to a tier that is about to be removed. -2. **E1** (§5.4's three parts, which do not work separately). Cheaper now than when the checkpoint was written: - the new `verify:consumer-types` gate mechanically proves a `lib` entry that is declared but whose floor was - not raised, and proves the reverse too. -3. **F1 is closed** — resolved to branch (b) and implemented 2026-08-26 as `packages/core/src/suppress.ts`. - Read it before designing against `SuppressedError` anywhere. See "F1 resolution — the verified version facts" under - "Open Findings — Phase 4b Validation Review" further down this document. That amendment changes 4b's design - input, not just its wording, and F1 already notes the resolution has to land in 5a, 6b and 6c at the same - time. - -**Not blocking Phase 4, ordered by how fast they decay:** E4, E6, E7. E5 is closed — it was the one that grew -with every phase, which is why it went first. - -### Phase-3-owned residuals - -Distinct from the checkpoint items above: these belong to Phase 3 itself and are recorded in its ledger and -checklist rather than being anyone else's to close. - -| Item | Level | Disposition | -|---|---|---| -| Multipart boundary **non-appearance** in part content | `HTTP-51`, ⚠️ partial | RFC 2046 puts two duties on the sender; only the `bchars` grammar half is checkable here, because a `StreamBody` part's bytes do not exist until the write and a partial scan would read as a complete guarantee. Mitigated by generating a 32-character Web Crypto boundary by default and documenting the obligation on both caller-supplied entry points. Revisit only if demand for caller-chosen boundaries appears | -| `StreamBody` always single-use, no mark/reset | `BODY-9` (SHOULD), bounded | Node's `ReadableStream` has no generic mark/reset. Closes only if the platform gains one | -| `BODY-34`'s shared preview-cap **value** | ⏳ Phase 7 | Both tees take the parameter today; Phase 7 owns the `Logger`/config surface that threads one value through them | -| `BODY-4`/`BODY-5` replayability **consultation** | ⏳ Phase 5 | Phase 3 guarantees the property is correct; retry/redirect/auth consult it | -| `FileBody` (`HTTP-40`/`BODY-11`/`12`/`13`/`36`) | ⏳ Phase 8a | Already resolved in 8a's design as `@dexpace/body-file` plus a structural `Body.kind === 'file'` contract | -| Both logging tees unwired to any `Logger` | ⏳ Phase 7 | Mechanism ships now because the IDs are `§6`; nothing constructs one yet. Matches Phase 2 shipping `Serde<T>` with no implementation | - -Also worth carrying forward, since three separate defects in 3b traced to the same root: **a `Body`/sink decorator -must forward BOTH teardown paths.** A `WritableStream` adapter that declares `write` and `close` but no `abort` -silently swallows the delegate's abort — the default abort algorithm is a no-op — leaving the real sink open and -locked and letting a truncated body be committed downstream as a complete one. Likewise `pipeTo`'s default -`preventCancel: false` cancels the *source* when the destination fails, which takes cancellation ownership away -from the caller (`BODY-8`). Phase 4c's stage pipeline and Phase 8a's transports both wrap sinks; both inherit this. - -## Open Findings — Phase 4b Validation Review (2026-07-28) - -A validation pass over `specs/2026-07-25-phase4b-recovery-chain-design.md` and -`plans/2026-07-25-phase4b-recovery-chain.md` (`docs/validation-prompts/phase4b-recovery-chain-validation-prompt.md`) -returned **BLOCKED**. The `RECOV-1`–`RECOV-16` mapping itself is sound and every cross-phase reference 4b consumes -checks out against the earlier phase plans — `toHttpError(): Promise<HttpStatusError | null>` (3b), `RequestOptions.EMPTY` -(Phase 1), `Transport.send(request, options?, signal?)` + `CancellationError` (Phase 2), and `Response.close()` latching -`#closed` *before* awaiting `body.cancel()` so it propagates a close rejection exactly once (3b). Nothing below is a -defect in that mapping. Recorded here rather than in the Deferred Items Log because these are review findings against -an unexecuted phase, not deferrals of work. - -**Status (2026-08-26): F1 and F2 are closed and Phase 4b is implemented.** F1 landed as branch (b) — the -runtime-guarded `suppress()` helper in `packages/core/src/suppress.ts`, shipped with both branches of the guard -forced in `bun test` and re-forced from real Node in `test/node-conformance/recovery-chain.test.mjs`. F2 landed -as a Deviation Ledger row in 4b's design, deferring the density rule to Phase 10's project-wide pass rather than -making 4b the one module that differs. Phases 5a, 6a, 6b and 6c now have a helper to call and no longer carry an -open decision — only the mechanical substitution of `suppress(...)` for `new SuppressedError(...)` when each -executes. - -**Status (2026-07-28): F3–F10 are applied** to `specs/2026-07-25-phase4b-recovery-chain-design.md` and -`plans/2026-07-25-phase4b-recovery-chain.md`. **F1 and F2 remain open — they need decisions**, and both documents now -carry a blocking notice pointing here. The rows below keep the full finding text so the reasoning survives; the -Resolution column records what was done. - -**F1 was cross-phase and blocked four phases, not one.** Phases 5a, 6b and 6c all reach for native -`SuppressedError` on the same false premise. The resolution landed as a shared helper rather than as four -parallel edits, so the cross-phase obligation is discharged by 4b: each of the other three substitutes -`suppress(...)` for `new SuppressedError(...)` when it executes, with no decision left to make. - -| # | Sev | Finding | Where | Resolution | -|---|---|---|---|---| -| F1 | **blocker** — ✅ closed | `SuppressedError` does not exist on the declared runtime floor. `engines.node` is `">=18.17"`, raised at most to `18.18.0` at the 2026-07-25 checkpoint (which exposes `Symbol.dispose`/`Symbol.asyncDispose` only — Node backported those two symbols; `SuppressedError` is a V8 global from the full Explicit Resource Management proposal). `esnext.disposable` in `lib` supplies its *type*, so `new SuppressedError(...)` type-checks and then throws `ReferenceError` at call time — the exact `NFR-10` trap `tooling-and-quality-gates.md:60-61` describes. `bun test` passes locally; the `node-floor-conformance` job pinned to `18.17.0`, `verify:node-floor` and `test:node` all fail | PLAN:19-20 (Tech Stack, claims it is "already available since Phase 3b's checkpoint lib bump" — false), PLAN:804, SPEC:124; also 5a plan:36, 6b design:163, 6c design:192 | **Resolved 2026-08-26: take branch (b)** — the runtime-guarded `suppress()` helper. The "confirm the first supporting Node release" condition this row left open is now discharged, and it settles the choice rather than merely informing it; two of this row's own premises also turn out to be false. See "F1 resolution — the verified version facts" below the table. **Partially applied 2026-07-28:** the false Tech Stack claim is deleted and replaced with a blocking notice at the top of the plan stating the real constraint; **Applied 2026-08-26:** `packages/core/src/suppress.ts` ships the guarded helper, `response-chain.ts` calls it, and assertions are written against its shape rather than `instanceof SuppressedError` — the `instanceof` form would silently assert nothing on the floor runtime | -| F2 | major — ✅ closed | Zero assertions across the whole `recovery/` module — a dozen functions, no `invariant()` call, against `assertions.md:6-7`'s 2-per-function module average (and `styleguide-overview.md:22-23` Rule 8). Neither document acknowledges the rule or argues an exemption. Concretely: no `apply()` checks that a step returned a value at all, so a step returning `undefined` poisons the fold silently. Project-wide inconsistency, not 4b's alone — Phases 1/2/3b/4a ship zero, 4c ships fifteen | PLAN:463-479, 818-859, 964-966, 1352-1370 | **Resolved 2026-08-26: Deviation Ledger row.** Recorded in 4b's design with the concrete cost named (a step returning `undefined` poisons the fold silently). Assertions added to 4b alone would deepen the 0-vs-15 split with 4c rather than close it, so the density rule is settled once at Phase 10 and applied project-wide. **Re-deferred 2026-08-30 (Phase 10): NOT SCHEDULED.** Phase 10's scope is deviation reconciliation; a project-wide assertion sweep is neither a deviation nor a reconciliation, and Phase 10 is the last row of the phase table, so there is no later phase to hand it to and none is invented here. The picture has changed since 4b: `invariant()` is now called from thirteen modules across `packages/core/src/` and `packages/body-file/src/` (`body`, `auth`, `observability`, `io`, `retry`, `pagination`, `config`, `sse`, `redirect`, `pipeline`, `serde`, `context`, `testing`), so the 0-vs-15 split is no longer the shape of the problem — `recovery/` is now the outlier, still with zero (`packages/core/src/recovery/` imports only `assertNever`, `outcome.ts:3`). **Trigger:** the next defect traced to an unasserted precondition, or a naming/assertion convention sweep commissioned as its own phase — whichever comes first. Logged in the Deferred Items Log above so it is tracked rather than silent | -| F3 | major — ✅ applied | SPEC:270 still says "the only new failure surface is `wrapCancellation()`'s `invariant()` crash" — stale text from a superseded draft. SPEC:194-204, SPEC:279 and PLAN:63-74 all state the opposite. An agent executing from the File Layout section would restore the `invariant()`, and because the helper runs inside `dispatchWithRecovery`'s own `catch`, that throw bypasses the response and recovery chains — the one failure mode `RECOV-2` exists to prevent | SPEC:270-271 | Replace with `assertNever`'s `InvariantViolation` crash, matching the already-correct PLAN:89-90 | -| F4 | minor — ✅ applied | Spec never designs the `assertNever` addition Task 1 builds. PLAN modifies `packages/core/src/invariant.ts` (new exported symbol, two tests, its own commit); SPEC's File Layout lists only `recovery/` | SPEC:258-268 vs PLAN:102-103, 124-197 | Add the `invariant.ts` line to the spec's File Layout with a one-line note that `fold()` is the codebase's first discriminated-union `switch` | -| F5 | minor — ✅ applied | `RECOV-14`'s second normative sentence (steps safe for concurrent invocation; per-request state never on the step instance) is claimed but neither designed nor tested — both documents cite `RECOV-14` for the defensive copy only. The design does satisfy it (all per-call state is local), but nothing records or guards that | SPEC:141-144, PLAN:49-51 | One sentence in the design + one plan test interleaving two `apply()` calls on one chain | -| F6 | minor — ✅ applied | `RECOV-32`/`RECOV-33` read as silent drops. 4b's deferral sentence covers "backoff, budget, pacing headers → Phase 5"; neither an idempotency-key header injector nor `User-Agent` composition is any of those. Both *are* built — `RECOV-32` in Phase 5a Task 11, `RECOV-33` in Phase 7a Task 9 — but 4b names neither, and 7a is not "Phase 5" | SPEC:18-20 | Extend the Scope sentence to name `RECOV-17`–`RECOV-31`/`RECOV-34` → 5a, `RECOV-32` → 5a, `RECOV-33` → 7a | -| F7 | minor — ✅ applied | `#private` fields with no justifying comment, against `data-modeling.md:20-23` (`private` is the default; `#private` needs a stated runtime-privacy requirement). Neither chain class needs it — unlike 3b's `Response`, whose `#closed` genuinely must survive `Object.freeze(this)`. Inherited pattern: 4a's `ContextStore` does the same | SPEC:64, 78-79; PLAN:464, 819-820, 833, 847 | Ledger row recording `#private` as the package-wide field style with no runtime-privacy claim; project-wide reconciliation is Phase 10's. **Re-deferred 2026-08-30 (Phase 10): NOT SCHEDULED, and mostly moot.** The finding's actual ask was a *stated* runtime-privacy requirement, and the project has since stated one: `CLAUDE.md:172-173` makes "`#private` fields only. Not TS `private`." a mandated construction rule and cites styleguide 6.7's carve-out for libraries whose internals must stay unreachable reflectively. That is the justification F7 asked for, adopted project-wide rather than argued per class — `packages/core/src/http/status.ts:22-23` and `packages/core/src/recovery/request-chain.ts:26` are the same shape. What is left is cosmetic (no per-class comment) and has no owner: Phase 10 is the last phase, and a convention already written into `CLAUDE.md` does not need a sweep to enforce it. **Trigger:** a lint rule that mechanizes the convention, or a styleguide revision that withdraws the 6.7 carve-out. Logged in the Deferred Items Log above | -| F8 | minor — ✅ applied | Plan's `ResponseRecoveryChain` property test drops half of what the spec specifies. SPEC promises the property also proves the response-step phase never runs on a `Failure` input (`RECOV-4`); the plan's generator emits recovery steps only and never seeds a `Failure`, asserting only that `apply()` settles | SPEC:293-295 vs PLAN:754-773 | **Applied 2026-07-28 — generator extended**, not spec narrowed: the property now generates response *and* recovery steps over a seed that is arbitrarily `Success` or `Failure`, and asserts `responseStepRuns === 0` on every `Failure` seed. Task 3's expected test count moves 12 → 13 | -| F9 | minor — ✅ applied | `fold(outcome, onSuccess, onFailure)` takes three positional parameters, tripping `function-design.md:22-23` ("options object at 3 or more"), which is one stricter than the lint gate (`max-params: ['error', 3]` errors at four). Passes CI while violating the corpus. Phase 2's shipped `Transport.send(request, options?, signal?)` is the same shape | SPEC:36, PLAN:320 | Ledger row recording it as deliberate (matching `Transport.send`), or `fold(outcome, {onSuccess, onFailure})`. See the corpus conflict below | -| F10 | minor — ✅ applied | `statusMappingStep` is a module-level `const` arrow, against `function-design.md:18-21` ("top-level named `function` declarations… arrows are reserved for inline callbacks"). `func-style`'s `allowArrowFunctions: true` will not catch it, and named declarations survive in stack traces — which matters for a function whose whole job is to `throw` | SPEC:227, PLAN:1081 | `export async function statusMappingStep(...)` plus `statusMappingStep satisfies ResponseStep` to keep the conformance check | - -### F1 resolution — the verified version facts - -Two of F1's premises are false, and the second changes which branch is affordable. - -**1. The floor was never raised.** F1 assumed `engines.node` had been "raised at most to `18.18.0` at the -2026-07-25 checkpoint" and that `esnext.disposable` was in `lib`. The checkpoint has not run at all — see -"Open Findings — Phase 3b Execution", finding E1. `engines.node` is still `">=18.17"` and `lib` is -`["ES2022", "DOM", "DOM.AsyncIterable"]`. - -**2. `SuppressedError` needs a far higher floor than `Symbol.asyncDispose`.** These are not the same bump, and -F1 treats them as comparable. Node backported the `Symbol.dispose`/`Symbol.asyncDispose` *symbols alone* in -**18.18.0** and **20.4.0**. `SuppressedError` belongs to the full Explicit Resource Management proposal, which -shipped in **V8 13.8 / Chromium 134** and reached Node only in **24.0.0**. So F1's branch (a) — "raise -`engines.node` past the first release shipping Explicit Resource Management" — is not a patch bump from 18.18. -It means `>=24.0.0`, **dropping Node 18, 20 and 22 outright**, which is disproportionate to the need and is -exactly the kind of unsanctioned floor move the checkpoint at plan:57 forbids. - -Branch **(b)** therefore wins on cost rather than as a compromise: a runtime-guarded -`suppress(primary, secondary)` helper in `packages/core/src/`, using native `SuppressedError` when -`globalThis.SuppressedError` exists and attaching a `suppressed` property otherwise. - -**A third point that must not be lost when E1 lands.** `esnext.disposable` in `lib` supplies -`Symbol.asyncDispose`'s *type*; it does **not** supply `SuppressedError`'s *runtime*. The -type-checks-then-throws-`ReferenceError` trap F1 describes therefore survives E1's floor bump intact. Adding the -`lib` entry is not a fix for F1 and must not be read as one — including by Phases 5a, 6b and 6c, which reach for -native `SuppressedError` on the same false premise and which F1 already notes must be resolved together. - -**Corpus conflict surfaced, not a finding.** `function-design.md:22-23` requires an options object at 3+ parameters -while `function-design.md:40-41` sets `max-params: ['error', 3]`, which errors only at four — the prose is one -parameter stricter than its own stated enforcement. F9 is filed against the prose; if the lint threshold is the -authority, F9 dissolves. Worth settling in the corpus rather than per-phase. - -A second conflict the 4b documents met and resolved correctly, recorded so a later reader does not re-litigate it: -`resource-management.md:4-5,72` mandates `using`/`await using` and documents that native disposal builds a -`SuppressedError` with the *disposal* failure primary, while `RECOV-12` requires the opposite priority. 4b picks -`RECOV-12` and argues it at SPEC:107-113 / PLAN:55-59. Correct call, already justified in-document. - -## Open Findings — Phase 4c Validation Review (2026-07-29) - -A validation pass over `specs/2026-07-25-phase4c-stage-pipeline-design.md` and -`plans/2026-07-25-phase4c-stage-pipeline.md` -(`docs/validation-prompts/phase4c-stage-pipeline-validation-prompt.md`) returned **NEEDS WORK — no blockers.** -The `PIPE-1`–`PIPE-40` mapping is sound and every cross-phase reference 4c consumes checks out against the earlier -phase plans: `Transport.send(request, options?, signal?)` + `close()` (Phase 2), `DexpaceError` as the taxonomy -root under `http/errors.ts` (Phase 2's retrofit), `RequestOptions.EMPTY` (Phase 1), `Status.of`/`Protocol.HTTP_1_1` -(Phase 1), and 4a's `createRequestContext(request, init?)`, `promoteToRequest`/`promoteToExchange`, -`ContextStore.install/get/close/clear/size` with the `kind`/`key`/`request`/`instrumentation`/`operationName` -context shape. Nothing below is a defect in that mapping. - -**Status: F1–F8 are applied** to both 4c documents. **F9 remains open — it needs a decision.** - -| # | Sev | Finding | Where | Resolution | -|---|---|---|---|---| -| F9 | major — **OPEN, needs a decision** | `Cursor` accepts the caller's `AbortSignal`, threads it to the terminal transport, and never checks it between steps. `concurrency-and-async.md:46` requires `signal.throwIfAborted()` "at the top of each loop iteration or before each expensive step"; the step walk (and, worse, a pillar step's fork-driven re-drives) is exactly that. An aborted call keeps walking steps and keeps re-driving until the transport hop finally rejects | PLAN `cursor.ts` `#dispatch`; SPEC "Cursor and fork" | **Undecided**, because the fix is not one line: a raw `signal.throwIfAborted()` surfaces a `DOMException` the SDK taxonomy does not own, against Phase 2's `CancellationError` and `XCUT-1`'s "cancellation is terminal, non-retryable, flag preserved" — and `RECOV-11`/4b's `wrapCancellation` already has a shape for this. Either (a) check in `#dispatch` and map to `CancellationError`, or (b) leave the cursor signal-blind and let 5a's `ctx.signal` + `RETRY-32` carry cancellation, recording (b) as a Deviation Ledger row. Settle before 5a Task 1 lands, since 5a is what makes the signal reachable from a step | -| F1 | major — ✅ applied | `PIPE-17`'s "options MUST be readable by any step" was claimed satisfied while `StepContext` exposes only `next`/`fork`/`context`. A MUST silently unmet is a blocker; it is a legitimate deferral only if the document names the phase that takes it — neither did. (The work itself is already scheduled: 5a Task 1, per the Deferred Items Log row below) | SPEC "Steps", PLAN Self-Review `PIPE-17` row | Both documents now record the partial deferral by name — `StepContext.options`/`.signal` land in **Phase 5a Task 1**; the plan's Global Constraints forbid adding them early, since their shape belongs to their first reader | -| F2 | major — ✅ applied | Spec listed `replace` among the operations that raise `PillarCollisionError` on an occupied pillar; the plan's `replace()` deliberately runs no pillar check. `PIPE-5` exempts replace by name ("it swaps a single occupant within its own stage 1:1") and the collision error points the caller *at* replace — an agent following the spec would have made replacing a pillar step impossible, since the incoming type is distinct by definition | SPEC:285 vs PLAN `replace()` | `replace` removed from the collision bullet, `prependAll` added to it, and the exemption spelled out with `PIPE-5`'s own wording | -| F3 | major — ✅ applied | `afterEach(() => contextStore.clear())` in `runtime.test.ts` and `builder.test.ts`. 4a's plan forbids this by name — it wipes entries a sibling test file installed in the same `bun test` process (`testing.md:50,52`), and 4a's own store tests avoid the singleton for exactly this reason. Not needed either: `Runtime.send()` evicts its own entry in a `finally` on both paths | PLAN runtime.test.ts, builder.test.ts | Both hooks deleted (and the now-unused `afterEach`/`contextStore` imports), replaced by a comment recording why. The one surviving `contextStore.size` read is a before/after **delta** inside a single test, which the 2026-07-26 review already sanctioned | -| F4 | major — ✅ applied | `NFR-13`'s SPDX header was absent from all eleven code listings and from Global Constraints, against "written into Phase 1's plan… line 1 of every new file, all phases onward" (Deferred Items Log) and 4a's precedent | PLAN, every code block | Global Constraints bullet added, `// SPDX-License-Identifier: MIT` prepended to every listing, and Task 6 gains Step 3b's grep — 4a's gate, copied. **Project-wide drift, not 4c's alone:** the 4b, 5a, 5b, 5c, 6b and 6c plans carry no SPDX header either; Phase 9's `NFR-13` sweep is where that gets closed | -| F5 | major — ✅ applied | The design's "**Property tests:**" heading and the Phase 4 checklist's "Property tests where invariants exist ✅ … 4c (edit-order independence, batch ordering)" row both claimed properties the plan never shipped — `builder.test.ts` had no `fast-check` import and two hand-picked examples. `testing.md:29` puts an invariant-bearing assembler like `build()` squarely in property-test territory | SPEC "Testing" vs PLAN builder.test.ts | Three real `fc.assert` properties added (edit-sequence-equals-from-scratch for `PIPE-22`; batch order preserved / reversed for `PIPE-38`), generated over the non-pillar stages so cases exercise ordering rather than `PIPE-5`'s collision. Task 5's expected count 19 → 22; Tech Stack names `fast-check`. The spec's "arbitrary sequence" now says `append`/`prepend`, matching what the generator emits — the anchored edits need a generated anchor that exists, which makes the model larger than the property it proves, so they stay example-tested | -| F6 | minor — ✅ applied | `PillarCollisionError` and `AnchorNotFoundError` carried their symbols as fields but never rendered them into the message, while `PIPE-5` asks the error to "name both step types", `PIPE-21` to identify "the missing type", both 4c documents claimed exactly that, and `error-handling.md:40` requires identifying inputs in the message — a bare `symbol` field is invisible in a stack trace or log line | PLAN errors.ts | Both messages interpolate `String(type)` (`Symbol(retry)`), matching 4a's `DuplicateContextKeyError`; the fields stay for `error-handling.md:44`, and `errors.test.ts` now asserts the message names them | -| F7 | minor — ✅ applied | `StepContext.fork?: () => Next` spelled bare, against the plan's own `exactOptionalPropertyTypes` constraint ("optional properties are spelled `?: T \| undefined`, never bare `?: T`") — the same shape 5a Task 1's added fields will use | SPEC:135, PLAN step.ts | `fork?: (() => Next) \| undefined` in both documents | -| F8 | minor — ✅ applied | Spec's `PipelineBuilder` listing tagged `insertBefore` with `PIPE-19` and `replace` with "PIPE-18/19"; `PIPE-18` covers both inserts and `PIPE-19` covers replace. Also `#exchangeSource` in prose for what is a module-level exported function, not a private field | SPEC:274-275, SPEC:386 | IDs corrected; the prose names `exchangeSource` and says it is the module-level helper | - -**Not findings, recorded so they are not re-raised.** Assertion density (`assertions.md:6-7`) is already open -project-wide as 4b's F2 — 4c is the phase that *satisfies* it, not one that violates it. `STAGE_ORDER` and -`PILLAR_STAGES` in `CONSTANT_CASE` sit against `naming-conventions.md:14`, whose worked example is literally a -module-level `new Set(...)` staying `lowerCamelCase` because its contents can mutate; a `ReadonlySet` type does -not make the underlying `Set` deeply immutable and `Object.freeze` cannot fix a `Set`. Left alone because the -casing question is project-wide (Phase 1's `Protocol`/`Status` statics, 4b's constants) and renaming one phase's -two constants would fork the convention rather than settle it — Phase 10's reconciliation owns it. - -**Re-deferred 2026-08-30 (Phase 10): NOT SCHEDULED.** Phase 10 does not own it and did not settle it. The -`CONSTANT_CASE`-vs-`lowerCamelCase` question for module-level immutable collections is a naming-convention -call, not a deviation from the reference contract, so it is outside a reconciliation phase's scope; Phase 10 is -also the last row of the phase table, so there is no later phase to hand it to and none is invented here. The -state is unchanged and still consistent within itself — `STAGE_ORDER` and `PILLAR_STAGES` remain `CONSTANT_CASE` -and remain the pipeline's only such pair (`packages/core/src/pipeline/builder.ts:12`, `:179`, `:248`, `:269`). -**Trigger:** the next module-level immutable collection added outside `pipeline/`, which would make the fork -visible in a third place and force the choice — or a naming-convention sweep commissioned as its own phase. -Logged in the Deferred Items Log above so it is tracked rather than silent. diff --git a/docs/work/mvp/2026-07-23-nodejs-sdk-v1-roadmap-design.md b/docs/work/mvp/2026-07-23-nodejs-sdk-v1-roadmap-design.md new file mode 100644 index 0000000..b970d77 --- /dev/null +++ b/docs/work/mvp/2026-07-23-nodejs-sdk-v1-roadmap-design.md @@ -0,0 +1,294 @@ +# Node.js SDK — v1 Roadmap + +**Status:** Draft, approved for planning. + +**Purpose:** High-level, ordered phase list from empty repo to a spec-conformant v1 of the `nodejs-sdk`. This is +an index, not an implementation plan — each phase gets its own brainstorm → spec → plan cycle when its turn +comes. Do not add implementation detail to this document as phases complete; instead link to the phase's own +spec file. + +**Governing documents:** + +- `docs/product-spec.md` (+ `docs/product-spec/*`) — the language-agnostic, normative contract. Requirement IDs + (`SEAM-*`, `HTTP-*`, `IO-*`, `BODY-*`, `CTX-*`, `PIPE-*`, `RECOV-*`, `RETRY-*`, `REDIR-*`, `AUTH-*`, `PAGE-*`, + `SSE-*`, `SERDE-*`, `OBS-*`, `CFG-*`, `TRANSPORT-*`, `ASYNC-*`, `XCUT-*`, `NFR-*`) are the vocabulary every + phase below cites against. +- `docs/sdk-design-nodejs.md` (+ `docs/sdk-design-nodejs/*`) — the Node/TS port design, already broken into the + seams this roadmap sequences. +- `/home/mohammad/Projects/dexpace/styleguide/typescript/` (core rules) and + `/home/mohammad/Projects/dexpace/styleguide/typescript-bun/` (toolchain/runtime rules) — binding, in force from + Phase 0 onward, for every phase without exception. + +## Cross-Cutting Constraints (apply to every phase, not their own phase) + +- **Styleguide enforcement is continuous**, not a one-time gate. Every phase's code is written and reviewed + against `styleguide/typescript`'s 15 chapters (Tiger Style overlay on Google's TS guide) from the moment the + toolchain exists (Phase 0). +- **Package manager and test runner: Bun, not pnpm.** `sdk-design-nodejs/02` specifies a pnpm workspace; the + styleguide mandates Bun (`bun install`, `bun.lock`, `.bun-version`, `bun test`) as binding for all dexpace + projects. Resolved 2026-07-23 in favor of the styleguide — see the + [scaffold milestone design](./scaffold/2026-07-23-scaffold-milestone-design.md) for the reconciled shape. The + multi-package workspace *layout* from `sdk-design-nodejs/02` (package map, project references, peer-dependency + discipline) still holds; only the pnpm-specific mechanics are replaced. Library packages still build with + plain `tsc` (never `Bun.build`, which is reserved for services), per `typescript-bun/08-build-and-distribution.md`. +- **Dual JS/TS consumption.** TypeScript is the source of truth; the SDK must serve both TS and plain-JS + consumers. `tsc` compiles to ESM JS + `.d.ts`; no TS-only runtime syntax leaks into shipped output (the + styleguide's erasable-syntax stance already helps here — no enums, no decorators, no constructor parameter + properties). Verified per-package as each package is built, not only once at the end. +- **Requirement-ID traceability.** Each phase's deliverable should be traceable back to the product-spec + requirement IDs it satisfies, feeding `docs/product-spec/appendix-c-consolidated-normative-requirement-index.md` + and the Phase 9 conformance pass. + +## Phase List + +| Phase | Name | Package(s) | Product-spec refs | sdk-design refs | +|---|---|---|---|---| +| 0 | Toolchain & Style Gate | workspace root, `@dexpace/core` (stub) | — | §2, §9 (see [scaffold milestone design](./scaffold/2026-07-23-scaffold-milestone-design.md)) | +| 1 | Core HTTP Domain Model | `@dexpace/core` | §4 | §4 | +| 2 | Seam Foundations | `@dexpace/core` | §3 | §3 | +| 3a | I/O Contracts | `@dexpace/core` | §5 | §3.1 (Web Streams direct, no pluggable provider) — see [Phase 3a design](./phase3/phase3a/2026-07-24-phase3a-io-contracts-design.md) | +| 3b | Body Lifecycle | `@dexpace/core` | §6 | §3.1 — see [Phase 3b design](./phase3/phase3b/2026-07-25-phase3b-body-lifecycle-design.md) | +| 4a | Execution Context | `@dexpace/core` | §7 | §5 — see [Phase 4a design](./phase4/phase4a/2026-07-25-phase4a-execution-context-design.md) | +| 4b | Recovery-Chain Primitives | `@dexpace/core` | §8.2 | §5 — see [Phase 4b design](./phase4/phase4b/2026-07-25-phase4b-recovery-chain-design.md) | +| 4c | Stage-Based Pipeline | `@dexpace/core` | §8.1 | §5 — see [Phase 4c design](./phase4/phase4c/2026-07-25-phase4c-stage-pipeline-design.md) | +| 5a | Resilience — Retry | `@dexpace/core` | §9, appendix C `RECOV-17`–`RECOV-34` | §6 — see [Phase 5a design](./phase5/phase5a/2026-07-26-phase5a-retry-design.md) | +| 5b | Resilience — Redirect | `@dexpace/core` | §10 | §6 — see [Phase 5b design](./phase5/phase5b/2026-07-26-phase5b-redirect-design.md) | +| 5c | Resilience — Auth | `@dexpace/core` | §11 | §6 — see [Phase 5c design](./phase5/phase5c/2026-07-26-phase5c-auth-design.md). Both 5b and 5c were drafted solo/concurrently (user away from keyboard); 5c's own doc records reconciling with 5b's cross-origin-marker design after finding it mid-draft — see its "Alignment with 5b's shipped design" sections | +| 6a | Serde | `@dexpace/core`, `@dexpace/codec-json` | §14 | §7.3 — see [Phase 6 segmentation design](./phase6/2026-07-28-phase6-segmentation-design.md) | +| 6b | SSE | `@dexpace/core` | §13 | §7.2 — see [Phase 6 segmentation design](./phase6/2026-07-28-phase6-segmentation-design.md) | +| 6c | Pagination | `@dexpace/core` | §12 | §7.1 — see [Phase 6 segmentation design](./phase6/2026-07-28-phase6-segmentation-design.md) | +| 7a | Configuration & Platform Primitives | `@dexpace/core` | §16, appendix C `RECOV-33` | §8 — see [Phase 7 segmentation design](./phase7/2026-07-28-phase7-segmentation-design.md) and [Phase 7a design](./phase7/phase7a/2026-07-28-phase7a-configuration-design.md) | +| 7b | Instrumentation & Observability | `@dexpace/core`, `@dexpace/logging-pino`, `@dexpace/logging-debug` | §15 | §8 — see [Phase 7 segmentation design](./phase7/2026-07-28-phase7-segmentation-design.md) and [Phase 7b design](./phase7/phase7b/2026-07-28-phase7b-observability-design.md) | +| 8a | Transport Adapters | `@dexpace/transport-fetch`, `@dexpace/transport-undici`, `@dexpace/body-file`, `@dexpace/transport-shared` | §17 | §3.2 (single `Promise` primitive collapses JVM's SEAM-11/SEAM-16 fragmentation) — see [Phase 8 segmentation design](./phase8/2026-07-28-phase8-segmentation-design.md) and [Phase 8a design](./phase8/phase8a/2026-07-28-phase8a-transport-design.md) | +| 8b | Async-Runtime Bridge | `@dexpace/rx` | §18 | §3.2 (RxJS `Observable` is the only Node-worthwhile async adapter) — see [Phase 8 segmentation design](./phase8/2026-07-28-phase8-segmentation-design.md) and [Phase 8b design](./phase8/phase8b/2026-07-28-phase8b-async-runtime-design.md) | +| 9 | Cross-Cutting Invariants & Conformance | all packages, `@dexpace/shrink-test` | §19, §20, appendix B | — see [Phase 9 design](./phase9/2026-07-28-phase9-cross-cutting-conformance-design.md) and [Phase 9 plan](./phase9/2026-07-28-phase9-cross-cutting-conformance.md) | +| 10 | Deviation Reconciliation | `@dexpace/core`, `@dexpace/transport-fetch`, `@dexpace/transport-undici`, `@dexpace/shrink-test` — **corrected 2026-08-30**; this cell read `— (review only)` and the phase shipped code. See the Phase 10 status note below | — | §10 | + +**Status note (2026-07-27).** Phases 5a/5b/5c have a design **and** a written implementation plan; none of the +three has been executed — no `src/retry/`, `src/redirect/`, or `src/auth/` exists yet. 5b's and 5c's plans were +reviewed against the knowledge corpus and against each other's declared APIs before execution; the corrections +that outlive their own phase are logged below (see the `cross-origin.ts`, `AuthTiers`, preemptive-stamp, and +`DigestChallengeUnsupportedError` rows). Everything else stayed inside the two plans' own Deviation Ledgers. + +**Status note (2026-07-28).** A cross-phase deferral review swept this log against every written design/plan. +Two real gaps were found and folded into the unexecuted plans: `StepContext` never exposed the caller's per-call +`RequestOptions` (`PIPE-17`'s "readable by any step" MUST — extended 5a Task 1's amendment to two fields), which +in turn left `RETRY-41`'s per-call retry-count override (`RequestOptions.maxRetries`, `HTTP-35`) wired to +nothing (now read by 5a Task 9) and left `AUTH-4`'s `perCall` tier with no per-call source (now +`RequestOptions.auth?: AuthDescriptor`, amended in 5c Task 14). Bookkeeping: the rows targeting Phase 2 and +Phase 3b below were marked resolved-at-design/plan level, and `NFR-13`'s SPDX convention was written into +Phase 1's plan. No executed code exists yet, so every change was a document edit, not a retrofit. + +**Status note (2026-07-28, later same day).** Phase 6 was brainstormed and split into 6a (Serde) / 6b (SSE) / +6c (Pagination) — see the [Phase 6 segmentation design](./phase6/2026-07-28-phase6-segmentation-design.md). The split +review produced three findings recorded in the log below that outlive the sizing question: three Phase-0 deferrals +(`NFR-2`, `NFR-14`, peer-dependency dedup) become live in 6a rather than Phase 8, because `@dexpace/codec-json` — +not a transport adapter — is the workspace's first second package; `sdk-design-nodejs/07`'s item-view snippet +contradicts `PAGE-11`'s close-before-yield MUST in a way appendix B's own conformance test does not catch; and +`PAGE-5`'s "synchronously inside parse" needs an explicit re-expression for a runtime with no synchronous body +read. + +**Status note (2026-07-28, end of day).** All three sub-phases now have **both** a design and a written +implementation plan (`specs/2026-07-28-phase6{a,b,c}-*-design.md`, `plans/2026-07-28-phase6{a,b,c}-*.md`); none +has been executed — no `src/serde/`, `src/sse/`, `src/pagination/`, or `packages/codec-json/` exists yet. The +three plans were then reviewed against each other and against the knowledge corpus, the same pass 5b/5c got. The +corrections that outlive their own sub-phase are logged below (the `Symbol.asyncDispose` row, whose stated +premise 6b/6c invalidate, and the `PAGE-11` erratum row, which needed carrying into `docs/knowledge/` and not +only into `sdk-design-nodejs/07`). Everything else stayed inside the three plans' own task lists and Deviation +Ledgers. One process note worth keeping: the segmentation design declares the three sub-phases order-free, but +each plan's **Prerequisite** section had been written as a linear chain (6b "Phases 0 through 6a", 6c "0 through +6b"), which would have silently re-imposed the dependency the split exists to avoid. All three now state +"Phases 0 through 5c" plus an explicit note naming what — if anything — a sibling sub-phase adds. + +**Ordering rationale:** toolchain first (Phase 0) so every subsequent phase is written under the style/quality +gates from line one. From there, bottom-up by dependency: domain model before the seams that operate on it, +seams before the pipelines built on top of them, pipelines before the resilience layer wrapping them, and +pagination/SSE/serde/instrumentation as the outer layers consuming everything underneath. Transport and +async-runtime adapters (Phase 8) come late because they are the most Node-specific judgment calls (per +sdk-design's §3 framing) and benefit from every other seam already being stable. Conformance (Phase 9) and +deviation reconciliation (Phase 10) close the roadmap by construction — they audit what phases 0-8 built rather +than building anything new. + +## How Phases Get Executed + +Each phase, when its turn comes: + +1. Its own brainstorming session — scoped to that phase alone, referencing this roadmap for context. +2. A spec file. +3. Its own implementation plan (via the writing-plans skill), executed independently. + +Both land in `docs/superpowers/` first — the `brainstorming` and `writing-plans` skills hard-code that +path — and are collected from there into `docs/work/mvp/phaseN/`, which is where a phase's design, plan +and checklist live once the phase is done. The `housekeeping` skill does the collecting. + +This document is updated only to mark a phase's status (not-started / in-progress / done) and link to its spec +once written — it does not absorb implementation detail from completed phases. It carried one exception until +2026-08-31, the Deferred Items Log, which is now [`docs/deferred-items.md`](../../deferred-items.md). Every +phase's brainstorming session should check that register for entries targeting it before starting, and append +any new deferral it produces before that phase is considered done — this is how a decision made in Phase 0 +("we'll handle NFR-2 properly once adapter packages exist") doesn't silently evaporate by Phase 8. + +## Deferred Items Log + +**Moved out on 2026-08-31.** The aggregate log — 74 rows — is now +[`docs/deferred-items.md`](../../deferred-items.md), a register at the `docs/` root beside `open-items.md` +and `deviations.md`. + +It was here because there was nowhere else to put it, and this document's own rule (["How Phases Get +Executed"](#how-phases-get-executed)) had to carve out an exception for it: the roadmap records phase +*status*, "**Exception:** the Deferred Items Log below." The exception is gone with the log. A phase's +brainstorm still checks the register before starting and appends to it before the phase is done — at the +new path. + +## Phase Status Notes + +**Reading these.** Each note below is dated and is not retro-edited. Written when the log sat in this file, +they say "the row above" and "the rows above"; every such reference now means a row of +[`docs/deferred-items.md`](../../deferred-items.md), and the ones that name a specific row have been +repointed in place. The four `## Open Findings` review sections that used to follow them are +[`docs/open-items.md`](../../open-items.md) Sections Q, R, S and T. + +**Status note (2026-07-28, Phase 7).** Phase 7 was brainstormed and split into 7a (Configuration & Platform +Primitives, `§16`) / 7b (Instrumentation & Observability, `§15`) — see the +[Phase 7 segmentation design](./phase7/2026-07-28-phase7-segmentation-design.md). Unlike Phase 6's three segments, this +split has one real (if soft) cross-segment dependency — `OBS-35`'s log-level resolution wants 7a's `Configuration` +— so 7a leads and 7b trails deliberately, rather than "order is convenience only." Both sub-phases got full +designs in this same session (not just a segmentation note): [7a](./phase7/phase7a/2026-07-28-phase7a-configuration-design.md) +and [7b](./phase7/phase7b/2026-07-28-phase7b-observability-design.md). All six Deferred Items Log rows that previously targeted +bare "Phase 7" are updated in `docs/deferred-items.md` to point at 7a or 7b specifically, each marked resolved-at-design-level. Three +new retrofits to 5a's already-written (still unexecuted) design/plan came out of 7a's brainstorm (`Clock`, RFC +1123 parser, and `RETRY-1`/`CFG-35` retryable-status single-sourcing); two more amendments — to 5a's and 5b's +steps for structured logging, and to 5c's preset for the `LOGGING` slot — came out of 7b's. No executed code +exists yet for any phase, so every change listed here is a document edit, not a retrofit to shipped code. + +**Execution order is no longer the numeric order for Phase 5.** These five retrofits do not merely annotate 5a/5b/5c +— they make Phase 7 a *prerequisite* of Phase 5's execution, in both directions the amendment banners record: +7a's `config/{clock,http-date,retryable}.ts` must exist before 5a's plan runs (its Task 8 consumes `Clock`), and +7b's `observability/{logger,redaction,logging-step}.ts` must exist before 5b's Task 6 and 5c's Task 16 run. The +**Ordering rationale** above ("resilience layer... instrumentation as the outer layers consuming everything +underneath") describes the dependency direction as originally designed; it holds for everything except these +named modules, which invert it. Anyone executing plans in roadmap order must run 7a (and, for 5b/5c, 7b) first, +or execute 5a/5b/5c against the pre-amendment text and accept a duplicate-implementation deviation. Each affected +plan's own **Prerequisite** section states this; this note exists so the roadmap does not read as contradicting +them. + +**Status note (2026-07-28, Phase 8).** Phase 8 was brainstormed solo (user away from keyboard, `docs/knowledge/` +as standing tie-breaker per standing instruction) and split into 8a (Transport Adapters, `§17`) / 8b +(Async-Runtime Bridge, `§18`) — see the [Phase 8 segmentation design](./phase8/2026-07-28-phase8-segmentation-design.md). +Only a segmentation document was produced this session, not full per-sub-phase designs (unlike Phase 7, which got +both in one sitting) — 8a and 8b each still need their own brainstorm → spec → plan cycle. Nine Deferred Items +Log rows that previously targeted bare "Phase 8" or "first concrete Transport" are updated there to point at 8a +or 8b specifically; none is resolved-at-design-level yet, only re-targeted and, where the segmentation review's +own analysis showed it, pre-dispositioned as collapsed/not-applicable (recorded in the segmentation design's §5, +carried forward into 8a's/8b's own row-by-row tables when those designs are written, not re-derived). Two package +column changes: Phase 8's roadmap-table row splits into 8a/8b, and the segmentation design flags a **possible +fourth package** (`FileBody`'s home, e.g. `@dexpace/body-node`) that 8a's own design must confirm or reject +before the roadmap table can be updated further — not decided by this pass. No executed code exists yet for any +phase, so every change here is a document edit. + +**Status note (2026-07-28, Phase 8, continued).** Both sub-phases got full designs and written implementation +plans in a follow-up pass this same day: [8a design](./phase8/phase8a/2026-07-28-phase8a-transport-design.md) / +[8a plan](./phase8/phase8a/2026-07-28-phase8a-transport.md) and [8b design](./phase8/phase8b/2026-07-28-phase8b-async-runtime-design.md) / +[8b plan](./phase8/phase8b/2026-07-28-phase8b-async-runtime.md). Neither plan has been executed — no `packages/` +directory exists in this repository as of this pass. The "possible fourth package" question above is settled: +8a's design confirms `@dexpace/body-file` (a fourth Phase 8a package, `FileBody`'s concrete factory) plus a fifth, +`@dexpace/transport-shared` (header-mapping helpers both transports need identically, found necessary only once +the plan reached implementation-level detail — the segmentation design and 8a's own design doc did not anticipate +this fifth package; it surfaced from "don't duplicate the same algorithm in two sibling packages" rather than +from any `TRANSPORT-N` requirement directly). The roadmap table's 8a row above is updated to list all four +published packages. `challengeHandler`'s protocol and the zero-copy-dispatch question are both resolved (not +merely flagged) in 8a's design — see the updated Deferred Items Log rows in `docs/deferred-items.md`. 8b's design resolved `ASYNC-18` +as inapplicable to the whole port, not merely out of 8b's scope — a correction to the segmentation design's +framing, recorded in 8b's design §3 and not requiring a Deferred Items Log row of its own since nothing was ever +targeted at a phase to begin with. + +**Status note (2026-08-26, Phase 5a EXECUTED).** Phase 5a is implemented and green across the full gate +sequence — the first phase to run out of numeric order, per the execution-order note above. Closed by this +execution: `PIPE-36`, `PIPE-17`'s "readable by any step" MUST (via `StepContext.options`), +`StepContext.signal`, the `FakeTransport` double, `RECOV-32`, and the `RECOV-17`-`RECOV-34` reconciliation — +each row there already anticipated 5a and is now satisfied in code rather than only at design level. Two new +rows were added: the Phase 7b log-event deferral, and the record that 7a's Tasks 1-3 were executed early as +5a's prerequisite. Still deferred out of 5a: `RETRY-29` (not scheduled), `RECOV-33` (7a Task 9), and +public-barrel promotion of the step-authoring surface (5c) — `packages/core/etc/core.api.md` is byte-identical +across the phase, which is that decision's mechanical proof. Per-requirement disposition: +[2026-07-26-phase5a-retry-checklist.md](./phase5/phase5a/2026-07-26-phase5a-retry-checklist.md). + +**Status note (2026-07-28, Phase 9).** Phase 9 was brainstormed solo (user away from keyboard, `docs/knowledge/` +as standing tie-breaker per standing precedent) and got a full design **and** a written implementation plan in +one session: [design](./phase9/2026-07-28-phase9-cross-cutting-conformance-design.md) / +[plan](./phase9/2026-07-28-phase9-cross-cutting-conformance.md). Neither has been executed — no `packages/` +directory exists in this repository as of this pass. Per the roadmap's own framing ("audits what phases 0-8 built +rather than building anything new"), Phase 9's scope is deliberately narrow: a per-ID disposition table for all +24 `XCUT` IDs and all 17 `NFR` IDs (the grep across every prior spec/plan turned up exactly two incidental +`XCUT-N` citations before this pass, confirming this is the first systematic tabulation of that family), one new +package (`@dexpace/shrink-test`, closing `NFR-9`), and one new top-level `tests/conformance/xcut/` integration +suite driving 5c/7b's `standardResilience()` composed pipeline — not a general re-litigation of every open +judgment call that happened to say "Phase 9" in this log. Three consequences of that narrower scope: + +- `NFR-9` closes here (design-level) — see the updated row in `docs/deferred-items.md`. +- One deferred item closes here too: whether `standardResilience()` needs a `tracerFactory`/`meter` pass-through + convenience — resolved no, the composed-pipeline fixture needed no such convenience (see the updated row there). +- Four deferred items that targeted "Phase 9 conformance sweep" turned out to be `AUTH-*`/`REDIR-*` interpretive + judgment calls or preset-shape questions, not `XCUT`/`NFR` conformance checks, and are retargeted there to + Phase 10 (Deviation Reconciliation) — the roadmap's other audit-only phase and the one that already carries + this class of write-up. This retargeting is a document edit only; it does not touch Phase 10's own design or + plan files. + +Also closed as part of this pass: three `unresolved 2026-07-25` markers in `docs/knowledge/tooling-and-quality-gates.md` +(package manager/lockfile, test-runner/coverage-gating, `gts` baseline) that a 2026-07-25 cross-phase checkpoint +had already decided but never back-ported into the corpus itself — directly relevant here since `NFR-5`/`NFR-6`/ +`NFR-7` are exactly the rows those stale markers left unconfirmed. + +**Status note (2026-08-30, Phase 10 EXECUTED — scope corrected).** Phase 10 is executed, and it **shipped code**. +The phase-table row above and this phase's own design (`2026-07-28-phase10-deviation-reconciliation-design.md:15`, +"Phase 10 ships no package") both said the opposite; both are corrected in place rather than overwritten, because +an unrecorded scope change is the exact failure mode this phase spent its audit correcting elsewhere. What +actually landed, on `25-phase-10-deviation-reconciliation`: + +- **A live defect, found by auditing the ledger against source rather than against the specs that produced it.** + `Page`, `FetchTransport` and `UndiciTransport` each declared `[Symbol.asyncDispose]` as a plain computed class + member. The symbol arrived in Node 20.4 and every package declares `engines.node ">=20.3"`, so on the declared + floor the computed key evaluated to `undefined` and the method bound to the string key `"undefined"` — junk on + the prototype, no disposal, and a `.d.ts` promising `AsyncDisposable` regardless (`NFR-10`). All three now + install it through a guarded module-scope `Object.defineProperty`, matching `SseStream` + (`packages/core/src/pagination/page.ts:114`, `packages/transport-fetch/src/fetch-transport.ts:314`, + `packages/transport-undici/src/undici-transport.ts:566`, `packages/core/src/sse/stream.ts:209`). +- **A breaking type change across three packages,** with two changesets: `Page` no longer declares `implements + AsyncDisposable` and the two transport factories no longer return `Transport & AsyncDisposable`, so `await + using` stops type-checking. Pre-1.0, so `minor` per the same initial-development carve-out the earlier `Body` + narrowing used. +- **A new blocking CI step** closing `NFR-12` on evidence — `bun run verify:reproducible-build` + (`scripts/verify-reproducible-build.mjs`), see the `NFR-12` row in `docs/deferred-items.md`. +- **Three further defects, from three subsequent review passes:** a `verify-dual-consumption` assertion that + passed on the floor only *because* of the junk prototype key, a dispatcher leak in `UndiciTransport.close()` + where the first rejecting `destroy()` aborted the reverse walk and stranded the `ProxyAgent` holding the pooled + connections, and a stranded body producer in `send()` from evaluating `prepareBody()` before header mapping. +- **An extended shrink guard** — `packages/shrink-test/` now asserts the disposal installs survive a real esbuild + `bundle + minify + treeShaking` pass, which is the standing evidence for keeping `"sideEffects": false` on the + three packages carrying one. + +**Why the "review only" scope was right to break, and where that judgment is recorded.** The audit's method — +re-derive every ledger claim from as-built source — is what surfaced the defect; a documents-only phase would +have copied the wrong claim forward. Fixing a live correctness defect found *by* the audit is inside the phase's +purpose, and leaving it recorded-but-unfixed would have shipped a `.d.ts` that lies on the declared floor. The +project-wide **convention sweeps** that also named Phase 10 were held to the original scope and re-deferred +instead — see the three rows added to `docs/deferred-items.md` and the dated dispositions on 4b's F2/F7 and +4c's `CONSTANT_CASE` note, now `docs/open-items.md` Sections S and T. Per-item evidence: `docs/deviations.md` (the as-built audit). + +## Open Findings + +**Moved out on 2026-08-31.** The four review sections that used to close this document are now +[`docs/open-items.md`](../../open-items.md): + +| Was | Now | +|---|---| +| `## Open Findings — Phase 3b Validation Review (2026-07-28)` | Section Q | +| `## Open Findings — Phase 3b Execution (2026-08-25, expanded 2026-08-26)` | Section R | +| `## Open Findings — Phase 4b Validation Review (2026-07-28)` | Section S | +| `## Open Findings — Phase 4c Validation Review (2026-07-29)` | Section T | + +They are review findings against phase documents, which is the running register's subject, not the roadmap's. +Each moved verbatim, with a relocation banner naming its origin. **Their row IDs did not change and are not +this register's item IDs:** Sections S and T each number their rows `F1`–`F10` and `F1`–`F9`, the reviews' +own numbering, which collides with Section F's items. A citation has to name the section — "Section S's F2", +never a bare "F2". diff --git a/docs/superpowers/plans/2026-07-25-checkpoint-scaffold-through-phase3a.md b/docs/work/mvp/2026-07-25-checkpoint-scaffold-through-phase3a.md similarity index 98% rename from docs/superpowers/plans/2026-07-25-checkpoint-scaffold-through-phase3a.md rename to docs/work/mvp/2026-07-25-checkpoint-scaffold-through-phase3a.md index 775fa29..32e4926 100644 --- a/docs/superpowers/plans/2026-07-25-checkpoint-scaffold-through-phase3a.md +++ b/docs/work/mvp/2026-07-25-checkpoint-scaffold-through-phase3a.md @@ -2,10 +2,10 @@ **Purpose:** a single gate to run before starting Phase 3b (or Phase 4, if 3b is folded elsewhere) that verifies the four already-planned phases — -[scaffold](./2026-07-23-scaffold-milestone.md), -[Phase 1 (core HTTP domain model)](./2026-07-23-phase1-core-http-domain-model.md), -[Phase 2 (seam foundations)](./2026-07-23-phase2-seam-foundations.md), -[Phase 3a (I/O contracts)](./2026-07-24-phase3a-io-contracts.md) +[scaffold](./scaffold/2026-07-23-scaffold-milestone.md), +[Phase 1 (core HTTP domain model)](./phase1/2026-07-23-phase1-core-http-domain-model.md), +[Phase 2 (seam foundations)](./phase2/2026-07-23-phase2-seam-foundations.md), +[Phase 3a (I/O contracts)](./phase3/phase3a/2026-07-24-phase3a-io-contracts.md) — are not just individually self-reviewed but hold together as one artifact, and that nothing they trade off against `docs/knowledge` (the styleguide + spec + design-doc corpus) went unrecorded. @@ -107,7 +107,7 @@ stop reading any single phase's file. | Bun workspace catalogs as the `NFR-14` mechanism (§5.8) | This checkpoint | Phase 8 — do not add a catalog block while `@dexpace/core` is the only package; there is nothing to deduplicate and it adds indirection with no payoff | | Node-runtime conformance suite `test:node`, matrixed floor + LTS (§5.9) | This checkpoint | Seed now with `composeSignal` + Phase 3a `io/`; **every later phase touching a runtime-divergent surface (Phase 3b bodies, Phase 4 pipelines, Phase 8 transports) must add to it, not just to `bun test`** | -- [ ] Each row's target phase still exists in the current roadmap (spot-check against `docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md` if it's been revised since these plans were written). +- [ ] Each row's target phase still exists in the current roadmap (spot-check against `docs/work/mvp/2026-07-23-nodejs-sdk-v1-roadmap-design.md` if it's been revised since these plans were written). ## 5. Knowledge-base validation findings diff --git a/docs/superpowers/plans/2026-07-23-phase1-core-http-domain-model-checklist.md b/docs/work/mvp/phase1/2026-07-23-phase1-core-http-domain-model-checklist.md similarity index 100% rename from docs/superpowers/plans/2026-07-23-phase1-core-http-domain-model-checklist.md rename to docs/work/mvp/phase1/2026-07-23-phase1-core-http-domain-model-checklist.md diff --git a/docs/superpowers/specs/2026-07-23-phase1-core-http-domain-model-design.md b/docs/work/mvp/phase1/2026-07-23-phase1-core-http-domain-model-design.md similarity index 98% rename from docs/superpowers/specs/2026-07-23-phase1-core-http-domain-model-design.md rename to docs/work/mvp/phase1/2026-07-23-phase1-core-http-domain-model-design.md index a8a86ff..67c63f4 100644 --- a/docs/superpowers/specs/2026-07-23-phase1-core-http-domain-model-design.md +++ b/docs/work/mvp/phase1/2026-07-23-phase1-core-http-domain-model-design.md @@ -5,8 +5,8 @@ **Purpose:** Implement the immutable, transport-agnostic HTTP domain model — Request, Response, Headers, Status, MediaType, Protocol, QueryParams, RequestOptions, and the conditional-request helpers (ETag, HttpRange, RequestConditions) — as the first piece of real domain code in `@dexpace/core`. This is Phase 1 of the -[v1 roadmap](./2026-07-23-nodejs-sdk-v1-roadmap-design.md), building on the toolchain the -[scaffold milestone](./2026-07-23-scaffold-milestone-design.md) established. +[v1 roadmap](../2026-07-23-nodejs-sdk-v1-roadmap-design.md), building on the toolchain the +[scaffold milestone](../scaffold/2026-07-23-scaffold-milestone-design.md) established. **Scope:** Full `product-spec/04-core-http-domain-model.md` (HTTP-3 through HTTP-53, both MUST and SHOULD level) in one phase, including the conditional-request helpers (HTTP-48/49/50) — they're small and self-contained, and diff --git a/docs/superpowers/plans/2026-07-23-phase1-core-http-domain-model.md b/docs/work/mvp/phase1/2026-07-23-phase1-core-http-domain-model.md similarity index 99% rename from docs/superpowers/plans/2026-07-23-phase1-core-http-domain-model.md rename to docs/work/mvp/phase1/2026-07-23-phase1-core-http-domain-model.md index fc58662..da3a98c 100644 --- a/docs/superpowers/plans/2026-07-23-phase1-core-http-domain-model.md +++ b/docs/work/mvp/phase1/2026-07-23-phase1-core-http-domain-model.md @@ -3507,7 +3507,7 @@ test file in one shot. ## Self-Review **Spec coverage** (every `HTTP-N`/`SEAM-N` ID cited in -`docs/superpowers/specs/2026-07-23-phase1-core-http-domain-model-design.md`, mapped to the task that implements +`docs/work/mvp/phase1/2026-07-23-phase1-core-http-domain-model-design.md`, mapped to the task that implements it): HTTP-3/4/5 (construction/immutability/derivation) → every task, pattern established in Task 1 and repeated throughout. HTTP-6/7/8/9 → Task 9 (Request) + Task 2 (Method). HTTP-46/47 → Task 9. HTTP-10/11/12 → Task 3. HTTP-13..22 → Tasks 6–7. HTTP-23..27, HTTP-53 → Task 5. HTTP-28..32 → Task 8. HTTP-33 → Task 4. HTTP-34/35 → Task diff --git a/docs/superpowers/specs/2026-07-28-phase10-deviation-reconciliation-design.md b/docs/work/mvp/phase10/2026-07-28-phase10-deviation-reconciliation-design.md similarity index 99% rename from docs/superpowers/specs/2026-07-28-phase10-deviation-reconciliation-design.md rename to docs/work/mvp/phase10/2026-07-28-phase10-deviation-reconciliation-design.md index 99d6b47..0d1e626 100644 --- a/docs/superpowers/specs/2026-07-28-phase10-deviation-reconciliation-design.md +++ b/docs/work/mvp/phase10/2026-07-28-phase10-deviation-reconciliation-design.md @@ -4,7 +4,7 @@ Judgment calls made without a live back-and-forth are called out explicitly in their own section below rather than folded silently into the ledger; flag any of them for revision on review. -**Purpose:** Phase 10 is the last-but-one phase in the [v1 roadmap](./2026-07-23-nodejs-sdk-v1-roadmap-design.md). +**Purpose:** Phase 10 is the last-but-one phase in the [v1 roadmap](../2026-07-23-nodejs-sdk-v1-roadmap-design.md). It audits every deliberate deviation from the JVM reference contract that Phases 0–8 introduced while building `@dexpace/core` and its satellite packages, reconciles them against the pre-implementation prediction in `docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md` (§10), and produces the @@ -178,7 +178,7 @@ with 5a's attempt-stamping producing fresh `Request` copies; 5c's marker also su hook on a marked hop, not just the outbound stamp (a bug the 5c design caught before shipping). Two items were originally left open pending Phase 9's conformance-test execution against reference fixtures. **That premise is now void**: Phase 9 was brainstormed and planned (2026-07-28) after this design's first draft, and its actual -scope is `XCUT`/`NFR` conformance only (`docs/superpowers/specs/2026-07-28-phase9-cross-cutting-conformance-design.md`) +scope is `XCUT`/`NFR` conformance only (`docs/work/mvp/phase9/2026-07-28-phase9-cross-cutting-conformance-design.md`) — it does not re-audit `AUTH-*`/`REDIR-*` interpretive calls, and no phase's code exists yet for a fixture-based sweep to run against regardless. The roadmap's own Deferred Items Log was updated to retarget both rows here; Phase 10 decides them rather than deferring a second time: diff --git a/docs/superpowers/plans/2026-07-28-phase10-deviation-reconciliation.md b/docs/work/mvp/phase10/2026-07-28-phase10-deviation-reconciliation.md similarity index 94% rename from docs/superpowers/plans/2026-07-28-phase10-deviation-reconciliation.md rename to docs/work/mvp/phase10/2026-07-28-phase10-deviation-reconciliation.md index fb8d55b..226c11d 100644 --- a/docs/superpowers/plans/2026-07-28-phase10-deviation-reconciliation.md +++ b/docs/work/mvp/phase10/2026-07-28-phase10-deviation-reconciliation.md @@ -4,7 +4,7 @@ **Goal:** Rewrite `docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md` in place with the as-built, reconciled deviation ledger from Phases 0-8, per -`docs/superpowers/specs/2026-07-28-phase10-deviation-reconciliation-design.md`, and update the roadmap's Deferred +`docs/work/mvp/phase10/2026-07-28-phase10-deviation-reconciliation-design.md`, and update the roadmap's Deferred Items Log rows that name Phase 10 by name. **Architecture:** No code, no package. Two document edits: (1) a full-content replacement of §10's twelve @@ -42,7 +42,7 @@ not modify, every Phase 2 and 3a-8b spec and the 5c/6a/6b/6c plans' Deviation Le ``` docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md # full rewrite (Task 1, 2) -docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md # Deferred Items Log # (Task 3) +docs/work/mvp/2026-07-23-nodejs-sdk-v1-roadmap-design.md # Deferred Items Log # (Task 3) rows + status note ``` @@ -76,7 +76,7 @@ This section is the as-built reconciliation of every place the port's Node-idiom pre-implementation prediction now that Phases 0-8 have each shipped a design and plan. None of these narrow a MUST-level correctness guarantee; each is a case where the JVM-specific mechanism a requirement was worded around does not exist in Node, and an equivalent, differently-shaped mechanism is substituted instead. Reconciled by -Phase 10 (`docs/superpowers/specs/2026-07-28-phase10-deviation-reconciliation-design.md`), 2026-07-28. +Phase 10 (`docs/work/mvp/phase10/2026-07-28-phase10-deviation-reconciliation-design.md`), 2026-07-28. 1. **Single execution model eliminates every thread/CAS/interrupt-flag primitive, and collapses the sync/async transport seam into one.** **SEAM-11** describes a synchronous, blocking transport contract as distinct from @@ -172,7 +172,7 @@ Phase 10 (`docs/superpowers/specs/2026-07-28-phase10-deviation-reconciliation-de challenge-reaction hook is suppressed on a marked hop too, not only the outbound stamp — a leak the Phase 5c design caught before shipping (Phase 5c). Two items from this area were originally left open pending Phase 9's conformance sweep against real fixtures; Phase 9's actual design scoped itself to `XCUT`/`NFR` - conformance only (`docs/superpowers/specs/2026-07-28-phase9-cross-cutting-conformance-design.md`) and will + conformance only (`docs/work/mvp/phase9/2026-07-28-phase9-cross-cutting-conformance-design.md`) and will never produce that evidence, so Phase 10 decides both directly instead of leaving them open indefinitely: - **Redirect predicate scope over safety mechanics — confirmed, 5b's reading is correct.** `REDIR-20`'s "fully override the built-in decision" scopes to the follow/no-follow determination the predicate is @@ -282,7 +282,7 @@ tokens — visually confirm that range string is present with a second check: `g ### Task 3: Update the roadmap's Deferred Items Log **Files:** -- Modify: `docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md` (Deferred Items Log table, currently +- Modify: `docs/work/mvp/2026-07-23-nodejs-sdk-v1-roadmap-design.md` (Deferred Items Log table, currently the table starting after the `## Deferred Items Log` heading) **Interfaces:** @@ -292,7 +292,7 @@ tokens — visually confirm that range string is present with a second check: `g the deferral inline. - [ ] **Step 1: Read the current table** from - `docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md` (the `## Deferred Items Log` section) to + `docs/work/mvp/2026-07-23-nodejs-sdk-v1-roadmap-design.md` (the `## Deferred Items Log` section) to get exact current row text before editing — table rows shift line numbers as earlier edits land, so match by row content (`| NFR-8 |`, `| NFR-12 |`, etc.), not by line number. @@ -386,7 +386,7 @@ installs it explicitly, already possible via the public authoring surface. - [ ] **Step 12: Verify the table still parses as Markdown** — every row (old and new) has the same number of `|`-delimited columns as the table's header row. -Run: `awk -F'|' '/^\|.*(NFR-8|NFR-12|NFR-16|SEAM-5|HTTP-18|DigestChallengeUnsupportedError|Basic\/Digest never stamp|Redirect predicate|Whether `clientIdentityStep`)/{print NF}' docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md` +Run: `awk -F'|' '/^\|.*(NFR-8|NFR-12|NFR-16|SEAM-5|HTTP-18|DigestChallengeUnsupportedError|Basic\/Digest never stamp|Redirect predicate|Whether `clientIdentityStep`)/{print NF}' docs/work/mvp/2026-07-23-nodejs-sdk-v1-roadmap-design.md` Expected: every printed number identical to the header row's own column count (check the header row's count first with the same `awk -F'|'` pattern against `| Item | Originated in |`). **Use `^\|.*(...)`, not `^\| \`(...)`** — three of these nine rows (`Basic/Digest never stamp preemptively`, `Redirect predicate's scope...`, `Whether \`clientIdentityStep\`...`) open with plain prose, not a backtick-quoted term, so an anchor requiring a backtick immediately after `| ` @@ -408,7 +408,7 @@ silently skips them and under-verifies. - [ ] **Step 1: Re-open every one of these 17 files and confirm every *row* of each one's ledger table has a corresponding sentence in the rewritten §10** (this list is exhaustive — every phase from 2 through 9 that has a Deviation Ledger section; regenerate it with - `grep -rln '^## Deviation Ledger (for Phase 10)' docs/superpowers/specs/` rather than trusting this transcription): + `grep -rln '^## Deviation Ledger (for Phase 10)' docs/work/mvp/` rather than trusting this transcription): Check rows, not phases. A phase-label grep is not sufficient evidence here: several phases are cited by many items, so `Phase 2` (or `Phase 5a`, or `Phase 8a`) appearing in §10 proves only that *something* from that @@ -417,23 +417,23 @@ silently skips them and under-verifies. validation. Walk each table row by row. ``` -2 docs/superpowers/specs/2026-07-23-phase2-seam-foundations-design.md -3a docs/superpowers/specs/2026-07-24-phase3a-io-contracts-design.md -3b docs/superpowers/specs/2026-07-25-phase3b-body-lifecycle-design.md -4a docs/superpowers/specs/2026-07-25-phase4a-execution-context-design.md -4b docs/superpowers/specs/2026-07-25-phase4b-recovery-chain-design.md -4c docs/superpowers/specs/2026-07-25-phase4c-stage-pipeline-design.md -5a docs/superpowers/specs/2026-07-26-phase5a-retry-design.md -5b docs/superpowers/specs/2026-07-26-phase5b-redirect-design.md -5c docs/superpowers/specs/2026-07-26-phase5c-auth-design.md + docs/superpowers/plans/2026-07-26-phase5c-auth.md -6a docs/superpowers/specs/2026-07-28-phase6a-serde-design.md + docs/superpowers/plans/2026-07-28-phase6a-serde.md -6b docs/superpowers/specs/2026-07-28-phase6b-sse-design.md + docs/superpowers/plans/2026-07-28-phase6b-sse.md -6c docs/superpowers/specs/2026-07-28-phase6c-pagination-design.md + docs/superpowers/plans/2026-07-28-phase6c-pagination.md -7a docs/superpowers/specs/2026-07-28-phase7a-configuration-design.md -7b docs/superpowers/specs/2026-07-28-phase7b-observability-design.md -8a docs/superpowers/specs/2026-07-28-phase8a-transport-design.md -8b docs/superpowers/specs/2026-07-28-phase8b-async-runtime-design.md -9 docs/superpowers/specs/2026-07-28-phase9-cross-cutting-conformance-design.md +2 docs/work/mvp/phase2/2026-07-23-phase2-seam-foundations-design.md +3a docs/work/mvp/phase3/phase3a/2026-07-24-phase3a-io-contracts-design.md +3b docs/work/mvp/phase3/phase3b/2026-07-25-phase3b-body-lifecycle-design.md +4a docs/work/mvp/phase4/phase4a/2026-07-25-phase4a-execution-context-design.md +4b docs/work/mvp/phase4/phase4b/2026-07-25-phase4b-recovery-chain-design.md +4c docs/work/mvp/phase4/phase4c/2026-07-25-phase4c-stage-pipeline-design.md +5a docs/work/mvp/phase5/phase5a/2026-07-26-phase5a-retry-design.md +5b docs/work/mvp/phase5/phase5b/2026-07-26-phase5b-redirect-design.md +5c docs/work/mvp/phase5/phase5c/2026-07-26-phase5c-auth-design.md + docs/work/mvp/phase5/phase5c/2026-07-26-phase5c-auth.md +6a docs/work/mvp/phase6/phase6a/2026-07-28-phase6a-serde-design.md + docs/work/mvp/phase6/phase6a/2026-07-28-phase6a-serde.md +6b docs/work/mvp/phase6/phase6b/2026-07-28-phase6b-sse-design.md + docs/work/mvp/phase6/phase6b/2026-07-28-phase6b-sse.md +6c docs/work/mvp/phase6/phase6c/2026-07-28-phase6c-pagination-design.md + docs/work/mvp/phase6/phase6c/2026-07-28-phase6c-pagination.md +7a docs/work/mvp/phase7/phase7a/2026-07-28-phase7a-configuration-design.md +7b docs/work/mvp/phase7/phase7b/2026-07-28-phase7b-observability-design.md +8a docs/work/mvp/phase8/phase8a/2026-07-28-phase8a-transport-design.md +8b docs/work/mvp/phase8/phase8b/2026-07-28-phase8b-async-runtime-design.md +9 docs/work/mvp/phase9/2026-07-28-phase9-cross-cutting-conformance-design.md ``` Expected, primary: every ledger table row across those 16 files is either represented by a sentence in the diff --git a/docs/superpowers/plans/2026-07-23-phase2-seam-foundations-checklist.md b/docs/work/mvp/phase2/2026-07-23-phase2-seam-foundations-checklist.md similarity index 97% rename from docs/superpowers/plans/2026-07-23-phase2-seam-foundations-checklist.md rename to docs/work/mvp/phase2/2026-07-23-phase2-seam-foundations-checklist.md index b0ab1f1..fde7ada 100644 --- a/docs/superpowers/plans/2026-07-23-phase2-seam-foundations-checklist.md +++ b/docs/work/mvp/phase2/2026-07-23-phase2-seam-foundations-checklist.md @@ -1,7 +1,7 @@ # Phase 2 — Seam Foundations Implementation Plan — Checklist Verification of [2026-07-23-phase2-seam-foundations.md](./2026-07-23-phase2-seam-foundations.md) against every -requirement ID cited in `docs/superpowers/specs/2026-07-23-phase2-seam-foundations-design.md`'s disposition table +requirement ID cited in `docs/work/mvp/phase2/2026-07-23-phase2-seam-foundations-design.md`'s disposition table (`docs/product-spec/03-pluggable-seams-and-extension-model.md`), plus the HTTP-29 retrofit (`docs/product-spec/04-core-http-domain-model.md`) and the NFR-10/NFR-17 residual pulled forward from Phase 3. diff --git a/docs/superpowers/specs/2026-07-23-phase2-seam-foundations-design.md b/docs/work/mvp/phase2/2026-07-23-phase2-seam-foundations-design.md similarity index 99% rename from docs/superpowers/specs/2026-07-23-phase2-seam-foundations-design.md rename to docs/work/mvp/phase2/2026-07-23-phase2-seam-foundations-design.md index 37dcbd1..87ed88d 100644 --- a/docs/superpowers/specs/2026-07-23-phase2-seam-foundations-design.md +++ b/docs/work/mvp/phase2/2026-07-23-phase2-seam-foundations-design.md @@ -4,7 +4,7 @@ **Purpose:** Build the seam *contracts* — `Transport`, `Serde<T>`, and the operation-input projection (`buildRequest()`) — that later phases' pipelines, resilience layer, and concrete adapters build on. This is -Phase 2 of the [v1 roadmap](./2026-07-23-nodejs-sdk-v1-roadmap-design.md), building on Phase 1's domain model. +Phase 2 of the [v1 roadmap](../2026-07-23-nodejs-sdk-v1-roadmap-design.md), building on Phase 1's domain model. **Scope is narrower than the JVM reference's "seams" concept.** Per `sdk-design-nodejs/03`, Node collapses most of what the JVM reference needs multiple seams and a discovery mechanism for: there is one `Transport` shape (not a diff --git a/docs/superpowers/plans/2026-07-23-phase2-seam-foundations.md b/docs/work/mvp/phase2/2026-07-23-phase2-seam-foundations.md similarity index 99% rename from docs/superpowers/plans/2026-07-23-phase2-seam-foundations.md rename to docs/work/mvp/phase2/2026-07-23-phase2-seam-foundations.md index 35d46c5..beffdff 100644 --- a/docs/superpowers/plans/2026-07-23-phase2-seam-foundations.md +++ b/docs/work/mvp/phase2/2026-07-23-phase2-seam-foundations.md @@ -4,7 +4,7 @@ **Goal:** Ship the seam *contracts* in `@dexpace/core` — `Transport`, `Serde<T>`, and `buildRequest()` / `OperationDescriptor` — that later phases' pipelines, resilience layer, and concrete adapters build on, per -`docs/superpowers/specs/2026-07-23-phase2-seam-foundations-design.md`. +`docs/work/mvp/phase2/2026-07-23-phase2-seam-foundations-design.md`. **Architecture:** Three new interfaces/functions in a new `packages/core/src/seams/` folder, plus two small retrofits to Phase 1's `src/http/` folder (`encodeRfc3986Component` extraction, a new `DexpaceError` taxonomy @@ -1145,7 +1145,7 @@ git commit -m "feat(core): wire Phase 2 public barrel, Node-floor CI conformance ## Self-Review -**Spec coverage** (every requirement ID in `docs/superpowers/specs/2026-07-23-phase2-seam-foundations-design.md`'s +**Spec coverage** (every requirement ID in `docs/work/mvp/phase2/2026-07-23-phase2-seam-foundations-design.md`'s disposition table, mapped to the task implementing it): - SEAM-11/SEAM-16 (collapsed) → Task 4, `Transport.send(): Promise<Response>` structurally covers the diff --git a/docs/superpowers/plans/2026-07-24-phase3a-io-contracts-checklist.md b/docs/work/mvp/phase3/phase3a/2026-07-24-phase3a-io-contracts-checklist.md similarity index 99% rename from docs/superpowers/plans/2026-07-24-phase3a-io-contracts-checklist.md rename to docs/work/mvp/phase3/phase3a/2026-07-24-phase3a-io-contracts-checklist.md index abc02e1..4c96e01 100644 --- a/docs/superpowers/plans/2026-07-24-phase3a-io-contracts-checklist.md +++ b/docs/work/mvp/phase3/phase3a/2026-07-24-phase3a-io-contracts-checklist.md @@ -2,7 +2,7 @@ Verification of [2026-07-24-phase3a-io-contracts.md](./2026-07-24-phase3a-io-contracts.md) against every requirement ID in `docs/product-spec/05-i-o-contracts.md`, as dispositioned by -`docs/superpowers/specs/2026-07-24-phase3a-io-contracts-design.md`. +`docs/work/mvp/phase3/phase3a/2026-07-24-phase3a-io-contracts-design.md`. **Legend:** ✅ Implemented and tested — 🚫 Not built (permanent simplification, named reason) — ⏳ Deferred (named target phase) — N/A Not applicable in this port. diff --git a/docs/superpowers/specs/2026-07-24-phase3a-io-contracts-design.md b/docs/work/mvp/phase3/phase3a/2026-07-24-phase3a-io-contracts-design.md similarity index 99% rename from docs/superpowers/specs/2026-07-24-phase3a-io-contracts-design.md rename to docs/work/mvp/phase3/phase3a/2026-07-24-phase3a-io-contracts-design.md index 79906b3..f21a9d5 100644 --- a/docs/superpowers/specs/2026-07-24-phase3a-io-contracts-design.md +++ b/docs/work/mvp/phase3/phase3a/2026-07-24-phase3a-io-contracts-design.md @@ -5,7 +5,7 @@ **Purpose:** Implement the byte-streaming primitives — `ByteQueue`, `BufferedSource`/`BufferedSink`, the non-consuming peek/slice views, `TeeSink`, the pump, and the provider factories — that Phase 3b's bodies, Phase 6's SSE and serde, and Phase 8's transports all read and write through. This is the first half of Phase 3 of the -[v1 roadmap](./2026-07-23-nodejs-sdk-v1-roadmap-design.md), building on Phase 2's seam contracts. +[v1 roadmap](../../2026-07-23-nodejs-sdk-v1-roadmap-design.md), building on Phase 2's seam contracts. **Scope:** every requirement in `docs/product-spec/05-i-o-contracts.md` — `IO-1` through `IO-42`, both MUST and SHOULD level — is dispositioned here. Most are implemented; three groups are deliberately not built and one is not @@ -14,7 +14,7 @@ full." **Governing documents:** `docs/product-spec/05-i-o-contracts.md` (normative, cited by ID throughout), `docs/sdk-design-nodejs/03-seam-by-seam-idiomatic-mapping.md` §3.1 (the Web Streams mapping this design follows), -and `docs/superpowers/specs/2026-07-23-phase2-seam-foundations-design.md` (the `DexpaceError` root and the +and `docs/work/mvp/phase2/2026-07-23-phase2-seam-foundations-design.md` (the `DexpaceError` root and the barrel-as-enforcement-point precedent this phase reuses). Styleguide: `styleguide/typescript/` chapters 05, 06, 08, 09, 10, 11, 12, 13, 15. diff --git a/docs/superpowers/plans/2026-07-24-phase3a-io-contracts.md b/docs/work/mvp/phase3/phase3a/2026-07-24-phase3a-io-contracts.md similarity index 99% rename from docs/superpowers/plans/2026-07-24-phase3a-io-contracts.md rename to docs/work/mvp/phase3/phase3a/2026-07-24-phase3a-io-contracts.md index 827eec6..038bcf5 100644 --- a/docs/superpowers/plans/2026-07-24-phase3a-io-contracts.md +++ b/docs/work/mvp/phase3/phase3a/2026-07-24-phase3a-io-contracts.md @@ -5,7 +5,7 @@ **Goal:** Ship the byte-streaming primitives in `@dexpace/core` — `ByteQueue`, `RetentionWindow`, `BufferedSource` (with peek/slice views), `BufferedSink`, `TeeSink`, `writeAll`, and the `IO-30` factories — satisfying `product-spec/05-i-o-contracts.md` (`IO-1`–`IO-42`), per -`docs/superpowers/specs/2026-07-24-phase3a-io-contracts-design.md`. +`docs/work/mvp/phase3/phase3a/2026-07-24-phase3a-io-contracts-design.md`. **Architecture:** A new `packages/core/src/io/` folder, layered strictly one way: `limits`/`errors` → `byte-queue` → `retention-window` → `buffered-source`/`buffered-sink` → `tee-sink`/`pump` → diff --git a/docs/superpowers/plans/2026-07-25-phase3b-body-lifecycle-checklist.md b/docs/work/mvp/phase3/phase3b/2026-07-25-phase3b-body-lifecycle-checklist.md similarity index 99% rename from docs/superpowers/plans/2026-07-25-phase3b-body-lifecycle-checklist.md rename to docs/work/mvp/phase3/phase3b/2026-07-25-phase3b-body-lifecycle-checklist.md index ad0eea9..433bf46 100644 --- a/docs/superpowers/plans/2026-07-25-phase3b-body-lifecycle-checklist.md +++ b/docs/work/mvp/phase3/phase3b/2026-07-25-phase3b-body-lifecycle-checklist.md @@ -2,7 +2,7 @@ Verification of [2026-07-25-phase3b-body-lifecycle.md](./2026-07-25-phase3b-body-lifecycle.md) against every requirement ID in `docs/product-spec/06-request-and-response-body-lifecycle.md`, as dispositioned by -`docs/superpowers/specs/2026-07-25-phase3b-body-lifecycle-design.md`. +`docs/work/mvp/phase3/phase3b/2026-07-25-phase3b-body-lifecycle-design.md`. **Legend:** ✅ Implemented and tested — 📄 Contract-obligation-only (this phase guarantees the property; a later phase consults it) — ⏳ Deferred (named target phase) — 🚫 Not built (permanent simplification, named reason). diff --git a/docs/superpowers/specs/2026-07-25-phase3b-body-lifecycle-design.md b/docs/work/mvp/phase3/phase3b/2026-07-25-phase3b-body-lifecycle-design.md similarity index 99% rename from docs/superpowers/specs/2026-07-25-phase3b-body-lifecycle-design.md rename to docs/work/mvp/phase3/phase3b/2026-07-25-phase3b-body-lifecycle-design.md index c0e6bb4..b464859 100644 --- a/docs/superpowers/specs/2026-07-25-phase3b-body-lifecycle-design.md +++ b/docs/work/mvp/phase3/phase3b/2026-07-25-phase3b-body-lifecycle-design.md @@ -7,16 +7,16 @@ planning branch), both open in the roadmap's "Open Findings — Phase 3b Validat **Purpose:** Implement the request/response body lifecycle — body production and replayability, materialize-once, response single-use/close, the lazy parsed-response wrapper, request/response body-logging tees, and bounded error-body buffering — on top of a tested and frozen Phase 3a. This is the second half of Phase 3 of the -[v1 roadmap](./2026-07-23-nodejs-sdk-v1-roadmap-design.md). +[v1 roadmap](../../2026-07-23-nodejs-sdk-v1-roadmap-design.md). **Scope:** every requirement in `docs/product-spec/06-request-and-response-body-lifecycle.md` — `BODY-1` through `BODY-37`, `HTTP-36` through `HTTP-52` — is dispositioned here, except the file-backed-body cluster (`HTTP-40`/`BODY-11`/`BODY-12`/`BODY-13`/`BODY-36`), deferred to Phase 8 (see "Explicitly Out of Scope"). **Governing documents:** `docs/product-spec/06-request-and-response-body-lifecycle.md` (normative, cited by ID -throughout), `docs/sdk-design-nodejs/03-seam-by-seam-idiomatic-mapping.md` §3.1, `docs/superpowers/specs/2026-07-24-phase3a-io-contracts-design.md` -(the frozen surface this phase builds on and, in one place, retrofits), `docs/superpowers/plans/2026-07-25-checkpoint-scaffold-through-phase3a.md` -(the two-level error-hierarchy rule this phase's error tree follows), and `docs/superpowers/specs/2026-07-23-phase1-core-http-domain-model-design.md` +throughout), `docs/sdk-design-nodejs/03-seam-by-seam-idiomatic-mapping.md` §3.1, `docs/work/mvp/phase3/phase3a/2026-07-24-phase3a-io-contracts-design.md` +(the frozen surface this phase builds on and, in one place, retrofits), `docs/work/mvp/2026-07-25-checkpoint-scaffold-through-phase3a.md` +(the two-level error-hierarchy rule this phase's error tree follows), and `docs/work/mvp/phase1/2026-07-23-phase1-core-http-domain-model-design.md` (the `Request`/`Response` classes whose `unknown` body placeholder this phase replaces). Styleguide: `styleguide/typescript/` chapters 05, 06, 08, 09, 10, 11, 12, 13, 15. diff --git a/docs/superpowers/plans/2026-07-25-phase3b-body-lifecycle.md b/docs/work/mvp/phase3/phase3b/2026-07-25-phase3b-body-lifecycle.md similarity index 99% rename from docs/superpowers/plans/2026-07-25-phase3b-body-lifecycle.md rename to docs/work/mvp/phase3/phase3b/2026-07-25-phase3b-body-lifecycle.md index f3f82a6..aa73152 100644 --- a/docs/superpowers/plans/2026-07-25-phase3b-body-lifecycle.md +++ b/docs/work/mvp/phase3/phase3b/2026-07-25-phase3b-body-lifecycle.md @@ -3,7 +3,7 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > **⚠ Two open items before this plan is fully executable** — see "Open Findings — Phase 3b Validation Review -> (2026-07-28)" in [the roadmap](../specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md). +> (2026-07-28)" in [the roadmap](../../2026-07-23-nodejs-sdk-v1-roadmap-design.md). > > - **D1 (Task 13 Step 6):** the changeset bump is **major** by default, because narrowing `RequestBuilder.body` > from `unknown` to `Body | undefined` is a breaking parameter change. If the repo's release policy treats 0.x @@ -17,7 +17,7 @@ `Request`/`Response`'s real body types, `TypedResponse<T>`, request/response body-logging tees, and bounded error-body buffering — satisfying `product-spec/06-request-and-response-body-lifecycle.md` (`BODY-1`–`BODY-37`, `HTTP-36`–`HTTP-52`, minus the file-backed-body cluster deferred to Phase 8), per -`docs/superpowers/specs/2026-07-25-phase3b-body-lifecycle-design.md`. +`docs/work/mvp/phase3/phase3b/2026-07-25-phase3b-body-lifecycle-design.md`. **Architecture:** A new `packages/core/src/body/` folder plus surgical retrofits to two already-shipped files: `packages/core/src/io/errors.ts` (flattening a leftover 3-tier error shape) and `packages/core/src/http/request.ts` @@ -32,7 +32,7 @@ already relied on elsewhere per `sdk-design-nodejs/10`'s SHA-256-via-`crypto.sub new runtime dependencies — `SEAM-1` untouched. **Prerequisite:** This plan assumes Phases 0, 1, 2, and 3a are already implemented exactly as their own plans -specify, **and** the checkpoint (`docs/superpowers/plans/2026-07-25-checkpoint-scaffold-through-phase3a.md`) has +specify, **and** the checkpoint (`docs/work/mvp/2026-07-25-checkpoint-scaffold-through-phase3a.md`) has signed off — concretely: `packages/core/src/http/*`, `seams/*`, `io/*` exist; `DexpaceError` is the flat taxonomy root with `DomainModelError` already removed as a class tier (checkpoint §5.2); `engines.node` and `tsconfig.base.json` `lib` are already bumped for `Symbol.dispose`/`Symbol.asyncDispose` (checkpoint §5.4), and `Transport` plus every diff --git a/docs/superpowers/plans/2026-07-26-phase4-execution-context-and-pipelines-checklist.md b/docs/work/mvp/phase4/2026-07-26-phase4-execution-context-and-pipelines-checklist.md similarity index 99% rename from docs/superpowers/plans/2026-07-26-phase4-execution-context-and-pipelines-checklist.md rename to docs/work/mvp/phase4/2026-07-26-phase4-execution-context-and-pipelines-checklist.md index 12524ea..f0382a5 100644 --- a/docs/superpowers/plans/2026-07-26-phase4-execution-context-and-pipelines-checklist.md +++ b/docs/work/mvp/phase4/2026-07-26-phase4-execution-context-and-pipelines-checklist.md @@ -1,9 +1,9 @@ # Phase 4 (4a + 4b + 4c) — Execution Context & Pipelines — Checklist Verification of the three Phase 4 implementation plans — -[4a Execution Context](./2026-07-25-phase4a-execution-context.md), -[4b Recovery-Chain Primitives](./2026-07-25-phase4b-recovery-chain.md), -[4c Stage-Based Pipeline](./2026-07-25-phase4c-stage-pipeline.md) — against every requirement ID in +[4a Execution Context](./phase4a/2026-07-25-phase4a-execution-context.md), +[4b Recovery-Chain Primitives](./phase4b/2026-07-25-phase4b-recovery-chain.md), +[4c Stage-Based Pipeline](./phase4c/2026-07-25-phase4c-stage-pipeline.md) — against every requirement ID in `docs/product-spec/07-execution-context-model.md` (`CTX-*`) and `docs/product-spec/08-execution-pipelines.md` (`PIPE-*`, `RECOV-*`), as dispositioned by their design docs. diff --git a/docs/superpowers/specs/2026-07-25-phase4a-execution-context-design.md b/docs/work/mvp/phase4/phase4a/2026-07-25-phase4a-execution-context-design.md similarity index 100% rename from docs/superpowers/specs/2026-07-25-phase4a-execution-context-design.md rename to docs/work/mvp/phase4/phase4a/2026-07-25-phase4a-execution-context-design.md diff --git a/docs/superpowers/plans/2026-07-25-phase4a-execution-context.md b/docs/work/mvp/phase4/phase4a/2026-07-25-phase4a-execution-context.md similarity index 99% rename from docs/superpowers/plans/2026-07-25-phase4a-execution-context.md rename to docs/work/mvp/phase4/phase4a/2026-07-25-phase4a-execution-context.md index 7b35814..84ff6cd 100644 --- a/docs/superpowers/plans/2026-07-25-phase4a-execution-context.md +++ b/docs/work/mvp/phase4/phase4a/2026-07-25-phase4a-execution-context.md @@ -5,7 +5,7 @@ **Goal:** Ship the execution context promotion chain and its bounded process-wide store in `@dexpace/core` — `DispatchContext`/`RequestContext`/`ExchangeContext`, the `InstrumentationBundle` shape, and `ContextStore` — satisfying `product-spec/07-execution-context-model.md` (`CTX-1`–`CTX-20`), per -`docs/superpowers/specs/2026-07-25-phase4a-execution-context-design.md`. +`docs/work/mvp/phase4/phase4a/2026-07-25-phase4a-execution-context-design.md`. **Architecture:** A new `packages/core/src/context/` folder, layered `instrumentation` → `errors` → `context` → `store` (there is deliberately **no `index.ts`** — see Global Constraints). The three context flavors are plain frozen interfaces plus free functions — no class, since diff --git a/docs/superpowers/specs/2026-07-25-phase4b-recovery-chain-design.md b/docs/work/mvp/phase4/phase4b/2026-07-25-phase4b-recovery-chain-design.md similarity index 100% rename from docs/superpowers/specs/2026-07-25-phase4b-recovery-chain-design.md rename to docs/work/mvp/phase4/phase4b/2026-07-25-phase4b-recovery-chain-design.md diff --git a/docs/superpowers/plans/2026-07-25-phase4b-recovery-chain.md b/docs/work/mvp/phase4/phase4b/2026-07-25-phase4b-recovery-chain.md similarity index 99% rename from docs/superpowers/plans/2026-07-25-phase4b-recovery-chain.md rename to docs/work/mvp/phase4/phase4b/2026-07-25-phase4b-recovery-chain.md index cc64d7c..1853569 100644 --- a/docs/superpowers/plans/2026-07-25-phase4b-recovery-chain.md +++ b/docs/work/mvp/phase4/phase4b/2026-07-25-phase4b-recovery-chain.md @@ -5,7 +5,7 @@ **Goal:** Ship `Outcome<T>`, the request/response recovery chains, the unified dispatch orchestrator, the cancellation-wrapping helper, and the status→typed-exception mapping step in `@dexpace/core`, satisfying `product-spec/08-execution-pipelines.md` §8.2 (`RECOV-1`–`RECOV-16`), per -`docs/superpowers/specs/2026-07-25-phase4b-recovery-chain-design.md`. +`docs/work/mvp/phase4/phase4b/2026-07-25-phase4b-recovery-chain-design.md`. **Architecture:** A new `packages/core/src/recovery/` folder, six independent files with no folder-level barrel (`docs/knowledge/module-organization.md`'s "never create internal barrels" applies — a future consumer imports diff --git a/docs/superpowers/specs/2026-07-25-phase4c-stage-pipeline-design.md b/docs/work/mvp/phase4/phase4c/2026-07-25-phase4c-stage-pipeline-design.md similarity index 99% rename from docs/superpowers/specs/2026-07-25-phase4c-stage-pipeline-design.md rename to docs/work/mvp/phase4/phase4c/2026-07-25-phase4c-stage-pipeline-design.md index c2e1325..e4b7df7 100644 --- a/docs/superpowers/specs/2026-07-25-phase4c-stage-pipeline-design.md +++ b/docs/work/mvp/phase4/phase4c/2026-07-25-phase4c-stage-pipeline-design.md @@ -3,7 +3,7 @@ **Status:** Draft, approved for planning. **One open finding (2026-07-29 validation review, F9):** whether `Cursor` should observe the caller's `AbortSignal` between steps, and as which error type — tracked in the roadmap's "Open Findings — Phase 4c Validation Review (2026-07-29)" section -(`docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md`). Decide before Phase 5a Task 1 lands +(`docs/work/mvp/2026-07-23-nodejs-sdk-v1-roadmap-design.md`). Decide before Phase 5a Task 1 lands `StepContext.signal`. Everything else from that review is applied. **Purpose:** Implement the stage-based pipeline — the fixed-stage step composition runtime, its builder with diff --git a/docs/superpowers/plans/2026-07-25-phase4c-stage-pipeline.md b/docs/work/mvp/phase4/phase4c/2026-07-25-phase4c-stage-pipeline.md similarity index 99% rename from docs/superpowers/plans/2026-07-25-phase4c-stage-pipeline.md rename to docs/work/mvp/phase4/phase4c/2026-07-25-phase4c-stage-pipeline.md index 088474c..bf1b11a 100644 --- a/docs/superpowers/plans/2026-07-25-phase4c-stage-pipeline.md +++ b/docs/work/mvp/phase4/phase4c/2026-07-25-phase4c-stage-pipeline.md @@ -6,13 +6,13 @@ > terminal transport but never checks it between steps, against `docs/knowledge/concurrency-and-async.md:46`. > The fix is not mechanical — a raw `throwIfAborted()` raises a `DOMException` the SDK taxonomy does not own — > so it is recorded, undecided, in the roadmap's "Open Findings — Phase 4c Validation Review (2026-07-29)" -> section (`docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md`). Build this plan as written; do +> section (`docs/work/mvp/2026-07-23-nodejs-sdk-v1-roadmap-design.md`). Build this plan as written; do > not improvise a cancellation check. Every other finding from that review is already applied below. **Goal:** Ship the stage-based pipeline in `@dexpace/core` — the fixed-stage step composition runtime, its builder with surgical edit operations, the per-call cursor/fork mechanism, and the execution-context-store wiring — satisfying `product-spec/08-execution-pipelines.md` §8.1 (`PIPE-1`–`PIPE-40`), per -`docs/superpowers/specs/2026-07-25-phase4c-stage-pipeline-design.md`. +`docs/work/mvp/phase4/phase4c/2026-07-25-phase4c-stage-pipeline-design.md`. **Architecture:** A new `packages/core/src/pipeline/` folder, six files with no folder-level barrel (`docs/knowledge/module-organization.md`'s "never create internal barrels" — matching Phase 4b's actual diff --git a/docs/superpowers/plans/2026-07-26-phase5a-retry-checklist.md b/docs/work/mvp/phase5/phase5a/2026-07-26-phase5a-retry-checklist.md similarity index 99% rename from docs/superpowers/plans/2026-07-26-phase5a-retry-checklist.md rename to docs/work/mvp/phase5/phase5a/2026-07-26-phase5a-retry-checklist.md index 85596da..2464cb5 100644 --- a/docs/superpowers/plans/2026-07-26-phase5a-retry-checklist.md +++ b/docs/work/mvp/phase5/phase5a/2026-07-26-phase5a-retry-checklist.md @@ -2,7 +2,7 @@ Verification of [2026-07-26-phase5a-retry.md](./2026-07-26-phase5a-retry.md) against every requirement ID in `docs/product-spec/09-retry-and-resilience.md` and appendix C's `RECOV-17`–`RECOV-34`, as dispositioned by -`docs/superpowers/specs/2026-07-26-phase5a-retry-design.md`. +`docs/work/mvp/phase5/phase5a/2026-07-26-phase5a-retry-design.md`. **Status: EXECUTED (2026-08-26).** Every task below is implemented, tested, and green across the full gate sequence (`typecheck`, `lint`, `build`, `bun test` with coverage, `api:ci`, `lint:publish`, @@ -19,7 +19,7 @@ This plan's Prerequisite section requires Phase 7a's `config/` module to exist f `Clock` seam, its Task 4 imports the shared RFC 1123 parser, and its Task 2 re-exports the shared retryable-status classifier. `packages/core/src/config/` did not exist. Rather than ship the private copies the plan's Global Constraints ban, the three files 7a's plan specifies were built first, verbatim from -[2026-07-28-phase7a-configuration.md](./2026-07-28-phase7a-configuration.md) Tasks 1–3, with their tests: +[2026-07-28-phase7a-configuration.md](../../phase7/phase7a/2026-07-28-phase7a-configuration.md) Tasks 1–3, with their tests: | File | Requirements | From | |---|---|---| diff --git a/docs/superpowers/specs/2026-07-26-phase5a-retry-design.md b/docs/work/mvp/phase5/phase5a/2026-07-26-phase5a-retry-design.md similarity index 99% rename from docs/superpowers/specs/2026-07-26-phase5a-retry-design.md rename to docs/work/mvp/phase5/phase5a/2026-07-26-phase5a-retry-design.md index 3eabc69..d690617 100644 --- a/docs/superpowers/specs/2026-07-26-phase5a-retry-design.md +++ b/docs/work/mvp/phase5/phase5a/2026-07-26-phase5a-retry-design.md @@ -15,8 +15,8 @@ Retry/Redirect/Auth") splits into: **5a** (this document, retry), 5b (redirect, > this document) is re-sourced from Phase 7a's shared `config/http-date.ts` rather than staying a private > copy; and `classify.ts`'s `RETRYABLE_STATUSES`/`isRetryableStatus` (`RETRY-1`) are re-exported from Phase 7a's > `config/retryable.ts` (`CFG-35`) rather than defined here a second time. See -> `docs/superpowers/specs/2026-07-28-phase7a-configuration-design.md`'s Scope section for the rationale; see -> the amended `docs/superpowers/plans/2026-07-26-phase5a-retry.md` for the concrete diffs. +> `docs/work/mvp/phase7/phase7a/2026-07-28-phase7a-configuration-design.md`'s Scope section for the rationale; see +> the amended `docs/work/mvp/phase5/phase5a/2026-07-26-phase5a-retry.md` for the concrete diffs. **Governing documents:** `docs/product-spec/09-retry-and-resilience.md` (normative, cited by ID throughout), `docs/product-spec/appendix-c-consolidated-normative-requirement-index.md` (`RECOV-17`–`RECOV-34`), diff --git a/docs/superpowers/plans/2026-07-26-phase5a-retry.md b/docs/work/mvp/phase5/phase5a/2026-07-26-phase5a-retry.md similarity index 99% rename from docs/superpowers/plans/2026-07-26-phase5a-retry.md rename to docs/work/mvp/phase5/phase5a/2026-07-26-phase5a-retry.md index 5ea52be..910f116 100644 --- a/docs/superpowers/plans/2026-07-26-phase5a-retry.md +++ b/docs/work/mvp/phase5/phase5a/2026-07-26-phase5a-retry.md @@ -6,14 +6,14 @@ pacing-header parser, validated settings, the attempt loop, per-attempt stamping, the idempotency-key recovery step, and the two thin adapters binding the engine to the stage pipeline (4c) and the recovery chain (4b) — satisfying `product-spec/09-retry-and-resilience.md` (`RETRY-1`–`RETRY-45`) and appendix C's -`RECOV-17`–`RECOV-34`, per `docs/superpowers/specs/2026-07-26-phase5a-retry-design.md`. +`RECOV-17`–`RECOV-34`, per `docs/work/mvp/phase5/phase5a/2026-07-26-phase5a-retry-design.md`. > **Amended 2026-07-28 (Phase 7a retrofit):** `RetryConfig.now`/`RetryStepOptions.now` are retyped to > `clock: Clock`, consuming Phase 7a's `config/clock.ts` seam instead of an ad hoc `() => number`; > `pacing.ts`'s private RFC 1123 parser is replaced by an import from Phase 7a's `config/http-date.ts`; and > `classify.ts`'s private `RETRYABLE_STATUSES`/`isRetryableStatus` are replaced by a re-export from Phase 7a's > `config/retryable.ts` (CFG-35). All three are single-sourcing corrections, not behavior changes — see -> `docs/superpowers/specs/2026-07-28-phase7a-configuration-design.md`'s Scope section. This plan's execution now +> `docs/work/mvp/phase7/phase7a/2026-07-28-phase7a-configuration-design.md`'s Scope section. This plan's execution now > depends on Phase 7a's `config/` module existing first (see Prerequisite below); every other task is > unaffected. > @@ -28,7 +28,7 @@ satisfying `product-spec/09-retry-and-resilience.md` (`RETRY-1`–`RETRY-45`) an > neither side could break. **An agent executing this plan must skip the Phase 7b retrofit blocks in Task 8** > and build `engine.ts` without any `observability/` import; Phase 7b's plan Task 9 adds the two emission > sites afterwards. The blocks stay here as the specification of what Task 9 will write. See -> `docs/superpowers/plans/2026-07-28-phase7b-observability.md`'s Prerequisite and Task 9. +> `docs/work/mvp/phase7/phase7b/2026-07-28-phase7b-observability.md`'s Prerequisite and Task 9. **Architecture:** A new `packages/core/src/retry/` folder of eight independent files with no folder-level barrel, plus one file in `src/recovery/` and one in a new `src/testing/`. The engine core is pure functions — @@ -55,7 +55,7 @@ platform-neutral). **Prerequisite:** This plan assumes Phases 0, 1, 2, 3a, 3b, 4a, 4b, and 4c are implemented exactly as their plans specify, **plus Phase 7a's `Clock` seam** (added by the 2026-07-28 Phase 7a brainstorm's retrofit — see the -"`Clock` retrofit" note in `docs/superpowers/specs/2026-07-28-phase7a-configuration-design.md`, Scope section). +"`Clock` retrofit" note in `docs/work/mvp/phase7/phase7a/2026-07-28-phase7a-configuration-design.md`, Scope section). This inverts this plan's original numeric ordering relative to Phase 7; 7a's `config/clock.ts` must exist before Task 8 of this plan can be executed. Concretely: @@ -1856,7 +1856,7 @@ Expected: FAIL — `Cannot find module './engine.js'`. import {HttpStatusError, toHttpError} from '../body/http-status-error.js'; import type {Clock} from '../config/clock.js'; // Phase 7b retrofit: getGlobalLogger() call sites below, narrow blast radius (only this file's own emission -// points; no other phase depends on them). See docs/superpowers/specs/2026-07-28-phase7b-observability-design.md's +// points; no other phase depends on them). See docs/work/mvp/phase7/phase7b/2026-07-28-phase7b-observability-design.md's // "Amendments to 5a and 5b" section. import {getGlobalLogger} from '../observability/logger.js'; import type {Request} from '../http/request.js'; @@ -2703,7 +2703,7 @@ git commit -m "feat(core): idempotency-key request recovery step" **Files:** - Verify unchanged: `packages/core/etc/core.api.md` - Verify unchanged: `packages/core/src/index.ts` -- Create: `docs/superpowers/plans/2026-07-26-phase5a-retry-checklist.md` +- Create: `docs/work/mvp/phase5/phase5a/2026-07-26-phase5a-retry-checklist.md` **Interfaces:** - Consumes: every symbol from Tasks 1–11. @@ -2740,7 +2740,7 @@ classifier's `TimeoutError`-name check is the one place a runtime difference wou - [ ] **Step 4: Write the requirement checklist** -Create `docs/superpowers/plans/2026-07-26-phase5a-retry-checklist.md` in the same format as +Create `docs/work/mvp/phase5/phase5a/2026-07-26-phase5a-retry-checklist.md` in the same format as `2026-07-24-phase3a-io-contracts-checklist.md` — `| ID | Level | Requirement gist | Status | Where |` tables, legend ✅ shipped / 🚫 never built / ⏳ deferred / N/A. @@ -2772,15 +2772,15 @@ State explicitly at the top whether the plan has been executed, matching the Pha The design doc's Deferred Items table is headed "add to the roadmap's Deferred Items Log" — writing the checklist does not discharge that. Append both rows to the log in -`docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md`, in the log's existing column shape: +`docs/work/mvp/2026-07-23-nodejs-sdk-v1-roadmap-design.md`, in the log's existing column shape: `RETRY-29` (opt-in server-driven retry-classification override, not scheduled) and `RECOV-33` (client-identity header step, Phase 7a). Do not restate the justifications — link to this phase's design doc. - [ ] **Step 6: Commit** ```bash -git add docs/superpowers/plans/2026-07-26-phase5a-retry-checklist.md \ - docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md +git add docs/work/mvp/phase5/phase5a/2026-07-26-phase5a-retry-checklist.md \ + docs/work/mvp/2026-07-23-nodejs-sdk-v1-roadmap-design.md git commit -m "docs: Phase 5a requirement checklist" ``` diff --git a/docs/superpowers/plans/2026-07-26-phase5b-redirect-checklist.md b/docs/work/mvp/phase5/phase5b/2026-07-26-phase5b-redirect-checklist.md similarity index 99% rename from docs/superpowers/plans/2026-07-26-phase5b-redirect-checklist.md rename to docs/work/mvp/phase5/phase5b/2026-07-26-phase5b-redirect-checklist.md index f450158..b9d5174 100644 --- a/docs/superpowers/plans/2026-07-26-phase5b-redirect-checklist.md +++ b/docs/work/mvp/phase5/phase5b/2026-07-26-phase5b-redirect-checklist.md @@ -2,7 +2,7 @@ Verification of [2026-07-26-phase5b-redirect.md](./2026-07-26-phase5b-redirect.md) against every requirement ID in `docs/product-spec/10-redirect-handling.md` (`REDIR-1`–`REDIR-28`) plus `PIPE-40`, as dispositioned by -`docs/superpowers/specs/2026-07-26-phase5b-redirect-design.md`. +`docs/work/mvp/phase5/phase5b/2026-07-26-phase5b-redirect-design.md`. **Status: EXECUTED (2026-08-27).** Every task below is implemented, tested, and green across the full gate sequence (`typecheck`, `lint`, `build`, `bun test` with coverage, `api:ci`, `lint:publish`, diff --git a/docs/superpowers/specs/2026-07-26-phase5b-redirect-design.md b/docs/work/mvp/phase5/phase5b/2026-07-26-phase5b-redirect-design.md similarity index 100% rename from docs/superpowers/specs/2026-07-26-phase5b-redirect-design.md rename to docs/work/mvp/phase5/phase5b/2026-07-26-phase5b-redirect-design.md diff --git a/docs/superpowers/plans/2026-07-26-phase5b-redirect.md b/docs/work/mvp/phase5/phase5b/2026-07-26-phase5b-redirect.md similarity index 99% rename from docs/superpowers/plans/2026-07-26-phase5b-redirect.md rename to docs/work/mvp/phase5/phase5b/2026-07-26-phase5b-redirect.md index ccaab51..f9072b2 100644 --- a/docs/superpowers/plans/2026-07-26-phase5b-redirect.md +++ b/docs/work/mvp/phase5/phase5b/2026-07-26-phase5b-redirect.md @@ -6,7 +6,7 @@ comparison and the credential-suppression marker, the pure per-hop decision function, scheme-downgrade and loop/hop-cap guarding, and the pillar adapter plus its bundled marker-stripping safety net — satisfying `docs/product-spec/10-redirect-handling.md` (`REDIR-1`–`REDIR-*`), per -`docs/superpowers/specs/2026-07-26-phase5b-redirect-design.md`, and closing the roadmap's `PIPE-40` deferred item. +`docs/work/mvp/phase5/phase5b/2026-07-26-phase5b-redirect-design.md`, and closing the roadmap's `PIPE-40` deferred item. > **Amended 2026-07-28 (Phase 7b retrofit):** Task 6's `redirect-step.ts` gains three `SHOULD`-level structured > log events via `getGlobalLogger()` — a hop event, a rejection event distinguishing `SchemeDowngradeError` @@ -25,7 +25,7 @@ loop/hop-cap guarding, and the pillar adapter plus its bundled marker-stripping > earlier "5b now depends on 7b first" wording was a cycle. **An agent executing this plan must skip the Phase > 7b retrofit blocks in Task 6** and build `redirect-step.ts` without any `observability/` import; Phase 7b's > plan Task 9 adds the three emission sites afterwards. See -> `docs/superpowers/plans/2026-07-28-phase7b-observability.md`'s Prerequisite and Task 9. +> `docs/work/mvp/phase7/phase7b/2026-07-28-phase7b-observability.md`'s Prerequisite and Task 9. **Architecture:** A new `packages/core/src/redirect/` folder of seven files. `decide.ts` is a pure function — no I/O, no clock, no side effects beyond the `Request` value it returns — that resolves one hop's outcome from a @@ -1504,7 +1504,7 @@ Expected: FAIL — `Cannot find module './redirect-step.js'`. // Amended 2026-07-28 (Phase 7b retrofit): three getGlobalLogger() call sites below, every URL field through // redactUrl() and every emission through emitQuietly(). Narrow blast radius -- only this file's own emission // points; no other phase depends on them. See -// docs/superpowers/specs/2026-07-28-phase7b-observability-design.md's "Amendments to 5a and 5b" section. +// docs/work/mvp/phase7/phase7b/2026-07-28-phase7b-observability-design.md's "Amendments to 5a and 5b" section. import {getGlobalLogger, type Logger} from '../observability/logger.js'; import {redactUrl} from '../observability/redaction.js'; import {invariant} from '../invariant.js'; @@ -1806,7 +1806,7 @@ git commit -m "feat(core): independent POST_AUTH marker guard + withRedirect() b **Files:** - Verify unchanged: `packages/core/etc/core.api.md` - Verify unchanged: `packages/core/src/index.ts` -- Create: `docs/superpowers/plans/2026-07-26-phase5b-redirect-checklist.md` +- Create: `docs/work/mvp/phase5/phase5b/2026-07-26-phase5b-redirect-checklist.md` **Interfaces:** - Consumes: every symbol from Tasks 1–7. @@ -1834,7 +1834,7 @@ Expected: every gate PASS. - [ ] **Step 4: Write the requirement checklist** -Create `docs/superpowers/plans/2026-07-26-phase5b-redirect-checklist.md`, same format as +Create `docs/work/mvp/phase5/phase5b/2026-07-26-phase5b-redirect-checklist.md`, same format as `2026-07-26-phase5a-retry-checklist.md` — `| ID | Level | Requirement gist | Status | Where |` tables, legend ✅ shipped / 🚫 never built / ⏳ deferred / N/A. @@ -1864,7 +1864,7 @@ State explicitly at the top whether the plan has been executed, matching the Pha - [ ] **Step 5: Commit** ```bash -git add docs/superpowers/plans/2026-07-26-phase5b-redirect-checklist.md +git add docs/work/mvp/phase5/phase5b/2026-07-26-phase5b-redirect-checklist.md git commit -m "docs: Phase 5b requirement checklist" ``` diff --git a/docs/superpowers/plans/2026-07-26-phase5c-auth-checklist.md b/docs/work/mvp/phase5/phase5c/2026-07-26-phase5c-auth-checklist.md similarity index 99% rename from docs/superpowers/plans/2026-07-26-phase5c-auth-checklist.md rename to docs/work/mvp/phase5/phase5c/2026-07-26-phase5c-auth-checklist.md index bdc2db7..ec2d196 100644 --- a/docs/superpowers/plans/2026-07-26-phase5c-auth-checklist.md +++ b/docs/work/mvp/phase5/phase5c/2026-07-26-phase5c-auth-checklist.md @@ -3,7 +3,7 @@ Verification of [2026-07-26-phase5c-auth.md](./2026-07-26-phase5c-auth.md) against every requirement ID in `docs/product-spec/11-authentication.md` (`AUTH-1`–`AUTH-38`) plus `PIPE-2`, `PIPE-24`, `PIPE-35`, and `PIPE-39`, as dispositioned by -`docs/superpowers/specs/2026-07-26-phase5c-auth-design.md`. +`docs/work/mvp/phase5/phase5c/2026-07-26-phase5c-auth-design.md`. **Status: EXECUTED (2026-08-27).** Every task below is implemented, tested, and green across the full gate sequence (`typecheck`, `lint`, `build`, `bun test` with coverage, `api:ci`, `lint:publish`, diff --git a/docs/superpowers/specs/2026-07-26-phase5c-auth-design.md b/docs/work/mvp/phase5/phase5c/2026-07-26-phase5c-auth-design.md similarity index 99% rename from docs/superpowers/specs/2026-07-26-phase5c-auth-design.md rename to docs/work/mvp/phase5/phase5c/2026-07-26-phase5c-auth-design.md index 15a63d6..e517941 100644 --- a/docs/superpowers/specs/2026-07-26-phase5c-auth-design.md +++ b/docs/work/mvp/phase5/phase5c/2026-07-26-phase5c-auth-design.md @@ -6,14 +6,14 @@ types, the RFC 7235 challenge parser, the Basic/Digest/static-key stamping handlers, and the single AUTH pillar step that ties them together — satisfying `docs/product-spec/11-authentication.md` (`AUTH-1`–`AUTH-38`). This is the third and final sub-phase of the roadmap's Phase 5 split: 5a (retry, done), 5b (redirect — see [Phase 5b -design](./2026-07-26-phase5b-redirect-design.md)), 5c (this document, auth). 5c also closes items the roadmap's +design](../phase5b/2026-07-26-phase5b-redirect-design.md)), 5c (this document, auth). 5c also closes items the roadmap's Deferred Items Log parked here: `PIPE-35`'s `seedFrom`, `AUTH-29`/marker-consumption (5b produced the marker and left consumption to 5c), the standard-resilience preset (`PIPE-24`/`PIPE-39`), and public-barrel promotion of the pillar-step authoring surface. **Governing documents:** `docs/product-spec/11-authentication.md` (normative, cited by ID throughout), `docs/product-spec/10-redirect-handling.md` (`REDIR-7`–`REDIR-11`, `REDIR-24` — the cross-origin marker contract -5c consumes) plus **the [Phase 5b design](./2026-07-26-phase5b-redirect-design.md) itself**, which is the actual +5c consumes) plus **the [Phase 5b design](../phase5b/2026-07-26-phase5b-redirect-design.md) itself**, which is the actual source of truth for that marker's concrete shape (see "Alignment with 5b's shipped design" below — an earlier draft of this section guessed a different, incompatible shape before 5b's doc was found on disk), `docs/product-spec/08-execution-pipelines.md` §8.1 (`PIPE-2`, `PIPE-24`, `PIPE-35`, `PIPE-39`, `PIPE-40`), diff --git a/docs/superpowers/plans/2026-07-26-phase5c-auth.md b/docs/work/mvp/phase5/phase5c/2026-07-26-phase5c-auth.md similarity index 99% rename from docs/superpowers/plans/2026-07-26-phase5c-auth.md rename to docs/work/mvp/phase5/phase5c/2026-07-26-phase5c-auth.md index da0fa0a..a41f9c9 100644 --- a/docs/superpowers/plans/2026-07-26-phase5c-auth.md +++ b/docs/work/mvp/phase5/phase5c/2026-07-26-phase5c-auth.md @@ -6,7 +6,7 @@ credential types, the RFC 7235 challenge parser, the Basic/Digest/static-key stamping handlers, the bearer token cache, the single AUTH pillar step, `PipelineBuilder.seedFrom()`, and the standard-resilience preset — satisfying `docs/product-spec/11-authentication.md` (`AUTH-1`–`AUTH-38`), per -`docs/superpowers/specs/2026-07-26-phase5c-auth-design.md`. Also closes `PIPE-35`'s `seedFrom`, `AUTH-29`'s +`docs/work/mvp/phase5/phase5c/2026-07-26-phase5c-auth-design.md`. Also closes `PIPE-35`'s `seedFrom`, `AUTH-29`'s marker-consumption side (5b produced the marker), `PIPE-24`/`PIPE-39`'s preset, and public-barrel promotion of the pillar-authoring surface. @@ -21,7 +21,7 @@ the pillar-authoring surface. > **An agent executing this plan must skip the Phase 7b retrofit blocks in Task 16**: build > `standardResilience()` installing the three pillars that exist by then (redirect, retry, auth), leaving > `LOGGING` empty. Phase 7b's plan Task 9 installs the fourth. See -> `docs/superpowers/plans/2026-07-28-phase7b-observability.md`'s Prerequisite and Task 9. +> `docs/work/mvp/phase7/phase7b/2026-07-28-phase7b-observability.md`'s Prerequisite and Task 9. **Architecture:** A new `packages/core/src/auth/` folder of fifteen files, plus one amendment to `packages/core/src/pipeline/builder.ts`. The descriptor/resolver half (`scheme.ts`/`requirement.ts`/ @@ -4128,7 +4128,7 @@ git commit -m "feat(core): standard-resilience preset + public pillar-authoring **Files:** - Verify: full gate sequence (already run at the end of Task 16; re-run here to confirm nothing regressed from the barrel edit's review) -- Create: `docs/superpowers/plans/2026-07-26-phase5c-auth-checklist.md` +- Create: `docs/work/mvp/phase5/phase5c/2026-07-26-phase5c-auth-checklist.md` **Interfaces:** - Consumes: every symbol from Tasks 1–16. @@ -4153,7 +4153,7 @@ Expected: every gate PASS. `test:node` matters specifically here: `globalThis.cr - [ ] **Step 3: Write the requirement checklist** -Create `docs/superpowers/plans/2026-07-26-phase5c-auth-checklist.md`, same format as +Create `docs/work/mvp/phase5/phase5c/2026-07-26-phase5c-auth-checklist.md`, same format as `2026-07-26-phase5a-retry-checklist.md`/`2026-07-26-phase5b-redirect-checklist.md` — `| ID | Level | Requirement gist | Status | Where |` tables, legend ✅ shipped / 🚫 never built / ⏳ deferred / N/A. @@ -4199,7 +4199,7 @@ State explicitly at the top whether the plan has been executed, matching the Pha - [ ] **Step 4: Commit** ```bash -git add docs/superpowers/plans/2026-07-26-phase5c-auth-checklist.md +git add docs/work/mvp/phase5/phase5c/2026-07-26-phase5c-auth-checklist.md git commit -m "docs: Phase 5c requirement checklist" ``` diff --git a/docs/superpowers/specs/2026-07-28-phase6-segmentation-design.md b/docs/work/mvp/phase6/2026-07-28-phase6-segmentation-design.md similarity index 100% rename from docs/superpowers/specs/2026-07-28-phase6-segmentation-design.md rename to docs/work/mvp/phase6/2026-07-28-phase6-segmentation-design.md diff --git a/docs/superpowers/plans/2026-07-28-phase6a-serde-checklist.md b/docs/work/mvp/phase6/phase6a/2026-07-28-phase6a-serde-checklist.md similarity index 99% rename from docs/superpowers/plans/2026-07-28-phase6a-serde-checklist.md rename to docs/work/mvp/phase6/phase6a/2026-07-28-phase6a-serde-checklist.md index 4119633..e72c415 100644 --- a/docs/superpowers/plans/2026-07-28-phase6a-serde-checklist.md +++ b/docs/work/mvp/phase6/phase6a/2026-07-28-phase6a-serde-checklist.md @@ -3,7 +3,7 @@ Verification of [2026-07-28-phase6a-serde.md](./2026-07-28-phase6a-serde.md) against every requirement ID in `docs/product-spec/14-serialization-serde.md` (`SERDE-1`–`SERDE-30`), appendix C's `SEAM-19`–`SEAM-23`, and the two Phase-0 deferrals this phase closes (`NFR-2`, `NFR-14`), as dispositioned by -`docs/superpowers/specs/2026-07-28-phase6a-serde-design.md`. +`docs/work/mvp/phase6/phase6a/2026-07-28-phase6a-serde-design.md`. **Status: EXECUTED (2026-08-27).** Every task below is implemented and tested. Deviations, deferrals, and the requirement clauses satisfied by delegation rather than by code are recorded in `docs/open-items.md` §H — @@ -15,7 +15,7 @@ evidence — 🚫 Not built (permanent simplification, named reason) — ⏳ Def N/A Not applicable in this port. **A note on the collapsed rows.** The design's "Collapsed Requirements" table -([design §Collapsed Requirements](../specs/2026-07-28-phase6a-serde-design.md)) dispositions six MUSTs as N/A +([design §Collapsed Requirements](./2026-07-28-phase6a-serde-design.md)) dispositions six MUSTs as N/A or satisfied-by-construction. Phase 9's sweep must read that table rather than re-deriving them, or those six read as uncovered. Every collapsed row below points back at it. diff --git a/docs/superpowers/specs/2026-07-28-phase6a-serde-design.md b/docs/work/mvp/phase6/phase6a/2026-07-28-phase6a-serde-design.md similarity index 99% rename from docs/superpowers/specs/2026-07-28-phase6a-serde-design.md rename to docs/work/mvp/phase6/phase6a/2026-07-28-phase6a-serde-design.md index fc96789..6f92bb9 100644 --- a/docs/superpowers/specs/2026-07-28-phase6a-serde-design.md +++ b/docs/work/mvp/phase6/phase6a/2026-07-28-phase6a-serde-design.md @@ -6,7 +6,7 @@ `SEAM-21`), `Tristate<T>`, the serde error leaves, `SERDE-2`'s media-type-as-default-`Content-Type` wiring, and `SERDE-27`/`SERDE-28`'s response handlers — plus the workspace's first second package, `@dexpace/codec-json`. Satisfies `docs/product-spec/14-serialization-serde.md` (`SERDE-1`–`SERDE-30`). First of the three sub-phases the -[Phase 6 segmentation design](./2026-07-28-phase6-segmentation-design.md) splits Phase 6 into: **6a** (this +[Phase 6 segmentation design](../2026-07-28-phase6-segmentation-design.md) splits Phase 6 into: **6a** (this document, serde), 6b (SSE, `§13`), 6c (pagination, `§12`). **Governing documents:** `docs/product-spec/14-serialization-serde.md` (normative, cited by ID throughout), diff --git a/docs/superpowers/plans/2026-07-28-phase6a-serde.md b/docs/work/mvp/phase6/phase6a/2026-07-28-phase6a-serde.md similarity index 99% rename from docs/superpowers/plans/2026-07-28-phase6a-serde.md rename to docs/work/mvp/phase6/phase6a/2026-07-28-phase6a-serde.md index b4ef2ca..ae7a0ee 100644 --- a/docs/superpowers/plans/2026-07-28-phase6a-serde.md +++ b/docs/work/mvp/phase6/phase6a/2026-07-28-phase6a-serde.md @@ -6,7 +6,7 @@ (closing `SEAM-21`), `Tristate<T>`, two serde error leaves, `serdeBody()`, and the two response handlers — plus the workspace's first second package, `@dexpace/codec-json`, satisfying `product-spec/14-serialization-serde.md` (`SERDE-1`–`SERDE-30`) per -`docs/superpowers/specs/2026-07-28-phase6a-serde-design.md`. +`docs/work/mvp/phase6/phase6a/2026-07-28-phase6a-serde-design.md`. **Architecture:** A new `packages/core/src/serde/` folder of five independent files with no folder-level barrel, plus one modified file in `src/seams/` (Phase 2's provisional `Serde<T>` is reshaped in place) and one new file in @@ -2584,7 +2584,7 @@ git commit -m "feat(codec-json): add the tristate() decode combinator resolving - Create: `packages/codec-json/src/conformance.test.ts` - Create: `packages/codec-json/etc/codec-json.api.md` (generated) - Create: `.changeset/phase6a-codec-json.md` -- Modify: `docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md` (mark the three retargeted rows +- Modify: `docs/work/mvp/2026-07-23-nodejs-sdk-v1-roadmap-design.md` (mark the three retargeted rows resolved) **Interfaces:** @@ -2759,7 +2759,7 @@ Initial release: `jsonSerde()`, the `Tristate` PATCH replacer (on by default), a - [ ] **Step 7: Close out the roadmap rows** -In `docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md`, mark these Deferred-Items-Log rows' +In `docs/work/mvp/2026-07-23-nodejs-sdk-v1-roadmap-design.md`, mark these Deferred-Items-Log rows' target column **Resolved in Phase 6a**, adding one sentence of evidence each: - `NFR-2` — codec half closed; `packages/codec-json` ships with `dependencies: {}` and zero external libraries. @@ -2786,7 +2786,7 @@ command's output instead if one does not. - [ ] **Step 9: Commit** ```bash -git add packages/codec-json .changeset docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md +git add packages/codec-json .changeset docs/work/mvp/2026-07-23-nodejs-sdk-v1-roadmap-design.md git commit -m "test(codec-json): guard the dual-package hazard and the collapsed SERDE requirements (SERDE-21/22/24/29)" ``` diff --git a/docs/superpowers/plans/2026-07-28-phase6b-sse-checklist.md b/docs/work/mvp/phase6/phase6b/2026-07-28-phase6b-sse-checklist.md similarity index 99% rename from docs/superpowers/plans/2026-07-28-phase6b-sse-checklist.md rename to docs/work/mvp/phase6/phase6b/2026-07-28-phase6b-sse-checklist.md index 474831e..bde92c3 100644 --- a/docs/superpowers/plans/2026-07-28-phase6b-sse-checklist.md +++ b/docs/work/mvp/phase6/phase6b/2026-07-28-phase6b-sse-checklist.md @@ -2,7 +2,7 @@ Verification of [2026-07-28-phase6b-sse.md](./2026-07-28-phase6b-sse.md) against every requirement ID in `docs/product-spec/13-server-sent-events-and-streaming.md` (`SSE-1`–`SSE-41`), as dispositioned by -`docs/superpowers/specs/2026-07-28-phase6b-sse-design.md`. +`docs/work/mvp/phase6/phase6b/2026-07-28-phase6b-sse-design.md`. **Status: EXECUTED (2026-08-27).** Every task below is implemented and tested across 1,497 repository tests, 40 script tests, and 79 Node conformance tests. Deviations, deferrals, and design rationales are recorded in `docs/open-items.md` §I and the roadmap design. diff --git a/docs/superpowers/specs/2026-07-28-phase6b-sse-design.md b/docs/work/mvp/phase6/phase6b/2026-07-28-phase6b-sse-design.md similarity index 99% rename from docs/superpowers/specs/2026-07-28-phase6b-sse-design.md rename to docs/work/mvp/phase6/phase6b/2026-07-28-phase6b-sse-design.md index 8be7cf6..09a3bcd 100644 --- a/docs/superpowers/specs/2026-07-28-phase6b-sse-design.md +++ b/docs/work/mvp/phase6/phase6b/2026-07-28-phase6b-sse-design.md @@ -5,7 +5,7 @@ **Purpose:** Implement the SSE subsystem — the WHATWG line/field grammar as a state machine, the immutable `SseEvent` value, the resource-owning single-pass stream facade, and the typed adapter — satisfying `docs/product-spec/13-server-sent-events-and-streaming.md` (`SSE-1`–`SSE-41`). Second of the three sub-phases the -[Phase 6 segmentation design](./2026-07-28-phase6-segmentation-design.md) splits Phase 6 into: 6a (serde, `§14`), +[Phase 6 segmentation design](../2026-07-28-phase6-segmentation-design.md) splits Phase 6 into: 6a (serde, `§14`), **6b** (this document, SSE), 6c (pagination, `§12`). **Governing documents:** `docs/product-spec/13-server-sent-events-and-streaming.md` (normative, cited by ID diff --git a/docs/superpowers/plans/2026-07-28-phase6b-sse.md b/docs/work/mvp/phase6/phase6b/2026-07-28-phase6b-sse.md similarity index 99% rename from docs/superpowers/plans/2026-07-28-phase6b-sse.md rename to docs/work/mvp/phase6/phase6b/2026-07-28-phase6b-sse.md index f2076eb..ea2591a 100644 --- a/docs/superpowers/plans/2026-07-28-phase6b-sse.md +++ b/docs/work/mvp/phase6/phase6b/2026-07-28-phase6b-sse.md @@ -5,7 +5,7 @@ **Goal:** Ship the SSE subsystem in `@dexpace/core` — the CR/LF/CRLF line reader, the WHATWG field-grammar state machine, the immutable `SseEvent` value, the resource-owning single-pass stream facade, and the typed adapter — satisfying `product-spec/13-server-sent-events-and-streaming.md` (`SSE-1`–`SSE-41`) per -`docs/superpowers/specs/2026-07-28-phase6b-sse-design.md`. +`docs/work/mvp/phase6/phase6b/2026-07-28-phase6b-sse-design.md`. **Architecture:** A new `packages/core/src/sse/` folder of six independent files with no folder-level barrel. Byte access and lifecycle come from Phase 3a's `BufferedSource`; **line framing does not** — `IO-14` keeps a lone @@ -2379,7 +2379,7 @@ git commit -m "build: enforce the SSE no-serde and no-reconnect invariants mecha - Create: `packages/core/src/sse/lifecycle.test.ts` - Modify: `packages/core/etc/core.api.md` (regenerated) - Create: `.changeset/phase6b-sse.md` -- Modify: `docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md` +- Modify: `docs/work/mvp/2026-07-23-nodejs-sdk-v1-roadmap-design.md` **Interfaces:** - Consumes: everything above. @@ -2547,7 +2547,7 @@ continuity, both of which remain the caller's responsibility. - [ ] **Step 6: Close out the roadmap rows** -In `docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md`: +In `docs/work/mvp/2026-07-23-nodejs-sdk-v1-roadmap-design.md`: - Mark the collapsed-disposition row's 6b half satisfied, pointing at the Phase 6b design's "Collapsed Requirements" table (`SSE-18` re-expressed, `SSE-31` re-expressed but **not** collapsed). @@ -2566,7 +2566,7 @@ command's output instead if one does not. - [ ] **Step 8: Commit** ```bash -git add packages/core/src/index.ts packages/core/src/index.public.test.ts packages/core/src/sse/lifecycle.test.ts packages/core/etc/core.api.md .changeset/phase6b-sse.md docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md +git add packages/core/src/index.ts packages/core/src/index.public.test.ts packages/core/src/sse/lifecycle.test.ts packages/core/etc/core.api.md .changeset/phase6b-sse.md docs/work/mvp/2026-07-23-nodejs-sdk-v1-roadmap-design.md git commit -m "feat(core): promote the SSE surface to the public barrel (SSE-23/26/37)" ``` diff --git a/docs/superpowers/plans/2026-07-28-phase6c-pagination-checklist.md b/docs/work/mvp/phase6/phase6c/2026-07-28-phase6c-pagination-checklist.md similarity index 99% rename from docs/superpowers/plans/2026-07-28-phase6c-pagination-checklist.md rename to docs/work/mvp/phase6/phase6c/2026-07-28-phase6c-pagination-checklist.md index 498efa8..fdab0f6 100644 --- a/docs/superpowers/plans/2026-07-28-phase6c-pagination-checklist.md +++ b/docs/work/mvp/phase6/phase6c/2026-07-28-phase6c-pagination-checklist.md @@ -1,6 +1,6 @@ # Phase 6c — Pagination Implementation Plan — Checklist -Verification of pagination requirements (`PAGE-1`–`PAGE-36`) from `docs/product-spec/16-pagination.md` and `docs/sdk-design-nodejs/07-pagination-sse-and-serialization.md`, as dispositioned by `docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md`. +Verification of pagination requirements (`PAGE-1`–`PAGE-36`) from `docs/product-spec/16-pagination.md` and `docs/sdk-design-nodejs/07-pagination-sse-and-serialization.md`, as dispositioned by `docs/work/mvp/2026-07-23-nodejs-sdk-v1-roadmap-design.md`. **Status: EXECUTED (2026-08-27).** All tasks implemented, tested, and reviewed. Deviations and design ledger rows are recorded in `docs/open-items.md` §I. diff --git a/docs/superpowers/specs/2026-07-28-phase6c-pagination-design.md b/docs/work/mvp/phase6/phase6c/2026-07-28-phase6c-pagination-design.md similarity index 99% rename from docs/superpowers/specs/2026-07-28-phase6c-pagination-design.md rename to docs/work/mvp/phase6/phase6c/2026-07-28-phase6c-pagination-design.md index b540d3b..e1aa746 100644 --- a/docs/superpowers/specs/2026-07-28-phase6c-pagination-design.md +++ b/docs/work/mvp/phase6/phase6c/2026-07-28-phase6c-pagination-design.md @@ -5,7 +5,7 @@ **Purpose:** Implement the pagination engine — the `Page` resource, the `PageInfo` strategy contract, the item-level and page-level views, the page cap, the three built-in strategies, the verbatim query splice, and the fetcher-based front-end — satisfying `docs/product-spec/12-pagination.md` (`PAGE-1`–`PAGE-36`). Last of the three -sub-phases the [Phase 6 segmentation design](./2026-07-28-phase6-segmentation-design.md) splits Phase 6 into: 6a +sub-phases the [Phase 6 segmentation design](../2026-07-28-phase6-segmentation-design.md) splits Phase 6 into: 6a (serde, `§14`), 6b (SSE, `§13`), **6c** (this document, pagination). **Governing documents:** `docs/product-spec/12-pagination.md` (normative, cited by ID throughout), diff --git a/docs/superpowers/plans/2026-07-28-phase6c-pagination.md b/docs/work/mvp/phase6/phase6c/2026-07-28-phase6c-pagination.md similarity index 99% rename from docs/superpowers/plans/2026-07-28-phase6c-pagination.md rename to docs/work/mvp/phase6/phase6c/2026-07-28-phase6c-pagination.md index b6af624..720aa5b 100644 --- a/docs/superpowers/plans/2026-07-28-phase6c-pagination.md +++ b/docs/work/mvp/phase6/phase6c/2026-07-28-phase6c-pagination.md @@ -5,7 +5,7 @@ **Goal:** Ship the pagination engine in `@dexpace/core` — `Page`/`PageInfo`, the strategy contract, the item- and page-level views, the page cap, the verbatim query splice, the three built-in strategies, and the fetcher-based front-end — satisfying `product-spec/12-pagination.md` (`PAGE-1`–`PAGE-36`) per -`docs/superpowers/specs/2026-07-28-phase6c-pagination-design.md`. +`docs/work/mvp/phase6/phase6c/2026-07-28-phase6c-pagination-design.md`. **Architecture:** A new `packages/core/src/pagination/` folder of eight independent files with no folder-level barrel. Both consumption views are `async function*` generators over one drive routine, which is why `PAGE-6`'s @@ -2845,7 +2845,7 @@ git commit -m "feat(core): add the fetcher-based pagination front-end (PAGE-34/3 - Modify: `packages/core/src/index.public.test.ts` (append) - Modify: `packages/core/etc/core.api.md` (regenerated) - Create: `.changeset/phase6c-pagination.md` -- Modify: `docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md` +- Modify: `docs/work/mvp/2026-07-23-nodejs-sdk-v1-roadmap-design.md` - Modify: `docs/sdk-design-nodejs/07-pagination-sse-and-serialization.md` (the `PAGE-11` erratum note) **Interfaces:** @@ -2984,7 +2984,7 @@ rather than re-adding it.) - [ ] **Step 6: Close out the roadmap rows** -In `docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md`: +In `docs/work/mvp/2026-07-23-nodejs-sdk-v1-roadmap-design.md`: - Mark the `PAGE-11` erratum row **Resolved in Phase 6c**, pointing at the erratum text above and at `lifecycle.test.ts`'s ordering assertion as the mechanical proof. @@ -3003,7 +3003,7 @@ command's output instead if one does not. - [ ] **Step 8: Commit** ```bash -git add packages/core/src/index.ts packages/core/src/index.public.test.ts packages/core/etc/core.api.md .changeset/phase6c-pagination.md docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md docs/sdk-design-nodejs/07-pagination-sse-and-serialization.md +git add packages/core/src/index.ts packages/core/src/index.public.test.ts packages/core/etc/core.api.md .changeset/phase6c-pagination.md docs/work/mvp/2026-07-23-nodejs-sdk-v1-roadmap-design.md docs/sdk-design-nodejs/07-pagination-sse-and-serialization.md git commit -m "feat(core): promote the pagination surface and close Phase 6 (PAGE-1-36)" ``` diff --git a/docs/superpowers/specs/2026-07-28-phase7-segmentation-design.md b/docs/work/mvp/phase7/2026-07-28-phase7-segmentation-design.md similarity index 97% rename from docs/superpowers/specs/2026-07-28-phase7-segmentation-design.md rename to docs/work/mvp/phase7/2026-07-28-phase7-segmentation-design.md index cd9f922..ca879cc 100644 --- a/docs/superpowers/specs/2026-07-28-phase7-segmentation-design.md +++ b/docs/work/mvp/phase7/2026-07-28-phase7-segmentation-design.md @@ -5,8 +5,8 @@ **Purpose:** Record why and how Phase 7 ("Instrumentation & Configuration") splits before either sub-phase gets its own detailed design, mirroring the sizing review that split Phases 3, 4, 5, and 6. This document is the segmentation rationale only; the two sub-phases' full designs are -[7a (Configuration & Platform Primitives)](./2026-07-28-phase7a-configuration-design.md) and -[7b (Instrumentation & Observability)](./2026-07-28-phase7b-observability-design.md). +[7a (Configuration & Platform Primitives)](./phase7a/2026-07-28-phase7a-configuration-design.md) and +[7b (Instrumentation & Observability)](./phase7b/2026-07-28-phase7b-observability-design.md). ## 1. Sizing diff --git a/docs/superpowers/plans/2026-07-28-phase7a-configuration-checklist.md b/docs/work/mvp/phase7/phase7a/2026-07-28-phase7a-configuration-checklist.md similarity index 98% rename from docs/superpowers/plans/2026-07-28-phase7a-configuration-checklist.md rename to docs/work/mvp/phase7/phase7a/2026-07-28-phase7a-configuration-checklist.md index 9043d4a..74ca5ba 100644 --- a/docs/superpowers/plans/2026-07-28-phase7a-configuration-checklist.md +++ b/docs/work/mvp/phase7/phase7a/2026-07-28-phase7a-configuration-checklist.md @@ -2,14 +2,14 @@ **Status: EXECUTED.** Every ✅ below names code and tests that exist on this branch, not a plan step. Verified against [2026-07-28-phase7a-configuration.md](./2026-07-28-phase7a-configuration.md) and -`docs/superpowers/specs/2026-07-28-phase7a-configuration-design.md`, over every requirement ID in +`docs/work/mvp/phase7/phase7a/2026-07-28-phase7a-configuration-design.md`, over every requirement ID in `docs/product-spec/16-configuration.md` plus appendix C's `RECOV-33` and `NFR-15`. **Legend:** ✅ Implemented and tested — 🚫 Not built (permanent simplification, named reason) — ⏳ Deferred (named target phase) — N/A Not applicable in this port. Deviations from the plan's own sketches, and everything left open, are recorded in -[`docs/open-items.md`](../../open-items.md) (entries K1–K12). +[`docs/open-items.md`](../../../../open-items.md) (entries K1–K12). ## 16.1 Layered lookup @@ -100,7 +100,7 @@ Deviations from the plan's own sketches, and everything left open, are recorded The three single-sourcing retrofits into Phase 5a's (written, unexecuted) design and plan were applied as document edits on 2026-07-28, ahead of this phase's execution — see that plan's amendment banner at -`docs/superpowers/plans/2026-07-26-phase5a-retry.md:11`. Nothing in 5a's code exists yet, so this phase +`docs/work/mvp/phase5/phase5a/2026-07-26-phase5a-retry.md:11`. Nothing in 5a's code exists yet, so this phase changed no 5a source. What 5a must consume when it runs: - `RetryConfig.clock: Clock` from `config/clock.js`, replacing the ad hoc `now: () => number`. diff --git a/docs/superpowers/specs/2026-07-28-phase7a-configuration-design.md b/docs/work/mvp/phase7/phase7a/2026-07-28-phase7a-configuration-design.md similarity index 99% rename from docs/superpowers/specs/2026-07-28-phase7a-configuration-design.md rename to docs/work/mvp/phase7/phase7a/2026-07-28-phase7a-configuration-design.md index 5605fe5..8be21be 100644 --- a/docs/superpowers/specs/2026-07-28-phase7a-configuration-design.md +++ b/docs/work/mvp/phase7/phase7a/2026-07-28-phase7a-configuration-design.md @@ -7,7 +7,7 @@ dates, UUID generation, deep equality, the retryability classifier, the build/ru client-identity header step — satisfying `docs/product-spec/16-configuration.md` (`CFG-1`–`CFG-38`), `NFR-15` (self-identifying version metadata), and `RECOV-33` (client-identity step, appendix C). This is the first of two sub-phases the roadmap's Phase 7 ("Instrumentation & Configuration") splits into — see the -[segmentation design](./2026-07-28-phase7-segmentation-design.md). 7a leads; 7b (Observability, `§15`) trails and +[segmentation design](../2026-07-28-phase7-segmentation-design.md). 7a leads; 7b (Observability, `§15`) trails and consumes this phase's `Configuration`/`CFG-14` key constant for its log-level resolution (`OBS-35`). **Governing documents:** `docs/product-spec/16-configuration.md` (normative, cited by ID throughout), diff --git a/docs/superpowers/plans/2026-07-28-phase7a-configuration.md b/docs/work/mvp/phase7/phase7a/2026-07-28-phase7a-configuration.md similarity index 99% rename from docs/superpowers/plans/2026-07-28-phase7a-configuration.md rename to docs/work/mvp/phase7/phase7a/2026-07-28-phase7a-configuration.md index ee23043..e4a6a01 100644 --- a/docs/superpowers/plans/2026-07-28-phase7a-configuration.md +++ b/docs/work/mvp/phase7/phase7a/2026-07-28-phase7a-configuration.md @@ -9,7 +9,7 @@ > unparseable), Task 7's `CFG-22` test (asserts a hard-coded literal returned by a fake `toString`, testing > nothing) and its omission of `CFG-26` entirely, and Task 8 Step 6 (rewrites `build` to `tsc -b`, replacing > the working `tsc -p tsconfig.build.json`). All six were corrected in the shipped code and are itemized in -> [`docs/open-items.md`](../../open-items.md) K6. **The as-built record is +> [`docs/open-items.md`](../../../../open-items.md) K6. **The as-built record is > [the checklist](./2026-07-28-phase7a-configuration-checklist.md) and the code, not this file.** The > sketches are left in place deliberately, as the historical artifact of a completed phase. @@ -19,7 +19,7 @@ generation, deep equality, the retryability classifier, the build/runtime version descriptor, and the client-identity header step in `@dexpace/core` — satisfying `docs/product-spec/16-configuration.md` (`CFG-1`–`CFG-38`), `NFR-15`, and appendix C's `RECOV-33`, per -`docs/superpowers/specs/2026-07-28-phase7a-configuration-design.md`. +`docs/work/mvp/phase7/phase7a/2026-07-28-phase7a-configuration-design.md`. **Architecture:** A new `packages/core/src/config/` folder of nine independent files (**ten as built** — see the file-structure note below), no folder-level barrel @@ -1713,7 +1713,7 @@ git commit -m "feat(core): client-identity header step (RECOV-33), closes NFR-15 **Files:** - Modify: `packages/core/src/index.ts` - Verify: `packages/core/etc/core.api.md` -- Create: `docs/superpowers/plans/2026-07-28-phase7a-configuration-checklist.md` +- Create: `docs/work/mvp/phase7/phase7a/2026-07-28-phase7a-configuration-checklist.md` **Interfaces:** - Consumes: every public symbol from Tasks 1–9. @@ -1778,7 +1778,7 @@ Expected: every gate PASS. - [ ] **Step 5: Write the requirement checklist** -Create `docs/superpowers/plans/2026-07-28-phase7a-configuration-checklist.md`, same `| ID | Level | +Create `docs/work/mvp/phase7/phase7a/2026-07-28-phase7a-configuration-checklist.md`, same `| ID | Level | Requirement gist | Status | Where |` table format as prior phase checklists (e.g. `2026-07-24-phase3a-io-contracts-checklist.md`), legend ✅ shipped / 🚫 never built / ⏳ deferred / N/A. @@ -1807,7 +1807,7 @@ State explicitly at the top whether the plan has been executed. ```bash git add packages/core/src/index.ts packages/core/etc/core.api.md \ - docs/superpowers/plans/2026-07-28-phase7a-configuration-checklist.md + docs/work/mvp/phase7/phase7a/2026-07-28-phase7a-configuration-checklist.md git commit -m "feat(core): promote Phase 7a's public surface; checklist" ``` diff --git a/docs/superpowers/plans/2026-07-28-phase7b-observability-checklist.md b/docs/work/mvp/phase7/phase7b/2026-07-28-phase7b-observability-checklist.md similarity index 100% rename from docs/superpowers/plans/2026-07-28-phase7b-observability-checklist.md rename to docs/work/mvp/phase7/phase7b/2026-07-28-phase7b-observability-checklist.md diff --git a/docs/superpowers/specs/2026-07-28-phase7b-observability-design.md b/docs/work/mvp/phase7/phase7b/2026-07-28-phase7b-observability-design.md similarity index 99% rename from docs/superpowers/specs/2026-07-28-phase7b-observability-design.md rename to docs/work/mvp/phase7/phase7b/2026-07-28-phase7b-observability-design.md index d29b3c0..2c33449 100644 --- a/docs/superpowers/specs/2026-07-28-phase7b-observability-design.md +++ b/docs/work/mvp/phase7/phase7b/2026-07-28-phase7b-observability-design.md @@ -7,7 +7,7 @@ the redaction policy, tracing (`Tracer`/`Span`, real W3C trace-context generatio `LOGGING` pillar step — satisfying `docs/product-spec/15-instrumentation-and-observability.md`'s `OBS-1`–`OBS-27` and `OBS-30`–`OBS-40` (`OBS-19`, `OBS-28`, and `OBS-29` are deferred to Phase 8a by name — see Scope). This is the second of two sub-phases the roadmap's Phase 7 splits into — see the -[segmentation design](./2026-07-28-phase7-segmentation-design.md). 7b trails 7a and consumes its `Configuration` +[segmentation design](../2026-07-28-phase7-segmentation-design.md). 7b trails 7a and consumes its `Configuration` (`OBS-35`'s log-level resolution) and `CFG-14`'s log-level key constant. **Governing documents:** `docs/product-spec/15-instrumentation-and-observability.md` (normative, cited by ID diff --git a/docs/superpowers/plans/2026-07-28-phase7b-observability.md b/docs/work/mvp/phase7/phase7b/2026-07-28-phase7b-observability.md similarity index 99% rename from docs/superpowers/plans/2026-07-28-phase7b-observability.md rename to docs/work/mvp/phase7/phase7b/2026-07-28-phase7b-observability.md index 6dbed3d..f09d64f 100644 --- a/docs/superpowers/plans/2026-07-28-phase7b-observability.md +++ b/docs/work/mvp/phase7/phase7b/2026-07-28-phase7b-observability.md @@ -7,7 +7,7 @@ redaction policy, tracing (`Tracer`/`Span`, real W3C trace-context generation), `LOGGING` pillar step in `@dexpace/core`, plus the `@dexpace/logging-pino` and `@dexpace/logging-debug` bridge packages — satisfying `docs/product-spec/15-instrumentation-and-observability.md`'s `OBS-1`–`OBS-18` and `OBS-20`–`OBS-27`, `OBS-30`–`OBS-40` (`OBS-19`/`OBS-28`/`OBS-29` → Phase 8a by name), per -`docs/superpowers/specs/2026-07-28-phase7b-observability-design.md`. +`docs/work/mvp/phase7/phase7b/2026-07-28-phase7b-observability-design.md`. **Architecture:** A new `packages/core/src/observability/` folder of six files, no folder-level barrel. `diagnostic-context.ts` (no dependencies within this package) is built first; `logger.ts` — the facade, a @@ -2660,7 +2660,7 @@ git commit -m "feat(core): retry/redirect structured logging and the preset's LO **Files:** - Modify: `packages/core/src/index.ts` - Verify: `packages/core/etc/core.api.md` -- Create: `docs/superpowers/plans/2026-07-28-phase7b-observability-checklist.md` +- Create: `docs/work/mvp/phase7/phase7b/2026-07-28-phase7b-observability-checklist.md` **Interfaces:** - Consumes: every public symbol from Tasks 1–6. @@ -2738,7 +2738,7 @@ git diff --exit-code packages/*/etc/*.api.md # an unreviewed API drift fails h - [ ] **Step 5: Write the requirement checklist** -Create `docs/superpowers/plans/2026-07-28-phase7b-observability-checklist.md`, same table format as prior +Create `docs/work/mvp/phase7/phase7b/2026-07-28-phase7b-observability-checklist.md`, same table format as prior phase checklists, legend ✅ shipped / 🚫 never built / ⏳ deferred / N/A. Sections and their sources: @@ -2785,7 +2785,7 @@ State explicitly at the top whether the plan has been executed. ```bash git add packages/core/src/index.ts packages/core/etc/core.api.md \ - docs/superpowers/plans/2026-07-28-phase7b-observability-checklist.md + docs/work/mvp/phase7/phase7b/2026-07-28-phase7b-observability-checklist.md git commit -m "feat(core): promote Phase 7b's public surface; checklist" ``` diff --git a/docs/superpowers/specs/2026-07-28-phase8-segmentation-design.md b/docs/work/mvp/phase8/2026-07-28-phase8-segmentation-design.md similarity index 99% rename from docs/superpowers/specs/2026-07-28-phase8-segmentation-design.md rename to docs/work/mvp/phase8/2026-07-28-phase8-segmentation-design.md index 26c7d64..9a49e6f 100644 --- a/docs/superpowers/specs/2026-07-28-phase8-segmentation-design.md +++ b/docs/work/mvp/phase8/2026-07-28-phase8-segmentation-design.md @@ -142,7 +142,7 @@ does not collapse (§5.2) plus `SSE-41` (§6), the reactive SSE adapter with bac (`ASYNC-21`), fatal/non-fatal error-family split, and documented source ownership. Reuses, does not rebuild: 7b's `AsyncLocalStorage`-backed diagnostic-context bridge -(`docs/superpowers/specs/2026-07-28-phase7b-observability-design.md` lines 151–169) for `ASYNC-8`–`ASYNC-12`'s +(`docs/work/mvp/phase7/phase7b/2026-07-28-phase7b-observability-design.md` lines 151–169) for `ASYNC-8`–`ASYNC-12`'s logging-context propagation — 7b's design already states `AsyncLocalStorage` auto-propagates across `await`, promise chains, and timers, covering "most of what `OBS-24`'s bridge... manually requires," with an explicit `captureDiagnosticSnapshot()`/`runWithSnapshot()` escape hatch already built for exactly the residual case diff --git a/docs/superpowers/plans/2026-07-28-phase8a-transport-checklist.md b/docs/work/mvp/phase8/phase8a/2026-07-28-phase8a-transport-checklist.md similarity index 100% rename from docs/superpowers/plans/2026-07-28-phase8a-transport-checklist.md rename to docs/work/mvp/phase8/phase8a/2026-07-28-phase8a-transport-checklist.md diff --git a/docs/superpowers/specs/2026-07-28-phase8a-transport-design.md b/docs/work/mvp/phase8/phase8a/2026-07-28-phase8a-transport-design.md similarity index 99% rename from docs/superpowers/specs/2026-07-28-phase8a-transport-design.md rename to docs/work/mvp/phase8/phase8a/2026-07-28-phase8a-transport-design.md index 6dafc43..1a4a4b0 100644 --- a/docs/superpowers/specs/2026-07-28-phase8a-transport-design.md +++ b/docs/work/mvp/phase8/phase8a/2026-07-28-phase8a-transport-design.md @@ -5,15 +5,15 @@ **Purpose:** Implement the concrete `Transport` implementations — `@dexpace/transport-fetch` and `@dexpace/transport-undici` — satisfying `docs/product-spec/17-transport-adapter-conformance-contract.md` (`TRANSPORT-1`–`TRANSPORT-30`), plus the nine Deferred Items Log rows the -[Phase 8 segmentation design](./2026-07-28-phase8-segmentation-design.md) routed here: `SEAM-30`/`SEAM-14`/ +[Phase 8 segmentation design](../2026-07-28-phase8-segmentation-design.md) routed here: `SEAM-30`/`SEAM-14`/ `SEAM-12` (Phase 2), the transport half of `NFR-2` and `NFR-15` (Phase 0), `FileBody` (Phase 3b brainstorm), and the `challengeHandler` protocol (Phase 7a brainstorm). This is the first of two Phase 8 sub-phases; 8b (`@dexpace/rx`, `§18`) has no dependency on this one and may execute in either order. **Governing documents:** `docs/product-spec/17-transport-adapter-conformance-contract.md` (normative, cited by ID -throughout), `docs/superpowers/specs/2026-07-28-phase8-segmentation-design.md` (the cut, the collapse tables, the -open items this document resolves), `docs/superpowers/specs/2026-07-23-phase2-seam-foundations-design.md` (the -`Transport` interface, `composeSignal`/`isTimeoutSignal`/`CancellationError`), `docs/superpowers/plans/ +throughout), `docs/work/mvp/phase8/2026-07-28-phase8-segmentation-design.md` (the cut, the collapse tables, the +open items this document resolves), `docs/work/mvp/phase2/2026-07-23-phase2-seam-foundations-design.md` (the +`Transport` interface, `composeSignal`/`isTimeoutSignal`/`CancellationError`), `docs/work/mvp/phase3/phase3b/ 2026-07-25-phase3b-body-lifecycle.md` (`Body`, `Request.body`, `Response.body`/`.close()`), `docs/sdk-design-nodejs/02-package-and-workspace-layout.md`, `docs/sdk-design-nodejs/03-seam-by-seam-idiomatic-mapping.md` §3.2, `docs/knowledge/{transport-adapter,concurrency-and-async,message-bodies,resource-management, diff --git a/docs/superpowers/plans/2026-07-28-phase8a-transport.md b/docs/work/mvp/phase8/phase8a/2026-07-28-phase8a-transport.md similarity index 99% rename from docs/superpowers/plans/2026-07-28-phase8a-transport.md rename to docs/work/mvp/phase8/phase8a/2026-07-28-phase8a-transport.md index 4f2201b..d9ca69c 100644 --- a/docs/superpowers/plans/2026-07-28-phase8a-transport.md +++ b/docs/work/mvp/phase8/phase8a/2026-07-28-phase8a-transport.md @@ -6,7 +6,7 @@ `@dexpace/body-file`, and `@dexpace/transport-shared`, plus the unpublished `@dexpace/transport-conformance` devDependency and two small retrofits to `@dexpace/core` (`TransportFailureError`, `FileBodyDescriptor`) — satisfying `docs/product-spec/17-transport-adapter-conformance-contract.md` -(`TRANSPORT-1`–`TRANSPORT-30`) per `docs/superpowers/specs/2026-07-28-phase8a-transport-design.md`. +(`TRANSPORT-1`–`TRANSPORT-30`) per `docs/work/mvp/phase8/phase8a/2026-07-28-phase8a-transport-design.md`. **Architecture:** Five new workspace packages plus two amendments to already-shipped `@dexpace/core` files. Both transports implement the identical `Transport` interface (Phase 2, unchanged) and are proven against one shared diff --git a/docs/superpowers/plans/2026-07-28-phase8b-async-runtime-checklist.md b/docs/work/mvp/phase8/phase8b/2026-07-28-phase8b-async-runtime-checklist.md similarity index 100% rename from docs/superpowers/plans/2026-07-28-phase8b-async-runtime-checklist.md rename to docs/work/mvp/phase8/phase8b/2026-07-28-phase8b-async-runtime-checklist.md diff --git a/docs/superpowers/specs/2026-07-28-phase8b-async-runtime-design.md b/docs/work/mvp/phase8/phase8b/2026-07-28-phase8b-async-runtime-design.md similarity index 97% rename from docs/superpowers/specs/2026-07-28-phase8b-async-runtime-design.md rename to docs/work/mvp/phase8/phase8b/2026-07-28-phase8b-async-runtime-design.md index a40f5ca..195296f 100644 --- a/docs/superpowers/specs/2026-07-28-phase8b-async-runtime-design.md +++ b/docs/work/mvp/phase8/phase8b/2026-07-28-phase8b-async-runtime-design.md @@ -4,15 +4,15 @@ **Purpose:** Implement `@dexpace/rx`, exposing Phase 6's `Paginator`/`Page` and `SseStream` as RxJS `Observable`s — satisfying the non-collapsed subset of `docs/product-spec/18-asynchronous-runtime-adapter-contract.md` -(`ASYNC-*`) identified in the [Phase 8 segmentation design](./2026-07-28-phase8-segmentation-design.md) §5.2, plus +(`ASYNC-*`) identified in the [Phase 8 segmentation design](../2026-07-28-phase8-segmentation-design.md) §5.2, plus `SSE-41` (the reactive SSE adapter, Phase 6 brainstorm). Second of two Phase 8 sub-phases; has no dependency on 8a and may execute in either order. **Governing documents:** `docs/product-spec/18-asynchronous-runtime-adapter-contract.md`, `docs/product-spec/ -13-server-sent-events-and-streaming.md` (`SSE-41`, `SSE-26`'s single-pass rule), `docs/superpowers/specs/ +13-server-sent-events-and-streaming.md` (`SSE-41`, `SSE-26`'s single-pass rule), `docs/work/mvp/phase8/ 2026-07-28-phase8-segmentation-design.md` §4/§5.2/§7 (this document resolves every 8b open item that document -flagged), `docs/superpowers/specs/2026-07-28-phase6b-sse-design.md` (`SseStream`, `typedSseStream`), -`docs/superpowers/specs/2026-07-28-phase6c-pagination-design.md` (`Paginator`, `Page`), `docs/superpowers/specs/ +flagged), `docs/work/mvp/phase6/phase6b/2026-07-28-phase6b-sse-design.md` (`SseStream`, `typedSseStream`), +`docs/work/mvp/phase6/phase6c/2026-07-28-phase6c-pagination-design.md` (`Paginator`, `Page`), `docs/work/mvp/phase7/phase7b/ 2026-07-28-phase7b-observability-design.md` (the `AsyncLocalStorage` diagnostic-context bridge this phase reuses), `docs/sdk-design-nodejs/02-package-and-workspace-layout.md`, `docs/knowledge/{concurrency-and-async, sse-streaming,pagination,observability,resource-management}.md`. diff --git a/docs/superpowers/plans/2026-07-28-phase8b-async-runtime.md b/docs/work/mvp/phase8/phase8b/2026-07-28-phase8b-async-runtime.md similarity index 99% rename from docs/superpowers/plans/2026-07-28-phase8b-async-runtime.md rename to docs/work/mvp/phase8/phase8b/2026-07-28-phase8b-async-runtime.md index e8ee2ce..7a0b8fc 100644 --- a/docs/superpowers/plans/2026-07-28-phase8b-async-runtime.md +++ b/docs/work/mvp/phase8/phase8b/2026-07-28-phase8b-async-runtime.md @@ -3,7 +3,7 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Ship `@dexpace/rx`, exposing Phase 6's `SseStream`/`typedSseStream` and `Paginator` as RxJS `Observable`s, -per `docs/superpowers/specs/2026-07-28-phase8b-async-runtime-design.md`. Satisfies the non-collapsed subset of +per `docs/work/mvp/phase8/phase8b/2026-07-28-phase8b-async-runtime-design.md`. Satisfies the non-collapsed subset of `docs/product-spec/18-asynchronous-runtime-adapter-contract.md` and `SSE-41`. **Architecture:** One thin package. The bridge logic itself is RxJS's own native `from(asyncIterable)` — this diff --git a/docs/superpowers/plans/2026-07-28-phase9-cross-cutting-conformance-checklist.md b/docs/work/mvp/phase9/2026-07-28-phase9-cross-cutting-conformance-checklist.md similarity index 100% rename from docs/superpowers/plans/2026-07-28-phase9-cross-cutting-conformance-checklist.md rename to docs/work/mvp/phase9/2026-07-28-phase9-cross-cutting-conformance-checklist.md diff --git a/docs/superpowers/specs/2026-07-28-phase9-cross-cutting-conformance-design.md b/docs/work/mvp/phase9/2026-07-28-phase9-cross-cutting-conformance-design.md similarity index 99% rename from docs/superpowers/specs/2026-07-28-phase9-cross-cutting-conformance-design.md rename to docs/work/mvp/phase9/2026-07-28-phase9-cross-cutting-conformance-design.md index 86d9473..3459872 100644 --- a/docs/superpowers/specs/2026-07-28-phase9-cross-cutting-conformance-design.md +++ b/docs/work/mvp/phase9/2026-07-28-phase9-cross-cutting-conformance-design.md @@ -13,8 +13,8 @@ a gap a prior phase left open. **Governing documents:** `docs/product-spec/19-cross-cutting-invariants-and-policies.md`, `docs/product-spec/20-non-functional-requirements-and-quality-bar.md`, -`docs/product-spec/appendix-b-conformance-test-checklist.md` (§B.8, §B.9), `docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md` -(Deferred Items Log), `docs/superpowers/plans/2026-07-25-checkpoint-scaffold-through-phase3a.md` (the one prior +`docs/product-spec/appendix-b-conformance-test-checklist.md` (§B.8, §B.9), `docs/work/mvp/2026-07-23-nodejs-sdk-v1-roadmap-design.md` +(Deferred Items Log), `docs/work/mvp/2026-07-25-checkpoint-scaffold-through-phase3a.md` (the one prior cross-phase audit this roadmap has produced — its structure and its unclosed action items are both inputs here), every prior phase's own design/plan (cited per-ID below). `docs/knowledge/{cross-cutting-invariants,testing, tooling-and-quality-gates,deliberate-deviations,seams-and-extensibility,resource-management,cancellation-and-timeouts, diff --git a/docs/superpowers/plans/2026-07-28-phase9-cross-cutting-conformance.md b/docs/work/mvp/phase9/2026-07-28-phase9-cross-cutting-conformance.md similarity index 99% rename from docs/superpowers/plans/2026-07-28-phase9-cross-cutting-conformance.md rename to docs/work/mvp/phase9/2026-07-28-phase9-cross-cutting-conformance.md index f5168df..e522335 100644 --- a/docs/superpowers/plans/2026-07-28-phase9-cross-cutting-conformance.md +++ b/docs/work/mvp/phase9/2026-07-28-phase9-cross-cutting-conformance.md @@ -5,7 +5,7 @@ **Goal:** Prove `docs/product-spec/19-cross-cutting-invariants-and-policies.md` (`XCUT-1`–`XCUT-24`) and `docs/product-spec/20-non-functional-requirements-and-quality-bar.md` (`NFR-1`–`NFR-17`) hold across the composed workspace, ship `@dexpace/shrink-test` (`NFR-9`), and close three stale `unresolved 2026-07-25` markers in -`docs/knowledge/tooling-and-quality-gates.md` — per `docs/superpowers/specs/2026-07-28-phase9-cross-cutting-conformance-design.md`. +`docs/knowledge/tooling-and-quality-gates.md` — per `docs/work/mvp/phase9/2026-07-28-phase9-cross-cutting-conformance-design.md`. **Architecture:** One new private/unpublished devDependency package (`@dexpace/shrink-test`) plus one new top-level integration-test directory (`tests/conformance/xcut/`, the first use of `docs/knowledge/testing.md:8`'s @@ -901,7 +901,7 @@ Expected: PASS, 3 tests. - [ ] **Step 3: Retrofit `XCUT-12` and `XCUT-22`** -Add `XCUT-12` to 5c's existing credential-cache single-flight test's header comment (`docs/superpowers/plans/2026-07-26-phase5c-auth.md`'s own test already races N callers on an expiring token). Add `XCUT-22` to 8a's +Add `XCUT-12` to 5c's existing credential-cache single-flight test's header comment (`docs/work/mvp/phase5/phase5c/2026-07-26-phase5c-auth.md`'s own test already races N callers on an expiring token). Add `XCUT-22` to 8a's existing `undici-transport.test.ts` BYO-dispatcher test ("closing a transport built from a BYO Agent does not close that agent") — both comment-only. @@ -1105,7 +1105,7 @@ git commit -m "test(conformance): add XCUT-24 large-body diagnostic-preview test confirmed 2026-07-28 Phase 9 audit). The scaffold implements Bun (`bun.lock`, `.bun-version`, `bun install --frozen-lockfile` as the CI gate) throughout; the design's pnpm/`catalog:` framing describes a toolchain this repository does not use. Decision recorded at - `docs/superpowers/plans/2026-07-23-scaffold-milestone-checklist.md:54`; the enforcement properties pnpm's + `docs/work/mvp/scaffold/2026-07-23-scaffold-milestone-checklist.md:54`; the enforcement properties pnpm's layout gave for free (isolated linker, workspace catalogs) were restored separately — see the Bun workspace catalogs adopted in Phase 6a and the isolated linker set at the 2026-07-25 checkpoint. <sub>design `docs/sdk-design-nodejs/02-package-and-workspace-layout.md:50-51` · styleguide diff --git a/docs/superpowers/plans/2026-07-23-scaffold-milestone-checklist.md b/docs/work/mvp/scaffold/2026-07-23-scaffold-milestone-checklist.md similarity index 98% rename from docs/superpowers/plans/2026-07-23-scaffold-milestone-checklist.md rename to docs/work/mvp/scaffold/2026-07-23-scaffold-milestone-checklist.md index d102d10..639a53c 100644 --- a/docs/superpowers/plans/2026-07-23-scaffold-milestone-checklist.md +++ b/docs/work/mvp/scaffold/2026-07-23-scaffold-milestone-checklist.md @@ -9,7 +9,7 @@ requirement-ID prefixes. Seventeen of them (`HTTP`, `IO`, `BODY`, `CTX`, `PIPE`, `PAGE`, `SSE`, `SERDE`, `OBS`, `CFG`, `TRANSPORT`, `ASYNC`, `XCUT`) are behavioral contracts on domain code — this phase ships zero domain code, so none of them are evaluable yet. They aren't listed item-by-item below; they're tracked at their respective phases in -[2026-07-23-nodejs-sdk-v1-roadmap-design.md](../specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md). Only the two +[2026-07-23-nodejs-sdk-v1-roadmap-design.md](../2026-07-23-nodejs-sdk-v1-roadmap-design.md). Only the two prefixes with toolchain/architectural-level applicability — `NFR` (17 requirements) and `SEAM` (2 of its 30 requirements: `SEAM-1`/`SEAM-2`, the architectural ones; `SEAM-3` onward are seam *behavior* contracts, equally out of scope until Phase 2) — are checked here. diff --git a/docs/superpowers/specs/2026-07-23-scaffold-milestone-design.md b/docs/work/mvp/scaffold/2026-07-23-scaffold-milestone-design.md similarity index 98% rename from docs/superpowers/specs/2026-07-23-scaffold-milestone-design.md rename to docs/work/mvp/scaffold/2026-07-23-scaffold-milestone-design.md index c9af5a7..27f86e2 100644 --- a/docs/superpowers/specs/2026-07-23-scaffold-milestone-design.md +++ b/docs/work/mvp/scaffold/2026-07-23-scaffold-milestone-design.md @@ -4,7 +4,7 @@ **Purpose:** Bootstrap the `nodejs-sdk` repository from its current state (docs only, no `package.json`) to a buildable, lintable, testable, dual-consumable state with **zero domain code**. This is Phase 0 of the -[v1 roadmap](./2026-07-23-nodejs-sdk-v1-roadmap-design.md) and the only phase this document covers in detail. +[v1 roadmap](../2026-07-23-nodejs-sdk-v1-roadmap-design.md) and the only phase this document covers in detail. **Why this comes first:** every later phase is written under the styleguide and toolchain gates from line one. Building domain code before the gates exist means retrofitting lint rules, coverage floors, and API-compatibility diff --git a/docs/superpowers/plans/2026-07-23-scaffold-milestone.md b/docs/work/mvp/scaffold/2026-07-23-scaffold-milestone.md similarity index 99% rename from docs/superpowers/plans/2026-07-23-scaffold-milestone.md rename to docs/work/mvp/scaffold/2026-07-23-scaffold-milestone.md index 6f14afe..e6c886f 100644 --- a/docs/superpowers/plans/2026-07-23-scaffold-milestone.md +++ b/docs/work/mvp/scaffold/2026-07-23-scaffold-milestone.md @@ -773,7 +773,7 @@ exist there. ## Self-Review -**Spec coverage** (against `docs/superpowers/specs/2026-07-23-scaffold-milestone-design.md`): +**Spec coverage** (against `docs/work/mvp/scaffold/2026-07-23-scaffold-milestone-design.md`): - Workspace init (Bun, `.bun-version`, `bun.lock`, `packages/*`) → Task 1, 3. - Stub `@dexpace/core` with placeholder export → Task 3, 4. - Full toolchain gate table (package manager, lint, type strictness, explicit API surface, API-compatibility diff --git a/open-items.md b/open-items.md deleted file mode 100644 index 2142aa0..0000000 --- a/open-items.md +++ /dev/null @@ -1,182 +0,0 @@ -# Open Items — Phase 5a (Retry) - -Findings from the Phase 5a code review (passes 1–3, 2026-08-26) that were **deliberately not fixed**, -each with the reason and the owner. Everything here is either correct-but-surprising behavior worth -pinning down, a limitation the platform imposes, or a defect whose fix belongs to another phase. - -Findings that *were* fixed are not listed — they are in the code and its tests. This file is only for -what is still open. - -**Status legend:** 🔴 defect, owner named — 🟡 accepted limitation — 🟢 correct, documented to stop a -future "fix" — 📄 documentation drift. - ---- - -## 🔴 `toHttpError`'s `finally` can let a teardown failure mask the drain failure - -**Where:** `packages/core/src/body/http-status-error.ts:106-109` (Phase 3b) - -```ts -} finally { - reader?.releaseLock(); - await response.close(); -} -``` - -`Response.close()` documents `@throws Whatever cancelling the body stream raises, other than the -TypeError a locked stream reports`. Awaiting it in a bare `finally` means a teardown failure replaces -whatever the `try` was propagating — the inversion `RECOV-12` forbids and `suppress()` exists to -prevent (`packages/core/src/suppress.ts` says so in its own doc comment, about native `using`). - -**Why it is not urgent:** cancelling an *errored* `ReadableStream` rejects with the stream's stored -error rather than invoking the source's `cancel` hook, so on the common path `close()` rethrows the -very error already propagating and the masking is unobservable. It becomes observable only for a -stream whose `cancel` hook fails independently of the read that failed. - -**Why it is not fixed here:** shipped Phase 3b code with its own tests, outside 5a's scope. Phase 5a -fixed the same shape at its own call site (`retry/engine.ts`'s `releaseQuietly` / -`withReleaseFailure`), which is what made the upstream instance visible. - -**Owner:** Phase 10 (Deviation Reconciliation), or a Phase 3b follow-up. - ---- - -## 🔴 `RequestOptionsBuilder.maxRetries` — fixed here, but the pattern deserves a sweep - -**Where:** `packages/core/src/http/request-options.ts` (Phase 1) - -Fixed in this phase (see `.changeset/2026-08-26-max-retries-range-check.md`): the setter rejected only -`value < 0`, so `Infinity`, `NaN`, and fractions reached a consumer as a retry budget that never -terminates. - -**What is still open:** the *class* of bug, not this instance. `timeoutMs` next door has the same -shape — it rejects `<= 0` and accepts `Infinity`/`NaN`. A non-finite timeout is less dangerous than a -non-finite retry ceiling (it degrades to "no deadline" rather than "never stop"), but it is the same -gap in the same requirement (`HTTP-35`), and no other numeric public setter has been audited. - -**Owner:** Phase 10, as a sweep over every public numeric setter — is the range check the full range, -or only its lower bound? - ---- - -## 🟡 `RetrySettings.retryableStatuses` is immutable by type, not at runtime - -**Where:** `packages/core/src/retry/settings.ts` - -`retrySettings()` returns `Object.freeze({...})`, but freeze is shallow and does not seal a `Set`'s -internal slots: anyone holding the settings object can still call `.add()` on the status set and -change policy for every later call. - -`RECOV-34`'s actual requirement — a *defensive copy* so a caller mutating **their own** source -collection cannot alter policy — is satisfied and tested. What is not achievable is `RETRY-42`'s -"immutable after construction" as a runtime guarantee. - -This is a deliberate house position, not an oversight: `config/retryable.ts` records it — *"`Object.freeze` -does not seal a `Set`'s internal slots, so a frozen `Set` would be a misleading no-op — typed -`ReadonlySet` instead, same treatment as Phase 1's `IDEMPOTENT_METHODS`."* A genuine runtime guarantee -would need a wrapper object with no mutators, which changes the shape every consumer reads. - -**Owner:** none. Recorded so the gap between the type-level and runtime guarantee is not rediscovered -as a bug. - ---- - -## 🟡 `RETRY-18`'s 365-day pacing ceiling is spec-mandated and operationally hazardous - -**Where:** `packages/core/src/retry/pacing.ts` - -A server that sends `X-RateLimit-Reset` in **milliseconds** instead of epoch seconds — a common -server-side mistake — produces a delta of roughly 56,000 years. `RETRY-18`/`RECOV-26` require -clamping to a 365-day ceiling, so the parser returns exactly that: a retry parked for a year, which -is indistinguishable from a hang. - -Nothing shortens it by default. `totalTimeoutMs` would, but `RETRY-28` makes it explicitly opt-in and -it is `undefined` by default. The caller's own `AbortSignal` is the only other exit. - -Implementing a tighter ceiling would be a deviation from a MUST, so the port complies. Recorded -because "spec-compliant" and "safe by default" diverge here, and the mitigation (set -`totalTimeoutMs`) is a caller decision that needs documenting when the retry surface is finally -published in Phase 5c. - -**Owner:** Phase 5c, as a documentation obligation on the public retry surface. - ---- - -## 🟡 `parsePacingHint` reads only the first value of a repeated header - -**Where:** `packages/core/src/retry/pacing.ts` - -`Headers.get()` returns the first value. Given `Retry-After: garbage` followed by `Retry-After: 5`, -the parser tries `garbage`, fails, falls through the remaining header names, and returns `null` — no -hint, fall back to backoff — rather than trying the second value. - -Safe (`RETRY-16`'s fallback is the conservative answer) and arguably correct, since a repeated -`Retry-After` is malformed to begin with. `RETRY-21`'s precedence is defined across header *names*, -not across duplicate values of one name, so nothing requires the second value to be tried. - -**Owner:** none. Recorded because "first usable value wins" reads, on a fast skim of `RETRY-21`, like -it should scan duplicates too. - ---- - -## 🟢 A fixed delay is deliberately not clamped to `maxDelayMs` - -**Where:** `packages/core/src/retry/backoff.ts` - -`computeDelay` returns `fixedDelayMs` before the cap is applied, so `fixedDelayMs: 3_600_000` with -`maxDelayMs: 8000` waits an hour. This looks like a missed clamp and is not: `RETRY-43` describes the -mode as *"zeroing the base and cap so only the fixed delay applies"* — the cap is part of the schedule -this mode replaces, not a bound that outlives it. - -Documented in the field's own TSDoc. Listed here so a future reviewer reaches the reasoning before -"fixing" it. - ---- - -## 🟢 A response that ends the retry loop is handed over live, not closed - -**Where:** `packages/core/src/retry/engine.ts` - -`RETRY-32` says *"any response that arrives from an already-in-flight attempt MUST be closed rather -than leaked."* The engine closes every response it **discards**. A response that survives the gates — -attempt cap reached, budget spent, status not retryable — is returned **live and unread**, even when -the caller has already aborted. - -That is not a leak: ownership transfers to the caller, which is the only reader that could close it, -and a `Promise` always resolves to its awaiter, so this port has no "value that can never be -delivered" case for the reference's orphan rule to bite on. Both halves are asserted. - -The narrowing is inseparable from `RETRY-36`'s disposition (`toHttpError` drains the body and drops -the headers irreversibly, and 4c's pillar signature must return a `Response`), which the phase design -already ledgers. - ---- - -## 📄 The Phase 5a design doc overstates the `RETRY-32` guarantee - -**Where:** `docs/superpowers/specs/2026-07-26-phase5a-retry-design.md`, "The wait" - -> `RETRY-32`: once the caller's signal is aborted the driver launches no further attempts, and any -> response arriving from an in-flight attempt is closed rather than leaked. - -The second clause describes only responses the engine discards — see the item above. The -implementation checklist carries the corrected wording; the design doc still carries the blanket -claim, and was left alone because it is a phase design of record, not a working document. - -**Owner:** Phase 9 (cross-cutting conformance), which reads these documents as its source. - ---- - -## 📄 Phase 7b still owes `engine.ts` two log events - -**Where:** `packages/core/src/retry/engine.ts` (head comment) - -`RETRY-40`'s "log the failure" clause and the two `SHOULD`-level structured events -(`retry.attemptFailed`, `retry.exhausted`) are specified in 5a's plan but written by Phase 7b Task 9 — -5a executes before 7b, and 7b depends on 5a's `FakeTransport`, so the cycle can only be broken in this -direction. The non-fatal half of `RETRY-40` **is** implemented here. - -Already recorded in the roadmap's Deferred Items Log; repeated here so this file is a complete picture -of what Phase 5a knowingly left undone. - -**Owner:** Phase 7b, Task 9. diff --git a/packages/codec-json/README.md b/packages/codec-json/README.md index ca35200..8d440dc 100644 --- a/packages/codec-json/README.md +++ b/packages/codec-json/README.md @@ -31,10 +31,10 @@ The schema you pass is both the runtime witness and the source of the static typ separate type argument to keep in sync. - **PATCH three-state fields** — `tristate()` and `tristateObject()`, documented on their own TSDoc in - [`src/tristate-schema.ts`](./src/tristate-schema.ts). Absent omits the key, Null emits a wire + [`src/tristate-schema.ts`](https://github.com/dexpace/nodejs-sdk/blob/main/packages/codec-json/src/tristate-schema.ts). Absent omits the key, Null emits a wire `null`, Present emits the value; the wiring is on by default and `jsonSerde({tristate: false})` is the only way out. - **Unknown wire fields** — your schema's decision, not this codec's. The rationale and the - recommendation are on `jsonSerde`'s own TSDoc in [`src/json-serde.ts`](./src/json-serde.ts). + recommendation are on `jsonSerde`'s own TSDoc in [`src/json-serde.ts`](https://github.com/dexpace/nodejs-sdk/blob/main/packages/codec-json/src/json-serde.ts). - **A top-level wire `null` never decodes**, and a top-level `undefined`, function, or symbol raises `SerializationError` rather than encoding as `null`. Both are on `jsonSerde`'s TSDoc too. diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000..76bf621 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,171 @@ +# @dexpace/core + +The transport-agnostic HTTP core of the dexpace SDK: an immutable request/response domain model, a +staged policy pipeline, and the seams everything else plugs into. **Zero runtime dependencies**, ESM +only, Node ≥ 20.3. + +It is deliberately not an HTTP client — it never opens a socket. Pair it with a transport. + +```sh +bun add @dexpace/core @dexpace/transport-fetch +``` + +```typescript +import {Request, standardResilience} from '@dexpace/core'; +import {fetchTransport} from '@dexpace/transport-fetch'; + +const client = standardResilience(fetchTransport()); + +const response = await client.send( + Request.newBuilder().url('https://api.example.com/v1/things').build(), +); +try { + console.log(response.status.code, await response.text()); +} finally { + await response.close(); // the caller owns the body, always (BODY-15) +} +``` + +That is the whole zero-to-one path. `standardResilience()` returns a `Runtime` with redirect, retry, +auth and logging already installed in the order `AUTH-27` requires — redirect wraps retry wraps auth +— so a retry re-resolves credentials and a redirect hop re-stamps them. + +## Which package do I install? + +`@dexpace/core` alone gets you the models and the pipeline. Everything that touches a platform API +lives in a sibling package, because core's zero-dependency and zero-`node:`-import invariants are +hard (`SEAM-1`, gate-enforced by `bun run verify:seam-1`). + +| You need | Install | +|---|---| +| To send a request, no extra dependencies | `@dexpace/transport-fetch` | +| Connection pools, proxies, real `close()` semantics | `@dexpace/transport-undici` | +| A JSON wire codec behind the `Serde` seam | `@dexpace/codec-json` | +| A file-backed request body (`node:fs`) | `@dexpace/body-file` | +| Logs routed to `pino` or `debug` | `@dexpace/logging-pino`, `@dexpace/logging-debug` | +| RxJS `Observable` views of SSE and pagination | `@dexpace/rx` | + +Every one of them declares `@dexpace/core` as a **peer**, never a dependency: two copies of core in +one install would defeat the branded symbols and `instanceof` checks the seams rely on. + +## The five things worth knowing before reading source + +**1. Models are frozen and builder-built.** There is no public constructor on `Request`, `Response`, +`Headers`, `QueryParams`, `RequestOptions` or `RequestConditions` — `newBuilder()` is the only way +in, so validation cannot be routed around (`HTTP-2`). `newBuilder()` on an *instance* returns a +pre-filled builder that deep-copies every collection, so deriving never aliases the source +(`HTTP-3`). + +```typescript +import {Request} from '@dexpace/core'; + +const request = Request.newBuilder().url('https://api.example.com/v1/things').build(); + +const authorized = request + .newBuilder() + .headers(request.headers.newBuilder().set('Authorization', 'Bearer …').build()) + .build(); +``` + +**2. `Status` is total.** `Status.of(599)` succeeds, reports `isServerError`, and has `name === +undefined` and `isRecognized === false`; `Status.recognized(599)` returns `undefined` so a caller can +tell a vendor code from a registered one. An unrecognized code is never an error — a server is free +to invent one. + +**3. A body is a producer, not a buffer.** `byteArrayBody`, `stringBody`, `formUrlEncodedBody`, +`multipartBody`, `streamBody` and `serdeBody` are the factories; the classes are exported as types +only. `body.replayable` decides whether a retry can re-send it, and `materialize(body)` buys +replayability by buffering. `streamBody` is single-use by construction. + +**4. The caller owns the response body.** `response.close()` is yours to call, on every path, +including the ones where an error is propagating. Nothing in the pipeline closes a response it hands +you. + +**5. Errors are a two-level tree.** `DexpaceError` at the root, one subclass per subsystem — +`DomainModelError`, `IoError`, `HttpStatusError`, `AuthResolutionError`, `PaginationError`, +`SerializationError`/`DeserializationError`, `SseStreamError`, `CancellationError`. Exactly one +sanctioned third level: `TransportFailureError extends IoError`, so `catch (e) { if (e instanceof +IoError) }` still catches a transport failure (`docs/deviations.md` item 17). Wrap-and-rethrow always +passes `{cause}`. + +## Building a pipeline yourself + +`standardResilience()` is a preset over `PipelineBuilder`. When it is the wrong shape, layer onto it: + +```typescript +import {PipelineBuilder, standardResilience, type Step} from '@dexpace/core'; +import {fetchTransport} from '@dexpace/transport-fetch'; + +const stamp: Step = async (request, ctx) => + ctx.next( + request + .newBuilder() + .headers(request.headers.newBuilder().set('X-Client-Phase', ctx.context.kind).build()) + .build(), + ); + +const runtime = PipelineBuilder.seedFrom( + standardResilience(fetchTransport(), {retry: {settings: {maxAttempts: 5}}}), + 'flatten', +) + .append({type: Symbol('x-client-phase'), stage: 'POST_SERDE', fn: stamp}) + .build(); +``` + +Steps run in `STAGE_ORDER`, sixteen stages from `PRE_REDIRECT` to `SEND`. The five **pillar** stages +— `REDIRECT`, `RETRY`, `AUTH`, `LOGGING`, `SERDE` (`PILLAR_STAGES`) — admit exactly one step each and +raise on a second; the surrounding `PRE_`/`POST_` stages stack. +`seedFrom(runtime, 'flatten' | 'nest')` is how the preset composes with a customized builder, rather +than the preset growing a "skip occupied slots" branch. + +**Start from `seedFrom`, not from a bare `new PipelineBuilder(transport)`, if you want redirects.** +`redirectStep()` is public but its companion `POST_AUTH` guard is not: the redirect pillar marks a +cross-origin hop with an internal header, and the step that strips it before dispatch is `@internal` +and reachable only through the preset. A hand-built pipeline that installs `redirectStep()` directly +forwards that marker to the wire (`docs/open-items.md` U7). + +`Runtime` implements `Transport`, so a pipeline is substitutable for the transport it wraps. +`Runtime.close()` is a documented no-op: the pipeline never owns the transport it was given +(`PIPE-27`). + +## Beyond request/response + +- **Serde.** `Serde`/`Serializer`/`Deserializer`/`Schema` are the seam; `decodeResponse` and + `decodeSuccessResponse` are the response handlers; `Tristate` models PATCH's + absent/null/present distinction so `{}` and `{"x": null}` stop being the same wire message. Core + ships no codec — `@dexpace/codec-json` is the reference one. +- **Server-Sent Events.** `sseStreamFrom(response)` yields an `SseStream` of `SseEvent`; + `typedSseStream(stream, mapper)` decodes into your own models. Single-pass over a response body + this stream does not own, and no reconnect path in core (`SSE-37`/`SSE-38`, gate-enforced). +- **Pagination.** `Paginator` iterates `items()` or `pages()`; `cursorStrategy`, + `pageNumberStrategy` and `linkHeaderStrategy` cover the three shipped shapes, and + `PaginationStrategy` is the seam for the rest. A `Page` is closed before its items are yielded + (`PAGE-11`). +- **Configuration.** `Configuration` is a layered lookup — explicit override, then the environment + source under the exact key, then the property source under a normalized (lower-cased, dotted) key, + then your fallback — built through `ConfigurationBuilder`. Both sources are caller-supplied seams + (`CFG-11`), so a test substitutes them without touching the real environment. + `getGlobalConfiguration()`/`setGlobalConfiguration()` hold the process-wide slot. +- **Observability.** `Logger` is a facade with `NOOP_LOGGER` as the default; `createLogger(sink)` + adapts anything. `Tracer`/`Span`/`Meter` are duck-typed, so an OpenTelemetry object satisfies them + with no adapter and no registration. + +## Where the details are + +This README gets you running. It is deliberately not the API reference — that is generated and +gate-verified, and a hand-written third copy would drift: + +- **Every exported symbol, with its signature:** + [`etc/core.api.md`](https://github.com/dexpace/nodejs-sdk/blob/main/packages/core/etc/core.api.md), regenerated by `bun run api:local` and + verified in CI by `bun run api`. +- **What each symbol means, `@throws` included:** the TSDoc, which ships in the emitted `.d.ts` and + shows up on hover. +- **How the packages compose, with worked cross-package examples:** + [`docs/sdk-documentation/`](https://github.com/dexpace/nodejs-sdk/blob/main/docs/sdk-documentation). +- **What is normative:** [`docs/product-spec/`](https://github.com/dexpace/nodejs-sdk/blob/main/docs/product-spec). Every `HTTP-N`, `SEAM-N`, + `RETRY-N` identifier in this README and in the source is an entry there. + +Every link above is absolute on purpose. `package.json` ships `files: ["dist"]`, so none of these +paths exist in the published tarball, and no manifest carries a `repository` field for npm's renderer +to rewrite a relative link with — so on npmjs.com a relative one renders broken. That is `U8`'s +failure class, and the first place to check when adding a link here. diff --git a/packages/core/src/auth/preset.ts b/packages/core/src/auth/preset.ts index 9455dd9..a67d0dd 100644 --- a/packages/core/src/auth/preset.ts +++ b/packages/core/src/auth/preset.ts @@ -67,6 +67,10 @@ function noAuthSettings(): AuthStepSettings { * configuration that determines whether they can occur at all. * * @param transport - the terminal transport. Never closed by the pipeline (PIPE-27). + * **Exceeding the redirect hop cap does NOT throw** (REDIR-17): the current 3xx response is returned + * to the caller unfollowed. See {@link redirectStep}. Stated before the tags below because TSDoc + * folds trailing prose into the preceding block tag. + * * @param options - per-pillar overrides. * @returns the built, immutable runtime. * @throws PlaintextCredentialError — from the returned runtime's `send()` — when a credentialed scheme @@ -77,7 +81,6 @@ function noAuthSettings(): AuthStepSettings { * @throws HeaderValidationError — from the returned runtime's `send()` — when credential material * will not fit in a header value (HTTP-18). * @throws SchemeDowngradeError — from the returned runtime's `send()` — when a redirect attempts an HTTPS to HTTP downgrade not permitted by settings (REDIR-14/15). - * @throws MaxHopsExceededError — from the returned runtime's `send()` — when redirects exceed maxHops (REDIR-17/22). * @throws InvariantViolation — synchronously from this function — when any pillar's settings are * invalid, including a non-finite bearer refresh margin or a non-header-safe Digest username. A * caller-supplied `TokenProvider` or `challengeHook` error passes through `send()` unwrapped. diff --git a/packages/core/src/config/build-info.ts b/packages/core/src/config/build-info.ts index 4ba9a67..3f8d55d 100644 --- a/packages/core/src/config/build-info.ts +++ b/packages/core/src/config/build-info.ts @@ -34,8 +34,8 @@ export interface RuntimeHost { * Printable ASCII plus HTAB -- the outbound header value grammar's character class, restated here. * * Deliberately *not* `hasForbiddenOutboundByte` from `http/ascii-validation.js`. `config/`'s - * outbound edges are already a live concern (`docs/open-items.md` G11), and adding a second one to - * reuse a four-line predicate is the wrong trade. `docs/open-items.md` G18 owns the duplication and + * outbound edges are already a live concern (`docs/open-items.md` K11), and adding a second one to + * reuse a four-line predicate is the wrong trade. `docs/open-items.md` K18 owns the duplication and * names this as one of the call sites a consolidation would fold in. */ function isHeaderSafe(value: string): boolean { diff --git a/packages/core/src/config/configuration.test.ts b/packages/core/src/config/configuration.test.ts index d1a7901..6493599 100644 --- a/packages/core/src/config/configuration.test.ts +++ b/packages/core/src/config/configuration.test.ts @@ -8,7 +8,7 @@ // platform environment), CFG-13 (global slot, last-write-wins), CFG-37 (fail-fast when a required // argument is not the shape its parameter names), CFG-38 (typed accessors resolve through the full // layered lookup). -// CFG-12 is deliberately untested: `docs/open-items.md` G3 records that a single-threaded-use +// CFG-12 is deliberately untested: `docs/open-items.md` K3 records that a single-threaded-use // statement has no observable behavior in this runtime to assert. import {describe, expect, test} from 'bun:test'; import fc from 'fast-check'; diff --git a/packages/core/src/config/configuration.ts b/packages/core/src/config/configuration.ts index 3db4ef8..b4649fb 100644 --- a/packages/core/src/config/configuration.ts +++ b/packages/core/src/config/configuration.ts @@ -32,7 +32,7 @@ const STRICT_INTEGER = /^[+-]?\d+$/u; * an absent key. The production seam additionally guards the prototype case at its own source; this * guard is the one that holds for a seam this package did not write. * - * The residue: a seam failure is now silently invisible. `docs/open-items.md` G14 owns surfacing it, + * The residue: a seam failure is now silently invisible. `docs/open-items.md` K14 owns surfacing it, * alongside CFG-24's warning, once a `Logger` seam exists to surface it *to*. */ function readLayer(source: SourceFn, key: string): string | undefined { diff --git a/packages/core/src/config/equality.test.ts b/packages/core/src/config/equality.test.ts index 0e38a5a..55466c7 100644 --- a/packages/core/src/config/equality.test.ts +++ b/packages/core/src/config/equality.test.ts @@ -37,7 +37,7 @@ describe('deepEqual (CFG-33)', () => { test('overflows the stack on a self-referential array rather than terminating', () => { // Pinned, not fixed. Both helpers recurse without a cycle guard or a depth cap, and neither is - // exported from the package barrel or called by anything yet. `docs/open-items.md` G16 records + // exported from the package barrel or called by anything yet. `docs/open-items.md` K16 records // the acyclic, bounded-depth precondition the first consumer inherits. const cyclic: unknown[] = []; cyclic.push(cyclic); diff --git a/packages/core/src/redirect/redirect-step.ts b/packages/core/src/redirect/redirect-step.ts index e700646..1461dc0 100644 --- a/packages/core/src/redirect/redirect-step.ts +++ b/packages/core/src/redirect/redirect-step.ts @@ -127,11 +127,16 @@ function emitFollowEvents( * `withRedirect()`, must also install `stripCrossOriginMarkerStep()` -- otherwise REDIR-11's internal * marker reaches the transport whenever no auth step is present to strip it. * + * **Exceeding `maxHops` does NOT throw** (REDIR-17). The hop cap returns the current 3xx response to + * the caller unfollowed, which is also what `maxHops: 0` reduces to -- there is no separate "disable + * redirects" branch. `decide.ts:205` is the gate. Stated here rather than after the tags below + * because TSDoc folds trailing prose into the preceding block tag, where it would render as part of + * a `@throws` description in the emitted `.d.ts`. + * * @param overrides - redirect policy overrides; a zero-argument call yields the spec defaults. * @returns the descriptor to install in a pipeline's REDIRECT slot. * @throws SchemeDowngradeError - when an HTTPS to HTTP redirect is rejected by downgrade policy (REDIR-14, REDIR-15). * @throws NonReplayableBodyError - when a redirect requiring body resend encounters a single-use body (REDIR-6, REDIR-22). - * @throws MaxHopsExceededError - when redirects exceed maxHops (REDIR-17, REDIR-22). * * @public */ diff --git a/packages/logging-debug/README.md b/packages/logging-debug/README.md index bafacfa..291361e 100644 --- a/packages/logging-debug/README.md +++ b/packages/logging-debug/README.md @@ -1,20 +1,70 @@ # @dexpace/logging-debug -Debug logging adapter for the dexpace Node.js SDK. +Routes the dexpace SDK's structured log events into [`debug`](https://www.npmjs.com/package/debug). +Zero runtime dependencies: `@dexpace/core` and `debug` are both peers, and `debug` is an **optional** +one — this package only ever calls a duck-typed subset of it. -## Installation - -```bash -npm install @dexpace/logging-debug debug +```sh +bun add @dexpace/logging-debug @dexpace/core debug ``` -## Usage - ```typescript import debug from 'debug'; -import {createDebugLogger} from '@dexpace/logging-debug'; import {setGlobalLogger} from '@dexpace/core'; +import {createDebugLogger} from '@dexpace/logging-debug'; -// Wrap debug factory: setGlobalLogger(createDebugLogger(debug, 'dexpace')); ``` + +```sh +DEBUG='dexpace:*' node app.js # everything +DEBUG='dexpace:error,dexpace:warning' node app.js # just the loud levels +``` + +## One namespace per level + +Pass `debug` itself — the **factory** — and this adapter calls it once per level, lazily, and caches +the result: `dexpace:error`, `dexpace:warning`, `dexpace:info`, `dexpace:verbose`. That is the whole +design, and it is what makes `DEBUG` a level filter without `debug` having levels. Change the base +namespace with the second argument; it defaults to `dexpace`. + +Pass a single **debugger** instead — `createDebugLogger(debug('myapp'))` — and every level goes to +that one namespace. The adapter tells the two apart structurally, by whether the argument has a +boolean `enabled` property, so there is no mode flag to get wrong. + +## What a record looks like + +The SDK's `Logger` is fluent and field-oriented (`atLevel(level).event(name).field(k, v).emit()`); +`debug` takes a format string. Each event's field map is flattened to `key=value` pairs joined by +spaces and emitted through `%s`: + +``` +dexpace:warning event=http.transport.headerDropped name=content-length +0ms +``` + +Values go through `String(v)`, so this is a human-readable channel, not a machine-parseable one. If +you need to query your logs, use `@dexpace/logging-pino`, which passes the field map to pino as an +object. + +**Suppressed events cost nothing.** `isLevelEnabled` is wired to that level's `debugger.enabled`, +which `debug` computes from `DEBUG` at construction — so an event at a disabled level never builds +its field map. + +## Options + +`createDebugLogger(debugOrFactory, namespace, options)` forwards `CreateLoggerOptions` minus +`isLevelEnabled`, which this adapter owns: + +- `globalFields` — merged into every record. +- `diagnosticAllowList` — the query parameters that survive URL redaction. Everything else is + redacted before it reaches `debug`, so a URL in a log line cannot leak a token. `null` means + "redact every parameter". + +A `null`, or anything that is neither a function nor an object, is a construction-time `TypeError` — +loud, at wiring time, rather than a swallowed no-op at the first log line. + +## The alternative + +`@dexpace/logging-pino` does the same job over pino, with structured records and a runtime-adjustable +level. Neither is required: the SDK's default is `NOOP_LOGGER`, and `createLogger(sink)` in +`@dexpace/core` adapts anything else in a few lines. diff --git a/packages/logging-pino/README.md b/packages/logging-pino/README.md index c38f95d..1f7f3c5 100644 --- a/packages/logging-pino/README.md +++ b/packages/logging-pino/README.md @@ -1,20 +1,68 @@ # @dexpace/logging-pino -Pino logging adapter for the dexpace Node.js SDK. +Routes the dexpace SDK's structured log events into [pino](https://getpino.io). Zero runtime +dependencies: `@dexpace/core` and `pino` are both peers, and `pino` is an **optional** one — this +package only ever calls a duck-typed subset of it. -## Installation - -```bash -npm install @dexpace/logging-pino pino +```sh +bun add @dexpace/logging-pino @dexpace/core pino ``` -## Usage - ```typescript import pino from 'pino'; -import {createPinoLogger} from '@dexpace/logging-pino'; import {setGlobalLogger} from '@dexpace/core'; +import {createPinoLogger} from '@dexpace/logging-pino'; -const loggerInstance = pino({level: 'info'}); -setGlobalLogger(createPinoLogger(loggerInstance)); +setGlobalLogger(createPinoLogger(pino({level: 'info'}))); ``` + +That is the whole wiring. Every SDK event — retry attempts, redirect hops, dropped outbound headers, +auth refresh failures — now arrives as a pino record with its fields as top-level keys. + +## What it actually does + +The SDK's `Logger` is a fluent, four-level facade (`atLevel(level).event(name).field(k, v).emit()`). +pino's is a five-method object taking `(obj, msg?)`. This package is the mapping between them, and +it is three decisions wide: + +| SDK level | pino method | +|---|---| +| `error` | `error` | +| `warning` | `warn` | +| `info` | `info` | +| `verbose` | `debug` | + +- **Fields become the record, not the message.** Each event's field map is passed as pino's `obj` + argument, so `{event: 'http.retry.attemptFailed', attempt: 2}` lands as queryable keys rather than + an interpolated string. No `msg` is set. +- **Level checks are delegated, per call.** `isLevelEnabled` is wired straight to + `pino.isLevelEnabled`, so a suppressed event costs one predicate call and never builds its field + map. Changing pino's level at runtime takes effect immediately; nothing is cached. +- **`pino.trace` is never called.** The SDK has four levels; `verbose` is the floor and maps to + `debug`. + +## Options + +`createPinoLogger(instance, options)` forwards `CreateLoggerOptions` minus `isLevelEnabled`, which +this adapter owns: + +- `globalFields` — merged into every record (a service name, a build id). +- `diagnosticAllowList` — the query parameters that survive URL redaction. Everything else is + redacted before it reaches pino, so a URL in a log line cannot leak a token. `null` means "redact + every parameter". + +## Anything pino-shaped works + +The parameter type is `PinoLike`, a five-method structural interface — `isLevelEnabled`, `error`, +`warn`, `info`, `debug` — not pino's own type. A real pino instance duck-types into it, and so does a +child logger (`pino().child({req: id})`), a test double, or a wrapper of your own. That is why this +package can declare `pino` optional and still carry zero dependencies. + +A non-object, or an object without a callable `isLevelEnabled`, is a construction-time `TypeError` — +loud, at wiring time, rather than a swallowed no-op at the first log line. + +## The alternative + +`@dexpace/logging-debug` does the same job over [`debug`](https://www.npmjs.com/package/debug), with +namespace-per-level filtering instead of a level threshold. Neither is required: the SDK's default +is `NOOP_LOGGER`, and `createLogger(sink)` in `@dexpace/core` adapts anything else in a few lines. diff --git a/packages/rx/README.md b/packages/rx/README.md index e420bf9..2ef018e 100644 --- a/packages/rx/README.md +++ b/packages/rx/README.md @@ -12,7 +12,10 @@ npm install @dexpace/rx rxjs ```typescript import {sseEvents$, typedSse$, pageItems$, pages$} from '@dexpace/rx'; -import {sseStreamFrom, Paginator} from '@dexpace/core'; +import {sseStreamFrom, Paginator, type Response} from '@dexpace/core'; + +declare const response: Response; +declare const paginator: Paginator<unknown>; // Server-Sent Events sseEvents$(sseStreamFrom(response)).subscribe({ diff --git a/packages/transport-fetch/README.md b/packages/transport-fetch/README.md index 234b944..3ef2b5b 100644 --- a/packages/transport-fetch/README.md +++ b/packages/transport-fetch/README.md @@ -11,7 +11,7 @@ bun add @dexpace/transport-fetch @dexpace/core import {Request} from '@dexpace/core'; import {fetchTransport} from '@dexpace/transport-fetch'; -await using transport = fetchTransport({headerDropLogging: 'first-per-name'}); +const transport = fetchTransport({headerDropLogging: 'first-per-name'}); const response = await transport.send( Request.newBuilder().url('https://example.com/v1/users').build(), @@ -23,6 +23,13 @@ try { } ``` +`close()` is the teardown, not `await using`. The factory returns a plain `Transport`: the disposal +member is installed only when `Symbol.asyncDispose` exists, which it does not on this package's +declared `engines.node` floor of `>=20.3` (the symbol arrived in 20.4). Declaring `AsyncDisposable` +in the `.d.ts` regardless would be a type that lies on the supported runtime — `NFR-10` forbids it, +and the [`await using` support row](https://github.com/dexpace/nodejs-sdk/blob/main/docs/open-items.md#d-nfr-10-await-using) in `docs/open-items.md` +records the decision and the four reasons the floor does not move instead. + ## What this transport deliberately does not do - **No proxy support, at all (`TRANSPORT-30`, scoped out).** There is no `proxy` option on diff --git a/packages/transport-undici/README.md b/packages/transport-undici/README.md index dbb338a..01d1c8e 100644 --- a/packages/transport-undici/README.md +++ b/packages/transport-undici/README.md @@ -11,17 +11,28 @@ bun add @dexpace/transport-undici @dexpace/core import {Request} from '@dexpace/core'; import {undiciTransport} from '@dexpace/transport-undici'; -await using transport = undiciTransport({ +const transport = undiciTransport({ agentOptions: {connections: 32}, defaultTimeoutMs: 30_000, }); -const response = await transport.send( - Request.newBuilder().url('https://example.com/v1/users').build(), -); -await response.close(); +try { + const response = await transport.send( + Request.newBuilder().url('https://example.com/v1/users').build(), + ); + await response.close(); +} finally { + await transport.close(); // this transport owns a real dispatcher — always close it +} ``` +`close()` is the teardown, not `await using`. The factory returns a plain `Transport`: the disposal +member is installed only when `Symbol.asyncDispose` exists, which it does not on this package's +declared `engines.node` floor of `>=20.3` (the symbol arrived in 20.4). Declaring `AsyncDisposable` +in the `.d.ts` regardless would be a type that lies on the supported runtime — `NFR-10` forbids it, +and the [`await using` support row](https://github.com/dexpace/nodejs-sdk/blob/main/docs/open-items.md#d-nfr-10-await-using) in `docs/open-items.md` +records the decision and the four reasons the floor does not move instead. Unlike `@dexpace/transport-fetch`, closing here is not optional: see below. + ## Dispatcher ownership Exactly one decision, made once at construction, fixing both the dispatcher and who closes it: diff --git a/scripts/changeset.mjs b/scripts/changeset.mjs index e6a5e2f..d1d9493 100644 --- a/scripts/changeset.mjs +++ b/scripts/changeset.mjs @@ -3,8 +3,8 @@ // // Wrapper around the changesets CLI that renames a newly created changeset // from `@changesets/write`'s random `human-id` name (`silly-pandas-jump.md`) -// to this repo's convention: `YYYY-MM-DD-<kebab-slug>.md`, matching -// `docs/superpowers/{specs,plans}`. +// to this repo's convention: `YYYY-MM-DD-<kebab-slug>.md`, the same name shape +// every document under `docs/work/mvp/` carries. // // The name is not a config knob — the ID comes from a hardcoded `humanId()` // call inside `@changesets/write`, and `.changeset/config.json`'s schema has diff --git a/scripts/verify-knowledge-structure.mjs b/scripts/verify-knowledge-structure.mjs index 0e0a739..6f57d18 100644 --- a/scripts/verify-knowledge-structure.mjs +++ b/scripts/verify-knowledge-structure.mjs @@ -77,7 +77,7 @@ function sourceRoots(text) { // One row at a root's parent would widen the allowlist to everything beneath // it — a future `docs/open-items.md` row makes `docs` a root, and then every - // `docs/superpowers/...` citation passes. Refuse rather than silently widen. + // `docs/work/...` citation passes. Refuse rather than silently widen. const sorted = [...roots].sort(); for (const root of sorted) { const swallowed = sorted.find( diff --git a/tests/node-conformance/README.md b/tests/node-conformance/README.md index d46fb0b..b9c62df 100644 --- a/tests/node-conformance/README.md +++ b/tests/node-conformance/README.md @@ -3,7 +3,7 @@ `tests/node-conformance/` — run by `bun run test:node` (`node --test tests/node-conformance/*.test.mjs`), never by `bun test`. -Closes checkpoint §5.9 (`docs/superpowers/plans/2026-07-25-checkpoint-scaffold-through-phase3a.md:341`). +Closes checkpoint §5.9 (`docs/work/mvp/2026-07-25-checkpoint-scaffold-through-phase3a.md:341`). > **This tree must not run on Bun.** That is the only reason it exists. Until Phase 10 it lived at > `test/node-conformance/`, outside anything `bun test` could reach; it now sits inside `tests/`, so @@ -60,7 +60,7 @@ runtime decides. `ls` this directory. An earlier revision kept a table of file-to-surface descriptions here; it listed 6 of 14 by the time anyone checked, because nothing regenerated it. What each case covers, and which requirement -IDs it discharges, is recorded once — in that phase's checklist under `docs/superpowers/plans/`. +IDs it discharges, is recorded once — in that phase's checklist under `docs/work/mvp/phaseN/`. One piece of provenance the tree cannot show: `seams.test.mjs` absorbed the retired `scripts/verify-node-floor.mjs`, whose two `AbortSignal.any()` assertions were the only Node coverage that From 3dc4179d7001aa1a3ee941f7b4ad0bed4e3939aa Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh <wahbehmo20@gmail.com> Date: Wed, 2 Sep 2026 20:42:08 +0300 Subject: [PATCH 3/3] docs: resolve lint issues. --- .claude/skills/housekeeping/probe.mjs | 4 +++- .claude/skills/housekeeping/probe.test.mjs | 9 ++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.claude/skills/housekeeping/probe.mjs b/.claude/skills/housekeeping/probe.mjs index e0bd883..f56f38c 100644 --- a/.claude/skills/housekeeping/probe.mjs +++ b/.claude/skills/housekeeping/probe.mjs @@ -375,7 +375,9 @@ function checkClaims(ctx, facts) { const text = ctx.read(doc); const prose = assertedProse(text); - for (const pkg of PACKAGE_ROSTER_DOCUMENTS.includes(doc) ? facts.packages : []) { + for (const pkg of PACKAGE_ROSTER_DOCUMENTS.includes(doc) + ? facts.packages + : []) { if (!text.includes(pkg.name)) { ctx.finding( 'claims', diff --git a/.claude/skills/housekeeping/probe.test.mjs b/.claude/skills/housekeeping/probe.test.mjs index 070aa8e..bda1002 100644 --- a/.claude/skills/housekeeping/probe.test.mjs +++ b/.claude/skills/housekeeping/probe.test.mjs @@ -156,7 +156,7 @@ test("claims: a community-health file's counts are checked when it exists", () = ); }); -test("claims: a community-health file is not required to NAME every package", () => { +test('claims: a community-health file is not required to NAME every package', () => { // The roster lives in CLAUDE.md and README.md. CONTRIBUTING.md states the shape once and // points at CLAUDE.md for the table; SECURITY.md names only the packages that carry a // security surface. Requiring the full list of either reported eighteen findings against @@ -164,14 +164,17 @@ test("claims: a community-health file is not required to NAME every package", () const found = onFixture( { overrides: { - 'CONTRIBUTING.md': '# contributing\n\nTwo packages, one of them published.\n', + 'CONTRIBUTING.md': + '# contributing\n\nTwo packages, one of them published.\n', 'SECURITY.md': '# security\n\nReport privately.\n', }, }, ['claims'], ); assert.ok( - !found.some(f => /CONTRIBUTING\.md never names|SECURITY\.md never names/.test(f.message)), + !found.some(f => + /CONTRIBUTING\.md never names|SECURITY\.md never names/.test(f.message), + ), JSON.stringify(messages(found)), ); });